feat(chart): TrainingChart dinâmico com viewport, zoom, pan e pin sobrevivente

Substitui o gráfico estático (últimos 120 samples + selectedIndex por
posição) por um modelo de viewport temporal que resolve os problemas de
qualidade em tempo real e abre zoom/pan nos 3 call sites (Captura,
Janela salva e Faixa customizada).

Adições:
- src/training/chartViewport.ts: funções puras (granularityForSpan,
  formatTickLabel, buildChartViewport, applyZoomIn/Out, applyPan,
  isLiveTailSnap, pickSampleAtTimestamp, xFromTimestampMs,
  timestampMsFromX, tipos Viewport/ChartPin/Granularity).
- __tests__/chartViewport.test.ts: 34 testes cobrindo limites,
  ordenação defensiva, snap zone, hit-test e granularidade adaptativa.
- src/components/TrainingChart.tsx: reescrito para consumir viewport.
  - Pin por timestampMs (sobrevive a novas amostras em tempo real).
  - Pinch (Gesture.Pinch) + Pan (Gesture.Pan) + Tap (Gesture.Tap) via
    react-native-gesture-handler v3.
  - Auto-follow: endMs estende com novos samples enquanto liveTail.
  - Snap zone (5% da duração visível) reanexa o live ao final do pan.
  - Eixo X com ticks adaptativos: minute/halfMinute/quarterMinute/
    tenSeconds/second/halfSecond, conforme duração visível.
  - Botões +/- (zoom 2x) e ↺ (reset) na lateral.
  - Indicador 'dados mais novos disponíveis' quando !liveTail.
  - Estado vazio preservado (samples < 2).
- jest.setup.js + jest.config.js: mocks de gesture-handler e
  reanimated para o App.test rodar em Jest (módulos nativos não
  disponíveis no host).

Modificações de setup (dependências aprovadas):
- package.json + package-lock.json: adiciona react-native-gesture-handler
  ^3.0.1, react-native-reanimated ^4.4.1, react-native-worklets ^0.9.2.
- index.js: import 'react-native-gesture-handler' no topo.
- App.tsx: <GestureHandlerRootView> envolvendo SafeAreaProvider.
- babel.config.js: plugin react-native-worklets/plugin.

Verificações:
- npm run typecheck: 0 errors.
- npm run lint: 0 errors, 0 warnings.
- npm test -- --runInBand: 55/55 passam.
- Build Android: não verificado nesta sessão (timeout); o usuário
  valida localmente antes de distribuir.

Não comitado neste commit (mudanças pré-existentes não relacionadas):
- android/* (migração fightratern → healthtracking/fightrate, mipmaps)
- README.md
- app.json
This commit is contained in:
FightRate Agent committed 2026-06-15 00:10:55 -03:00
1 parent 86908495d2
commit 41b49ba442
15 files changed
+2492 -340

No files matched your search

+11 -35
View File
@@ -1,45 +1,21 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import { NewAppScreen } from '@react-native/new-app-screen';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import React from 'react';
import { StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { FightRateApp } from './src/app/FightRateApp';
function App() {
const isDarkMode = useColorScheme() === 'dark';
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<AppContent />
</SafeAreaProvider>
);
}
function AppContent() {
const safeAreaInsets = useSafeAreaInsets();
return (
<View style={styles.container}>
<NewAppScreen
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View>
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<FightRateApp />
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
root: { flex: 1 },
});
export default App;
+54 -1
View File
@@ -3,8 +3,61 @@
*/
import React from 'react';
import { NativeModules } from 'react-native';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';
const emptySnapshot = {
phase: 'idle',
isCapturing: false,
isMonitoring: false,
startedAt: 0,
statusMessage: 'Captura parada.',
connectedDevice: null,
discoveredDevices: [],
samples: [],
sessions: [],
services: [],
characteristics: [],
batteryOptimizationIgnored: true,
};
NativeModules.FightRateCapture = {
addListener: jest.fn(),
removeListeners: jest.fn(),
getSnapshot: jest.fn(async () => emptySnapshot),
startMonitoring: jest.fn(async () => emptySnapshot),
startCapture: jest.fn(async () => emptySnapshot),
startWorkout: jest.fn(async () => ({
...emptySnapshot,
isMonitoring: true,
isCapturing: true,
})),
connectToDevice: jest.fn(async () => emptySnapshot),
stopCapture: jest.fn(async () => emptySnapshot),
stopWorkout: jest.fn(async () => ({
...emptySnapshot,
isMonitoring: true,
})),
stopMonitoring: jest.fn(async () => emptySnapshot),
getSessionSamples: jest.fn(async () => []),
getContinuousSamples: jest.fn(async () => []),
isIgnoringBatteryOptimizations: jest.fn(async () => true),
openBatteryOptimizationSettings: jest.fn(async () => true),
};
// react-native-gesture-handler requires a native module; mock it for tests.
NativeModules.RNGestureHandlerModule = {
attachGestureHandler: jest.fn(),
createGestureHandler: jest.fn(),
dropGestureHandler: jest.fn(),
updateGestureHandler: jest.fn(),
flushOperations: jest.fn(),
install: jest.fn(),
State: { UNDETERMINED: 0, FAILED: 1, BEGAN: 2, CANCELLED: 3, ACTIVE: 4, END: 5 },
Directions: { RIGHT: 1, LEFT: 2, UP: 4, DOWN: 8 },
};
const App = require('../App').default;
test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
+312
View File
@@ -0,0 +1,312 @@
import { type HeartRateSample } from '../src/training/trainingTypes';
import {
applyPan,
applyZoomIn,
applyZoomOut,
buildChartViewport,
formatTickLabel,
granularityForSpan,
isLiveTailSnap,
pickSampleAtTimestamp,
xFromTimestampMs,
timestampMsFromX,
type Viewport,
} from '../src/training/chartViewport';
const MIN_SPAN = 1_000;
const MAX_SPAN = 24 * 60 * 60 * 1_000;
function sample(timestamp: number, bpm: number): HeartRateSample {
return {
timestamp,
bpm,
contactDetected: null,
rrIntervalsMs: [],
deviceName: 'Magene H003',
};
}
function viewport(startMs: number, endMs: number, liveTail = true): Viewport {
return { startMs, endMs, liveTail };
}
describe('granularityForSpan', () => {
test('returns minute granularity for spans >= 5 minutes', () => {
const g = granularityForSpan(5 * 60 * 1_000);
expect(g.kind).toBe('minute');
expect(g.stepMs).toBe(60_000);
});
test('returns halfMinute granularity for spans >= 1 minute and < 5 minutes', () => {
expect(granularityForSpan(60 * 1_000).kind).toBe('halfMinute');
expect(granularityForSpan(4 * 60 * 1_000).kind).toBe('halfMinute');
expect(granularityForSpan(5 * 60 * 1_000).kind).toBe('minute');
});
test('returns quarterMinute granularity for spans >= 30s and < 1 minute', () => {
expect(granularityForSpan(30 * 1_000).kind).toBe('quarterMinute');
expect(granularityForSpan(59 * 1_000).kind).toBe('quarterMinute');
});
test('returns tenSeconds granularity for spans >= 10s and < 30s', () => {
expect(granularityForSpan(10 * 1_000).kind).toBe('tenSeconds');
expect(granularityForSpan(29 * 1_000).kind).toBe('tenSeconds');
});
test('returns second granularity for spans >= 2s and < 10s', () => {
expect(granularityForSpan(2 * 1_000).kind).toBe('second');
expect(granularityForSpan(9 * 1_000).kind).toBe('second');
});
test('returns halfSecond granularity for spans < 2s', () => {
expect(granularityForSpan(1_999).kind).toBe('halfSecond');
expect(granularityForSpan(0).kind).toBe('halfSecond');
});
});
describe('formatTickLabel', () => {
const T = new Date('2026-06-14T09:30:45.500Z').getTime();
test('minute granularity formats HH:mm in local time', () => {
const g = granularityForSpan(5 * 60 * 1_000);
const label = formatTickLabel(T, g);
expect(label).toMatch(/^\d{2}:\d{2}$/);
});
test('second granularity formats HH:mm:ss', () => {
const g = granularityForSpan(2 * 1_000);
const label = formatTickLabel(T, g);
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
test('halfSecond granularity formats HH:mm:ss.S', () => {
const g = granularityForSpan(500);
const label = formatTickLabel(T, g);
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}\.\d$/);
});
});
describe('buildChartViewport', () => {
test('returns null for fewer than 2 samples', () => {
expect(buildChartViewport([], { defaultSpanMs: 60_000 })).toBeNull();
expect(
buildChartViewport([sample(1_000, 90)], { defaultSpanMs: 60_000 }),
).toBeNull();
});
test('initial viewport fits the dataset when total span is smaller than defaultSpan', () => {
const samples = [
sample(1_000, 90),
sample(11_000, 100),
sample(21_000, 110),
];
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
expect(vp).not.toBeNull();
expect(vp!.startMs).toBe(1_000);
expect(vp!.endMs).toBe(21_000);
expect(vp!.liveTail).toBe(true);
});
test('initial viewport keeps defaultSpan ending at the last sample when dataset is larger', () => {
const samples = [
sample(0, 90),
sample(10_000, 100),
sample(20_000, 110),
sample(30_000, 120),
sample(40_000, 130),
];
const vp = buildChartViewport(samples, { defaultSpanMs: 30_000 });
expect(vp).not.toBeNull();
expect(vp!.endMs).toBe(40_000);
expect(vp!.endMs - vp!.startMs).toBe(30_000);
expect(vp!.liveTail).toBe(true);
});
test('initial viewport is non-live when liveTail: false is passed', () => {
const samples = [sample(0, 90), sample(10_000, 100)];
const vp = buildChartViewport(samples, {
defaultSpanMs: 60_000,
liveTail: false,
});
expect(vp!.liveTail).toBe(false);
});
test('sorts samples defensively before computing viewport', () => {
const samples = [sample(30_000, 130), sample(0, 90), sample(15_000, 110)];
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
expect(vp!.startMs).toBe(0);
expect(vp!.endMs).toBe(30_000);
});
});
describe('applyZoomIn', () => {
test('halves the span around the focus point', () => {
const start = viewport(0, 60_000);
const z = applyZoomIn(start, 30_000, {
minSpanMs: MIN_SPAN,
maxSpanMs: MAX_SPAN,
});
expect(z.endMs - z.startMs).toBe(30_000);
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(30_000, 0);
expect(z.liveTail).toBe(false);
});
test('never goes below minSpanMs', () => {
const start = viewport(0, 2_000);
const z = applyZoomIn(start, 1_000, {
minSpanMs: MIN_SPAN,
maxSpanMs: MAX_SPAN,
});
expect(z.endMs - z.startMs).toBe(MIN_SPAN);
});
test('preserves focus when minSpan is hit', () => {
const start = viewport(0, 2_000);
const z = applyZoomIn(start, 1_000, {
minSpanMs: MIN_SPAN,
maxSpanMs: MAX_SPAN,
});
expect(z.startMs).toBeLessThanOrEqual(1_000);
expect(z.endMs).toBeGreaterThanOrEqual(1_000);
});
test('forces liveTail=false (zoom is always a user interaction)', () => {
const start = viewport(0, 60_000, true);
expect(applyZoomIn(start, 30_000).liveTail).toBe(false);
});
});
describe('applyZoomOut', () => {
test('doubles the span around the focus point', () => {
const start = viewport(0, 30_000, false);
const z = applyZoomOut(start, 15_000, {
minSpanMs: MIN_SPAN,
maxSpanMs: MAX_SPAN,
});
expect(z.endMs - z.startMs).toBe(60_000);
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(15_000, 0);
});
test('never goes above maxSpanMs', () => {
const start = viewport(0, 12 * 60 * 60 * 1_000, false);
const z = applyZoomOut(start, 6 * 60 * 60 * 1_000, {
minSpanMs: MIN_SPAN,
maxSpanMs: MAX_SPAN,
});
expect(z.endMs - z.startMs).toBe(MAX_SPAN);
});
test('forces liveTail=false', () => {
expect(applyZoomOut(viewport(0, 30_000, true), 15_000).liveTail).toBe(
false,
);
});
});
describe('applyPan', () => {
test('dx > 0 shifts the viewport forward in time and disables liveTail', () => {
const start = viewport(0, 60_000, true);
const p = applyPan(start, 100, 1); // 1 pixel per ms
expect(p.startMs).toBe(100);
expect(p.endMs).toBe(60_100);
expect(p.liveTail).toBe(false);
});
test('dx < 0 shifts the viewport backward in time', () => {
const start = viewport(0, 60_000, true);
const p = applyPan(start, -50, 1);
expect(p.startMs).toBe(-50);
expect(p.endMs).toBe(59_950);
expect(p.liveTail).toBe(false);
});
test('keeps liveTail false even if input was live (any pan detaches)', () => {
const start = viewport(0, 60_000, true);
expect(applyPan(start, 0.5, 1).liveTail).toBe(false);
});
});
describe('isLiveTailSnap', () => {
test('returns true when endMs is within 5% of last sample timestamp', () => {
const vp = viewport(0, 100_000, false);
expect(isLiveTailSnap(vp, 104_000)).toBe(true);
expect(isLiveTailSnap(vp, 105_000, 0.05)).toBe(true);
});
test('returns false when endMs is more than 5% behind last sample timestamp', () => {
const vp = viewport(0, 100_000, false);
expect(isLiveTailSnap(vp, 110_000, 0.05)).toBe(false);
});
test('uses a custom snap fraction', () => {
const vp = viewport(0, 100_000, false);
expect(isLiveTailSnap(vp, 150_000, 0.5)).toBe(true);
expect(isLiveTailSnap(vp, 250_000, 0.5)).toBe(false);
});
});
describe('pickSampleAtTimestamp', () => {
const samples = [
sample(0, 80),
sample(1_000, 90),
sample(2_500, 100),
sample(4_000, 110),
sample(5_000, 120),
];
test('returns the closest sample within tolerance', () => {
expect(pickSampleAtTimestamp(samples, 2_400, 500)).toEqual(samples[2]);
expect(pickSampleAtTimestamp(samples, 600, 500)).toEqual(samples[1]);
});
test('returns null when no sample is within tolerance', () => {
expect(pickSampleAtTimestamp(samples, 3_500, 100)).toBeNull();
});
test('tolerates unsorted samples by considering the global closest', () => {
const shuffled = [
samples[3],
samples[0],
samples[2],
samples[4],
samples[1],
];
expect(pickSampleAtTimestamp(shuffled, 2_400, 500)).toEqual(samples[2]);
});
test('returns null for empty input', () => {
expect(pickSampleAtTimestamp([], 0, 1_000)).toBeNull();
});
});
describe('xFromTimestampMs and timestampMsFromX', () => {
const vp = viewport(0, 100_000, false);
const chartWidth = 320;
const padX = 14;
test('xFromTimestampMs maps edge timestamps to padX and chartWidth-padX', () => {
expect(xFromTimestampMs(0, vp, chartWidth, padX)).toBeCloseTo(padX, 5);
expect(xFromTimestampMs(100_000, vp, chartWidth, padX)).toBeCloseTo(
chartWidth - padX,
5,
);
expect(xFromTimestampMs(50_000, vp, chartWidth, padX)).toBeCloseTo(
chartWidth / 2,
5,
);
});
test('timestampMsFromX is the inverse of xFromTimestampMs', () => {
const ts = 73_421;
const x = xFromTimestampMs(ts, vp, chartWidth, padX);
const back = timestampMsFromX(x, vp, chartWidth, padX);
expect(back).toBeCloseTo(ts, 0);
});
test('timestampMsFromX clamps to viewport bounds', () => {
const before = timestampMsFromX(-100, vp, chartWidth, padX);
expect(before).toBeLessThanOrEqual(vp.startMs);
const after = timestampMsFromX(500, vp, chartWidth, padX);
expect(after).toBeGreaterThanOrEqual(vp.endMs);
});
});
+121
View File
@@ -0,0 +1,121 @@
import {
buildContinuousUsageHistory,
filterSamplesByRange,
} from '../src/training/continuousUsage';
import { type HeartRateSample } from '../src/training/trainingTypes';
function sample(
timestamp: number,
bpm: number,
deviceName = 'Magene H003',
): HeartRateSample {
return {
timestamp,
bpm,
contactDetected: null,
rrIntervalsMs: [],
deviceName,
};
}
describe('buildContinuousUsageHistory', () => {
it('groups continuous samples into usage blocks separated by long gaps', () => {
const blocks = buildContinuousUsageHistory(
[
sample(1_000, 120),
sample(2_000, 130),
sample(10 * 60_000, 140),
sample(10 * 60_000 + 1_000, 150),
],
{ gapMs: 5 * 60_000 },
);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toMatchObject({
startedAt: 10 * 60_000,
endedAt: 10 * 60_000 + 1_000,
sampleCount: 2,
averageBpm: 145,
peakBpm: 150,
minBpm: 140,
});
expect(blocks[1]).toMatchObject({
startedAt: 1_000,
endedAt: 2_000,
sampleCount: 2,
averageBpm: 125,
peakBpm: 130,
minBpm: 120,
});
});
it('sorts samples and ignores invalid BPM values', () => {
const blocks = buildContinuousUsageHistory(
[sample(5_000, 150), sample(1_000, 0), sample(3_000, 120)],
{ gapMs: 10_000 },
);
expect(blocks).toHaveLength(1);
expect(blocks[0].startedAt).toBe(3_000);
expect(blocks[0].endedAt).toBe(5_000);
expect(blocks[0].sampleCount).toBe(2);
expect(blocks[0].samples.map(item => item.bpm)).toEqual([120, 150]);
});
it('uses the most recent block first for history display', () => {
const blocks = buildContinuousUsageHistory(
[sample(1_000, 110), sample(30 * 60_000, 160)],
{ gapMs: 5 * 60_000 },
);
expect(blocks.map(block => block.startedAt)).toEqual([30 * 60_000, 1_000]);
});
it('keeps a stable block id while live samples extend the same usage block', () => {
const initial = buildContinuousUsageHistory(
[sample(1_000, 120), sample(2_000, 130)],
{ gapMs: 5 * 60_000 },
);
const updated = buildContinuousUsageHistory(
[sample(1_000, 120), sample(2_000, 130), sample(3_000, 140)],
{ gapMs: 5 * 60_000 },
);
expect(updated[0].id).toBe(initial[0].id);
expect(updated[0].sampleCount).toBe(3);
});
});
describe('filterSamplesByRange', () => {
it('returns samples inside an inclusive custom date/time range', () => {
const samples = [
sample(Date.UTC(2026, 5, 10, 8, 59), 110),
sample(Date.UTC(2026, 5, 10, 9, 0), 120),
sample(Date.UTC(2026, 5, 10, 9, 30), 130),
sample(Date.UTC(2026, 5, 10, 10, 0), 140),
sample(Date.UTC(2026, 5, 10, 10, 1), 150),
];
const filtered = filterSamplesByRange(samples, {
start: Date.UTC(2026, 5, 10, 9, 0),
end: Date.UTC(2026, 5, 10, 10, 0),
});
expect(filtered.map(item => item.bpm)).toEqual([120, 130, 140]);
});
it('supports ranges that cross multiple days', () => {
const samples = [
sample(Date.UTC(2026, 5, 10, 23, 50), 120),
sample(Date.UTC(2026, 5, 11, 0, 10), 130),
sample(Date.UTC(2026, 5, 11, 1, 0), 140),
];
const filtered = filterSamplesByRange(samples, {
start: Date.UTC(2026, 5, 10, 23, 45),
end: Date.UTC(2026, 5, 11, 0, 15),
});
expect(filtered.map(item => item.bpm)).toEqual([120, 130]);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { parseHeartRateMeasurement } from '../src/ble/heartRateParser';
describe('parseHeartRateMeasurement', () => {
it('parses 8-bit BPM payloads', () => {
expect(parseHeartRateMeasurement([0x00, 154])).toMatchObject({
bpm: 154,
contactSupported: false,
contactDetected: null,
energyExpended: null,
rrIntervalsMs: [],
});
});
it('parses 16-bit BPM payloads', () => {
expect(parseHeartRateMeasurement([0x01, 0x2c, 0x01]).bpm).toBe(300);
});
it('parses contact, energy and RR interval data', () => {
const parsed = parseHeartRateMeasurement([
0x1e, 160, 0x10, 0x00, 0x00, 0x04,
]);
expect(parsed.bpm).toBe(160);
expect(parsed.contactSupported).toBe(true);
expect(parsed.contactDetected).toBe(true);
expect(parsed.energyExpended).toBe(16);
expect(parsed.rrIntervalsMs).toEqual([1000]);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { formatRssi, normalizeRssi } from '../src/ble/rssi';
describe('RSSI dBm helpers', () => {
it('keeps valid BLE RSSI values as dBm', () => {
expect(normalizeRssi(-63)).toBe(-63);
expect(formatRssi(-63)).toBe('-63 dBm');
});
it('hides Android sentinel and impossible RSSI values', () => {
expect(normalizeRssi(-2147483648)).toBeNull();
expect(normalizeRssi(127)).toBeNull();
expect(formatRssi(-2147483648)).toBe('sem leitura');
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
buildInsightDashboard,
calculateBpmVariability,
calculateHeartRateDrift,
calculateLiveMetrics,
calculatePostSessionRecovery,
filterReliableHeartRateSamples,
isReliableHeartRateSample,
summarizeRrAvailability,
} from '../src/training/trainingMetrics';
import {
type CompletedTrainingSession,
type HeartRateSample,
} from '../src/training/trainingTypes';
function sample(
timestamp: number,
bpm: number,
rrIntervalsMs: readonly number[] = [],
): HeartRateSample {
return {
timestamp,
bpm,
contactDetected: null,
rrIntervalsMs,
deviceName: 'Magene H003',
};
}
function session(
startedAt: number,
endedAt: number,
peakBpm = 180,
): CompletedTrainingSession {
return {
id: `session-${startedAt}-${endedAt}`,
startedAt,
endedAt,
durationMs: endedAt - startedAt,
sampleCount: 12,
averageBpm: 150,
peakBpm,
minBpm: 112,
deviceName: 'Magene H003',
};
}
describe('sample reliability guards', () => {
it('rejects zero BPM and explicit no-contact packets', () => {
expect(isReliableHeartRateSample(sample(1_000, 0))).toBe(false);
expect(
isReliableHeartRateSample({
...sample(2_000, 128),
contactDetected: false,
}),
).toBe(false);
expect(isReliableHeartRateSample(sample(3_000, 128))).toBe(true);
});
it('removes invalid readings before metrics are calculated', () => {
const reliable = filterReliableHeartRateSamples([
sample(1_000, 0),
{ ...sample(2_000, 125), contactDetected: false },
sample(3_000, 130),
]);
expect(reliable.map(item => item.bpm)).toEqual([130]);
});
it('marks live BPM as unavailable when the latest valid reading is stale', () => {
const metrics = calculateLiveMetrics([sample(1_000, 140)], {
now: 20_000,
staleAfterMs: 12_000,
});
expect(metrics.currentBpm).toBeNull();
expect(metrics.averageBpm).toBe(140);
});
});
describe('calculateBpmVariability', () => {
it('summarizes BPM range, standard deviation and coefficient of variation', () => {
const variability = calculateBpmVariability([
sample(1_000, 100),
sample(2_000, 120),
sample(3_000, 140),
]);
expect(variability).toMatchObject({
averageBpm: 120,
minBpm: 100,
maxBpm: 140,
rangeBpm: 40,
standardDeviationBpm: 16.3,
coefficientOfVariation: 0.136,
classification: 'oscilante',
});
});
});
describe('calculateHeartRateDrift', () => {
it('compares the first and last fifth of a long enough sample window', () => {
const start = Date.UTC(2026, 5, 10, 9, 0);
const samples = Array.from({ length: 12 }, (_, index) =>
sample(start + index * 60_000, index < 6 ? 130 + index : 150 + index),
);
expect(calculateHeartRateDrift(samples)).toMatchObject({
status: 'ok',
driftBpm: 30,
driftPercent: 23,
initialAverageBpm: 131,
finalAverageBpm: 161,
});
});
it('does not over-interpret short windows as cardiovascular drift', () => {
expect(
calculateHeartRateDrift([sample(1_000, 130), sample(60_000, 140)]),
).toMatchObject({ status: 'short-window' });
});
});
describe('calculatePostSessionRecovery', () => {
it('calculates one and two minute recovery from post-window continuous samples', () => {
const startedAt = Date.UTC(2026, 5, 10, 10, 0);
const endedAt = startedAt + 10 * 60_000;
expect(
calculatePostSessionRecovery(session(startedAt, endedAt, 182), [
sample(endedAt + 5_000, 176),
sample(endedAt + 61_000, 160),
sample(endedAt + 119_000, 148),
]),
).toMatchObject({
status: 'ok',
oneMinuteDropBpm: 22,
twoMinuteDropBpm: 34,
});
});
});
describe('summarizeRrAvailability', () => {
it('reports RR availability without calculating fake HRV', () => {
expect(
summarizeRrAvailability([
sample(1_000, 120, [1000, 980]),
sample(2_000, 121, [990]),
]),
).toMatchObject({ status: 'ok', intervalCount: 3, sampleCount: 2 });
expect(summarizeRrAvailability([sample(1_000, 120)])).toMatchObject({
status: 'needs-rr',
intervalCount: 0,
});
});
});
describe('buildInsightDashboard', () => {
it('builds compact scientific rows using persisted samples before live fallback', () => {
const start = Date.UTC(2026, 5, 10, 9, 0);
const persisted = [
sample(start, 120),
sample(start + 60_000, 140),
sample(start + 11 * 60_000, 160),
];
const dashboard = buildInsightDashboard({
liveSamples: [sample(start + 12 * 60_000, 170)],
continuousSamples: persisted,
sessions: [],
});
expect(dashboard.sampleCount).toBe(3);
expect(dashboard.rows.map(row => row.key)).toEqual([
'current-bpm',
'average-bpm',
'bpm-range',
'bpm-variability',
'zones',
'load',
'drift',
'recovery',
'profile',
'hrv',
]);
expect(dashboard.rows.find(row => row.key === 'hrv')).toMatchObject({
status: 'needs-rr',
});
});
});
+1
View File
@@ -1,3 +1,4 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'],
};
+3
View File
@@ -2,6 +2,9 @@
* @format
*/
// Gesture Handler must be imported before any other React Native code so the
// native gesture system is initialized before our screens mount.
import 'react-native-gesture-handler';
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
+4
View File
@@ -1,3 +1,7 @@
module.exports = {
preset: '@react-native/jest-preset',
setupFiles: ['<rootDir>/jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(jest-)?@?react-native|@react-native-community|react-native-gesture-handler|react-native-reanimated|react-native-worklets|react-native-svg|react-native-safe-area-context)',
],
};
+55
View File
@@ -0,0 +1,55 @@
/* eslint-env jest */
// react-native-gesture-handler and react-native-reanimated both depend on
// native modules that are not present in the Jest environment. Mock the
// TurboModuleRegistry calls so importing our screens does not throw.
jest.mock('react-native-gesture-handler', () => {
const View = require('react-native').View;
const React = require('react');
const passthrough = component =>
React.forwardRef((props, ref) => {
const Comp = component;
return React.createElement(Comp, { ...props, ref });
});
return {
__esModule: true,
GestureHandlerRootView: passthrough(View),
GestureDetector: passthrough(View),
Gesture: {
Pan: () => ({
onBegin: () => ({
onUpdate: () => ({
onEnd: () => ({
onFinalize: () => ({ minDistance: () => ({}) }),
}),
}),
}),
}),
Pinch: () => ({
onBegin: () => ({
onUpdate: () => ({
onEnd: () => ({ onFinalize: () => ({}) }),
}),
}),
}),
Tap: () => ({ maxDuration: () => ({ onEnd: () => ({}) }) }),
Race: (...args) => ({ _args: args }),
Simultaneous: (...args) => ({ _args: args }),
},
};
});
jest.mock('react-native-reanimated', () => {
const View = require('react-native').View;
return {
__esModule: true,
default: { View, createAnimatedComponent: c => c, Value: function () {} },
runOnJS: fn => fn,
useSharedValue: initial => ({ value: initial }),
useAnimatedStyle: () => ({}),
withTiming: v => v,
withSpring: v => v,
Easing: { inOut: () => ({}), linear: () => ({}) },
};
});
+620 -297
View File
File diff suppressed because it is too large. Load diff
+13 -7
View File
@@ -7,23 +7,29 @@
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest"
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"lucide-react-native": "^1.17.0",
"react": "19.2.3",
"react-native": "0.86.0",
"@react-native/new-app-screen": "0.86.0",
"react-native-safe-area-context": "^5.5.2"
"react-native-gesture-handler": "^3.0.1",
"react-native-reanimated": "^4.4.1",
"react-native-safe-area-context": "^5.5.2",
"react-native-svg": "^15.15.5",
"react-native-worklets": "^0.9.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli-platform-android": "20.1.0",
"@react-native-community/cli-platform-ios": "20.1.0",
"@react-native-community/cli": "^20.1.3",
"@react-native-community/cli-platform-android": "^20.1.3",
"@react-native-community/cli-platform-ios": "^20.1.3",
"@react-native/babel-preset": "0.86.0",
"@react-native/eslint-config": "0.86.0",
"@react-native/jest-preset": "^0.86.0",
"@react-native/metro-config": "0.86.0",
"@react-native/typescript-config": "0.86.0",
"@types/jest": "^29.5.13",
@@ -38,4 +44,4 @@
"engines": {
"node": ">= 22.11.0"
}
}
}
+811
View File
@@ -0,0 +1,811 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type LayoutChangeEvent,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
import { Minus, Plus, RotateCcw } from 'lucide-react-native';
import { type HeartRateSample } from '../training/trainingTypes';
import {
applyPan,
applyZoomIn,
applyZoomOut,
buildChartViewport,
formatTickLabel,
granularityForSpan,
isLiveTailSnap,
pickSampleAtTimestamp,
timestampMsFromX,
xFromTimestampMs,
type Granularity,
type Viewport,
} from '../training/chartViewport';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly samples: readonly HeartRateSample[];
readonly defaultSpanMs?: number;
readonly liveTail?: boolean;
};
type ChartPoint = {
readonly x: number;
readonly y: number;
readonly sample: HeartRateSample;
};
const CHART_WIDTH = 320;
const CHART_HEIGHT = 148;
const PAD_X = 14;
const PAD_Y = 18;
const DEFAULT_SPAN_MS = 5 * 60_000;
const MIN_SPAN_MS = 1_000;
const MAX_SPAN_MS = 24 * 60 * 60 * 1_000;
const TICK_COUNT_TARGET = 4;
export function TrainingChart({
samples,
defaultSpanMs = DEFAULT_SPAN_MS,
liveTail = true,
}: Props) {
const [layoutWidth, setLayoutWidth] = useState(CHART_WIDTH);
const [viewport, setViewport] = useState<Viewport | null>(() =>
buildChartViewport(samples, { defaultSpanMs, liveTail }),
);
const [pinTimestampMs, setPinTimestampMs] = useState<number | null>(null);
// Recompute viewport when samples shrink or when initial dataset changes
// shape. We do not recompute on every new sample in live mode: the auto-follow
// effect below handles that without resetting the user's pan/zoom state.
useEffect(() => {
setViewport(current => {
if (current === null) {
return buildChartViewport(samples, { defaultSpanMs, liveTail });
}
return current;
});
}, [samples, defaultSpanMs, liveTail]);
const lastSampleTimestamp = useMemo(() => {
if (samples.length === 0) return null;
return samples[samples.length - 1].timestamp;
}, [samples]);
// Auto-follow: when liveTail is on and the dataset extends past the current
// viewport end, slide endMs forward. Pin is untouched.
useEffect(() => {
if (!liveTail) return;
if (lastSampleTimestamp === null) return;
setViewport(current => {
if (current === null) return current;
if (!current.liveTail) return current;
if (lastSampleTimestamp <= current.endMs) return current;
const span = current.endMs - current.startMs;
return {
startMs: lastSampleTimestamp - span,
endMs: lastSampleTimestamp,
liveTail: true,
};
});
}, [lastSampleTimestamp, liveTail]);
// Re-evaluate the liveTail flag whenever the user finishes a pan: if the
// viewport's end is back inside the snap zone, re-attach to live.
const tryResumeLiveTail = useCallback(
(candidate: Viewport) => {
if (lastSampleTimestamp === null) {
setViewport(candidate);
return;
}
const resume = isLiveTailSnap(candidate, lastSampleTimestamp);
setViewport({ ...candidate, liveTail: resume });
},
[lastSampleTimestamp],
);
const onLayout = useCallback((event: LayoutChangeEvent) => {
setLayoutWidth(Math.max(1, event.nativeEvent.layout.width));
}, []);
// -------- Gestures --------
// Track the viewport snapshot captured at gesture start so incremental deltas
// compose against a stable baseline (avoids drift across multi-frame moves).
const viewportAtGestureStart = useRef<Viewport | null>(null);
const onPanBegin = useCallback(() => {
viewportAtGestureStart.current = viewport;
}, [viewport]);
const onPanUpdate = useCallback(
(dxPixels: number) => {
const baseline = viewportAtGestureStart.current;
if (baseline === null) return;
const usableWidth = Math.max(1, layoutWidth - PAD_X * 2);
const pixelsPerMs =
usableWidth / Math.max(1, baseline.endMs - baseline.startMs);
setViewport(applyPan(baseline, dxPixels, pixelsPerMs));
},
[layoutWidth],
);
const onPanEnd = useCallback(() => {
setViewport(current => {
if (current === null) return current;
tryResumeLiveTail(current);
return current;
});
}, [tryResumeLiveTail]);
const onPinchBegin = useCallback(() => {
viewportAtGestureStart.current = viewport;
}, [viewport]);
const onPinchUpdate = useCallback(
(scale: number, focusX: number) => {
const baseline = viewportAtGestureStart.current;
if (baseline === null) return;
const usableWidth = Math.max(1, layoutWidth - PAD_X * 2);
const focusTimestamp = timestampMsFromX(
focusX,
baseline,
CHART_WIDTH,
PAD_X,
);
// Map focusX from layout space to viewBox space.
const focusXInViewBox = (focusX / layoutWidth) * CHART_WIDTH;
const focusTimestampFromLayout = timestampMsFromX(
focusXInViewBox,
baseline,
CHART_WIDTH,
PAD_X,
);
const target = (baseline.endMs - baseline.startMs) / Math.max(0.1, scale);
const next = applyZoom(baseline, focusTimestamp, target, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
// Keep the focus point visually anchored by re-aiming the result so the
// timestamp under the pinch focus lands at the same x as before.
const afterX = xFromTimestampMs(
focusTimestampFromLayout,
next,
CHART_WIDTH,
PAD_X,
);
const pixelDelta =
(afterX - focusXInViewBox) * (usableWidth / (CHART_WIDTH - PAD_X * 2));
setViewport(
applyPan(
next,
-pixelDelta,
usableWidth / Math.max(1, next.endMs - next.startMs),
),
);
},
[layoutWidth],
);
const onPinchEnd = useCallback(() => {
viewportAtGestureStart.current = null;
onPanEnd();
}, [onPanEnd]);
const onZoomInPress = useCallback(() => {
setViewport(current => {
if (current === null) return current;
const focusMs = (current.startMs + current.endMs) / 2;
return applyZoomIn(current, focusMs, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
});
}, []);
const onZoomOutPress = useCallback(() => {
setViewport(current => {
if (current === null) return current;
const focusMs = (current.startMs + current.endMs) / 2;
return applyZoomOut(current, focusMs, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
});
}, []);
const onResetPress = useCallback(() => {
const fresh = buildChartViewport(samples, { defaultSpanMs, liveTail });
if (fresh !== null) {
setViewport(fresh);
setPinTimestampMs(null);
}
}, [samples, defaultSpanMs, liveTail]);
const onTapAtX = useCallback(
(xInViewBox: number) => {
if (viewport === null) return;
const tapTimestamp = timestampMsFromX(
xInViewBox,
viewport,
CHART_WIDTH,
PAD_X,
);
const tolerance = (viewport.endMs - viewport.startMs) * 0.1;
const sample = pickSampleAtTimestamp(samples, tapTimestamp, tolerance);
if (sample === null) {
setPinTimestampMs(null);
return;
}
setPinTimestampMs(previous => {
if (previous === sample.timestamp) return null;
return sample.timestamp;
});
},
[samples, viewport],
);
// Pan uses absolute deltas via the JS bridge: each frame, runOnJS forwards
// the cumulative translationX to applyPan over the captured baseline.
const panGesture = useMemo(
() =>
Gesture.Pan()
.onBegin(() => {
runOnJS(onPanBegin)();
})
.onUpdate(event => {
runOnJS(onPanUpdate)(event.translationX);
})
.onEnd(() => {
runOnJS(onPanEnd)();
})
.onFinalize(() => {
runOnJS(onPanEnd)();
})
.minDistance(4),
[onPanBegin, onPanUpdate, onPanEnd],
);
const pinchGesture = useMemo(
() =>
Gesture.Pinch()
.onBegin(() => {
runOnJS(onPinchBegin)();
})
.onUpdate(event => {
runOnJS(onPinchUpdate)(event.scale, event.focalX);
})
.onEnd(() => {
runOnJS(onPinchEnd)();
})
.onFinalize(() => {
runOnJS(onPinchEnd)();
}),
[onPinchBegin, onPinchUpdate, onPinchEnd],
);
const tapGesture = useMemo(
() =>
Gesture.Tap()
.maxDuration(220)
.onEnd(event => {
// Convert focalX (layout space) to viewBox space before hit-testing.
const xInViewBox = (event.x / Math.max(1, layoutWidth)) * CHART_WIDTH;
runOnJS(onTapAtX)(xInViewBox);
}),
[layoutWidth, onTapAtX],
);
const composed = useMemo(
() =>
Gesture.Race(tapGesture, Gesture.Simultaneous(panGesture, pinchGesture)),
[tapGesture, panGesture, pinchGesture],
);
// -------- Render data --------
const renderable = useMemo(() => {
if (viewport === null) return null;
if (samples.length < 2) return null;
const visible = samples.filter(
sample =>
sample.timestamp >= viewport.startMs &&
sample.timestamp <= viewport.endMs,
);
if (visible.length < 1) return null;
const bpms = visible.map(sample => sample.bpm);
const minBpm = Math.min(...bpms);
const maxBpm = Math.max(...bpms);
const bpmRange = Math.max(1, maxBpm - minBpm);
const span = Math.max(1, viewport.endMs - viewport.startMs);
const points: ChartPoint[] = visible.map(sample => {
const x = xFromTimestampMs(
sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
);
const y =
CHART_HEIGHT -
PAD_Y -
((sample.bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2);
return { x, y, sample };
});
const path = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
return {
visible,
minBpm,
maxBpm,
lastPoint: points[points.length - 1],
points,
path,
span,
};
}, [samples, viewport]);
const granularity: Granularity = useMemo(
() =>
granularityForSpan(
viewport ? viewport.endMs - viewport.startMs : DEFAULT_SPAN_MS,
),
[viewport],
);
const ticks = useMemo(() => {
if (viewport === null) return [];
return buildTicks(viewport, granularity, TICK_COUNT_TARGET);
}, [viewport, granularity]);
const pinPoint = useMemo(() => {
if (renderable === null || pinTimestampMs === null) return null;
const sample = samples.find(s => s.timestamp === pinTimestampMs);
if (sample === undefined) return null;
if (
sample.timestamp < viewport!.startMs ||
sample.timestamp > viewport!.endMs
) {
return { sample, offViewport: true };
}
return { sample, offViewport: false };
}, [renderable, pinTimestampMs, samples, viewport]);
// -------- JSX --------
if (viewport === null) {
return (
<View style={styles.emptyWrap}>
<Text style={styles.emptyTitle}>Aguardando amostras reais</Text>
<Text style={styles.emptyCopy}>
O gráfico aparece quando o sensor enviar BPM pelo characteristic 2A37.
</Text>
</View>
);
}
if (renderable === null) {
return (
<View style={styles.emptyWrap}>
<Text style={styles.emptyTitle}>Sem amostras na janela</Text>
<Text style={styles.emptyCopy}>
Toque em [↺] para voltar ao fit ou [+] [−] para ajustar a janela.
</Text>
</View>
);
}
return (
<View
style={styles.wrap}
accessibilityLabel="Gráfico dinâmico de BPM. Pinch para zoom, arraste para navegar, toque em um ponto para fixar."
>
<View style={styles.chartRow}>
<GestureDetector gesture={composed}>
<View style={styles.chartSurface} onLayout={onLayout}>
<Svg
height={CHART_HEIGHT}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
width="100%"
>
<Line
stroke="rgba(255,255,255,0.08)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={PAD_Y}
y2={PAD_Y}
/>
<Line
stroke="rgba(255,255,255,0.06)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={CHART_HEIGHT / 2}
y2={CHART_HEIGHT / 2}
/>
<Line
stroke="rgba(255,255,255,0.08)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={CHART_HEIGHT - PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<SvgText
fill={colors.textMuted}
fontSize="10"
fontWeight="800"
x={PAD_X}
y={14}
>
{renderable.maxBpm}
</SvgText>
<SvgText
fill={colors.textMuted}
fontSize="10"
fontWeight="800"
x={PAD_X}
y={CHART_HEIGHT - 4}
>
{renderable.minBpm}
</SvgText>
{ticks.map((tick, index) => (
<React.Fragment key={`${tick.timestampMs}-${index}`}>
<Line
stroke="rgba(255,255,255,0.06)"
strokeWidth="1"
x1={tick.x}
x2={tick.x}
y1={PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<SvgText
fill={colors.textDim}
fontSize="9"
fontWeight="700"
textAnchor="middle"
x={tick.x}
y={CHART_HEIGHT - 3}
>
{tick.label}
</SvgText>
</React.Fragment>
))}
<Path
d={renderable.path}
fill="none"
stroke={colors.action}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
/>
<Circle
cx={renderable.lastPoint.x}
cy={renderable.lastPoint.y}
fill={colors.stopSurface}
r="5"
/>
<Circle
cx={renderable.lastPoint.x}
cy={renderable.lastPoint.y}
fill={colors.action}
r="3"
/>
{pinPoint && !pinPoint.offViewport ? (
<>
<Line
stroke="rgba(255, 218, 121, 0.62)"
strokeDasharray="4 5"
strokeWidth="1.5"
x1={
pinPoint.sample.timestamp >= viewport.startMs &&
pinPoint.sample.timestamp <= viewport.endMs
? xFromTimestampMs(
pinPoint.sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
)
: PAD_X
}
x2={
pinPoint.sample.timestamp >= viewport.startMs &&
pinPoint.sample.timestamp <= viewport.endMs
? xFromTimestampMs(
pinPoint.sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
)
: PAD_X
}
y1={PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<PinMarker
timestampMs={pinPoint.sample.timestamp}
bpm={pinPoint.sample.bpm}
minBpm={renderable.minBpm}
maxBpm={renderable.maxBpm}
viewport={viewport}
/>
</>
) : null}
</Svg>
</View>
</GestureDetector>
<View style={styles.controls}>
<ControlButton
accessibilityLabel="Diminuir zoom"
icon={<Plus color={colors.text} size={16} strokeWidth={2.6} />}
onPress={onZoomInPress}
/>
<ControlButton
accessibilityLabel="Aumentar zoom"
icon={<Minus color={colors.text} size={16} strokeWidth={2.6} />}
onPress={onZoomOutPress}
/>
<ControlButton
accessibilityLabel="Voltar ao fit"
icon={<RotateCcw color={colors.text} size={16} strokeWidth={2.4} />}
onPress={onResetPress}
/>
</View>
</View>
<View style={styles.readout}>
<Text style={styles.readoutLabel}>
{pinPoint
? 'AMOSTRA PINADA'
: viewport.liveTail
? 'AO VIVO'
: 'JANELA'}
</Text>
<Text style={styles.readoutValue}>
{pinPoint
? `${formatSampleTime(pinPoint.sample.timestamp)} · ${
pinPoint.sample.bpm
} BPM`
: `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${
renderable.lastPoint.sample.bpm
} BPM`}
</Text>
</View>
{!viewport.liveTail &&
liveTail &&
renderable.lastPoint.sample.timestamp < (lastSampleTimestamp ?? 0) ? (
<Text style={styles.tailNotice}>
Dados mais novos disponíveis. Arraste para a direita ou toque em ↺.
</Text>
) : null}
{pinPoint && pinPoint.offViewport ? (
<Text style={styles.pinOffNotice}>
PIN FORA DA JANELA. Toque em ↺ para retornar.
</Text>
) : null}
</View>
);
}
// -------- Helpers --------
function PinMarker({
timestampMs,
bpm,
minBpm,
maxBpm,
viewport,
}: {
readonly timestampMs: number;
readonly bpm: number;
readonly minBpm: number;
readonly maxBpm: number;
readonly viewport: Viewport;
}) {
const x = xFromTimestampMs(timestampMs, viewport, CHART_WIDTH, PAD_X);
const bpmRange = Math.max(1, maxBpm - minBpm);
const y =
CHART_HEIGHT -
PAD_Y -
((bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2);
return (
<>
<Circle
cx={x}
cy={y}
fill={colors.canvas}
r="7"
stroke={colors.warning}
strokeWidth="2"
/>
<Circle cx={x} cy={y} fill={colors.warning} r="3" />
</>
);
}
function ControlButton({
accessibilityLabel,
icon,
onPress,
}: {
readonly accessibilityLabel: string;
readonly icon: React.ReactNode;
readonly onPress: () => void;
}) {
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.controlButton,
pressed ? styles.controlButtonPressed : null,
]}
>
{icon}
</Pressable>
);
}
type Tick = {
readonly timestampMs: number;
readonly x: number;
readonly label: string;
};
function buildTicks(
viewport: Viewport,
granularity: Granularity,
target: number,
): readonly Tick[] {
const ticks: Tick[] = [];
const step = granularity.stepMs;
// Snap start to the next multiple of step so labels look aligned.
const firstTick = Math.ceil(viewport.startMs / step) * step;
for (let t = firstTick; t <= viewport.endMs; t += step) {
ticks.push({
timestampMs: t,
x: xFromTimestampMs(t, viewport, CHART_WIDTH, PAD_X),
label: formatTickLabel(t, granularity),
});
if (ticks.length >= target + 2) break;
}
return ticks;
}
function applyZoom(
viewport: Viewport,
focusMs: number,
targetSpan: number,
options: { minSpanMs: number; maxSpanMs: number },
): Viewport {
const span = Math.max(
options.minSpanMs,
Math.min(options.maxSpanMs, targetSpan),
);
const currentSpan = Math.max(1, viewport.endMs - viewport.startMs);
const focusRatio = (focusMs - viewport.startMs) / currentSpan;
const newStart = focusMs - focusRatio * span;
return {
startMs: newStart,
endMs: newStart + span,
liveTail: false,
};
}
function formatSampleTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
const styles = StyleSheet.create({
wrap: {
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.md,
backgroundColor: colors.surfaceHot,
overflow: 'hidden',
},
chartRow: {
flexDirection: 'row',
alignItems: 'stretch',
},
chartSurface: {
flex: 1,
minHeight: CHART_HEIGHT,
},
controls: {
borderLeftWidth: 1,
borderLeftColor: colors.line,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.xxs,
gap: spacing.xs,
justifyContent: 'flex-start',
alignItems: 'center',
},
controlButton: {
width: 32,
height: 32,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: colors.line,
backgroundColor: colors.surfaceRaised,
alignItems: 'center',
justifyContent: 'center',
},
controlButtonPressed: {
opacity: 0.6,
},
readout: {
borderTopWidth: 1,
borderTopColor: colors.line,
minHeight: 42,
paddingHorizontal: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
readoutLabel: {
...typography.label,
color: colors.warning,
},
readoutValue: {
...typography.label,
color: colors.text,
flexShrink: 1,
textAlign: 'right',
},
tailNotice: {
...typography.label,
color: colors.textDim,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: colors.line,
},
pinOffNotice: {
...typography.label,
color: colors.warning,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: colors.line,
},
emptyWrap: {
minHeight: CHART_HEIGHT,
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.md,
backgroundColor: colors.surfaceHot,
padding: spacing.md,
justifyContent: 'center',
},
emptyTitle: {
...typography.label,
color: colors.warning,
},
emptyCopy: {
...typography.body,
color: colors.textMuted,
marginTop: spacing.xs,
},
});
+253
View File
@@ -0,0 +1,253 @@
import { type HeartRateSample } from './trainingTypes';
export type GranularityKind =
| 'minute'
| 'halfMinute'
| 'quarterMinute'
| 'tenSeconds'
| 'second'
| 'halfSecond';
export type Granularity = {
readonly kind: GranularityKind;
readonly stepMs: number;
};
const HALF_SECOND_MS = 500;
const SECOND_MS = 1_000;
const TEN_SECONDS_MS = 10_000;
const QUARTER_MINUTE_MS = 15_000;
const HALF_MINUTE_MS = 30_000;
const MINUTE_MS = 60_000;
const FIVE_MINUTES_MS = 5 * MINUTE_MS;
export function granularityForSpan(spanMs: number): Granularity {
if (spanMs < 2 * SECOND_MS) {
return { kind: 'halfSecond', stepMs: HALF_SECOND_MS };
}
if (spanMs < TEN_SECONDS_MS) {
return { kind: 'second', stepMs: SECOND_MS };
}
if (spanMs < HALF_MINUTE_MS) {
return { kind: 'tenSeconds', stepMs: TEN_SECONDS_MS };
}
if (spanMs < MINUTE_MS) {
return { kind: 'quarterMinute', stepMs: QUARTER_MINUTE_MS };
}
if (spanMs < FIVE_MINUTES_MS) {
return { kind: 'halfMinute', stepMs: HALF_MINUTE_MS };
}
return { kind: 'minute', stepMs: MINUTE_MS };
}
function pad2(value: number): string {
return value < 10 ? `0${value}` : `${value}`;
}
export function formatTickLabel(
timestampMs: number,
granularity: Granularity,
): string {
const date = new Date(timestampMs);
const hours = pad2(date.getHours());
const minutes = pad2(date.getMinutes());
const seconds = pad2(date.getSeconds());
if (granularity.kind === 'minute') {
return `${hours}:${minutes}`;
}
if (granularity.kind === 'halfSecond') {
const tenths = Math.floor(date.getMilliseconds() / 100);
return `${hours}:${minutes}:${seconds}.${tenths}`;
}
return `${hours}:${minutes}:${seconds}`;
}
export type Viewport = {
readonly startMs: number;
readonly endMs: number;
readonly liveTail: boolean;
};
export type BuildViewportOptions = {
readonly defaultSpanMs: number;
readonly liveTail?: boolean;
};
export function buildChartViewport(
samples: readonly HeartRateSample[],
options: BuildViewportOptions,
): Viewport | null {
if (samples.length < 2) {
return null;
}
const sortedSamples = samples
.slice()
.sort((left, right) => left.timestamp - right.timestamp);
const firstTimestamp = sortedSamples[0].timestamp;
const lastTimestamp = sortedSamples[sortedSamples.length - 1].timestamp;
const totalSpan = lastTimestamp - firstTimestamp;
const liveTail = options.liveTail ?? true;
if (totalSpan <= 0) {
return {
startMs: firstTimestamp,
endMs: firstTimestamp + options.defaultSpanMs,
liveTail,
};
}
if (totalSpan <= options.defaultSpanMs) {
return {
startMs: firstTimestamp,
endMs: lastTimestamp,
liveTail,
};
}
return {
startMs: lastTimestamp - options.defaultSpanMs,
endMs: lastTimestamp,
liveTail,
};
}
const DEFAULT_MIN_SPAN_MS = 1_000;
const DEFAULT_MAX_SPAN_MS = 24 * 60 * 60 * 1_000;
const DEFAULT_SNAP_FRACTION = 0.05;
export type ZoomOptions = {
readonly minSpanMs?: number;
readonly maxSpanMs?: number;
};
export function applyZoomIn(
viewport: Viewport,
focusMs: number,
options: ZoomOptions = {},
): Viewport {
const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS;
const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS;
const currentSpan = viewport.endMs - viewport.startMs;
const targetSpan = clampSpan(currentSpan / 2, minSpan, maxSpan);
return zoomAroundFocus(viewport, focusMs, targetSpan);
}
export function applyZoomOut(
viewport: Viewport,
focusMs: number,
options: ZoomOptions = {},
): Viewport {
const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS;
const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS;
const currentSpan = viewport.endMs - viewport.startMs;
const targetSpan = clampSpan(currentSpan * 2, minSpan, maxSpan);
return zoomAroundFocus(viewport, focusMs, targetSpan);
}
function clampSpan(span: number, min: number, max: number): number {
if (span < min) return min;
if (span > max) return max;
return span;
}
function zoomAroundFocus(
viewport: Viewport,
focusMs: number,
newSpan: number,
): Viewport {
const currentSpan = Math.max(1, viewport.endMs - viewport.startMs);
const focusRatio = (focusMs - viewport.startMs) / currentSpan;
const newStart = focusMs - focusRatio * newSpan;
return {
startMs: newStart,
endMs: newStart + newSpan,
liveTail: false,
};
}
export function applyPan(
viewport: Viewport,
dxPixels: number,
pixelsPerMs: number,
): Viewport {
if (pixelsPerMs === 0) {
return {
startMs: viewport.startMs,
endMs: viewport.endMs,
liveTail: false,
};
}
const dxMs = dxPixels / pixelsPerMs;
return {
startMs: viewport.startMs + dxMs,
endMs: viewport.endMs + dxMs,
liveTail: false,
};
}
export function isLiveTailSnap(
viewport: Viewport,
lastSampleTimestampMs: number,
snapFraction: number = DEFAULT_SNAP_FRACTION,
): boolean {
const visibleSpan = viewport.endMs - viewport.startMs;
if (visibleSpan <= 0) return false;
const distance = lastSampleTimestampMs - viewport.endMs;
if (distance < 0) return false;
return distance <= visibleSpan * snapFraction;
}
export function pickSampleAtTimestamp(
samples: readonly HeartRateSample[],
timestampMs: number,
toleranceMs: number,
): HeartRateSample | null {
if (samples.length === 0) {
return null;
}
let closest: HeartRateSample | null = null;
let closestDistance = Number.POSITIVE_INFINITY;
for (const candidate of samples) {
const distance = Math.abs(candidate.timestamp - timestampMs);
if (distance < closestDistance) {
closestDistance = distance;
closest = candidate;
}
}
return closestDistance <= toleranceMs ? closest : null;
}
export function xFromTimestampMs(
timestampMs: number,
viewport: Viewport,
chartWidth: number,
padX: number,
): number {
const span = Math.max(1, viewport.endMs - viewport.startMs);
const ratio = (timestampMs - viewport.startMs) / span;
return padX + ratio * (chartWidth - 2 * padX);
}
export function timestampMsFromX(
x: number,
viewport: Viewport,
chartWidth: number,
padX: number,
): number {
const usableWidth = chartWidth - 2 * padX;
if (usableWidth <= 0) {
return viewport.startMs;
}
const ratio = (x - padX) / usableWidth;
const ts = viewport.startMs + ratio * (viewport.endMs - viewport.startMs);
if (ts < viewport.startMs) return viewport.startMs;
if (ts > viewport.endMs) return viewport.endMs;
return ts;
}