Files
FightRate Agent 41b49ba442 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
2026-06-15 00:10:55 -03:00

313 lines
9.8 KiB
TypeScript

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);
});
});