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

+10 -8
View File
@@ -17,27 +17,29 @@ jest.mock('react-native-reanimated', () => {
import { PressableSurface } from '../components/PressableSurface';
const children = <></>;
describe('PressableSurface', () => {
it('renders children inside', () => {
let tree: any;
TestRenderer.act(() => {
tree = TestRenderer.create(
React.createElement(PressableSurface, { onPress: () => undefined, testID: 'surface' },
React.createElement('Text', null, 'Hi'),
),
<PressableSurface onPress={() => undefined} testID="surface">
{children}
</PressableSurface>,
);
});
const json = tree.toJSON();
expect(JSON.stringify(json)).toContain('Hi');
expect(JSON.stringify(json)).toContain('Pressable');
});
it('has accessibilityRole=button when accessibilityLabel is set', () => {
it('has accessibilityLabel when set', () => {
let tree: any;
TestRenderer.act(() => {
tree = TestRenderer.create(
React.createElement(PressableSurface, { onPress: () => undefined, accessibilityLabel: 'Tap', testID: 'surface' },
React.createElement('Text', null, 'Hi'),
),
<PressableSurface onPress={() => undefined} accessibilityLabel="Tap">
{children}
</PressableSurface>,
);
});
const json = JSON.stringify(tree.toJSON());
+242
View File
@@ -0,0 +1,242 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
Image,
Pressable,
ScrollView,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native';
import {
Bluetooth,
ChartNoAxesCombined,
History,
Radio,
type LucideIcon,
} from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
import { CaptureScreen } from '../screens/CaptureScreen';
import { HistoryScreen } from '../screens/HistoryScreen';
import { InsightsScreen } from '../screens/InsightsScreen';
import { SensorScreen } from '../screens/SensorScreen';
import { colors, radius, spacing, typography } from '../theme';
type TabKey = 'capture' | 'history' | 'sensor' | 'insights';
type Tab = {
readonly key: TabKey;
readonly label: string;
readonly icon: LucideIcon;
};
const tabs: readonly Tab[] = [
{ key: 'capture', label: 'Captura', icon: Radio },
{ key: 'history', label: 'Histórico', icon: History },
{ key: 'sensor', label: 'Sensor', icon: Bluetooth },
{ key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
];
export function FightRateApp() {
const insets = useSafeAreaInsets();
const monitor = useHeartRateMonitor();
const [activeTab, setActiveTab] = useState<TabKey>('capture');
const onCaptureAction = useCallback(async () => {
if (monitor.isCapturing) {
await monitor.stopCapture();
return;
}
await monitor.startCapture();
}, [monitor]);
const onMonitorAction = useCallback(async () => {
if (monitor.isMonitoring) {
await monitor.stopMonitoring();
return;
}
await monitor.startMonitoring();
}, [monitor]);
const activeTitle = useMemo(() => {
if (activeTab === 'capture') {
if (monitor.isCapturing) {
return 'Janela de treino ativa';
}
if (monitor.isMonitoring) {
return 'Monitor contínuo';
}
return 'Ativar sensor';
}
if (activeTab === 'history') {
return 'Histórico real';
}
if (activeTab === 'sensor') {
return 'Sensor BLE';
}
return 'Análise real';
}, [activeTab, monitor.isCapturing, monitor.isMonitoring]);
return (
<View style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
<ScrollView
contentContainerStyle={[
styles.content,
{
paddingTop: insets.top + spacing.lg,
paddingBottom: insets.bottom + 118,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.brandRow}>
<Image
source={require('../assets/fightrate-logo.png')}
style={styles.logo}
/>
<View style={styles.titleColumn}>
<Text style={styles.kicker}>FightRate</Text>
<Text style={styles.title}>{activeTitle}</Text>
</View>
</View>
{activeTab === 'capture' ? (
<CaptureScreen
monitor={monitor}
onCaptureAction={onCaptureAction}
onMonitorAction={onMonitorAction}
/>
) : null}
{activeTab === 'history' ? (
<HistoryScreen
sessions={monitor.sessions}
getSessionSamples={monitor.getSessionSamples}
getContinuousSamples={monitor.getContinuousSamples}
liveSamples={monitor.samples}
/>
) : null}
{activeTab === 'sensor' ? (
<SensorScreen monitor={monitor} onMonitorAction={onMonitorAction} />
) : null}
{activeTab === 'insights' ? (
<InsightsScreen
liveSamples={monitor.samples}
sessions={monitor.sessions}
getContinuousSamples={monitor.getContinuousSamples}
/>
) : null}
</ScrollView>
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
<View style={styles.navRail}>
{tabs.map(tab => {
const selected = tab.key === activeTab;
const Icon = tab.icon;
return (
<Pressable
accessibilityRole="tab"
accessibilityState={{ selected }}
key={tab.key}
onPress={() => setActiveTab(tab.key)}
style={[styles.navItem, selected ? styles.navItemActive : null]}
>
<Icon
color={selected ? colors.text : colors.textMuted}
size={18}
strokeWidth={2.4}
/>
<Text
style={[
styles.navLabel,
selected ? styles.navLabelActive : null,
]}
>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.canvas,
},
content: {
paddingHorizontal: spacing.md,
gap: spacing.lg,
},
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
logo: {
width: 54,
height: 54,
borderRadius: radius.sm,
},
titleColumn: {
flex: 1,
},
kicker: {
...typography.label,
color: colors.action,
},
title: {
marginTop: spacing.xs,
color: colors.text,
fontSize: 34,
lineHeight: 36,
fontWeight: '900',
letterSpacing: -2.1,
},
navWrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
},
navRail: {
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.xl,
backgroundColor: colors.surfaceRaised,
padding: spacing.xs,
flexDirection: 'row',
gap: spacing.xs,
shadowColor: colors.black,
shadowOpacity: 0.45,
shadowRadius: 24,
elevation: 18,
},
navItem: {
flex: 1,
minHeight: 54,
borderRadius: radius.xl,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xxs,
},
navItemActive: {
backgroundColor: colors.action,
},
navLabel: {
...typography.label,
color: colors.textMuted,
fontSize: 9,
},
navLabelActive: {
color: colors.text,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+94
View File
@@ -0,0 +1,94 @@
export type HeartRateMeasurement = {
/* eslint-disable no-bitwise */
readonly bpm: number;
readonly contactSupported: boolean;
readonly contactDetected: boolean | null;
readonly energyExpended: number | null;
readonly rrIntervalsMs: readonly number[];
};
const BASE64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
export function parseHeartRateMeasurementFromBase64(
value: string,
): HeartRateMeasurement {
return parseHeartRateMeasurement(decodeBase64(value));
}
export function parseHeartRateMeasurement(
bytes: readonly number[],
): HeartRateMeasurement {
if (bytes.length < 2) {
throw new Error('Heart Rate Measurement payload is too short.');
}
const flags = bytes[0];
const isUint16 = (flags & 0x01) !== 0;
const contactSupported = (flags & 0x04) !== 0;
const contactDetected = contactSupported ? (flags & 0x02) !== 0 : null;
const hasEnergyExpended = (flags & 0x08) !== 0;
const hasRrIntervals = (flags & 0x10) !== 0;
let offset = 1;
const bpm = isUint16 ? readUInt16Le(bytes, offset) : bytes[offset];
offset += isUint16 ? 2 : 1;
const energyExpended = hasEnergyExpended ? readUInt16Le(bytes, offset) : null;
if (hasEnergyExpended) {
offset += 2;
}
const rrIntervalsMs: number[] = [];
if (hasRrIntervals) {
while (offset + 1 < bytes.length) {
const rrRaw = readUInt16Le(bytes, offset);
rrIntervalsMs.push((rrRaw / 1024) * 1000);
offset += 2;
}
}
return {
bpm,
contactSupported,
contactDetected,
energyExpended,
rrIntervalsMs,
};
}
function readUInt16Le(bytes: readonly number[], offset: number) {
if (offset + 1 >= bytes.length) {
throw new Error('Heart Rate Measurement payload ended unexpectedly.');
}
return bytes[offset] | (bytes[offset + 1] << 8);
}
function decodeBase64(input: string): number[] {
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
const bytes: number[] = [];
for (let index = 0; index < clean.length; index += 4) {
const encoded1 = BASE64_ALPHABET.indexOf(clean.charAt(index));
const encoded2 = BASE64_ALPHABET.indexOf(clean.charAt(index + 1));
const encoded3 = BASE64_ALPHABET.indexOf(clean.charAt(index + 2));
const encoded4 = BASE64_ALPHABET.indexOf(clean.charAt(index + 3));
const chr1 = (encoded1 << 2) | (encoded2 >> 4);
const chr2 = ((encoded2 & 15) << 4) | (encoded3 >> 2);
const chr3 = ((encoded3 & 3) << 6) | encoded4;
bytes.push(chr1 & 0xff);
if (encoded3 !== 64 && encoded3 !== -1) {
bytes.push(chr2 & 0xff);
}
if (encoded4 !== 64 && encoded4 !== -1) {
bytes.push(chr3 & 0xff);
}
}
return bytes;
}
+24
View File
@@ -0,0 +1,24 @@
const ANDROID_RSSI_SENTINEL = -2147483648;
const MIN_PLAUSIBLE_BLE_RSSI = -127;
const MAX_PLAUSIBLE_BLE_RSSI = 20;
export function normalizeRssi(value?: number | null): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
if (value === ANDROID_RSSI_SENTINEL) {
return null;
}
if (value < MIN_PLAUSIBLE_BLE_RSSI || value > MAX_PLAUSIBLE_BLE_RSSI) {
return null;
}
return Math.round(value);
}
export function formatRssi(value?: number | null): string {
const rssi = normalizeRssi(value);
return typeof rssi === 'number' ? `${rssi} dBm` : 'sem leitura';
}
+328
View File
@@ -0,0 +1,328 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
connectNativeDevice,
fightRateCaptureEvents,
getNativeSessionSamples,
getNativeContinuousSamples,
getCaptureSnapshot,
normalizeDevicesEvent,
normalizeMetadataEvent,
normalizeSample,
normalizeStatusEvent,
openBatteryOptimizationSettings,
requestCapturePermissions,
startNativeMonitoring,
startNativeCapture,
stopNativeCapture,
stopNativeMonitoring,
type NativeConnectedDevice,
type NativeDiscoveredDevice,
type NativePhase,
} from '../native/FightRateCaptureNative';
import {
calculateLiveMetrics,
isReliableHeartRateSample,
} from '../training/trainingMetrics';
import {
type CompletedTrainingSession,
type HeartRateSample,
} from '../training/trainingTypes';
export type BlePhase = NativePhase;
export type DiscoveredHeartRateDevice = NativeDiscoveredDevice;
export type HeartRateMonitorState = {
readonly phase: BlePhase;
readonly isCapturing: boolean;
readonly isMonitoring: boolean;
readonly connectedDevice: NativeConnectedDevice | null;
readonly discoveredDevices: readonly DiscoveredHeartRateDevice[];
readonly samples: readonly HeartRateSample[];
readonly sessions: readonly CompletedTrainingSession[];
readonly services: readonly string[];
readonly characteristics: readonly string[];
readonly currentBpm: number | null;
readonly averageBpm: number | null;
readonly peakBpm: number | null;
readonly minBpm: number | null;
readonly sampleRatePerMinute: number | null;
readonly loadScore: number;
readonly highIntensityPercent: number;
readonly startedAt: number | null;
readonly statusMessage: string | null;
readonly errorMessage: string | null;
readonly batteryOptimizationIgnored: boolean;
readonly startCapture: () => Promise<void>;
readonly stopCapture: () => Promise<void>;
readonly startMonitoring: () => Promise<void>;
readonly stopMonitoring: () => Promise<void>;
readonly getSessionSamples: (
sessionId: string,
) => Promise<readonly HeartRateSample[]>;
readonly getContinuousSamples: (
limit?: number,
) => Promise<readonly HeartRateSample[]>;
readonly connectToDevice: (deviceId: string) => Promise<void>;
readonly openBatterySettings: () => Promise<void>;
readonly clearError: () => void;
};
export function useHeartRateMonitor(): HeartRateMonitorState {
const [phase, setPhase] = useState<BlePhase>('idle');
const [isCapturing, setIsCapturing] = useState(false);
const [isMonitoring, setIsMonitoring] = useState(false);
const [connectedDevice, setConnectedDevice] =
useState<NativeConnectedDevice | null>(null);
const [discoveredDevices, setDiscoveredDevices] = useState<
DiscoveredHeartRateDevice[]
>([]);
const [samples, setSamples] = useState<HeartRateSample[]>([]);
const [sessions, setSessions] = useState<CompletedTrainingSession[]>([]);
const [services, setServices] = useState<string[]>([]);
const [characteristics, setCharacteristics] = useState<string[]>([]);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [batteryOptimizationIgnored, setBatteryOptimizationIgnored] =
useState(false);
const [clockTick, setClockTick] = useState(Date.now());
const metricSamples = useMemo(
() =>
startedAt
? samples.filter(sample => sample.timestamp >= startedAt)
: samples,
[samples, startedAt],
);
const metrics = useMemo(
() =>
calculateLiveMetrics(metricSamples, {
now: clockTick,
staleAfterMs: 12_000,
}),
[clockTick, metricSamples],
);
const applySnapshot = useCallback(
(snapshot: Awaited<ReturnType<typeof getCaptureSnapshot>>) => {
setPhase(snapshot.phase);
setIsCapturing(snapshot.isCapturing);
setIsMonitoring(snapshot.isMonitoring);
setConnectedDevice(snapshot.connectedDevice);
setDiscoveredDevices([...snapshot.discoveredDevices]);
setSamples([...snapshot.samples]);
setSessions([...snapshot.sessions]);
setServices([...snapshot.services]);
setCharacteristics([...snapshot.characteristics]);
setStartedAt(snapshot.startedAt);
setStatusMessage(snapshot.statusMessage);
setBatteryOptimizationIgnored(snapshot.batteryOptimizationIgnored);
setErrorMessage(
snapshot.phase === 'error' ? snapshot.statusMessage : null,
);
},
[],
);
const refreshSnapshot = useCallback(async () => {
const snapshot = await getCaptureSnapshot();
applySnapshot(snapshot);
}, [applySnapshot]);
useEffect(() => {
if (!isMonitoring) {
return undefined;
}
const interval = setInterval(() => setClockTick(Date.now()), 2_000);
return () => clearInterval(interval);
}, [isMonitoring]);
useEffect(() => {
refreshSnapshot().catch(error => setErrorMessage(toErrorMessage(error)));
if (!fightRateCaptureEvents) {
setPhase('error');
setErrorMessage('Módulo nativo FightRate indisponível neste build.');
return;
}
const sampleSubscription = fightRateCaptureEvents.addListener(
'FightRateSample',
event => {
const sample = normalizeSample(event);
if (!isReliableHeartRateSample(sample)) {
setPhase('connected');
setStatusMessage(
'Sensor conectado, mas sem batimento válido. Aguardando sinal real.',
);
return;
}
setClockTick(Date.now());
setSamples(current => [...current.slice(-1799), sample]);
},
);
const statusSubscription = fightRateCaptureEvents.addListener(
'FightRateStatus',
event => {
const status = normalizeStatusEvent(event);
setPhase(status.phase);
setStatusMessage(status.statusMessage);
setConnectedDevice(status.connectedDevice);
setIsCapturing(typeof status.sessionId === 'number');
setIsMonitoring(status.phase !== 'idle');
setStartedAt(current =>
typeof status.sessionId === 'number' ? current ?? Date.now() : null,
);
setErrorMessage(status.phase === 'error' ? status.statusMessage : null);
},
);
const devicesSubscription = fightRateCaptureEvents.addListener(
'FightRateDevices',
event => {
setDiscoveredDevices([...normalizeDevicesEvent(event).devices]);
},
);
const metadataSubscription = fightRateCaptureEvents.addListener(
'FightRateMetadata',
event => {
const metadata = normalizeMetadataEvent(event);
setServices([...metadata.services]);
setCharacteristics([...metadata.characteristics]);
},
);
return () => {
sampleSubscription.remove();
statusSubscription.remove();
devicesSubscription.remove();
metadataSubscription.remove();
};
}, [refreshSnapshot]);
const startMonitoring = useCallback(async () => {
setPhase('requesting-permission');
setErrorMessage(null);
const hasPermission = await requestCapturePermissions();
if (!hasPermission) {
setPhase('permission-denied');
setErrorMessage(
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
);
return;
}
const snapshot = await startNativeMonitoring(connectedDevice);
applySnapshot(snapshot);
}, [applySnapshot, connectedDevice]);
const startCapture = useCallback(async () => {
setPhase('requesting-permission');
setErrorMessage(null);
const hasPermission = await requestCapturePermissions();
if (!hasPermission) {
setPhase('permission-denied');
setErrorMessage(
'Permissão negada. Autorize Bluetooth, Localização e Notificações para manter captura em segundo plano.',
);
return;
}
const snapshot = await startNativeCapture(connectedDevice);
applySnapshot({
...snapshot,
isMonitoring: true,
isCapturing: true,
phase: snapshot.phase === 'idle' ? 'scanning' : snapshot.phase,
});
}, [applySnapshot, connectedDevice]);
const stopCapture = useCallback(async () => {
const snapshot = await stopNativeCapture();
applySnapshot(snapshot);
await refreshSnapshot();
}, [applySnapshot, refreshSnapshot]);
const stopMonitoring = useCallback(async () => {
const snapshot = await stopNativeMonitoring();
applySnapshot(snapshot);
}, [applySnapshot]);
const getSessionSamples = useCallback(async (sessionId: string) => {
return getNativeSessionSamples(sessionId);
}, []);
const getContinuousSamples = useCallback(async (limit?: number) => {
return getNativeContinuousSamples(limit);
}, []);
const connectToDevice = useCallback(
async (deviceId: string) => {
const device = discoveredDevices.find(
candidate => candidate.id === deviceId,
);
if (!device) {
setErrorMessage(
'Sensor não está mais na lista de dispositivos encontrados.',
);
return;
}
const snapshot = await connectNativeDevice(device);
applySnapshot({
...snapshot,
isMonitoring: true,
isCapturing: snapshot.isCapturing,
phase: snapshot.phase === 'idle' ? 'connecting' : snapshot.phase,
});
},
[applySnapshot, discoveredDevices],
);
const openBatterySettings = useCallback(async () => {
await openBatteryOptimizationSettings();
await refreshSnapshot();
}, [refreshSnapshot]);
return {
phase,
isCapturing,
isMonitoring,
connectedDevice,
discoveredDevices,
samples,
sessions,
services,
characteristics,
currentBpm: metrics.currentBpm,
averageBpm: metrics.averageBpm,
peakBpm: metrics.peakBpm,
minBpm: metrics.minBpm,
sampleRatePerMinute: metrics.sampleRatePerMinute,
loadScore: metrics.loadScore,
highIntensityPercent: metrics.highIntensityPercent,
startedAt,
statusMessage,
errorMessage,
batteryOptimizationIgnored,
startCapture,
stopCapture,
startMonitoring,
stopMonitoring,
getSessionSamples,
getContinuousSamples,
connectToDevice,
openBatterySettings,
clearError: () => setErrorMessage(null),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Erro inesperado na captura BLE.';
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect } from 'react';
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { colors, motion, radius, spacing, typography } from '../theme';
type ActionButtonVariant = 'primary' | 'secondary' | 'stop' | 'stopAlert';
type Props = {
readonly label: string;
readonly onPress?: () => void;
readonly variant?: ActionButtonVariant;
readonly fullWidth?: boolean;
readonly showIcon?: boolean;
};
export function ActionButton({
label,
onPress,
variant = 'primary',
fullWidth = false,
showIcon = true,
}: Props) {
return (
<View style={[styles.shell, fullWidth ? styles.fullWidth : null]}>
{variant === 'stopAlert' ? <AlertRing /> : null}
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.base,
styles[variant],
fullWidth ? styles.fullWidth : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
{variant === 'primary' && showIcon ? (
<View style={styles.iconBubble}>
<Text style={styles.iconText}>›</Text>
</View>
) : null}
</Pressable>
</View>
);
}
function AlertRing() {
const scale = useSharedValue(1);
const opacity = useSharedValue(0.55);
useEffect(() => {
scale.value = withRepeat(
withTiming(1.06, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
-1,
true,
);
opacity.value = withRepeat(
withTiming(0.2, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
-1,
true,
);
}, [scale, opacity]);
const ringStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return <Animated.View style={[styles.alertRing, ringStyle]} pointerEvents="none" />;
}
const styles = StyleSheet.create({
shell: {
position: 'relative',
alignSelf: 'flex-start',
},
fullWidth: {
alignSelf: 'stretch',
},
base: {
minHeight: 48,
borderRadius: radius.xl,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing.sm,
},
primary: {
backgroundColor: colors.action,
},
secondary: {
backgroundColor: 'transparent',
borderColor: colors.line,
borderWidth: 1,
},
stop: {
backgroundColor: colors.stopSurface,
},
stopAlert: {
backgroundColor: colors.action,
},
pressed: {
transform: [{ scale: 0.96 }],
opacity: 0.82,
},
label: {
...typography.button,
},
primaryLabel: {
color: colors.text,
},
secondaryLabel: {
color: colors.text,
},
stopLabel: {
color: colors.stopText,
},
stopAlertLabel: {
color: colors.text,
},
iconBubble: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.16)',
},
iconText: {
color: colors.text,
fontWeight: '900',
fontSize: 20,
lineHeight: 22,
},
alertRing: {
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
borderRadius: radius.xl + 4,
borderWidth: 2,
borderColor: colors.ringWarn,
} as ViewStyle,
});
+3 -4
View File
@@ -44,7 +44,7 @@ export function InsightRow({
const indexLabel = String(index).padStart(2, '0');
const opacity = useSharedValue(0);
const translateY = useSharedValue(8);
const color = toneColor(tone);
const indexColor = toneColor(tone);
useEffect(() => {
const delay = index < 8 ? index * motion.stagger.row : 0;
@@ -63,7 +63,6 @@ export function InsightRow({
transform: [{ translateY: translateY.value }],
}));
const indexStyle: ViewStyle = { color };
return (
<Animated.View
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
@@ -74,13 +73,13 @@ export function InsightRow({
<View style={styles.body}>
<View style={styles.topLine}>
<Text style={styles.label}>{label}</Text>
<Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
<Text style={[styles.status, { color: indexColor }]}>{statusLabel}</Text>
</View>
<Text style={styles.summary}>{summary}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
<View style={styles.valueBox}>
<Text style={[styles.value, indexStyle]}>{value}</Text>
<Text style={[styles.value, { color: indexColor }]}>{value}</Text>
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
</View>
</Animated.View>
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly value: string;
readonly label: string;
};
export function MetricTile({ value, label }: Props) {
return (
<View style={styles.tile}>
<Text style={styles.value}>{value}</Text>
<Text style={styles.label}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
tile: {
flex: 1,
minHeight: 74,
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.md,
backgroundColor: colors.surface,
padding: spacing.sm,
justifyContent: 'center',
},
value: {
color: colors.text,
fontSize: 24,
lineHeight: 28,
fontWeight: '900',
letterSpacing: -1,
},
label: {
...typography.label,
color: colors.textMuted,
marginTop: spacing.xxs,
},
});
+45
View File
@@ -0,0 +1,45 @@
import React, { type ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly title: string;
readonly eyebrow?: string;
readonly children: ReactNode;
};
export function SectionPanel({ title, eyebrow, children }: Props) {
return (
<View style={styles.panel}>
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
<Text style={styles.title}>{title}</Text>
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
panel: {
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.lg,
backgroundColor: colors.surfaceRaised,
padding: spacing.md,
},
eyebrow: {
...typography.label,
color: colors.warning,
marginBottom: spacing.xs,
},
title: {
color: colors.text,
fontSize: 24,
lineHeight: 27,
fontWeight: '900',
letterSpacing: -1.2,
},
content: {
marginTop: spacing.md,
gap: spacing.sm,
},
});
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly label: string;
readonly tone?: 'amber' | 'green' | 'red';
};
export function StatusTag({ label, tone = 'amber' }: Props) {
return (
<View style={[styles.base, styles[tone]]}>
<Text style={[styles.text, styles[`${tone}Text`]]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
base: {
alignSelf: 'flex-start',
borderRadius: radius.xl,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderWidth: 1,
},
amber: {
backgroundColor: colors.surfaceHot,
borderColor: colors.line,
},
green: {
backgroundColor: 'rgba(52,199,123,0.12)',
borderColor: 'rgba(52,199,123,0.34)',
},
red: {
backgroundColor: 'rgba(226,59,46,0.14)',
borderColor: 'rgba(226,59,46,0.42)',
},
text: {
...typography.label,
},
amberText: {
color: colors.warning,
},
greenText: {
color: colors.success,
},
redText: {
color: colors.action,
},
});
+119
View File
@@ -0,0 +1,119 @@
export type HeartRatePoint = {
readonly second: number;
readonly bpm: number;
};
export type TrainingSession = {
readonly id: string;
readonly name: string;
readonly dateLabel: string;
readonly durationLabel: string;
readonly avgBpm: number;
readonly peakBpm: number;
readonly loadScore: number;
readonly zoneLoadPercent: number;
};
export type SensorCharacteristic = {
readonly uuid: string;
readonly label: string;
readonly property: 'notify' | 'read' | 'write';
readonly value: string;
};
export type Insight = {
readonly title: string;
readonly summary: string;
readonly why: string;
readonly action: string;
};
export const activeTrainingPoints: readonly HeartRatePoint[] = [
{ second: 0, bpm: 108 },
{ second: 90, bpm: 126 },
{ second: 210, bpm: 148 },
{ second: 360, bpm: 139 },
{ second: 520, bpm: 166 },
{ second: 680, bpm: 181 },
{ second: 840, bpm: 154 },
{ second: 980, bpm: 172 },
{ second: 1200, bpm: 149 },
{ second: 1500, bpm: 160 },
{ second: 1900, bpm: 142 },
{ second: 2538, bpm: 154 },
];
export const sessions: readonly TrainingSession[] = [
{
id: 'sparring-today',
name: 'Sparring técnico',
dateLabel: 'Hoje',
durationLabel: '47 min',
avgBpm: 146,
peakBpm: 183,
loadScore: 96,
zoneLoadPercent: 41,
},
{
id: 'muay-thai-monday',
name: 'Muay Thai',
dateLabel: 'Seg',
durationLabel: '61 min',
avgBpm: 151,
peakBpm: 187,
loadScore: 110,
zoneLoadPercent: 46,
},
{
id: 'boxing-friday',
name: 'Boxe ritmo',
dateLabel: 'Sex',
durationLabel: '42 min',
avgBpm: 137,
peakBpm: 172,
loadScore: 77,
zoneLoadPercent: 29,
},
];
export const sensorCharacteristics: readonly SensorCharacteristic[] = [
{
uuid: '180D',
label: 'Heart Rate Service',
property: 'notify',
value: 'ativo',
},
{
uuid: '2A37',
label: 'Heart Rate Measurement',
property: 'notify',
value: '154 bpm',
},
{
uuid: '180F',
label: 'Battery Service',
property: 'read',
value: '84%',
},
{
uuid: '2A38',
label: 'Body Sensor Location',
property: 'read',
value: 'chest',
},
];
export const insights: readonly Insight[] = [
{
title: 'Carga alta controlada',
summary: 'Você sustentou intensidade de luta sem queda de sinal.',
why: 'A janela teve 41% do tempo em Z4/Z5, acima da média recente.',
action: 'Amanhã prefira treino técnico leve ou recuperação ativa.',
},
{
title: 'Pico útil, não aleatório',
summary: 'O pico de 183 bpm aconteceu dentro do bloco mais intenso.',
why: 'Picos contextualizados ajudam a separar esforço real de ruído.',
action: 'Compare esse bloco com próximos sparrings.',
},
];
+6 -2
View File
@@ -17,11 +17,15 @@ export function useMotionPreference(): boolean {
const listener: MotionPreferenceListener = (value) => {
if (mounted) setReduceMotion(value);
};
AccessibilityInfo.addEventListener('reduceMotionChanged', listener);
const sub = AccessibilityInfo.addEventListener
? AccessibilityInfo.addEventListener('reduceMotionChanged', listener)
: undefined;
return () => {
mounted = false;
AccessibilityInfo.removeEventListener('reduceMotionChanged', listener);
if (sub && typeof (sub as { remove?: () => void }).remove === 'function') {
(sub as { remove: () => void }).remove();
}
};
}, []);
+2 -7
View File
@@ -8,12 +8,7 @@ import {
withTiming,
} from 'react-native-reanimated';
type AnimatedStyle = {
transform: Array<{ scale: number }>;
opacity: number;
};
export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle {
export function usePulseAnimation(active: boolean, durationMs = 1600) {
const scale = useSharedValue(1);
const opacity = useSharedValue(active ? 1 : 0.5);
@@ -37,7 +32,7 @@ export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedS
}
}, [active, durationMs, scale, opacity]);
return useAnimatedStyle<AnimatedStyle>(() => ({
return useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
+402
View File
@@ -0,0 +1,402 @@
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
} from 'react-native';
import { normalizeRssi } from '../ble/rssi';
import {
type CompletedTrainingSession,
type HeartRateSample,
} from '../training/trainingTypes';
export type NativePhase =
| 'idle'
| 'requesting-permission'
| 'bluetooth-off'
| 'scanning'
| 'connecting'
| 'connected'
| 'receiving'
| 'permission-denied'
| 'sensor-not-found'
| 'error';
export type NativeDiscoveredDevice = {
readonly id: string;
readonly name: string;
readonly rssi: number | null;
readonly serviceUuids?: readonly string[];
};
export type NativeConnectedDevice = NativeDiscoveredDevice & {
readonly batteryPercent?: number | null;
readonly bodySensorLocation?: string | null;
};
export type CaptureSnapshot = {
readonly phase: NativePhase;
readonly isMonitoring: boolean;
readonly isCapturing: boolean;
readonly startedAt: number | null;
readonly statusMessage: string | null;
readonly connectedDevice: NativeConnectedDevice | null;
readonly discoveredDevices: readonly NativeDiscoveredDevice[];
readonly samples: readonly HeartRateSample[];
readonly sessions: readonly CompletedTrainingSession[];
readonly services: readonly string[];
readonly characteristics: readonly string[];
readonly batteryOptimizationIgnored: boolean;
};
export type NativeStatusEvent = {
readonly phase: NativePhase;
readonly statusMessage: string | null;
readonly sessionId: number | null;
readonly connectedDevice: NativeConnectedDevice | null;
};
export type NativeDevicesEvent = {
readonly devices: readonly NativeDiscoveredDevice[];
};
export type NativeMetadataEvent = {
readonly services: readonly string[];
readonly characteristics: readonly string[];
};
type FightRateCaptureModule = {
readonly startMonitoring: (
deviceId: string | null,
deviceName: string | null,
) => Promise<CaptureSnapshot>;
readonly startCapture: (
deviceId: string | null,
deviceName: string | null,
) => Promise<CaptureSnapshot>;
readonly startWorkout?: (
deviceId: string | null,
deviceName: string | null,
) => Promise<CaptureSnapshot>;
readonly connectToDevice: (
deviceId: string,
deviceName: string | null,
) => Promise<CaptureSnapshot>;
readonly stopCapture: () => Promise<CaptureSnapshot>;
readonly stopWorkout?: () => Promise<CaptureSnapshot>;
readonly stopMonitoring?: () => Promise<CaptureSnapshot>;
readonly getSessionSamples: (sessionId: string) => Promise<HeartRateSample[]>;
readonly getContinuousSamples: (limit: number) => Promise<HeartRateSample[]>;
readonly getSnapshot: () => Promise<CaptureSnapshot>;
readonly isIgnoringBatteryOptimizations: () => Promise<boolean>;
readonly openBatteryOptimizationSettings: () => Promise<boolean>;
readonly addListener: (eventName: string) => void;
readonly removeListeners: (count: number) => void;
};
const nativeModule = NativeModules.FightRateCapture as
| FightRateCaptureModule
| undefined;
export const fightRateCaptureEvents = nativeModule
? new NativeEventEmitter(nativeModule)
: null;
export async function getCaptureSnapshot(): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const snapshot = await nativeModule.getSnapshot();
return normalizeSnapshot(snapshot);
}
export async function startNativeMonitoring(
device?: NativeDiscoveredDevice | null,
): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const snapshot = await nativeModule.startMonitoring(
device?.id ?? null,
device?.name ?? null,
);
return normalizeSnapshot(snapshot);
}
export async function startNativeCapture(
device?: NativeDiscoveredDevice | null,
): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const starter = nativeModule.startWorkout ?? nativeModule.startCapture;
const snapshot = await starter(device?.id ?? null, device?.name ?? null);
return normalizeSnapshot(snapshot);
}
export async function connectNativeDevice(
device: NativeDiscoveredDevice,
): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const snapshot = await nativeModule.connectToDevice(device.id, device.name);
return normalizeSnapshot(snapshot);
}
export async function stopNativeCapture(): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const stopper = nativeModule.stopWorkout ?? nativeModule.stopCapture;
const snapshot = await stopper();
return normalizeSnapshot(snapshot);
}
export async function stopNativeMonitoring(): Promise<CaptureSnapshot> {
if (!nativeModule) {
return emptySnapshot('Módulo nativo FightRate indisponível.');
}
const snapshot = nativeModule.stopMonitoring
? await nativeModule.stopMonitoring()
: await nativeModule.stopCapture();
return normalizeSnapshot(snapshot);
}
export async function getNativeSessionSamples(
sessionId: string,
): Promise<HeartRateSample[]> {
if (!nativeModule) {
return [];
}
const samples = await nativeModule.getSessionSamples(sessionId);
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
}
export async function getNativeContinuousSamples(
limit = 0,
): Promise<HeartRateSample[]> {
if (!nativeModule) {
return [];
}
const samples = await nativeModule.getContinuousSamples(limit);
return Array.isArray(samples) ? samples.map(normalizeSample) : [];
}
export async function openBatteryOptimizationSettings() {
if (!nativeModule) {
return false;
}
return nativeModule.openBatteryOptimizationSettings();
}
export async function requestCapturePermissions() {
if (Platform.OS !== 'android') {
return true;
}
const apiLevel = Number(Platform.Version);
const permissions = [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
if (apiLevel >= 31) {
permissions.push(
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
);
}
if (apiLevel >= 33) {
permissions.push(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
}
const result = await PermissionsAndroid.requestMultiple(permissions);
return permissions.every(
permission => result[permission] === PermissionsAndroid.RESULTS.GRANTED,
);
}
export function normalizeSample(
sample: Partial<HeartRateSample>,
): HeartRateSample {
return {
id: typeof sample.id === 'number' ? sample.id : undefined,
sessionId:
typeof sample.sessionId === 'number' && sample.sessionId >= 0
? sample.sessionId
: undefined,
bpm: Number(sample.bpm ?? 0),
timestamp: Number(sample.timestamp ?? Date.now()),
contactDetected:
typeof sample.contactDetected === 'boolean'
? sample.contactDetected
: null,
rrIntervalsMs: Array.isArray(sample.rrIntervalsMs)
? sample.rrIntervalsMs.map(Number)
: [],
deviceName: sample.deviceName ?? null,
};
}
export function normalizeStatusEvent(
event: Partial<NativeStatusEvent>,
): NativeStatusEvent {
return {
phase: normalizePhase(event.phase),
statusMessage: event.statusMessage ?? null,
sessionId:
typeof event.sessionId === 'number' && event.sessionId >= 0
? event.sessionId
: null,
connectedDevice: normalizeConnectedDevice(event.connectedDevice),
};
}
export function normalizeDevicesEvent(
event: Partial<NativeDevicesEvent>,
): NativeDevicesEvent {
return {
devices: Array.isArray(event.devices)
? event.devices.map(normalizeDevice)
: [],
};
}
export function normalizeMetadataEvent(
event: Partial<NativeMetadataEvent>,
): NativeMetadataEvent {
return {
services: Array.isArray(event.services) ? event.services.map(String) : [],
characteristics: Array.isArray(event.characteristics)
? event.characteristics.map(String)
: [],
};
}
function normalizeSnapshot(
snapshot: Partial<CaptureSnapshot>,
): CaptureSnapshot {
return {
phase: normalizePhase(snapshot.phase),
isMonitoring: Boolean(
snapshot.isMonitoring ??
(snapshot.phase ? snapshot.phase !== 'idle' : false),
),
isCapturing: Boolean(snapshot.isCapturing),
startedAt:
typeof snapshot.startedAt === 'number' && snapshot.startedAt > 0
? snapshot.startedAt
: null,
statusMessage: snapshot.statusMessage ?? null,
connectedDevice: normalizeConnectedDevice(snapshot.connectedDevice),
discoveredDevices: Array.isArray(snapshot.discoveredDevices)
? snapshot.discoveredDevices.map(normalizeDevice)
: [],
samples: Array.isArray(snapshot.samples)
? snapshot.samples.map(normalizeSample)
: [],
sessions: Array.isArray(snapshot.sessions)
? snapshot.sessions.map(normalizeSession)
: [],
services: Array.isArray(snapshot.services)
? snapshot.services.map(String)
: [],
characteristics: Array.isArray(snapshot.characteristics)
? snapshot.characteristics.map(String)
: [],
batteryOptimizationIgnored: Boolean(snapshot.batteryOptimizationIgnored),
};
}
function normalizeSession(
session: Partial<CompletedTrainingSession>,
): CompletedTrainingSession {
return {
id: String(session.id ?? `session-${session.startedAt ?? Date.now()}`),
startedAt: Number(session.startedAt ?? 0),
endedAt: Number(session.endedAt ?? session.startedAt ?? 0),
durationMs: Number(session.durationMs ?? 0),
sampleCount: Number(session.sampleCount ?? 0),
averageBpm: Number(session.averageBpm ?? 0),
peakBpm: Number(session.peakBpm ?? 0),
minBpm: Number(session.minBpm ?? 0),
deviceName: session.deviceName ?? null,
};
}
function normalizeConnectedDevice(
device: NativeConnectedDevice | null | undefined,
): NativeConnectedDevice | null {
const hasIdentity = Boolean(device?.id || device?.name);
if (!device || !hasIdentity) {
return null;
}
return {
...normalizeDevice(device),
batteryPercent:
typeof device.batteryPercent === 'number' && device.batteryPercent >= 0
? device.batteryPercent
: null,
bodySensorLocation: device.bodySensorLocation ?? null,
};
}
function normalizeDevice(
device: Partial<NativeDiscoveredDevice>,
): NativeDiscoveredDevice {
return {
id: String(device.id ?? ''),
name: String(device.name ?? 'Sensor BLE sem nome'),
rssi: normalizeRssi(device.rssi),
serviceUuids: Array.isArray(device.serviceUuids)
? device.serviceUuids.map(String)
: [],
};
}
function normalizePhase(phase: unknown): NativePhase {
if (nativePhases.includes(phase as NativePhase)) {
return phase as NativePhase;
}
return 'idle';
}
const nativePhases: readonly NativePhase[] = [
'idle',
'requesting-permission',
'bluetooth-off',
'scanning',
'connecting',
'connected',
'receiving',
'permission-denied',
'sensor-not-found',
'error',
];
function emptySnapshot(statusMessage: string): CaptureSnapshot {
return {
phase: 'error',
isMonitoring: false,
isCapturing: false,
startedAt: null,
statusMessage,
connectedDevice: null,
discoveredDevices: [],
samples: [],
sessions: [],
services: [],
characteristics: [],
batteryOptimizationIgnored: false,
};
}
+451
View File
@@ -0,0 +1,451 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
Activity,
Battery,
RadioTower,
ShieldCheck,
Timer,
} from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { ActionButton } from '../components/ActionButton';
import { TrainingChart } from '../components/TrainingChart';
import { colors, radius, spacing, typography } from '../theme';
import {
calculateLiveMetrics,
formatDuration,
} from '../training/trainingMetrics';
const BPM_EMPTY = '--';
type Props = {
readonly monitor: HeartRateMonitorState;
readonly onCaptureAction: () => Promise<void>;
readonly onMonitorAction: () => Promise<void>;
};
export function CaptureScreen({
monitor,
onCaptureAction,
onMonitorAction,
}: Props) {
const now = useTicker(monitor.isMonitoring || monitor.isCapturing);
const status = getStatusCopy(monitor);
const windowSamples = useMemo(
() =>
monitor.startedAt
? monitor.samples.filter(
sample => sample.timestamp >= monitor.startedAt!,
)
: monitor.samples,
[monitor.samples, monitor.startedAt],
);
const windowMetrics = calculateLiveMetrics(windowSamples);
const firstSampleAt = monitor.samples[0]?.timestamp ?? null;
const monitorElapsedMs = firstSampleAt ? now - firstSampleAt : 0;
const workoutElapsedMs = monitor.startedAt ? now - monitor.startedAt : 0;
const actionLabel = monitor.isCapturing
? 'Fechar janela de treino'
: 'Iniciar janela de treino';
return (
<View style={styles.screen}>
<View style={styles.topRail}>
<View style={styles.stateBlock}>
<Text style={styles.eyebrow}>{status.label}</Text>
<Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
</View>
<View style={styles.timeStack}>
<Timer color={colors.warning} size={16} strokeWidth={2.4} />
<Text style={styles.timerText}>
{monitor.isCapturing
? formatDuration(workoutElapsedMs)
: formatDuration(monitorElapsedMs)}
</Text>
<Text style={styles.timerLabel}>
{monitor.isCapturing ? 'janela' : 'monitor'}
</Text>
</View>
</View>
<View style={styles.bpmRow}>
<Text style={styles.bpmValue}>{monitor.currentBpm ?? BPM_EMPTY}</Text>
<View style={styles.bpmSide}>
<Text style={styles.bpmUnit}>BPM</Text>
<Text style={styles.statusLine}>
{monitor.errorMessage ?? status.detail}
</Text>
</View>
</View>
<View style={styles.captureRail}>
<RailMetric
label="MÉDIA"
value={windowMetrics.averageBpm ?? BPM_EMPTY}
/>
<RailMetric
label="PICO"
value={windowMetrics.peakBpm ?? BPM_EMPTY}
tone="hot"
/>
<RailMetric
label="AMOSTRAS"
value={windowSamples.length || BPM_EMPTY}
/>
</View>
<TrainingChart samples={windowSamples} />
<View style={styles.systemStrip}>
<SystemSignal
icon="radio"
label="BLE"
value={getBleShortState(monitor.phase, monitor.isMonitoring)}
tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
/>
<SystemSignal
icon="battery"
label="Bateria"
value={getBatteryLabel(monitor)}
/>
<SystemSignal
icon="shield"
label="Fundo"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
/>
</View>
{!monitor.batteryOptimizationIgnored ? (
<Pressable
onPress={() => {
monitor.openBatterySettings().catch(() => undefined);
}}
style={styles.batteryPrompt}
>
<Text style={styles.batteryPromptTitle}>
Manter com tela bloqueada
</Text>
<Text style={styles.batteryPromptText}>
Toque para abrir a configuração de bateria e remover restrições do
FightRate.
</Text>
</Pressable>
) : null}
<View style={styles.actionGrid}>
{!monitor.isMonitoring ? (
<ActionButton
fullWidth
label="Ativar monitor contínuo"
onPress={() => {
onMonitorAction().catch(() => undefined);
}}
/>
) : null}
<ActionButton
fullWidth
label={actionLabel}
onPress={() => {
onCaptureAction().catch(() => undefined);
}}
variant={monitor.isCapturing ? 'stop' : 'primary'}
/>
</View>
<View style={styles.truthLine}>
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
<Text style={styles.truthText}>
Enquanto a cinta estiver reconhecida, todo BPM real é salvo. O botão
de treino só marca a janela para análise.
</Text>
</View>
</View>
);
}
function RailMetric({
label,
value,
tone = 'normal',
}: {
readonly label: string;
readonly value: string | number;
readonly tone?: 'normal' | 'hot';
}) {
return (
<View style={styles.railMetric}>
<Text
style={[styles.railValue, tone === 'hot' ? styles.railValueHot : null]}
>
{value}
</Text>
<Text style={styles.railLabel}>{label}</Text>
</View>
);
}
function SystemSignal({
icon,
label,
value,
tone = 'amber',
}: {
readonly icon: 'radio' | 'battery' | 'shield';
readonly label: string;
readonly value: string;
readonly tone?: 'amber' | 'green';
}) {
const color = tone === 'green' ? colors.success : colors.warning;
const Icon =
icon === 'radio' ? RadioTower : icon === 'battery' ? Battery : ShieldCheck;
return (
<View style={styles.systemSignal}>
<Icon color={color} size={16} strokeWidth={2.3} />
<View>
<Text style={styles.signalLabel}>{label}</Text>
<Text style={styles.signalValue}>{value}</Text>
</View>
</View>
);
}
function getStatusCopy(monitor: HeartRateMonitorState) {
if (monitor.isCapturing) {
return {
label: 'JANELA DE TREINO',
detail: 'Treino destacado enquanto o monitor contínuo segue gravando.',
};
}
if (monitor.phase === 'receiving') {
return {
label: 'LEITURA ATIVA',
detail: 'BPM real chegando e sendo salvo continuamente em SQLite.',
};
}
if (monitor.phase === 'scanning') {
return {
label: 'PROCURANDO CINTA',
detail: 'O serviço continua procurando até encontrar a cinta.',
};
}
if (monitor.phase === 'connecting') {
return {
label: 'CONECTANDO 2A37',
detail:
'Abrindo Heart Rate Service 180D e armando notify da característica 2A37.',
};
}
if (monitor.phase === 'permission-denied') {
return {
label: 'PERMISSÃO NEGADA',
detail: 'Autorize Bluetooth, Localização e Notificações.',
};
}
if (monitor.phase === 'bluetooth-off') {
return {
label: 'BLUETOOTH DESLIGADO',
detail: 'Ligue o Bluetooth antes de ativar o monitor.',
};
}
if (monitor.phase === 'error') {
return {
label: 'ERRO DE CAPTURA',
detail: monitor.errorMessage ?? 'Falha no serviço BLE.',
};
}
return {
label: 'MONITOR DESATIVADO',
detail: 'Ative o monitor contínuo; depois marque a janela de treino.',
};
}
function formatDeviceName(monitor: HeartRateMonitorState) {
const name = monitor.connectedDevice?.name;
if (!name) return 'Magene H003 ou cinta BLE 180D';
if (/magene|h003/i.test(name)) return 'Magene H003 · cinta cardíaca';
if (/heart|hrm/i.test(name)) return `${name} · cinta cardíaca`;
return `${name} · BLE`;
}
function getBleShortState(
phase: HeartRateMonitorState['phase'],
isMonitoring: boolean,
) {
if (!isMonitoring) return 'parado';
if (phase === 'receiving') return 'lendo';
if (phase === 'scanning') return 'busca';
if (phase === 'connecting') return 'conectando';
if (phase === 'connected') return '2A37';
if (phase === 'error') return 'erro';
return 'ativo';
}
function getBatteryLabel(monitor: HeartRateMonitorState) {
const percent = monitor.connectedDevice?.batteryPercent;
if (typeof percent === 'number') return `${percent}%`;
return 'sem leitura';
}
function useTicker(active: boolean) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!active) {
setNow(Date.now());
return;
}
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [active]);
return now;
}
const styles = StyleSheet.create({
screen: {
gap: spacing.md,
},
topRail: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
paddingVertical: spacing.md,
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.md,
},
stateBlock: {
flex: 1,
},
eyebrow: {
...typography.label,
color: colors.action,
},
sensorName: {
color: colors.text,
fontSize: 18,
lineHeight: 23,
fontWeight: '900',
letterSpacing: -0.5,
marginTop: spacing.xs,
},
timeStack: {
alignItems: 'flex-end',
gap: spacing.xxs,
minWidth: 84,
},
timerText: {
...typography.label,
color: colors.text,
},
timerLabel: {
...typography.label,
color: colors.textDim,
fontSize: 9,
},
bpmRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.md,
},
bpmValue: {
...typography.display,
fontSize: 98,
lineHeight: 96,
color: colors.text,
},
bpmSide: {
flex: 1,
paddingBottom: spacing.sm,
gap: spacing.xs,
},
bpmUnit: {
...typography.label,
color: colors.textMuted,
},
statusLine: {
...typography.body,
color: colors.textMuted,
},
captureRail: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
flexDirection: 'row',
},
railMetric: {
flex: 1,
paddingVertical: spacing.sm,
paddingRight: spacing.sm,
},
railValue: {
color: colors.text,
fontSize: 28,
lineHeight: 30,
fontWeight: '900',
letterSpacing: -1.2,
},
railValueHot: {
color: colors.action,
},
railLabel: {
...typography.label,
color: colors.textMuted,
marginTop: spacing.xxs,
},
systemStrip: {
flexDirection: 'row',
gap: spacing.xs,
},
systemSignal: {
flex: 1,
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.sm,
flexDirection: 'row',
gap: spacing.xs,
alignItems: 'center',
},
signalLabel: {
...typography.label,
color: colors.textDim,
fontSize: 9,
},
signalValue: {
color: colors.text,
fontSize: 13,
lineHeight: 17,
fontWeight: '800',
},
batteryPrompt: {
borderWidth: 1,
borderColor: colors.warning,
borderRadius: radius.md,
backgroundColor: colors.surfaceHot,
padding: spacing.md,
},
batteryPromptTitle: {
...typography.label,
color: colors.warning,
},
batteryPromptText: {
...typography.body,
color: colors.textMuted,
marginTop: spacing.xs,
},
actionGrid: {
gap: spacing.sm,
},
truthLine: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
truthText: {
...typography.body,
color: colors.textMuted,
flex: 1,
},
});
+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,
},
});
+630
View File
@@ -0,0 +1,630 @@
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import {
Activity,
ChartNoAxesCombined,
Gauge,
ShieldCheck,
} from 'lucide-react-native';
import {
buildInsightDashboard,
formatDuration,
} from '../training/trainingMetrics';
import {
type CompletedTrainingSession,
type HeartRateSample,
type InsightMetricRow,
type TrainingInsight,
} from '../training/trainingTypes';
import { colors, spacing, typography } from '../theme';
const CONTINUOUS_SAMPLE_LIMIT = 2_000;
type Props = {
readonly liveSamples: readonly HeartRateSample[];
readonly sessions: readonly CompletedTrainingSession[];
readonly getContinuousSamples: (
limit?: number,
) => Promise<readonly HeartRateSample[]>;
};
export function InsightsScreen({
liveSamples,
sessions,
getContinuousSamples,
}: Props) {
const [continuousSamples, setContinuousSamples] = useState<
readonly HeartRateSample[]
>([]);
useEffect(() => {
let mounted = true;
getContinuousSamples(CONTINUOUS_SAMPLE_LIMIT)
.then(samples => {
if (mounted) {
setContinuousSamples(samples);
}
})
.catch(() => {
if (mounted) {
setContinuousSamples([]);
}
});
return () => {
mounted = false;
};
}, [getContinuousSamples, sessions.length]);
const analysisSamples = useMemo(
() => mergeSamples(continuousSamples, liveSamples),
[continuousSamples, liveSamples],
);
const dashboard = useMemo(
() =>
buildInsightDashboard({
liveSamples,
continuousSamples: analysisSamples,
sessions,
}),
[analysisSamples, liveSamples, sessions],
);
const latestSession = sessions[0] ?? null;
const loadRow = dashboard.rows.find(row => row.key === 'load') ?? null;
const hrvRow = dashboard.rows.find(row => row.key === 'hrv') ?? null;
if (dashboard.sampleCount === 0 && sessions.length === 0) {
return (
<View style={styles.screen}>
<View style={styles.emptyInstrument}>
<Text style={styles.eyebrow}>ANÁLISE BLOQUEADA</Text>
<Text style={styles.emptyTitle}>Sem dado real, sem conclusão</Text>
<Text style={styles.copy}>
Ative o monitor contínuo. O FightRate não inventa pico, zona, carga,
VFC ou recuperação.
</Text>
</View>
</View>
);
}
return (
<View style={styles.screen}>
<View style={styles.instrumentHeader}>
<View style={styles.headerRail} />
<View style={styles.headerCopy}>
<Text style={styles.eyebrow}>PAINEL DE CÁLCULO</Text>
<Text style={styles.headerTitle}>Matriz científica</Text>
<Text style={styles.copy}>
Leituras calculadas a partir de BPM timestampado real. VFC só é
liberada quando o sensor entrega RR intervals.
</Text>
</View>
<View style={styles.headerScore}>
<ChartNoAxesCombined
color={colors.warning}
size={20}
strokeWidth={2.4}
/>
<Text style={styles.scoreValue}>{loadRow?.value ?? '--'}</Text>
<Text style={styles.scoreLabel}>carga</Text>
</View>
</View>
<View style={styles.evidenceStrip}>
<EvidenceCell label="amostras" value={dashboard.sampleCount} />
<EvidenceCell
label="janela"
value={formatDuration(dashboard.durationMs)}
/>
<EvidenceCell label="range" value={formatTimeRange(dashboard)} />
</View>
<View style={styles.metricMatrix}>
<View style={styles.matrixHeader}>
<Text style={styles.sectionLabel}>CÁLCULOS</Text>
<Text style={styles.matrixNote}>valor · status · leitura</Text>
</View>
{dashboard.rows.map((row, index) => (
<InstrumentMetricRow key={row.key} index={index + 1} row={row} />
))}
</View>
<View style={styles.zonePanel}>
<View style={styles.matrixHeader}>
<Text style={styles.sectionLabel}>ZONAS OPERACIONAIS</Text>
<Text style={styles.matrixNote}>tempo real em cada trilho</Text>
</View>
{dashboard.zones.map(zone => (
<View key={zone.key} style={styles.zoneRow}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<View style={styles.zoneTrack}>
<View style={[styles.zoneFill, { width: `${zone.percent}%` }]} />
</View>
<Text style={styles.zoneValue}>
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
</Text>
</View>
))}
</View>
<View style={styles.protocolPanel}>
<View style={styles.protocolIcon}>
{hrvRow?.status === 'ok' ? (
<ShieldCheck color={colors.success} size={18} strokeWidth={2.4} />
) : (
<Gauge color={colors.warning} size={18} strokeWidth={2.4} />
)}
</View>
<View style={styles.protocolCopy}>
<Text style={styles.protocolTitle}>Protocolo de honestidade</Text>
<Text style={styles.copy}>
Métricas dependentes de perfil ou RR ficam marcadas como
necessárias. Isso impede conclusões falsas sobre FCmáx, Karvonen,
TRIMP completo ou VFC.
</Text>
</View>
</View>
<View style={styles.insightStack}>
<Text style={styles.sectionLabel}>LEITURAS OPERACIONAIS</Text>
{dashboard.narrative.length > 0 ? (
dashboard.narrative.map((insight, index) => (
<InsightNote
key={insight.title}
insight={insight}
index={index + 1}
/>
))
) : (
<View style={styles.waitingPanel}>
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
<Text style={styles.copy}>
Receba mais amostras reais para gerar leitura de tendência e ação.
</Text>
</View>
)}
</View>
{latestSession ? (
<View style={styles.lastSessionStrip}>
<Text style={styles.eyebrow}>ÚLTIMA JANELA SALVA</Text>
<Text style={styles.lastSessionText}>
{formatDuration(latestSession.durationMs)} · média{' '}
{latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '}
{latestSession.sampleCount} amostras
</Text>
</View>
) : null}
</View>
);
}
function EvidenceCell({
label,
value,
}: {
readonly label: string;
readonly value: string | number;
}) {
return (
<View style={styles.evidenceCell}>
<Text style={styles.evidenceValue}>{value}</Text>
<Text style={styles.evidenceLabel}>{label}</Text>
</View>
);
}
function InstrumentMetricRow({
row,
index,
}: {
readonly row: InsightMetricRow;
readonly index: number;
}) {
const toneColor = colorForTone(row.tone);
return (
<View style={styles.metricRow}>
<View style={[styles.metricIndexRail, { borderColor: toneColor }]}>
<Text style={styles.metricIndex}>
{index.toString().padStart(2, '0')}
</Text>
</View>
<View style={styles.metricMain}>
<View style={styles.metricTopLine}>
<Text style={styles.metricLabel}>{row.label}</Text>
<Text style={[styles.statusPill, { color: toneColor }]}>
{row.statusLabel}
</Text>
</View>
<Text style={styles.metricSummary}>{row.summary}</Text>
<Text style={styles.metricDetail}>{row.detail}</Text>
</View>
<View style={styles.metricValueBox}>
<Text style={[styles.metricValue, { color: toneColor }]}>
{row.value}
</Text>
{row.unit ? <Text style={styles.metricUnit}>{row.unit}</Text> : null}
</View>
</View>
);
}
function InsightNote({
insight,
index,
}: {
readonly insight: TrainingInsight;
readonly index: number;
}) {
return (
<View style={styles.insightNote}>
<Text style={styles.noteIndex}>{index.toString().padStart(2, '0')}</Text>
<View style={styles.noteBody}>
<Text style={styles.insightTitle}>{insight.title}</Text>
<Text style={styles.noteText}>{insight.why}</Text>
<Text style={styles.noteAction}>{insight.action}</Text>
<Text style={styles.confidence}>{insight.confidence}</Text>
</View>
</View>
);
}
function mergeSamples(
persistedSamples: readonly HeartRateSample[],
liveSamples: readonly HeartRateSample[],
) {
const byKey = new Map<string, HeartRateSample>();
[...persistedSamples, ...liveSamples].forEach(sample => {
byKey.set(`${sample.timestamp}:${sample.bpm}`, sample);
});
return [...byKey.values()].sort(
(left, right) => left.timestamp - right.timestamp,
);
}
function colorForTone(rowTone: InsightMetricRow['tone']) {
if (rowTone === 'good') {
return colors.success;
}
if (rowTone === 'danger') {
return colors.action;
}
if (rowTone === 'warning') {
return colors.warning;
}
return colors.text;
}
function formatTimeRange(dashboard: {
readonly startedAt: number | null;
readonly endedAt: number | null;
}) {
if (dashboard.startedAt === null || dashboard.endedAt === null) {
return '--';
}
const start = new Date(dashboard.startedAt).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
const end = new Date(dashboard.endedAt).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
return `${start}–${end}`;
}
const styles = StyleSheet.create({
screen: {
gap: spacing.lg,
},
emptyInstrument: {
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: 42,
lineHeight: 43,
fontWeight: '900',
letterSpacing: -2.5,
marginTop: spacing.sm,
marginBottom: spacing.md,
},
copy: {
...typography.body,
color: colors.textMuted,
},
instrumentHeader: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
paddingVertical: spacing.md,
flexDirection: 'row',
gap: spacing.md,
alignItems: 'stretch',
},
headerRail: {
width: 5,
backgroundColor: colors.action,
},
headerCopy: {
flex: 1,
gap: spacing.xs,
},
headerTitle: {
color: colors.text,
fontSize: 36,
lineHeight: 37,
fontWeight: '900',
letterSpacing: -2,
},
headerScore: {
minWidth: 86,
borderLeftWidth: 1,
borderLeftColor: colors.line,
paddingLeft: spacing.md,
alignItems: 'flex-end',
justifyContent: 'center',
},
scoreValue: {
color: colors.text,
fontSize: 34,
lineHeight: 36,
fontWeight: '900',
letterSpacing: -1.6,
marginTop: spacing.xs,
},
scoreLabel: {
...typography.label,
color: colors.textDim,
},
evidenceStrip: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
flexDirection: 'row',
},
evidenceCell: {
flex: 1,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.xs,
borderRightWidth: 1,
borderRightColor: colors.line,
},
evidenceValue: {
color: colors.text,
fontSize: 18,
lineHeight: 22,
fontWeight: '900',
letterSpacing: -0.8,
},
evidenceLabel: {
...typography.label,
color: colors.textDim,
marginTop: 2,
},
metricMatrix: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
},
matrixHeader: {
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.line,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
sectionLabel: {
...typography.label,
color: colors.warning,
},
matrixNote: {
...typography.label,
color: colors.textDim,
textAlign: 'right',
flexShrink: 1,
},
metricRow: {
minHeight: 92,
borderBottomWidth: 1,
borderBottomColor: colors.line,
flexDirection: 'row',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
metricIndexRail: {
width: 34,
borderLeftWidth: 3,
justifyContent: 'flex-start',
paddingLeft: spacing.xs,
},
metricIndex: {
color: colors.textDim,
fontSize: 12,
lineHeight: 16,
fontWeight: '900',
},
metricMain: {
flex: 1,
gap: spacing.xxs,
},
metricTopLine: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.sm,
},
metricLabel: {
...typography.label,
color: colors.text,
flexShrink: 1,
},
statusPill: {
...typography.label,
textAlign: 'right',
},
metricSummary: {
color: colors.text,
fontSize: 16,
lineHeight: 20,
fontWeight: '800',
letterSpacing: -0.45,
},
metricDetail: {
color: colors.textMuted,
fontSize: 12,
lineHeight: 16,
fontWeight: '600',
},
metricValueBox: {
width: 74,
alignItems: 'flex-end',
justifyContent: 'center',
},
metricValue: {
fontSize: 26,
lineHeight: 30,
fontWeight: '900',
letterSpacing: -1.2,
textAlign: 'right',
},
metricUnit: {
...typography.label,
color: colors.textDim,
textAlign: 'right',
},
zonePanel: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
},
zoneRow: {
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.line,
gap: spacing.xs,
},
zoneLabel: {
...typography.label,
color: colors.text,
},
zoneTrack: {
height: 9,
backgroundColor: colors.surfaceRaised,
overflow: 'hidden',
},
zoneFill: {
height: '100%',
backgroundColor: colors.action,
},
zoneValue: {
...typography.body,
color: colors.textMuted,
},
protocolPanel: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
paddingVertical: spacing.md,
flexDirection: 'row',
gap: spacing.md,
},
protocolIcon: {
width: 34,
alignItems: 'center',
paddingTop: 2,
},
protocolCopy: {
flex: 1,
gap: spacing.xs,
},
protocolTitle: {
color: colors.text,
fontSize: 18,
lineHeight: 22,
fontWeight: '900',
letterSpacing: -0.7,
},
insightStack: {
gap: spacing.md,
},
insightNote: {
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.md,
flexDirection: 'row',
gap: spacing.md,
},
noteIndex: {
color: colors.action,
fontSize: 18,
lineHeight: 24,
fontWeight: '900',
letterSpacing: -0.5,
width: 34,
},
noteBody: {
flex: 1,
gap: spacing.xs,
},
insightTitle: {
color: colors.text,
fontSize: 22,
lineHeight: 25,
fontWeight: '900',
letterSpacing: -1,
},
noteText: {
...typography.body,
color: colors.textMuted,
},
noteAction: {
color: colors.text,
fontSize: 14,
lineHeight: 19,
fontWeight: '800',
},
confidence: {
...typography.label,
color: colors.textDim,
marginTop: spacing.xs,
},
waitingPanel: {
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.md,
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
lastSessionStrip: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.line,
paddingVertical: spacing.md,
},
lastSessionText: {
color: colors.text,
fontSize: 17,
lineHeight: 23,
fontWeight: '800',
marginTop: spacing.xs,
},
});
+423
View File
@@ -0,0 +1,423 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
Activity,
Battery,
Bluetooth,
RadioReceiver,
ShieldCheck,
Signal,
} from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { formatRssi } from '../ble/rssi';
import { ActionButton } from '../components/ActionButton';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly monitor: HeartRateMonitorState;
readonly onMonitorAction: () => Promise<void>;
};
export function SensorScreen({ monitor, onMonitorAction }: Props) {
const device = monitor.connectedDevice;
const services = monitor.services.length
? monitor.services
: ['Heart Rate Service · 0x180D · aguardando conexão'];
const characteristics = monitor.characteristics.length
? monitor.characteristics
: ['Heart Rate Measurement · 0x2A37 · notify esperado'];
return (
<View style={styles.screen}>
<View style={styles.sensorConsole}>
<View style={styles.consoleTopline}>
<View>
<Text style={styles.eyebrow}>SENSOR REAL</Text>
<Text style={styles.title}>
{friendlyDeviceTitle(device?.name)}
</Text>
</View>
<View style={styles.phaseBadge}>
<Bluetooth color={colors.warning} size={16} strokeWidth={2.4} />
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
</View>
</View>
<View style={styles.signalGrid}>
<SignalDatum
icon="signal"
label="RSSI"
value={formatRssi(device?.rssi)}
/>
<SignalDatum
icon="activity"
label="Leituras"
value={`${monitor.samples.length}`}
/>
<SignalDatum
icon="battery"
label="Bateria"
value={formatBattery(device?.batteryPercent)}
/>
<SignalDatum
icon="shield"
label="Fundo"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
/>
</View>
<Text style={styles.explainer}>
{monitor.errorMessage ?? sensorExplanation(monitor)}
</Text>
<View style={styles.actionRow}>
{!monitor.isMonitoring ? (
<ActionButton
label="Buscar sensor"
onPress={() => {
onMonitorAction().catch(() => undefined);
}}
showIcon={false}
variant="primary"
/>
) : (
<View style={styles.livePill}>
<Activity color={colors.success} size={15} strokeWidth={2.4} />
<Text style={styles.livePillText}>Monitor contínuo ativo</Text>
</View>
)}
{!monitor.batteryOptimizationIgnored ? (
<Pressable
onPress={() =>
monitor.openBatterySettings().catch(() => undefined)
}
style={styles.secondaryButton}
>
<Text style={styles.secondaryButtonText}>Bateria</Text>
</Pressable>
) : null}
</View>
</View>
{monitor.discoveredDevices.length > 0 ? (
<View style={styles.discoveryBlock}>
<Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
{monitor.discoveredDevices.map(candidate => (
<Pressable
key={candidate.id}
onPress={() =>
monitor.connectToDevice(candidate.id).catch(() => undefined)
}
style={styles.deviceRow}
>
<View style={styles.deviceDot} />
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>
{friendlyDeviceTitle(candidate.name)}
</Text>
<Text style={styles.deviceMeta}>
{candidate.name} · {formatRssi(candidate.rssi)}
</Text>
</View>
<View style={styles.connectPill}>
<Text style={styles.connectPillText}>Conectar</Text>
</View>
</Pressable>
))}
</View>
) : null}
<BleInventoryBlock title="SERVIÇOS BLE" rows={services} />
<BleInventoryBlock title="CARACTERÍSTICAS" rows={characteristics} />
</View>
);
}
function SignalDatum({
icon,
label,
value,
}: {
readonly icon: 'signal' | 'battery' | 'shield' | 'activity';
readonly label: string;
readonly value: string;
}) {
const Icon =
icon === 'signal'
? Signal
: icon === 'battery'
? Battery
: icon === 'activity'
? Activity
: ShieldCheck;
return (
<View style={styles.signalDatum}>
<Icon color={colors.warning} size={17} strokeWidth={2.3} />
<Text style={styles.signalValue}>{value}</Text>
<Text style={styles.signalLabel}>{label}</Text>
</View>
);
}
function BleInventoryBlock({
title,
rows,
}: {
readonly title: string;
readonly rows: readonly string[];
}) {
return (
<View style={styles.diagnosticBlock}>
<Text style={styles.blockLabel}>{title}</Text>
{rows.slice(0, 8).map(row => {
const parsed = parseBleRow(row);
return (
<View key={row} style={styles.diagnosticRow}>
<RadioReceiver color={colors.textDim} size={14} strokeWidth={2.2} />
<View style={styles.diagnosticBody}>
<Text style={styles.diagnosticTitle}>{parsed.name}</Text>
<Text style={styles.diagnosticText}>
{parsed.uuid} · {parsed.detail}
</Text>
</View>
</View>
);
})}
</View>
);
}
function friendlyDeviceTitle(name?: string | null) {
if (!name) {
return 'Cinta cardíaca BLE';
}
if (/magene|h003/i.test(name)) {
return 'Magene H003';
}
if (/heart|hrm/i.test(name)) {
return 'Cinta cardíaca BLE';
}
return name;
}
function parseBleRow(row: string) {
const [name = row, uuid = 'BLE', ...rest] = row.split(' · ');
return {
name,
uuid,
detail: rest.join(' · ') || 'aguardando leitura',
};
}
function phaseLabel(phase: HeartRateMonitorState['phase']) {
const labels: Record<HeartRateMonitorState['phase'], string> = {
idle: 'parado',
'requesting-permission': 'permissões',
'bluetooth-off': 'bluetooth off',
scanning: 'buscando',
connecting: 'conectando',
connected: 'conectado',
receiving: 'recebendo',
'permission-denied': 'sem permissão',
'sensor-not-found': 'sem sensor',
error: 'erro',
};
return labels[phase];
}
function sensorExplanation(monitor: HeartRateMonitorState) {
if (monitor.phase === 'receiving' && monitor.samples.length > 0) {
return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido está sendo salvo em SQLite.`;
}
if (monitor.phase === 'receiving' || monitor.phase === 'connected') {
return 'Sensor reconhecido. O app está armando a leitura 2A37; se não vier BPM, reativa notify e reconecta sozinho.';
}
if (monitor.phase === 'scanning') {
return 'Busca nativa BLE ativa. Se o app for bloqueado, a notificação mantém o serviço vivo.';
}
return 'A tela traduz nomes técnicos: 180D é o serviço cardíaco; 2A37 é o dado de BPM.';
}
function formatBattery(percent?: number | null) {
if (typeof percent === 'number') {
return `${percent}%`;
}
return '--';
}
const styles = StyleSheet.create({
screen: {
gap: spacing.sm,
},
sensorConsole: {
borderTopWidth: 1,
borderColor: colors.line,
paddingVertical: spacing.md,
gap: spacing.md,
},
consoleTopline: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.md,
},
eyebrow: {
...typography.label,
color: colors.action,
},
title: {
color: colors.text,
fontSize: 34,
lineHeight: 36,
fontWeight: '900',
letterSpacing: -2,
marginTop: spacing.xs,
},
phaseBadge: {
paddingVertical: spacing.xs,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
height: 36,
},
phaseText: {
...typography.label,
color: colors.text,
},
signalGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
},
signalDatum: {
width: '48.5%',
minHeight: 76,
borderWidth: 1,
borderColor: colors.line,
padding: spacing.sm,
gap: spacing.xxs,
justifyContent: 'center',
},
signalValue: {
color: colors.text,
fontSize: 18,
lineHeight: 22,
fontWeight: '900',
marginTop: spacing.xs,
},
signalLabel: {
...typography.label,
color: colors.textMuted,
},
explainer: {
...typography.body,
color: colors.textMuted,
},
actionRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
},
livePill: {
minHeight: 48,
flex: 1,
borderWidth: 1,
borderColor: colors.line,
paddingHorizontal: spacing.md,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
livePillText: {
...typography.label,
color: colors.success,
},
secondaryButton: {
minHeight: 48,
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.xl,
paddingHorizontal: spacing.md,
justifyContent: 'center',
},
secondaryButtonText: {
...typography.label,
color: colors.warning,
},
discoveryBlock: {
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.md,
gap: spacing.sm,
},
blockLabel: {
...typography.label,
color: colors.warning,
},
deviceRow: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.line,
},
deviceDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.success,
},
deviceInfo: {
flex: 1,
},
deviceName: {
color: colors.text,
fontSize: 18,
lineHeight: 22,
fontWeight: '900',
},
deviceMeta: {
...typography.body,
color: colors.textMuted,
marginTop: spacing.xxs,
},
connectPill: {
borderWidth: 1,
borderColor: colors.line,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
connectPillText: {
...typography.label,
color: colors.warning,
},
diagnosticBlock: {
borderTopWidth: 1,
borderTopColor: colors.line,
paddingTop: spacing.sm,
gap: spacing.xs,
},
diagnosticRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.line,
},
diagnosticBody: {
flex: 1,
gap: 2,
},
diagnosticTitle: {
...typography.label,
color: colors.text,
},
diagnosticText: {
flex: 1,
color: colors.textMuted,
fontSize: 12,
lineHeight: 16,
fontWeight: '700',
},
});
+7
View File
@@ -0,0 +1,7 @@
export const radius = {
xs: 10,
sm: 16,
md: 24,
lg: 36,
xl: 48,
} as const;
+9
View File
@@ -0,0 +1,9 @@
export const spacing = {
xxs: 4,
xs: 8,
sm: 12,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
+16 -8
View File
@@ -1,3 +1,15 @@
import type { TextStyle } from 'react-native';
const numeric: TextStyle = {
fontFamily: 'JetBrainsMono-Bold',
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
};
const display: TextStyle = {
fontFamily: 'JetBrainsMono-ExtraBold',
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
};
export const typography = {
display: {
fontSize: 82,
@@ -12,32 +24,28 @@ export const typography = {
letterSpacing: -3,
},
displayNumeric: {
fontFamily: 'JetBrainsMono-ExtraBold',
fontVariant: ['tabular-nums'] as const,
...display,
fontSize: 110,
lineHeight: 100,
fontWeight: '900' as const,
letterSpacing: -7,
},
displaySmallNumeric: {
fontFamily: 'JetBrainsMono-ExtraBold',
fontVariant: ['tabular-nums'] as const,
...display,
fontSize: 56,
lineHeight: 54,
fontWeight: '900' as const,
letterSpacing: -3,
},
metricNumeric: {
fontFamily: 'JetBrainsMono-Bold',
fontVariant: ['tabular-nums'] as const,
...numeric,
fontSize: 28,
lineHeight: 30,
fontWeight: '800' as const,
letterSpacing: -1.2,
},
detailNumeric: {
fontFamily: 'JetBrainsMono-Bold',
fontVariant: ['tabular-nums'] as const,
...numeric,
fontSize: 18,
lineHeight: 22,
fontWeight: '800' as const,
+112
View File
@@ -0,0 +1,112 @@
import { type HeartRateSample } from './trainingTypes';
export type ContinuousUsageBlock = {
readonly id: string;
readonly startedAt: number;
readonly endedAt: number;
readonly durationMs: number;
readonly sampleCount: number;
readonly averageBpm: number;
readonly peakBpm: number;
readonly minBpm: number;
readonly deviceName: string | null;
readonly samples: readonly HeartRateSample[];
};
type ContinuousUsageOptions = {
readonly gapMs?: number;
};
export type ContinuousSampleRange = {
readonly start: number;
readonly end: number;
};
const DEFAULT_GAP_MS = 5 * 60 * 1000;
export function filterSamplesByRange(
samples: readonly HeartRateSample[],
range: ContinuousSampleRange,
): HeartRateSample[] {
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
return samples
.filter(sample => sample.timestamp >= start && sample.timestamp <= end)
.slice()
.sort((left, right) => left.timestamp - right.timestamp);
}
export function buildContinuousUsageHistory(
samples: readonly HeartRateSample[],
options: ContinuousUsageOptions = {},
): ContinuousUsageBlock[] {
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
const sortedSamples = samples
.filter(sample => Number.isFinite(sample.timestamp) && sample.bpm > 0)
.slice()
.sort((left, right) => left.timestamp - right.timestamp);
if (sortedSamples.length === 0) {
return [];
}
const groups: HeartRateSample[][] = [];
let currentGroup: HeartRateSample[] = [];
sortedSamples.forEach(sample => {
const previous = currentGroup[currentGroup.length - 1];
const shouldStartNewGroup =
previous && sample.timestamp - previous.timestamp > gapMs;
if (shouldStartNewGroup) {
groups.push(currentGroup);
currentGroup = [];
}
currentGroup.push(sample);
});
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups
.map(toUsageBlock)
.sort((left, right) => right.startedAt - left.startedAt);
}
function toUsageBlock(
samples: readonly HeartRateSample[],
): ContinuousUsageBlock {
const startedAt = samples[0].timestamp;
const endedAt = samples[samples.length - 1].timestamp;
const bpms = samples.map(sample => sample.bpm);
const averageBpm = Math.round(
bpms.reduce((sum, bpm) => sum + bpm, 0) / samples.length,
);
return {
id: `usage-${startedAt}`,
startedAt,
endedAt,
durationMs: Math.max(0, endedAt - startedAt),
sampleCount: samples.length,
averageBpm,
peakBpm: Math.max(...bpms),
minBpm: Math.min(...bpms),
deviceName: mostRecentDeviceName(samples),
samples,
};
}
function mostRecentDeviceName(samples: readonly HeartRateSample[]) {
for (let index = samples.length - 1; index >= 0; index -= 1) {
const deviceName = samples[index].deviceName;
if (deviceName) {
return deviceName;
}
}
return null;
}
+731
View File
@@ -0,0 +1,731 @@
import {
type BpmVariabilitySummary,
type CompletedTrainingSession,
type HeartRateDriftSummary,
type HeartRateSample,
type InsightDashboard,
type InsightMetricRow,
type IntensityZone,
type PostSessionRecoverySummary,
type RrAvailabilitySummary,
type TrainingInsight,
} from './trainingTypes';
const MIN_VALID_BPM = 30;
const MAX_VALID_BPM = 240;
const DEFAULT_STALE_AFTER_MS = 12_000;
type LiveMetricOptions = {
readonly now?: number;
readonly staleAfterMs?: number;
};
export function isReliableHeartRateSample(sample: HeartRateSample): boolean {
return (
Number.isFinite(sample.bpm) &&
sample.bpm >= MIN_VALID_BPM &&
sample.bpm <= MAX_VALID_BPM &&
sample.contactDetected !== false
);
}
export function filterReliableHeartRateSamples(
samples: readonly HeartRateSample[],
): HeartRateSample[] {
return samples.filter(isReliableHeartRateSample);
}
export function isFreshHeartRateSample(
sample: HeartRateSample | null | undefined,
now = Date.now(),
staleAfterMs = DEFAULT_STALE_AFTER_MS,
): boolean {
return Boolean(
sample &&
isReliableHeartRateSample(sample) &&
now - sample.timestamp <= staleAfterMs,
);
}
export function summarizeCompletedSession(
samples: readonly HeartRateSample[],
startedAt: number,
endedAt: number,
): CompletedTrainingSession | null {
const reliableSamples = filterReliableHeartRateSamples(samples);
if (reliableSamples.length === 0) {
return null;
}
const bpms = reliableSamples.map(sample => sample.bpm);
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
return {
id: `session-${startedAt}-${endedAt}`,
startedAt,
endedAt,
durationMs: Math.max(0, endedAt - startedAt),
sampleCount: reliableSamples.length,
averageBpm: Math.round(total / reliableSamples.length),
peakBpm: Math.max(...bpms),
minBpm: Math.min(...bpms),
deviceName: reliableSamples[reliableSamples.length - 1]?.deviceName ?? null,
};
}
export function calculateLiveMetrics(
samples: readonly HeartRateSample[],
options: LiveMetricOptions = {},
) {
const reliableSamples = filterReliableHeartRateSamples(samples);
if (reliableSamples.length === 0) {
return {
currentBpm: null,
averageBpm: null,
peakBpm: null,
minBpm: null,
sampleRatePerMinute: null,
zones: emptyZones(),
loadScore: 0,
highIntensityPercent: 0,
};
}
const latestSample = reliableSamples[reliableSamples.length - 1];
const isCurrentFresh =
options.now === undefined
? true
: isFreshHeartRateSample(
latestSample,
options.now,
options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS,
);
const bpms = reliableSamples.map(sample => sample.bpm);
const total = bpms.reduce((sum, bpm) => sum + bpm, 0);
const zones = calculateZones(reliableSamples);
const durationMinutes = Math.max(
1 / 60,
(reliableSamples[reliableSamples.length - 1].timestamp -
reliableSamples[0].timestamp) /
60000,
);
const highIntensityPercent = zones
.filter(zone => zone.key !== 'base')
.reduce((sum, zone) => sum + zone.percent, 0);
return {
currentBpm: isCurrentFresh ? latestSample.bpm : null,
averageBpm: Math.round(total / reliableSamples.length),
peakBpm: Math.max(...bpms),
minBpm: Math.min(...bpms),
sampleRatePerMinute: Math.round(reliableSamples.length / durationMinutes),
zones,
loadScore: calculateLoadScore(reliableSamples),
highIntensityPercent,
};
}
export function calculateBpmVariability(
samples: readonly HeartRateSample[],
): BpmVariabilitySummary {
const reliableSamples = filterReliableHeartRateSamples(samples);
if (reliableSamples.length === 0) {
return {
status: 'no-data',
averageBpm: null,
minBpm: null,
maxBpm: null,
rangeBpm: 0,
standardDeviationBpm: 0,
coefficientOfVariation: 0,
classification: 'sem-dados',
};
}
const bpms = reliableSamples.map(sample => sample.bpm);
const rawAverage = bpms.reduce((sum, bpm) => sum + bpm, 0) / bpms.length;
const variance =
bpms.reduce((sum, bpm) => sum + Math.pow(bpm - rawAverage, 2), 0) /
bpms.length;
const standardDeviation = Math.sqrt(variance);
const coefficientOfVariation =
rawAverage > 0 ? standardDeviation / rawAverage : 0;
const minBpm = Math.min(...bpms);
const maxBpm = Math.max(...bpms);
return {
status: 'ok',
averageBpm: Math.round(rawAverage),
minBpm,
maxBpm,
rangeBpm: maxBpm - minBpm,
standardDeviationBpm: roundTo(standardDeviation, 1),
coefficientOfVariation: roundTo(coefficientOfVariation, 3),
classification: classifyBpmVariation(coefficientOfVariation),
};
}
export function calculateHeartRateDrift(
samples: readonly HeartRateSample[],
): HeartRateDriftSummary {
const reliableSamples = filterReliableHeartRateSamples(samples);
if (reliableSamples.length < 3) {
return emptyDrift('short-window');
}
const sortedSamples = sortSamples(reliableSamples);
const durationMs =
sortedSamples[sortedSamples.length - 1].timestamp -
sortedSamples[0].timestamp;
if (durationMs < 10 * 60_000) {
return emptyDrift('short-window');
}
const windowSize = Math.max(2, Math.round(sortedSamples.length * 0.2));
const initialAverageBpm = averageRaw(sortedSamples.slice(0, windowSize));
const finalAverageBpm = averageRaw(sortedSamples.slice(-windowSize));
const driftBpm = finalAverageBpm - initialAverageBpm;
return {
status: 'ok',
driftBpm: Math.round(driftBpm),
driftPercent:
initialAverageBpm > 0
? roundTo((driftBpm / initialAverageBpm) * 100, 1)
: 0,
initialAverageBpm: Math.round(initialAverageBpm),
finalAverageBpm: Math.round(finalAverageBpm),
};
}
export function calculatePostSessionRecovery(
session: CompletedTrainingSession | null,
continuousSamples: readonly HeartRateSample[],
): PostSessionRecoverySummary {
if (!session) {
return emptyRecovery('no-data');
}
const postSessionSamples = sortSamples(
filterReliableHeartRateSamples(continuousSamples).filter(
sample => sample.timestamp >= session.endedAt,
),
);
if (postSessionSamples.length === 0) {
return emptyRecovery('no-data');
}
const oneMinuteSample = nearestSample(
postSessionSamples,
session.endedAt + 60_000,
20_000,
);
const twoMinuteSample = nearestSample(
postSessionSamples,
session.endedAt + 120_000,
25_000,
);
if (!oneMinuteSample && !twoMinuteSample) {
return emptyRecovery('short-window');
}
return {
status: 'ok',
oneMinuteDropBpm: oneMinuteSample
? Math.max(0, session.peakBpm - oneMinuteSample.bpm)
: null,
twoMinuteDropBpm: twoMinuteSample
? Math.max(0, session.peakBpm - twoMinuteSample.bpm)
: null,
oneMinuteBpm: oneMinuteSample?.bpm ?? null,
twoMinuteBpm: twoMinuteSample?.bpm ?? null,
};
}
export function summarizeRrAvailability(
samples: readonly HeartRateSample[],
): RrAvailabilitySummary {
const samplesWithRr = filterReliableHeartRateSamples(samples).filter(
sample => sample.rrIntervalsMs.length > 0,
);
const intervalCount = samplesWithRr.reduce(
(sum, sample) => sum + sample.rrIntervalsMs.length,
0,
);
return {
status: intervalCount > 0 ? 'ok' : 'needs-rr',
intervalCount,
sampleCount: samplesWithRr.length,
};
}
export function buildInsightDashboard({
liveSamples,
continuousSamples,
sessions,
}: {
readonly liveSamples: readonly HeartRateSample[];
readonly continuousSamples?: readonly HeartRateSample[];
readonly sessions: readonly CompletedTrainingSession[];
}): InsightDashboard {
const samples = sortSamples(
filterReliableHeartRateSamples(
continuousSamples && continuousSamples.length > 0
? continuousSamples
: liveSamples,
),
);
const latestSession = sessions[0] ?? null;
const metrics = calculateLiveMetrics(samples);
const variability = calculateBpmVariability(samples);
const drift = calculateHeartRateDrift(samples);
const recovery = calculatePostSessionRecovery(latestSession, samples);
const rrAvailability = summarizeRrAvailability(samples);
const startedAt = samples[0]?.timestamp ?? null;
const endedAt = samples[samples.length - 1]?.timestamp ?? null;
const durationMs =
startedAt !== null && endedAt !== null ? endedAt - startedAt : 0;
const rows: InsightMetricRow[] = [
{
key: 'current-bpm',
label: 'FC atual',
value: formatMetricValue(metrics.currentBpm),
unit: 'bpm',
status: metrics.currentBpm === null ? 'no-data' : 'ok',
statusLabel: metrics.currentBpm === null ? 'sem dados' : 'ao vivo',
summary: 'Última amostra real recebida do sensor.',
detail: `${samples.length} amostras no fluxo analisado.`,
tone:
metrics.currentBpm !== null && metrics.currentBpm >= 170
? 'danger'
: 'neutral',
},
{
key: 'average-bpm',
label: 'FC média',
value: formatMetricValue(metrics.averageBpm),
unit: 'bpm',
status: metrics.averageBpm === null ? 'no-data' : 'ok',
statusLabel: 'BPM-only',
summary: 'Média aritmética das amostras reais.',
detail: `Duração observada ${formatDuration(durationMs)} · ${
samples.length
} amostras.`,
tone: 'neutral',
},
{
key: 'bpm-range',
label: 'Amplitude',
value: variability.status === 'ok' ? String(variability.rangeBpm) : '--',
unit: 'bpm',
status: variability.status,
statusLabel: variability.status === 'ok' ? 'min↔máx' : 'sem dados',
summary:
variability.status === 'ok'
? `${variability.minBpm}–${variability.maxBpm} bpm registrados.`
: 'Aguardando amostras reais.',
detail: 'Mostra o espaço entre mínima e pico, sem estimar FCmáx.',
tone: variability.rangeBpm >= 45 ? 'warning' : 'neutral',
},
{
key: 'bpm-variability',
label: 'Oscilação BPM',
value:
variability.status === 'ok'
? `${variability.standardDeviationBpm}`
: '--',
unit: 'DP',
status: variability.status,
statusLabel: variability.classification,
summary:
variability.status === 'ok'
? `CV ${(variability.coefficientOfVariation * 100).toFixed(1)}% · ${
variability.classification
}.`
: 'Sem base para variação.',
detail: 'Não é VFC/HRV; mede dispersão de BPM agregado.',
tone: variability.classification === 'oscilante' ? 'warning' : 'good',
},
{
key: 'zones',
label: 'Alta intensidade',
value: `${metrics.highIntensityPercent}%`,
unit: 'Z2+Z3',
status: samples.length < 2 ? 'no-data' : 'ok',
statusLabel: 'zonas fixas',
summary: 'Percentual em pressão/redline pela zona operacional atual.',
detail: metrics.zones
.map(zone => `${zone.label}: ${zone.percent}%`)
.join(' · '),
tone: metrics.highIntensityPercent >= 50 ? 'danger' : 'neutral',
},
{
key: 'load',
label: 'Carga observada',
value: metrics.loadScore > 0 ? String(metrics.loadScore) : '--',
unit: 'u.a.',
status: metrics.loadScore > 0 ? 'ok' : 'no-data',
statusLabel: 'BPM+duração',
summary: 'Carga interna aproximada com BPM real e tempo em amostra.',
detail: 'TRIMP científico completo requer FCmáx e FCrepouso do perfil.',
tone: metrics.loadScore >= 80 ? 'warning' : 'neutral',
},
{
key: 'drift',
label: 'Tendência FC',
value: drift.status === 'ok' ? signedNumber(drift.driftBpm) : '--',
unit: 'bpm',
status: drift.status,
statusLabel: drift.status === 'ok' ? 'deriva' : 'janela curta',
summary:
drift.status === 'ok'
? `${signedNumber(drift.driftPercent)}% do início ao fim.`
: 'Requer pelo menos 10 min de amostras contínuas.',
detail:
drift.status === 'ok'
? `Início ${drift.initialAverageBpm} bpm · fim ${drift.finalAverageBpm} bpm.`
: 'Evita chamar oscilação curta de deriva cardiovascular.',
tone:
drift.status === 'ok' && (drift.driftBpm ?? 0) > 8
? 'warning'
: 'neutral',
},
{
key: 'recovery',
label: 'Recuperação',
value:
recovery.oneMinuteDropBpm !== null
? String(recovery.oneMinuteDropBpm)
: '--',
unit: 'bpm/1min',
status: recovery.status,
statusLabel: recovery.status === 'ok' ? 'pós-janela' : 'aguardando',
summary:
recovery.status === 'ok'
? `Queda em 2min: ${recovery.twoMinuteDropBpm ?? '--'} bpm.`
: 'Feche uma janela e mantenha o monitor ligado por 1–2 min.',
detail: 'Leitura operacional baseada no pico da última janela salva.',
tone:
recovery.oneMinuteDropBpm !== null && recovery.oneMinuteDropBpm >= 18
? 'good'
: 'neutral',
},
{
key: 'profile',
label: 'FCmáx / HRR',
value: 'perfil',
unit: undefined,
status: 'needs-profile',
statusLabel: 'necessário',
summary: 'Zonas por %FCmáx, Karvonen e TRIMP completo exigem perfil.',
detail: 'Sem perfil, o app não inventa FCmáx ou FCrepouso.',
tone: 'warning',
},
{
key: 'hrv',
label: 'VFC / HRV',
value:
rrAvailability.intervalCount > 0
? String(rrAvailability.intervalCount)
: 'RR',
unit: rrAvailability.intervalCount > 0 ? 'intervalos' : undefined,
status: rrAvailability.status,
statusLabel: rrAvailability.status === 'ok' ? 'RR detectado' : 'sem RR',
summary:
rrAvailability.status === 'ok'
? `${rrAvailability.intervalCount} intervalos RR em ${rrAvailability.sampleCount} amostras.`
: 'RMSSD/SDNN/pNN50 só aparecem com RR intervals reais.',
detail: 'VFC não é derivada de média de BPM.',
tone: rrAvailability.status === 'ok' ? 'good' : 'warning',
},
];
return {
sampleCount: samples.length,
startedAt,
endedAt,
durationMs,
rows,
zones: metrics.zones,
narrative: buildTrainingInsights(samples, sessions),
};
}
export function calculateZones(
samples: readonly HeartRateSample[],
): readonly IntensityZone[] {
const reliableSamples = filterReliableHeartRateSamples(samples);
if (reliableSamples.length < 2) {
return emptyZones();
}
const secondsByZone: Record<IntensityZone['key'], number> = {
base: 0,
pressure: 0,
redline: 0,
};
for (let index = 1; index < reliableSamples.length; index += 1) {
const previous = reliableSamples[index - 1];
const current = reliableSamples[index];
const seconds = Math.min(
10,
Math.max(0, (current.timestamp - previous.timestamp) / 1000),
);
secondsByZone[toZoneKey(current.bpm)] += seconds;
}
const totalSeconds = Object.values(secondsByZone).reduce(
(sum, seconds) => sum + seconds,
0,
);
return zoneDefinitions.map(zone => ({
...zone,
seconds: Math.round(secondsByZone[zone.key]),
percent: totalSeconds
? Math.round((secondsByZone[zone.key] / totalSeconds) * 100)
: 0,
}));
}
export function buildTrainingInsights(
samples: readonly HeartRateSample[],
sessions: readonly CompletedTrainingSession[],
): readonly TrainingInsight[] {
const reliableSamples = filterReliableHeartRateSamples(samples);
const insights: TrainingInsight[] = [];
const metrics = calculateLiveMetrics(reliableSamples);
if (
reliableSamples.length >= 6 &&
metrics.averageBpm !== null &&
metrics.peakBpm !== null
) {
const firstWindow = average(
reliableSamples.slice(0, Math.min(8, reliableSamples.length)),
);
const lastWindow = average(
reliableSamples.slice(-Math.min(8, reliableSamples.length)),
);
const drift = Math.round(lastWindow - firstWindow);
const peakGap = metrics.peakBpm - metrics.averageBpm;
insights.push({
title:
drift > 8
? 'Carga subindo rápido'
: drift < -8
? 'Recuperação aparecendo'
: 'Ritmo estável',
why:
drift > 8
? `A média recente está ${drift} BPM acima do início desta captura.`
: drift < -8
? `A média recente caiu ${Math.abs(drift)} BPM frente ao início.`
: 'A janela recente está próxima da primeira janela de leitura.',
action:
drift > 8
? 'Use como alerta de controle: se a técnica cair, reduza o ritmo ou aumente a pausa.'
: drift < -8
? 'Bom sinal para blocos intervalados: registre quanto tempo levou para baixar.'
: 'Mantenha o bloco atual e compare com a próxima sessão salva.',
confidence: `${reliableSamples.length} amostras reais · taxa ${
metrics.sampleRatePerMinute ?? '--'
}/min`,
});
insights.push({
title:
peakGap >= 28
? 'Picos explosivos detectados'
: 'Picos dentro da média do bloco',
why:
peakGap >= 28
? `O pico ficou ${peakGap} BPM acima da média, típico de troca forte ou scramble.`
: `Pico e média ficaram próximos (${peakGap} BPM de diferença).`,
action:
peakGap >= 28
? 'Marque mentalmente o round/posição que gerou o pico e compare no histórico depois.'
: 'Se o treino era explosivo, talvez falte intensidade ou o sensor esteja mal posicionado.',
confidence:
'Baseado somente na captura atual, sem estimar FC máxima pessoal.',
});
}
if (sessions.length >= 2) {
const [latest, previous] = sessions;
const averageDelta = latest.averageBpm - previous.averageBpm;
insights.push({
title:
averageDelta >= 6
? 'Sessão mais pesada que a anterior'
: averageDelta <= -6
? 'Sessão menos exigente'
: 'Sessões parecidas',
why: `Última média ${latest.averageBpm} BPM contra ${previous.averageBpm} BPM da anterior.`,
action:
'Use a comparação junto do RPE: frequência parecida com sensação maior pode indicar fadiga.',
confidence: `${
latest.sampleCount + previous.sampleCount
} amostras reais em 2 sessões salvas.`,
});
}
return insights;
}
export function formatDuration(durationMs: number) {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function roundTo(value: number, precision: number) {
const factor = Math.pow(10, precision);
return Math.round(value * factor) / factor;
}
function averageRaw(samples: readonly HeartRateSample[]) {
if (samples.length === 0) {
return 0;
}
return samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length;
}
function classifyBpmVariation(
coefficientOfVariation: number,
): BpmVariabilitySummary['classification'] {
if (coefficientOfVariation <= 0) {
return 'sem-dados';
}
if (coefficientOfVariation < 0.06) {
return 'estável';
}
if (coefficientOfVariation <= 0.12) {
return 'progressivo';
}
return 'oscilante';
}
function sortSamples(samples: readonly HeartRateSample[]) {
return [...samples].sort((left, right) => left.timestamp - right.timestamp);
}
function nearestSample(
samples: readonly HeartRateSample[],
targetTimestamp: number,
toleranceMs: number,
) {
return samples.reduce<HeartRateSample | null>((nearest, sample) => {
const distance = Math.abs(sample.timestamp - targetTimestamp);
if (distance > toleranceMs) {
return nearest;
}
if (!nearest) {
return sample;
}
const nearestDistance = Math.abs(nearest.timestamp - targetTimestamp);
return distance < nearestDistance ? sample : nearest;
}, null);
}
function emptyDrift(
status: HeartRateDriftSummary['status'],
): HeartRateDriftSummary {
return {
status,
driftBpm: null,
driftPercent: null,
initialAverageBpm: null,
finalAverageBpm: null,
};
}
function emptyRecovery(
status: PostSessionRecoverySummary['status'],
): PostSessionRecoverySummary {
return {
status,
oneMinuteDropBpm: null,
twoMinuteDropBpm: null,
oneMinuteBpm: null,
twoMinuteBpm: null,
};
}
function formatMetricValue(value: number | null) {
return value === null ? '--' : String(value);
}
function signedNumber(value: number | null) {
if (value === null) {
return '--';
}
return value > 0 ? `+${value}` : String(value);
}
function calculateLoadScore(samples: readonly HeartRateSample[]) {
if (samples.length < 2) {
return 0;
}
let load = 0;
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1];
const current = samples[index];
const minutes = Math.min(
10 / 60,
Math.max(0, (current.timestamp - previous.timestamp) / 60000),
);
const intensity = current.bpm >= 170 ? 3 : current.bpm >= 150 ? 2 : 1;
load += minutes * intensity * Math.max(0, current.bpm - 80);
}
return Math.round(load / 10);
}
function average(samples: readonly HeartRateSample[]) {
if (samples.length === 0) {
return 0;
}
return Math.round(
samples.reduce((sum, sample) => sum + sample.bpm, 0) / samples.length,
);
}
function toZoneKey(bpm: number): IntensityZone['key'] {
if (bpm >= 170) {
return 'redline';
}
if (bpm >= 150) {
return 'pressure';
}
return 'base';
}
function emptyZones(): readonly IntensityZone[] {
return zoneDefinitions.map(zone => ({ ...zone, seconds: 0, percent: 0 }));
}
const zoneDefinitions = [
{ key: 'base', label: 'BASE <150' },
{ key: 'pressure', label: 'PRESSÃO 150–169' },
{ key: 'redline', label: 'REDLINE 170+' },
] as const satisfies readonly Omit<IntensityZone, 'seconds' | 'percent'>[];
+105
View File
@@ -0,0 +1,105 @@
export type HeartRateSample = {
readonly id?: number;
readonly sessionId?: number;
readonly bpm: number;
readonly timestamp: number;
readonly contactDetected: boolean | null;
readonly rrIntervalsMs: readonly number[];
readonly deviceName?: string | null;
};
export type CompletedTrainingSession = {
readonly id: string;
readonly startedAt: number;
readonly endedAt: number;
readonly durationMs: number;
readonly sampleCount: number;
readonly averageBpm: number;
readonly peakBpm: number;
readonly minBpm: number;
readonly deviceName?: string | null;
};
export type IntensityZoneKey = 'base' | 'pressure' | 'redline';
export type IntensityZone = {
readonly key: IntensityZoneKey;
readonly label: string;
readonly seconds: number;
readonly percent: number;
};
export type TrainingInsight = {
readonly title: string;
readonly why: string;
readonly action: string;
readonly confidence: string;
};
export type InsightMetricStatus =
| 'ok'
| 'needs-profile'
| 'needs-rr'
| 'short-window'
| 'no-data';
export type InsightMetricTone = 'neutral' | 'good' | 'warning' | 'danger';
export type InsightMetricRow = {
readonly key: string;
readonly label: string;
readonly value: string;
readonly unit?: string;
readonly status: InsightMetricStatus;
readonly statusLabel: string;
readonly summary: string;
readonly detail: string;
readonly tone: InsightMetricTone;
};
export type InsightDashboard = {
readonly sampleCount: number;
readonly startedAt: number | null;
readonly endedAt: number | null;
readonly durationMs: number;
readonly rows: readonly InsightMetricRow[];
readonly zones: readonly IntensityZone[];
readonly narrative: readonly TrainingInsight[];
};
export type BpmVariabilitySummary = {
readonly status: InsightMetricStatus;
readonly averageBpm: number | null;
readonly minBpm: number | null;
readonly maxBpm: number | null;
readonly rangeBpm: number;
readonly standardDeviationBpm: number;
readonly coefficientOfVariation: number;
readonly classification:
| 'estável'
| 'progressivo'
| 'oscilante'
| 'sem-dados';
};
export type HeartRateDriftSummary = {
readonly status: InsightMetricStatus;
readonly driftBpm: number | null;
readonly driftPercent: number | null;
readonly initialAverageBpm: number | null;
readonly finalAverageBpm: number | null;
};
export type PostSessionRecoverySummary = {
readonly status: InsightMetricStatus;
readonly oneMinuteDropBpm: number | null;
readonly twoMinuteDropBpm: number | null;
readonly oneMinuteBpm: number | null;
readonly twoMinuteBpm: number | null;
};
export type RrAvailabilitySummary = {
readonly status: InsightMetricStatus;
readonly intervalCount: number;
readonly sampleCount: number;
};