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
122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
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]);
|
|
});
|
|
});
|