From 2f14379066c700a4350300ac49544dfe381d7e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:41:24 +0200 Subject: [PATCH] Carry the workout heart-rate curve to the end of the session `durationSec` is a denormalised duration, and not every source reports the elapsed one. Strava sends `moving_time` while the row's `endedAt` is `startedAt + elapsed_time`, so a ride with twenty minutes of stops carries a duration twenty minutes shorter than its own span. The curve folded against that duration, which dropped every reading after the moving-time mark: the tail of the session vanished with no gap and no marker, and the detail page showed a complete-looking profile of an incomplete session. Fold over the span instead, with the duration as the floor for rows whose `endedAt` is not after their `startedAt`. The bucket width tracks the span too, so a stop-heavy ride keeps the same on-screen density as a continuous one of the same length. The reconstruction path also gets an integration suite over the real route and a real Postgres. Two boundaries decide whether it is honest and neither was provable against a mocked client: the query pads by five minutes so a boundary reading still lands in the first or last bucket, and a reading inside that pad must not extend the curve past the finish; the measurement read is owner-scoped, and another account's pulse at the same minute must not enter this curve. Both fixtures place a reading on the far side with a bpm value no in-window reading has, so a leak shows up in the numbers rather than only in a count. Verified by breaking each boundary in turn and watching the matching assertion fail. --- src/lib/workouts/__tests__/hr-series.test.ts | 71 +++++ src/lib/workouts/hr-series.ts | 50 +++- .../workout-detail-hr-series.test.ts | 270 ++++++++++++++++++ 3 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 tests/integration/workout-detail-hr-series.test.ts diff --git a/src/lib/workouts/__tests__/hr-series.test.ts b/src/lib/workouts/__tests__/hr-series.test.ts index 0beab9c57..6e09a7413 100644 --- a/src/lib/workouts/__tests__/hr-series.test.ts +++ b/src/lib/workouts/__tests__/hr-series.test.ts @@ -180,3 +180,74 @@ describe("buildWorkoutHrSeries", () => { expect(findMany).not.toHaveBeenCalled(); }); }); + +/** + * `durationSec` is a denormalised DURATION, and not every source reports + * the elapsed one: Strava sends `moving_time` while the row's `endedAt` + * is `startedAt + elapsed_time`. A ride with 20 minutes of stops + * therefore carries a `durationSec` 20 minutes shorter than its own + * wall-clock span, and folding the curve against it drops every reading + * after the moving-time mark — the tail of the session vanishes with no + * gap, no marker, nothing that reads as missing. + */ +describe("buildWorkoutHrSeries — sessions whose duration is not their span", () => { + const SPAN_SEC = 3600; + const MOVING_SEC = 2400; + const strava = { + userId: "u1", + startedAt: START, + endedAt: new Date(startMs + SPAN_SEC * 1000), + durationSec: MOVING_SEC, + now: new Date(startMs + SPAN_SEC * 1000 + 1000), + }; + + it("carries a stored series to the end of the session, not to the moving-time mark", async () => { + const stored = Array.from({ length: SPAN_SEC / 15 }, (_, i) => + storedSample(i * 15, 120 + (i % 20)), + ); + const result = await buildWorkoutHrSeries({ + ...strava, + storedSamples: stored, + }); + expect(result).not.toBeNull(); + const lastTSec = result!.points[result!.points.length - 1].tSec; + // The final bucket sits within one bucket width of the session end. + expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - result!.bucketSec); + expect(lastTSec).toBeLessThan(SPAN_SEC); + }); + + it("carries a pulse-window series past the moving-time mark too", async () => { + const rows = Array.from({ length: SPAN_SEC / 20 }, (_, i) => ({ + value: 125 + (i % 9), + measuredAt: new Date(startMs + i * 20_000), + externalId: null, + })); + findMany.mockResolvedValue(rows); + const result = await buildWorkoutHrSeries({ + ...strava, + storedSamples: null, + }); + expect(result).not.toBeNull(); + expect(result!.source).toBe("pulse_window"); + const lastTSec = result!.points[result!.points.length - 1].tSec; + expect(lastTSec).toBeGreaterThan(MOVING_SEC); + // The rows sit 20 s apart, so the final one can miss the very last + // bucket; landing within a sample interval of the end is the claim. + expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - 20 - result!.bucketSec); + }); + + it("keeps the duration as the floor when the row carries no usable span", async () => { + const stored = Array.from({ length: 40 }, (_, i) => + storedSample(i * 15, 130), + ); + const result = await buildWorkoutHrSeries({ + ...strava, + // A row whose end is not after its start: the span is unusable, so + // the denormalised duration has to stay the fold window. + endedAt: START, + storedSamples: stored, + }); + expect(result).not.toBeNull(); + expect(result!.bucketSec).toBe(adaptiveBucketSec(MOVING_SEC)); + }); +}); diff --git a/src/lib/workouts/hr-series.ts b/src/lib/workouts/hr-series.ts index e1db75335..92f4e651c 100644 --- a/src/lib/workouts/hr-series.ts +++ b/src/lib/workouts/hr-series.ts @@ -82,27 +82,55 @@ function median(values: number[]): number { : sorted[mid]; } +/** + * The session's own length on the wall clock. + * + * `durationSec` is a denormalised DURATION, and not every source reports + * the elapsed one — Strava sends `moving_time` while the row's `endedAt` + * is `startedAt + elapsed_time` (`src/lib/strava/client.ts`). A ride with + * twenty minutes of stops therefore carries a `durationSec` twenty + * minutes shorter than its own span, and a curve folded against it ends + * at the moving-time mark with no gap and no marker: the tail of the + * session simply is not there. The curve covers the session, so the span + * is what it folds over. + * + * The duration stays the floor. A row whose `endedAt` is not after its + * `startedAt` (a manual entry saved with one timestamp, a provider that + * shipped an end it could not resolve) has no usable span, and every + * source that computes `endedAt` from the duration lands on the same + * number either way. + */ +export function sessionSpanSec( + startedAt: Date, + endedAt: Date, + durationSec: number, +): number { + const floor = Math.max(0, durationSec); + const span = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000); + return Number.isFinite(span) && span > floor ? span : floor; +} + /** * Fold raw HR samples into session-grain buckets. Samples outside the - * session window `[0, durationSec)` are dropped from the curve (they - * only widen the DB query so a boundary reading still lands in bucket - * 0 / the last bucket). Empty buckets stay as gaps — no interpolation. + * session window `[0, spanSec)` are dropped from the curve (they only + * widen the DB query so a boundary reading still lands in bucket 0 / the + * last bucket). Empty buckets stay as gaps — no interpolation. * Pure; exported for unit testing. */ export function foldHrBuckets( samples: readonly RawHrSample[], startMs: number, - durationSec: number, + spanSec: number, bucketSec: number, ): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } { - const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec)); + const bucketCount = Math.max(1, Math.ceil(spanSec / bucketSec)); const acc = new Map< number, { sum: number; min: number; max: number; n: number } >(); for (const s of samples) { const rel = (s.tMs - startMs) / 1000; - if (rel < 0 || rel >= durationSec) continue; + if (rel < 0 || rel >= spanSec) continue; const idx = Math.floor(rel / bucketSec); const bucket = acc.get(idx); if (bucket) { @@ -171,7 +199,11 @@ export async function buildWorkoutHrSeries( const { userId, startedAt, endedAt, durationSec, storedSamples } = input; const now = input.now ?? new Date(); const startMs = startedAt.getTime(); - const bucketSec = adaptiveBucketSec(durationSec); + // Fold over the session's span, not its duration — see `sessionSpanSec`. + // The bucket width tracks the span too, so a stop-heavy ride keeps the + // same on-screen point density as a continuous one of the same length. + const spanSec = sessionSpanSec(startedAt, endedAt, durationSec); + const bucketSec = adaptiveBucketSec(spanSec); // 1. Stored series — the native, highest-fidelity path. const stored = parseStoredSamples(storedSamples); @@ -179,7 +211,7 @@ export async function buildWorkoutHrSeries( const { points, medianDensity } = foldHrBuckets( stored, startMs, - durationSec, + spanSec, bucketSec, ); if (points.length >= 2) { @@ -222,7 +254,7 @@ export async function buildWorkoutHrSeries( const { points, bucketCount, medianDensity } = foldHrBuckets( raw, startMs, - durationSec, + spanSec, bucketSec, ); diff --git a/tests/integration/workout-detail-hr-series.test.ts b/tests/integration/workout-detail-hr-series.test.ts new file mode 100644 index 000000000..fff801a84 --- /dev/null +++ b/tests/integration/workout-detail-hr-series.test.ts @@ -0,0 +1,270 @@ +/** + * Integration suite for the heart-rate curve on `GET /api/workouts/{id}`. + * + * The curve is the one part of the workout-detail envelope that is + * assembled from rows the workout does not own: when a session carries no + * stored sensor stream, the server reconstructs it from the account's raw + * `PULSE` measurements around the session. Two boundaries decide whether + * that reconstruction is honest, and neither is provable from a unit test + * with a mocked Prisma: + * + * - the WINDOW. A reading taken after the session ended is not part of + * the session. The query pads by five minutes so a boundary reading + * still lands in the first / last bucket, and the fold then clamps to + * the session itself — a pad reading must not extend the curve past + * the finish. + * - the OWNER. The measurement read is scoped to the calling account. + * Another account's pulse at the same wall-clock minute must not + * enter this curve. + * + * Both run through the real route against a real Postgres, and the + * fixture places a reading on the far side of each boundary with a bpm + * value no in-window reading has, so a leak is visible in the numbers + * rather than only in a count. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const TEST_USER_ID = "user-workout-hr-series"; +const OTHER_USER_ID = "user-workout-hr-series-other"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const { GET } = await import("@/app/api/workouts/[id]/route"); + +/** Session window: 07:00 → 07:30 on a fixed day. */ +const STARTED_AT = new Date("2026-05-15T07:00:00.000Z"); +const ENDED_AT = new Date("2026-05-15T07:30:00.000Z"); +const SPAN_SEC = 1800; + +/** + * In-window readings sit in this band; every out-of-window reading is + * placed outside it, so an assertion on the curve's bpm bounds names any + * leak directly. + */ +const IN_WINDOW_LOW = 120; +const IN_WINDOW_HIGH = 168; +/** After the finish but inside the query's five-minute pad. */ +const PAD_TAIL_BPM = 44; +/** Well outside the padded window on either side. */ +const FAR_BEFORE_BPM = 201; +const FAR_AFTER_BPM = 51; +/** Another account's reading in the middle of this session. */ +const FOREIGN_BPM = 199; + +interface HrSeriesPoint { + tSec: number; + mean: number; + min: number; + max: number; +} + +interface WorkoutDetailBody { + data: { + id: string; + hrSeries: { + source: string; + bucketSec: number; + envelope: boolean; + points: HrSeriesPoint[]; + } | null; + } | null; + error: string | null; +} + +async function createUser(id: string, username: string) { + await getPrismaClient().user.create({ + data: { id, username, email: `${username}@example.test` }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await createUser(TEST_USER_ID, "workout-hr-series"); + await createUser(OTHER_USER_ID, "workout-hr-series-other"); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +/** + * The session under test, with no stored sample stream — so the route has + * to reconstruct the curve from `PULSE` rows and the boundaries above are + * the ones that decide what it sees. + */ +async function createWorkout(): Promise { + const row = await getPrismaClient().workout.create({ + data: { + userId: TEST_USER_ID, + sportType: "running", + startedAt: STARTED_AT, + endedAt: ENDED_AT, + durationSec: SPAN_SEC, + avgHeartRate: 144, + maxHeartRate: IN_WINDOW_HIGH, + source: "MANUAL", + }, + select: { id: true }, + }); + return row.id; +} + +function pulseRow(userId: string, offsetSec: number, value: number) { + return { + userId, + type: "PULSE" as const, + value, + unit: "bpm", + measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000), + }; +} + +/** + * A dense in-window sweep plus one reading on the far side of each + * boundary. The sweep alternates across the in-window band so the fold + * has a real shape to carry. + */ +async function seedPulse() { + const rows = [] as ReturnType[]; + for (let offset = 0; offset < SPAN_SEC; offset += 5) { + const swing = Math.round( + ((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) * + (1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)), + ); + rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing)); + } + // Two minutes after the finish: inside the query's pad, outside the + // session. The query returns it; the curve must not. + rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM)); + // Far outside the padded window on both sides. + rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM)); + rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM)); + // Another account, mid-session. + rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM)); + await getPrismaClient().measurement.createMany({ data: rows }); +} + +async function fetchDetail(id: string): Promise { + const request = new NextRequest( + `http://localhost/api/workouts/${id}?compact=1`, + ); + const response = await GET(request, { params: Promise.resolve({ id }) }); + return (await response.json()) as WorkoutDetailBody; +} + +describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => { + it("reconstructs the curve from the account's pulse readings", async () => { + const id = await createWorkout(); + await seedPulse(); + + const body = await fetchDetail(id); + expect(body.error).toBeNull(); + const series = body.data?.hrSeries; + expect(series).not.toBeNull(); + expect(series!.source).toBe("pulse_window"); + expect(series!.points.length).toBeGreaterThan(2); + }); + + it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => { + const id = await createWorkout(); + await seedPulse(); + + const series = (await fetchDetail(id)).data!.hrSeries!; + const lastTSec = series.points[series.points.length - 1].tSec; + expect(lastTSec).toBeLessThan(SPAN_SEC); + // The pad reading is the only source of a sub-100 bpm value in the + // fixture, so naming the value proves it stayed out rather than + // merely proving a count. + expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM); + expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual( + IN_WINDOW_LOW, + ); + }); + + it("keeps readings from outside the window and from other accounts out of the curve", async () => { + const id = await createWorkout(); + await seedPulse(); + + const series = (await fetchDetail(id)).data!.hrSeries!; + const maxima = series.points.map((p) => p.max); + expect(maxima).not.toContain(FAR_BEFORE_BPM); + expect(maxima).not.toContain(FOREIGN_BPM); + expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM); + expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH); + }); + + it("serves no curve when the account's readings do not cover the session", async () => { + const id = await createWorkout(); + // A handful of opportunistic readings in the first minute. Enough + // rows to clear the sample floor, nowhere near enough to describe a + // half-hour session — the route says nothing rather than drawing a + // curve out of a corner of it. + await getPrismaClient().measurement.createMany({ + data: Array.from({ length: 12 }, (_, i) => + pulseRow(TEST_USER_ID, i * 4, 130 + i), + ), + }); + + const body = await fetchDetail(id); + expect(body.error).toBeNull(); + expect(body.data?.hrSeries).toBeNull(); + }); + + it("does not reach the reconstruction at all for another account's workout", async () => { + const foreign = await getPrismaClient().workout.create({ + data: { + userId: OTHER_USER_ID, + sportType: "running", + startedAt: STARTED_AT, + endedAt: ENDED_AT, + durationSec: SPAN_SEC, + source: "MANUAL", + }, + select: { id: true }, + }); + await seedPulse(); + + const request = new NextRequest( + `http://localhost/api/workouts/${foreign.id}?compact=1`, + ); + const response = await GET(request, { + params: Promise.resolve({ id: foreign.id }), + }); + expect(response.status).toBe(404); + }); +});