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
192 lines
5.0 KiB
TypeScript
192 lines
5.0 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|