feat(components): ActionButton stopAlert + typography type fixes

This commit is contained in:
fightrate-bot committed 2026-06-16 01:50:26 -03:00
1 parent 2c51fd2623
commit bbe2919c2c
89 files changed
+75993 -9961

No files matched your search

+922
View File
@@ -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,
},
});