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 { TrackRow } from '../components/TrackRow'; import { EmptyState } from '../components/EmptyState'; import { colors, spacing, typography } from '../theme'; type HistoryMode = 'windows' | 'continuous' | 'range'; type Props = { readonly sessions: readonly CompletedTrainingSession[]; readonly getSessionSamples: ( sessionId: string, ) => Promise; readonly getContinuousSamples: ( limit?: number, ) => Promise; readonly liveSamples: readonly HeartRateSample[]; }; export function HistoryScreen({ sessions, getSessionSamples, getContinuousSamples, liveSamples, }: Props) { const [mode, setMode] = useState('windows'); const [selectedId, setSelectedId] = useState(null); const [selectedSamples, setSelectedSamples] = useState< readonly HeartRateSample[] >([]); const [selectedUsageId, setSelectedUsageId] = useState(null); const [loadingId, setLoadingId] = useState(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 ( setMode('windows')} /> setMode('continuous')} /> setMode('range')} /> {mode === 'windows' ? ( ) : null} {mode === 'continuous' ? ( ) : null} {mode === 'range' ? ( ) : null} ); } function ModeChip({ active, label, onPress, }: { readonly active: boolean; readonly label: string; readonly onPress: () => void; }) { return ( {label} ); } 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 ( FAIXA CUSTOMIZADA Dia, hora e minuto. Use AAAA-MM-DD HH:mm para início e fim. {!rangeBounds ? ( Informe as duas datas no formato AAAA-MM-DD HH:mm. ) : null} {rangeBounds && samples.length > 0 ? ( ) : null} {rangeBounds && samples.length === 0 ? ( ) : null} ); } function RangeInput({ label, onChangeText, value, }: { readonly label: string; readonly onChangeText: (value: string) => void; readonly value: string; }) { return ( {label} ); } 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; readonly selectedSamples: readonly HeartRateSample[]; readonly sessions: readonly CompletedTrainingSession[]; readonly totalDuration: number; readonly totalSamples: number; readonly zones: ReturnType; }) { if (sessions.length === 0) { return ( ); } return ( <> JANELAS SALVAS {sessions.length} {formatDuration(totalDuration)} · {totalSamples} amostras reais destacadas {sessions.map((session, index) => { const isOpen = selectedId === session.id; return ( onOpenSession(session)} title={session.deviceName || 'Cinta cardíaca BLE'} trackNumber={index + 1} > {isOpen ? ( ) : null} ); })} ); } 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 ( Carregando histórico contínuo real... ); } if (blocks.length === 0) { return ( ); } return ( <> USO TOTAL {blocks.length} {formatDuration(totalDuration)} · {totalSamples} amostras contínuas salvas {blocks.map((block, index) => { const isOpen = selectedUsageId === block.id; return ( onOpenBlock(block)} title={block.deviceName || 'Uso contínuo'} trackNumber={index + 1} > {isOpen && selectedBlock ? ( ) : null} ); })} ); } function WindowDetail({ loading, metrics, samples, zones, }: { readonly loading: boolean; readonly metrics: ReturnType; readonly samples: readonly HeartRateSample[]; readonly zones: ReturnType; }) { return ( {loading ? ( Carregando amostras salvas... ) : null} {zones.map(zone => ( {zone.label} {zone.percent}% · {formatDuration(zone.seconds * 1000)} ))} ); } function UsageDetail({ block }: { readonly block: ContinuousUsageBlock }) { const metrics = calculateLiveMetrics(block.samples); return ( ); } function EmptyHistory({ copy, eyebrow, title, }: { readonly copy: string; readonly eyebrow: string; readonly title: string; }) { return ( ); } function DetailMetric({ label, value, }: { readonly label: string; readonly value: string | number; }) { return ( {value} {label} ); } function OpenIcon() { return ; } function ClosedIcon() { return ; } 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(); [...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, }, });