feat(components): ActionButton stopAlert + typography type fixes
This commit is contained in:
1 parent
2c51fd2623
commit
bbe2919c2c
89 files changed
+75993
-9961
No files matched your search
@@ -0,0 +1,922 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { Activity, ChevronDown, ChevronUp } from 'lucide-react-native';
|
||||
import {
|
||||
calculateLiveMetrics,
|
||||
calculateZones,
|
||||
formatDuration,
|
||||
} from '../training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../training/trainingTypes';
|
||||
import {
|
||||
buildContinuousUsageHistory,
|
||||
filterSamplesByRange,
|
||||
type ContinuousUsageBlock,
|
||||
} from '../training/continuousUsage';
|
||||
import { TrainingChart } from '../components/TrainingChart';
|
||||
import { colors, spacing, typography } from '../theme';
|
||||
|
||||
type HistoryMode = 'windows' | 'continuous' | 'range';
|
||||
|
||||
type Props = {
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly getSessionSamples: (
|
||||
sessionId: string,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly getContinuousSamples: (
|
||||
limit?: number,
|
||||
) => Promise<readonly HeartRateSample[]>;
|
||||
readonly liveSamples: readonly HeartRateSample[];
|
||||
};
|
||||
|
||||
export function HistoryScreen({
|
||||
sessions,
|
||||
getSessionSamples,
|
||||
getContinuousSamples,
|
||||
liveSamples,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<HistoryMode>('windows');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedSamples, setSelectedSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [selectedUsageId, setSelectedUsageId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [loadingContinuous, setLoadingContinuous] = useState(true);
|
||||
const [continuousSamples, setContinuousSamples] = useState<
|
||||
readonly HeartRateSample[]
|
||||
>([]);
|
||||
const [rangeStartText, setRangeStartText] = useState(() =>
|
||||
formatRangeInput(Date.now() - 60 * 60 * 1000),
|
||||
);
|
||||
const [rangeEndText, setRangeEndText] = useState(() =>
|
||||
formatRangeInput(Date.now()),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingContinuous(true);
|
||||
|
||||
getContinuousSamples()
|
||||
.then(samples => {
|
||||
if (mounted) {
|
||||
setContinuousSamples(samples);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setLoadingContinuous(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [getContinuousSamples]);
|
||||
|
||||
const mergedContinuousSamples = useMemo(
|
||||
() => mergeSamples(continuousSamples, liveSamples),
|
||||
[continuousSamples, liveSamples],
|
||||
);
|
||||
|
||||
const usageBlocks = useMemo(
|
||||
() => buildContinuousUsageHistory(mergedContinuousSamples),
|
||||
[mergedContinuousSamples],
|
||||
);
|
||||
const rangeBounds = useMemo(
|
||||
() => parseHistoryRange(rangeStartText, rangeEndText),
|
||||
[rangeStartText, rangeEndText],
|
||||
);
|
||||
const rangeSamples = useMemo(
|
||||
() =>
|
||||
rangeBounds
|
||||
? filterSamplesByRange(mergedContinuousSamples, rangeBounds)
|
||||
: [],
|
||||
[mergedContinuousSamples, rangeBounds],
|
||||
);
|
||||
const selectedMetrics = useMemo(
|
||||
() => calculateLiveMetrics(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const zones = useMemo(
|
||||
() => calculateZones(selectedSamples),
|
||||
[selectedSamples],
|
||||
);
|
||||
const selectedUsage =
|
||||
usageBlocks.find(block => block.id === selectedUsageId) ?? null;
|
||||
|
||||
const openSession = async (session: CompletedTrainingSession) => {
|
||||
if (selectedId === session.id) {
|
||||
setSelectedId(null);
|
||||
setSelectedSamples([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingId(session.id);
|
||||
setSelectedId(session.id);
|
||||
try {
|
||||
const samples = await getSessionSamples(session.id);
|
||||
setSelectedSamples(samples);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openUsageBlock = (block: ContinuousUsageBlock) => {
|
||||
if (selectedUsageId === block.id) {
|
||||
setSelectedUsageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUsageId(block.id);
|
||||
};
|
||||
|
||||
const totalWindowSamples = sessions.reduce(
|
||||
(sum, session) => sum + session.sampleCount,
|
||||
0,
|
||||
);
|
||||
const totalWindowDuration = sessions.reduce(
|
||||
(sum, session) => sum + session.durationMs,
|
||||
0,
|
||||
);
|
||||
const totalContinuousDuration = usageBlocks.reduce(
|
||||
(sum, block) => sum + block.durationMs,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.modeSwitch}>
|
||||
<ModeChip
|
||||
active={mode === 'windows'}
|
||||
label="Janelas"
|
||||
onPress={() => setMode('windows')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'continuous'}
|
||||
label="Uso total"
|
||||
onPress={() => setMode('continuous')}
|
||||
/>
|
||||
<ModeChip
|
||||
active={mode === 'range'}
|
||||
label="Faixa"
|
||||
onPress={() => setMode('range')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{mode === 'windows' ? (
|
||||
<WindowHistory
|
||||
loadingId={loadingId}
|
||||
onOpenSession={openSession}
|
||||
selectedId={selectedId}
|
||||
selectedMetrics={selectedMetrics}
|
||||
selectedSamples={selectedSamples}
|
||||
sessions={sessions}
|
||||
totalDuration={totalWindowDuration}
|
||||
totalSamples={totalWindowSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'continuous' ? (
|
||||
<ContinuousHistory
|
||||
blocks={usageBlocks}
|
||||
loading={loadingContinuous}
|
||||
onOpenBlock={openUsageBlock}
|
||||
selectedBlock={selectedUsage}
|
||||
selectedUsageId={selectedUsageId}
|
||||
totalDuration={totalContinuousDuration}
|
||||
totalSamples={mergedContinuousSamples.length}
|
||||
/>
|
||||
) : null}
|
||||
{mode === 'range' ? (
|
||||
<RangeHistory
|
||||
endText={rangeEndText}
|
||||
rangeBounds={rangeBounds}
|
||||
samples={rangeSamples}
|
||||
setEndText={setRangeEndText}
|
||||
setStartText={setRangeStartText}
|
||||
startText={rangeStartText}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeChip({
|
||||
active,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
readonly active: boolean;
|
||||
readonly label: string;
|
||||
readonly onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={onPress}
|
||||
style={[styles.modeChip, active ? styles.modeChipActive : null]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.modeChipText, active ? styles.modeChipTextActive : null]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeHistory({
|
||||
endText,
|
||||
rangeBounds,
|
||||
samples,
|
||||
setEndText,
|
||||
setStartText,
|
||||
startText,
|
||||
}: {
|
||||
readonly endText: string;
|
||||
readonly rangeBounds: { readonly start: number; readonly end: number } | null;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly setEndText: (value: string) => void;
|
||||
readonly setStartText: (value: string) => void;
|
||||
readonly startText: string;
|
||||
}) {
|
||||
const metrics = calculateLiveMetrics(samples);
|
||||
|
||||
return (
|
||||
<View style={styles.rangePanel}>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>FAIXA CUSTOMIZADA</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
Dia, hora e minuto. Use AAAA-MM-DD HH:mm para início e fim.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rangeGrid}>
|
||||
<RangeInput
|
||||
label="INÍCIO"
|
||||
onChangeText={setStartText}
|
||||
value={startText}
|
||||
/>
|
||||
<RangeInput label="FIM" onChangeText={setEndText} value={endText} />
|
||||
</View>
|
||||
|
||||
{!rangeBounds ? (
|
||||
<Text style={styles.rangeWarning}>
|
||||
Informe as duas datas no formato AAAA-MM-DD HH:mm.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length > 0 ? (
|
||||
<View style={styles.rangeResult}>
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="média" value={metrics.averageBpm ?? '--'} />
|
||||
<DetailMetric label="pico" value={metrics.peakBpm ?? '--'} />
|
||||
<DetailMetric label="amostras" value={samples.length} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{rangeBounds && samples.length === 0 ? (
|
||||
<EmptyHistory
|
||||
eyebrow="SEM AMOSTRAS"
|
||||
title="Nada nesta faixa"
|
||||
copy="A consulta usa apenas BPM real salvo no histórico contínuo. Ajuste o início ou fim para encontrar dados."
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeInput({
|
||||
label,
|
||||
onChangeText,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly onChangeText: (value: string) => void;
|
||||
readonly value: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.rangeInputBlock}>
|
||||
<Text style={styles.rangeInputLabel}>{label}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
onChangeText={onChangeText}
|
||||
placeholder="2026-06-10 09:30"
|
||||
placeholderTextColor={colors.textDim}
|
||||
style={styles.rangeInput}
|
||||
value={value}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowHistory({
|
||||
loadingId,
|
||||
onOpenSession,
|
||||
selectedId,
|
||||
selectedMetrics,
|
||||
selectedSamples,
|
||||
sessions,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loadingId: string | null;
|
||||
readonly onOpenSession: (session: CompletedTrainingSession) => void;
|
||||
readonly selectedId: string | null;
|
||||
readonly selectedMetrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly selectedSamples: readonly HeartRateSample[];
|
||||
readonly sessions: readonly CompletedTrainingSession[];
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="JANELAS SALVAS"
|
||||
title="Nenhuma janela salva"
|
||||
copy="O monitor grava continuamente; a janela só destaca um intervalo que você abriu e fechou para análise."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>JANELAS SALVAS</Text>
|
||||
<Text style={styles.archiveNumber}>{sessions.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras reais
|
||||
destacadas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{sessions.map(session => {
|
||||
const isOpen = selectedId === session.id;
|
||||
return (
|
||||
<View key={session.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenSession(session)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(session.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(session.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{session.deviceName || 'Cinta cardíaca BLE'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatDuration(session.durationMs)} · {session.sampleCount}{' '}
|
||||
amostras · toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {session.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {session.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {session.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen ? (
|
||||
<WindowDetail
|
||||
loading={loadingId === session.id}
|
||||
metrics={selectedMetrics}
|
||||
samples={selectedSamples}
|
||||
zones={zones}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuousHistory({
|
||||
blocks,
|
||||
loading,
|
||||
onOpenBlock,
|
||||
selectedBlock,
|
||||
selectedUsageId,
|
||||
totalDuration,
|
||||
totalSamples,
|
||||
}: {
|
||||
readonly blocks: readonly ContinuousUsageBlock[];
|
||||
readonly loading: boolean;
|
||||
readonly onOpenBlock: (block: ContinuousUsageBlock) => void;
|
||||
readonly selectedBlock: ContinuousUsageBlock | null;
|
||||
readonly selectedUsageId: string | null;
|
||||
readonly totalDuration: number;
|
||||
readonly totalSamples: number;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando histórico contínuo real...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<EmptyHistory
|
||||
eyebrow="USO TOTAL"
|
||||
title="Sem uso contínuo ainda"
|
||||
copy="Conecte a cinta. Todo BPM real recebido fora ou dentro de uma janela aparecerá aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.archiveHeader}>
|
||||
<Text style={styles.eyebrow}>USO TOTAL</Text>
|
||||
<Text style={styles.archiveNumber}>{blocks.length}</Text>
|
||||
<Text style={styles.archiveLabel}>
|
||||
{formatDuration(totalDuration)} · {totalSamples} amostras contínuas
|
||||
salvas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{blocks.map(block => {
|
||||
const isOpen = selectedUsageId === block.id;
|
||||
return (
|
||||
<View key={block.id} style={styles.timelineNode}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onOpenBlock(block)}
|
||||
style={({ pressed }) => [
|
||||
styles.sessionStrip,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.dateBlock}>
|
||||
<Text style={styles.sessionDay}>
|
||||
{formatSessionDay(block.startedAt)}
|
||||
</Text>
|
||||
<Text style={styles.sessionHour}>
|
||||
{formatSessionHour(block.startedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionBody}>
|
||||
<Text style={styles.sessionTitle}>
|
||||
{block.deviceName || 'Uso contínuo'}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{formatUsageRange(block)} · {block.sampleCount} amostras ·
|
||||
toque para detalhes
|
||||
</Text>
|
||||
<View style={styles.metricLine}>
|
||||
<Text style={styles.metricText}>
|
||||
MÉD {block.averageBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricTextHot}>
|
||||
PICO {block.peakBpm}
|
||||
</Text>
|
||||
<Text style={styles.metricText}>MIN {block.minBpm}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOpen ? <OpenIcon /> : <ClosedIcon />}
|
||||
</Pressable>
|
||||
|
||||
{isOpen && selectedBlock ? (
|
||||
<UsageDetail block={selectedBlock} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowDetail({
|
||||
loading,
|
||||
metrics,
|
||||
samples,
|
||||
zones,
|
||||
}: {
|
||||
readonly loading: boolean;
|
||||
readonly metrics: ReturnType<typeof calculateLiveMetrics>;
|
||||
readonly samples: readonly HeartRateSample[];
|
||||
readonly zones: ReturnType<typeof calculateZones>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingLine}>
|
||||
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
||||
<Text style={styles.copy}>Carregando amostras salvas...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TrainingChart samples={samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric label="carga" value={metrics.loadScore || '--'} />
|
||||
<DetailMetric label="alta" value={`${metrics.highIntensityPercent}%`} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.zoneStack}>
|
||||
{zones.map(zone => (
|
||||
<View key={zone.key} style={styles.zoneRow}>
|
||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||
<Text style={styles.zoneValue}>
|
||||
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageDetail({ block }: { readonly block: ContinuousUsageBlock }) {
|
||||
const metrics = calculateLiveMetrics(block.samples);
|
||||
|
||||
return (
|
||||
<View style={styles.detailPanel}>
|
||||
<TrainingChart samples={block.samples} />
|
||||
<View style={styles.detailRail}>
|
||||
<DetailMetric
|
||||
label="duração"
|
||||
value={formatDuration(block.durationMs)}
|
||||
/>
|
||||
<DetailMetric label="amostras" value={block.sampleCount} />
|
||||
<DetailMetric
|
||||
label="taxa"
|
||||
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHistory({
|
||||
copy,
|
||||
eyebrow,
|
||||
title,
|
||||
}: {
|
||||
readonly copy: string;
|
||||
readonly eyebrow: string;
|
||||
readonly title: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.emptyBlock}>
|
||||
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
<Text style={styles.copy}>{copy}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailMetric}>
|
||||
<Text style={styles.detailValue}>{value}</Text>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenIcon() {
|
||||
return <ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function ClosedIcon() {
|
||||
return <ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />;
|
||||
}
|
||||
|
||||
function formatSessionDay(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSessionHour(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUsageRange(block: ContinuousUsageBlock) {
|
||||
return `${formatDuration(block.durationMs)} · ${formatSessionHour(
|
||||
block.startedAt,
|
||||
)}-${formatSessionHour(block.endedAt)}`;
|
||||
}
|
||||
|
||||
function mergeSamples(
|
||||
persistedSamples: readonly HeartRateSample[],
|
||||
liveSamples: readonly HeartRateSample[],
|
||||
) {
|
||||
const map = new Map<string, HeartRateSample>();
|
||||
|
||||
[...persistedSamples, ...liveSamples].forEach(sample => {
|
||||
const key = sample.id
|
||||
? `id:${sample.id}`
|
||||
: `ts:${sample.timestamp}:${sample.bpm}`;
|
||||
map.set(key, sample);
|
||||
});
|
||||
|
||||
return [...map.values()].sort(
|
||||
(left, right) => left.timestamp - right.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHistoryRange(startText: string, endText: string) {
|
||||
const start = parseRangeInput(startText);
|
||||
const end = parseRangeInput(endText);
|
||||
|
||||
if (start === null || end === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseRangeInput(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/.exec(
|
||||
value.trim(),
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute] = match.map(Number);
|
||||
const timestamp = new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function formatRangeInput(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
modeSwitch: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
modeChip: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
modeChipActive: {
|
||||
borderColor: colors.line,
|
||||
backgroundColor: colors.surfaceHot,
|
||||
},
|
||||
modeChipText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
modeChipTextActive: {
|
||||
color: colors.warning,
|
||||
},
|
||||
emptyBlock: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.xl,
|
||||
minHeight: 260,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 44,
|
||||
lineHeight: 45,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2.6,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
flex: 1,
|
||||
},
|
||||
archiveHeader: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.line,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
archiveNumber: {
|
||||
...typography.display,
|
||||
color: colors.text,
|
||||
fontSize: 82,
|
||||
lineHeight: 84,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
archiveLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
timeline: {
|
||||
gap: 0,
|
||||
},
|
||||
timelineNode: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
sessionStrip: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
dateBlock: {
|
||||
width: 68,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: colors.line,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
sessionDay: {
|
||||
color: colors.warning,
|
||||
fontSize: 20,
|
||||
lineHeight: 23,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.8,
|
||||
},
|
||||
sessionHour: {
|
||||
...typography.label,
|
||||
color: colors.textDim,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
sessionBody: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
lineHeight: 25,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
sessionMeta: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xxs,
|
||||
},
|
||||
metricLine: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
metricText: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
metricTextHot: {
|
||||
...typography.label,
|
||||
color: colors.action,
|
||||
},
|
||||
rangePanel: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
rangeGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rangeInputBlock: {
|
||||
flex: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rangeInputLabel: {
|
||||
...typography.label,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeInput: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line,
|
||||
color: colors.text,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
rangeWarning: {
|
||||
...typography.body,
|
||||
color: colors.warning,
|
||||
},
|
||||
rangeResult: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
detailPanel: {
|
||||
paddingBottom: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
detailRail: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
borderColor: colors.line,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
detailMetric: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
detailValue: {
|
||||
color: colors.text,
|
||||
fontSize: 27,
|
||||
lineHeight: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
detailLabel: {
|
||||
...typography.label,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
zoneStack: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
zoneRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
zoneLabel: {
|
||||
...typography.label,
|
||||
color: colors.text,
|
||||
},
|
||||
zoneValue: {
|
||||
...typography.body,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user