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