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 1/3] 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); + }); +}); From 0c99c27bfce625759e066b9d9ae3dae3111e0b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:50:03 +0200 Subject: [PATCH 2/3] Keep the workout heart-rate axis on the readings it plots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The min-to-max envelope shipped as a stacked pair: a transparent base at the bucket minimum plus a `max - min` delta above it. Stacked series are measured from zero — recharts derives a stacked axis domain from the stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10` resolved to -10 rather than to the bottom of the band. A session spent between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of the effort, which is the entire point of the curve, squashed into the top quarter of the plot. It only happened on the dense sessions the envelope gate lets through, which are the ones with the most shape to lose. The band is now one range area over the bucket's own `[min, max]` pair. No stack, so the domain stays on the bpm values that are in the data. The axis domain is computed rather than inferred, because the session's peak has to be inside it: recharts discards an out-of-range reference line instead of clamping it, and a reported peak is a single reading while the curve plots bucket means, so a peak marker over a data-derived domain would go missing on most sessions that have one. Adds that peak marker alongside the average one, both labelled, and reads the envelope back in the tooltip as the spread it is. The mapping moves into a pure module so the values the axis sees can be asserted without mounting recharts. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../__tests__/workout-hr-chart-data.test.ts | 114 ++++++++++++++ .../charts/workout-hr-chart-data.ts | 106 +++++++++++++ src/components/charts/workout-hr-chart.tsx | 145 ++++++++++-------- .../insights/workout-detail/hr-section.tsx | 3 +- 10 files changed, 328 insertions(+), 64 deletions(-) create mode 100644 src/components/charts/__tests__/workout-hr-chart-data.test.ts create mode 100644 src/components/charts/workout-hr-chart-data.ts diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..f4414e635 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3110,6 +3110,10 @@ "notFound": "Workout nicht gefunden.", "hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit", "hrAxisMinutes": "Min.", + "hrAvgMarker": "Ø {bpm}", + "hrMaxMarker": "Max. {bpm}", + "hrRangeValue": "{low} bis {high} bpm", + "hrRangeLabel": "Spanne", "sportAverageLabel": "Dein aktueller Durchschnitt:", "zonesTitle": "Belastungszonen", "zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..dfcbd366f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3110,6 +3110,10 @@ "notFound": "Workout not found.", "hrFromPulse": "From your heart-rate data around this session", "hrAxisMinutes": "min", + "hrAvgMarker": "Avg {bpm}", + "hrMaxMarker": "Peak {bpm}", + "hrRangeValue": "{low} to {high} bpm", + "hrRangeLabel": "Range", "sportAverageLabel": "Your recent average:", "zonesTitle": "Effort zones", "zonesCaption": "How your effort was distributed across heart-rate zones.", diff --git a/messages/es.json b/messages/es.json index a800a3727..900b2fb4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3110,6 +3110,10 @@ "notFound": "Entrenamiento no encontrado.", "hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión", "hrAxisMinutes": "min", + "hrAvgMarker": "Media {bpm}", + "hrMaxMarker": "Máx. {bpm}", + "hrRangeValue": "{low} a {high} bpm", + "hrRangeLabel": "Rango", "sportAverageLabel": "Tu promedio reciente:", "zonesTitle": "Zonas de esfuerzo", "zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..d87e1247a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3110,6 +3110,10 @@ "notFound": "Entraînement introuvable.", "hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance", "hrAxisMinutes": "min", + "hrAvgMarker": "Moy. {bpm}", + "hrMaxMarker": "Max. {bpm}", + "hrRangeValue": "{low} à {high} bpm", + "hrRangeLabel": "Plage", "sportAverageLabel": "Votre moyenne récente :", "zonesTitle": "Zones d'effort", "zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..5cf1d6856 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3110,6 +3110,10 @@ "notFound": "Allenamento non trovato.", "hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione", "hrAxisMinutes": "min", + "hrAvgMarker": "Media {bpm}", + "hrMaxMarker": "Max {bpm}", + "hrRangeValue": "da {low} a {high} bpm", + "hrRangeLabel": "Intervallo", "sportAverageLabel": "La tua media recente:", "zonesTitle": "Zone di sforzo", "zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..3d3f2c64e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3110,6 +3110,10 @@ "notFound": "Nie znaleziono treningu.", "hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji", "hrAxisMinutes": "min", + "hrAvgMarker": "Śr. {bpm}", + "hrMaxMarker": "Maks. {bpm}", + "hrRangeValue": "{low} do {high} bpm", + "hrRangeLabel": "Zakres", "sportAverageLabel": "Twoja ostatnia średnia:", "zonesTitle": "Strefy wysiłku", "zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.", diff --git a/src/components/charts/__tests__/workout-hr-chart-data.test.ts b/src/components/charts/__tests__/workout-hr-chart-data.test.ts new file mode 100644 index 000000000..a4911e729 --- /dev/null +++ b/src/components/charts/__tests__/workout-hr-chart-data.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { + buildWorkoutHrChartData, + workoutHrAxisDomain, + type WorkoutHrCurvePoint, +} from "@/components/charts/workout-hr-chart-data"; + +/** + * The workout heart-rate curve's data mapping. The assertions that matter + * are the two that decide whether the rendered curve tells the truth: + * every value the axis sees has to be a bpm reading (so the domain lands + * on the band the session was actually in), and a hole in the recording + * has to stay a hole. + */ + +const BUCKET = 10; + +/** A dense stretch with a two-bucket hole at 20 s and 30 s. */ +const POINTS: WorkoutHrCurvePoint[] = [ + { tSec: 0, mean: 128, min: 122, max: 134 }, + { tSec: 10, mean: 141, min: 133, max: 149 }, + { tSec: 40, mean: 166, min: 158, max: 174 }, +]; + +describe("buildWorkoutHrChartData", () => { + it("lays the buckets on a complete elapsed grid", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data.map((d) => d.tSec)).toEqual([0, 10, 20, 30, 40]); + }); + + it("keeps a hole in the recording as a hole", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data[2]).toEqual({ tSec: 20, mean: null, band: null }); + expect(data[3]).toEqual({ tSec: 30, mean: null, band: null }); + }); + + it("carries the envelope as the bucket's own [min, max] pair", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data[0].band).toEqual([122, 134]); + expect(data[4].band).toEqual([158, 174]); + }); + + /** + * The regression this mapping exists to prevent. The band used to ship + * as a stacked pair — a base at `min` plus a `max - min` delta — and a + * stack is measured from zero, so the axis domain collapsed to include + * 0 and the curve was drawn squashed against the top of the plot. Every + * number handed to the chart must be a bpm reading in the session's own + * range; a spread posing as a value is what broke it. + */ + it("hands the axis nothing but bpm readings — no spread, no zero baseline", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + const lowestReading = Math.min(...POINTS.map((p) => p.min)); + const highestReading = Math.max(...POINTS.map((p) => p.max)); + + const values = data.flatMap((d) => [ + ...(d.mean == null ? [] : [d.mean]), + ...(d.band ?? []), + ]); + expect(values.length).toBeGreaterThan(0); + for (const value of values) { + expect(value).toBeGreaterThanOrEqual(lowestReading); + expect(value).toBeLessThanOrEqual(highestReading); + } + }); + + it("drops the band when the server judged the buckets too sparse for one", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, false); + expect(data.every((d) => d.band === null)).toBe(true); + expect(data[0].mean).toBe(128); + }); + + it("maps an empty or unusable series to no data at all", () => { + expect(buildWorkoutHrChartData([], BUCKET, true)).toEqual([]); + expect(buildWorkoutHrChartData(POINTS, 0, true)).toEqual([]); + }); +}); + +describe("workoutHrAxisDomain", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + + it("covers the plotted readings with breathing room", () => { + expect(workoutHrAxisDomain(data)).toEqual([112, 184]); + }); + + /** + * A reference line outside the axis domain is discarded by recharts, + * not clamped — so a peak above the curve's own highest bucket mean + * would simply never be drawn. The reported peak is a single reading + * and the curve plots bucket means, so that is the common case, not + * the edge one. + */ + it("stretches to hold a peak the bucketed curve never reaches", () => { + const domain = workoutHrAxisDomain(data, [145, 191]); + expect(domain).not.toBeNull(); + expect(domain![1]).toBeGreaterThanOrEqual(191); + }); + + it("stretches downwards for an average below the curve too", () => { + const domain = workoutHrAxisDomain(data, [96, null]); + expect(domain![0]).toBeLessThanOrEqual(96); + }); + + it("ignores markers that are absent or not numbers", () => { + expect(workoutHrAxisDomain(data, [null, undefined, Number.NaN])).toEqual( + workoutHrAxisDomain(data), + ); + }); + + it("leaves the axis alone when there is nothing plotted", () => { + expect(workoutHrAxisDomain([], [140])).toBeNull(); + }); +}); diff --git a/src/components/charts/workout-hr-chart-data.ts b/src/components/charts/workout-hr-chart-data.ts new file mode 100644 index 000000000..e61ea1ba0 --- /dev/null +++ b/src/components/charts/workout-hr-chart-data.ts @@ -0,0 +1,106 @@ +/** + * Data mapping for the per-workout heart-rate curve. + * + * Pure and separate from the chart component so the shape the axis reads + * can be asserted without mounting recharts — the y-axis domain is + * computed from these values, and getting them wrong flattens the curve + * rather than erroring. + */ + +export interface WorkoutHrCurvePoint { + /** Elapsed seconds from the workout start (bucket left edge). */ + tSec: number; + mean: number; + min: number; + max: number; +} + +export interface WorkoutHrChartDatum { + tSec: number; + /** Bucket mean, or null where the recording has a hole. */ + mean: number | null; + /** + * The min→max ribbon as an explicit `[low, high]` pair — recharts reads + * an array `dataKey` as a range area and takes its baseline from the + * first element. + * + * This deliberately is NOT a stacked pair of `min` and `max - min`. + * Stacked series are measured from zero: recharts derives the axis + * domain for a stack from the stacked offsets, whose baseline is 0 + * (`getDomainOfStackGroups` over `stackOffsetNone`). A chart asking for + * `dataMin - 10` then resolves to −10 instead of to the bottom of the + * band, and a session spent between 120 and 170 bpm gets drawn on a + * −10…180 axis — the shape of the effort, which is the whole point of + * the curve, squashes into the top quarter. A range area keeps the + * domain on the bpm values that are actually in the data. + */ + band: [number, number] | null; +} + +/** + * Lay the bucket points onto a complete elapsed-time grid. + * + * A missing bucket becomes an explicit null rather than being skipped, so + * a hole in the recording breaks the line (`connectNulls` false) instead + * of being drawn across as if the reading had continued. Absence reads as + * absence. + * + * `envelope` is the server's density verdict: below its threshold the + * per-bucket min→max spread is noise rather than signal, and the chart + * draws the mean line alone. + */ +export function buildWorkoutHrChartData( + points: readonly WorkoutHrCurvePoint[], + bucketSec: number, + envelope: boolean, +): WorkoutHrChartDatum[] { + if (points.length === 0 || bucketSec <= 0) return []; + const byT = new Map(points.map((p) => [p.tSec, p])); + const maxT = points[points.length - 1].tSec; + const grid: WorkoutHrChartDatum[] = []; + for (let tSec = 0; tSec <= maxT; tSec += bucketSec) { + const p = byT.get(tSec); + grid.push({ + tSec, + mean: p ? p.mean : null, + band: p && envelope ? [p.min, p.max] : null, + }); + } + return grid; +} + +/** Breathing room above and below the plotted readings, in bpm. */ +const AXIS_PAD_BPM = 10; + +/** + * The bpm range the y-axis has to cover: every plotted reading plus every + * reference line drawn over them. + * + * The markers have to be in the domain or they are not drawn at all — + * recharts discards an out-of-range `ReferenceLine` rather than clamping + * it. The session's reported peak is routinely above the curve's own + * highest point, because the curve plots bucket means and a peak is a + * single reading, so a domain derived from the data alone drops the peak + * marker on exactly the sessions that have one. Silently: no warning, no + * gap, just a line that never appears. + * + * Returns null for an empty series, leaving the axis to recharts. + */ +export function workoutHrAxisDomain( + data: readonly WorkoutHrChartDatum[], + markers: readonly (number | null | undefined)[] = [], +): [number, number] | null { + const values: number[] = []; + for (const datum of data) { + if (datum.mean != null) values.push(datum.mean); + if (datum.band) values.push(datum.band[0], datum.band[1]); + } + if (values.length === 0) return null; + for (const marker of markers) { + if (marker != null && Number.isFinite(marker)) values.push(marker); + } + return [ + Math.floor(Math.min(...values) - AXIS_PAD_BPM), + Math.ceil(Math.max(...values) + AXIS_PAD_BPM), + ]; +} diff --git a/src/components/charts/workout-hr-chart.tsx b/src/components/charts/workout-hr-chart.tsx index 9a5fe387e..78731cee0 100644 --- a/src/components/charts/workout-hr-chart.tsx +++ b/src/components/charts/workout-hr-chart.tsx @@ -15,6 +15,11 @@ import { } from "recharts"; import { useTranslations } from "@/lib/i18n/context"; +import { + buildWorkoutHrChartData, + workoutHrAxisDomain, + type WorkoutHrCurvePoint, +} from "./workout-hr-chart-data"; /** * Per-workout heart-rate curve. Rendered ONLY through the shared @@ -22,20 +27,15 @@ import { useTranslations } from "@/lib/i18n/context"; * library stays a single shared chunk — never import this file directly * at a call site. * - * `ComposedChart`: an `Area` for the min→max envelope (drawn only when - * the series is dense enough to be honest), a `Line` for the bucket - * mean, optional %HRmax zone bands behind them, and a dashed reference - * line at the workout's average HR. Gaps stay gaps (`connectNulls` + * `ComposedChart`: a range `Area` for the min→max envelope (drawn only + * when the series is dense enough to be honest), a `Line` for the bucket + * mean, optional %HRmax zone bands behind them, and dashed reference + * lines at the session's average and peak. Gaps stay gaps (`connectNulls` * false) — a hole in the recording never reads as an interpolated * curve. */ -export interface WorkoutHrChartPoint { - tSec: number; - mean: number; - min: number; - max: number; -} +export type WorkoutHrChartPoint = WorkoutHrCurvePoint; export interface WorkoutHrZoneBand { zone: number; @@ -50,6 +50,8 @@ export interface WorkoutHrChartProps { envelope: boolean; /** Workout average HR for the reference line. */ avgHr: number | null; + /** Workout peak HR for the reference line. */ + maxHr?: number | null; /** Optional %HRmax zone bands for the shaded background. */ zones?: WorkoutHrZoneBand[] | null; } @@ -71,33 +73,19 @@ export function WorkoutHrChart({ bucketSec, envelope, avgHr, + maxHr, zones, }: WorkoutHrChartProps) { const { t } = useTranslations(); - // Build a full elapsed-time grid so a gap of missing buckets breaks - // the line rather than interpolating across it. - const data = useMemo(() => { - if (points.length === 0) return []; - const byT = new Map(points.map((p) => [p.tSec, p])); - const maxT = points[points.length - 1].tSec; - const grid: Array<{ - tSec: number; - mean: number | null; - lo: number | null; - hi: number | null; - }> = []; - for (let tSec = 0; tSec <= maxT; tSec += bucketSec) { - const p = byT.get(tSec); - grid.push({ - tSec, - mean: p ? p.mean : null, - lo: p ? p.min : null, - hi: p && envelope ? p.max - p.min : null, - }); - } - return grid; - }, [points, bucketSec, envelope]); + const data = useMemo( + () => buildWorkoutHrChartData(points, bucketSec, envelope), + [points, bucketSec, envelope], + ); + const yDomain = useMemo( + () => workoutHrAxisDomain(data, [avgHr, maxHr]), + [data, avgHr, maxHr], + ); return (
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}` } - formatter={(value, name) => - name === "mean" - ? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")] - : [null, null] - } + formatter={(value, name) => { + if (name === "mean") { + return [ + `${value} bpm`, + t("insights.workouts.detail.hrChartTitle"), + ]; + } + // The envelope's own `[min, max]` pair, read back as the + // spread inside that bucket rather than as a second series. + if (name === "band" && Array.isArray(value)) { + return [ + t("insights.workouts.detail.hrRangeValue", { + low: String(value[0]), + high: String(value[1]), + }), + t("insights.workouts.detail.hrRangeLabel"), + ]; + } + return [null, null]; + }} /> - {/* Envelope band: a transparent base at `lo` plus a stacked - `hi` (= max − min) renders the min→max ribbon. Only present - when `envelope` gated it in. */} + {/* Envelope band: one range `Area` over the bucket's own + `[min, max]` pair. Deliberately not a stack — a stacked pair + is measured from zero and drags the y-axis domain down with + it, squashing the curve (see `workout-hr-chart-data.ts`). */} {envelope ? ( - <> - - - + ) : null} {avgHr != null ? ( + ) : null} + {/* The session's peak, the second number the stats grid already + names. Drawn so the curve can be read against it rather than + leaving the reader to eyeball where the top was. */} + {maxHr != null ? ( + ) : null} From cea330bf70aff9dc3dfb90e71c809aa744b16fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:51:13 +0200 Subject: [PATCH 3/3] Say when a session has heart-rate figures but no profile behind them The heart-rate card hid itself whenever the server resolved no curve. That is right for a session with no heart rate at all, but wrong for the common case it also covered: a ride logged from a chest strap through a provider that reports an average and a peak and nothing in between. The stats grid shows those two numbers, the card next to them renders nothing, and the page reads as if it has no heart-rate section rather than as if the shape behind the numbers is not on record. Three states now. A curve when there is one; a named absence when the session carries heart-rate figures without a profile; nothing at all when nothing was ever measured, since there is no absence to report there and an empty card would be a shell. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/workout-detail.test.tsx | 23 ++++++++- .../insights/workout-detail/hr-section.tsx | 51 ++++++++++++++----- 8 files changed, 66 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index f4414e635..fd827a575 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Max. {bpm}", "hrRangeValue": "{low} bis {high} bpm", "hrRangeLabel": "Spanne", + "hrNoProfile": "Für diese Einheit liegen keine einzelnen Pulswerte vor.", "sportAverageLabel": "Dein aktueller Durchschnitt:", "zonesTitle": "Belastungszonen", "zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.", diff --git a/messages/en.json b/messages/en.json index dfcbd366f..c27ca78b6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Peak {bpm}", "hrRangeValue": "{low} to {high} bpm", "hrRangeLabel": "Range", + "hrNoProfile": "No reading-by-reading heart-rate data covers this session.", "sportAverageLabel": "Your recent average:", "zonesTitle": "Effort zones", "zonesCaption": "How your effort was distributed across heart-rate zones.", diff --git a/messages/es.json b/messages/es.json index 900b2fb4d..a4c12b689 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Máx. {bpm}", "hrRangeValue": "{low} a {high} bpm", "hrRangeLabel": "Rango", + "hrNoProfile": "No hay valores de pulso individuales que cubran esta sesión.", "sportAverageLabel": "Tu promedio reciente:", "zonesTitle": "Zonas de esfuerzo", "zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.", diff --git a/messages/fr.json b/messages/fr.json index d87e1247a..e33fbc113 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Max. {bpm}", "hrRangeValue": "{low} à {high} bpm", "hrRangeLabel": "Plage", + "hrNoProfile": "Aucune valeur de pouls détaillée ne couvre cette séance.", "sportAverageLabel": "Votre moyenne récente :", "zonesTitle": "Zones d'effort", "zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.", diff --git a/messages/it.json b/messages/it.json index 5cf1d6856..6f61e22e6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Max {bpm}", "hrRangeValue": "da {low} a {high} bpm", "hrRangeLabel": "Intervallo", + "hrNoProfile": "Nessun valore di battito dettagliato copre questa sessione.", "sportAverageLabel": "La tua media recente:", "zonesTitle": "Zone di sforzo", "zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.", diff --git a/messages/pl.json b/messages/pl.json index 3d3f2c64e..2b303a47f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3114,6 +3114,7 @@ "hrMaxMarker": "Maks. {bpm}", "hrRangeValue": "{low} do {high} bpm", "hrRangeLabel": "Zakres", + "hrNoProfile": "Brak pojedynczych pomiarów tętna dla tej sesji.", "sportAverageLabel": "Twoja ostatnia średnia:", "zonesTitle": "Strefy wysiłku", "zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.", diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index aa6306131..a7916eaaa 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -207,8 +207,27 @@ describe("", () => { }); describe("", () => { - it("returns null without an HR series", () => { - expect(render()).toBe(""); + it("names the absence when the session has heart-rate figures but no profile", () => { + // The stats grid is showing an average and a peak for this session, + // so the reader can see heart rate WAS recorded. Rendering nothing + // here would read as "this page has no heart-rate section" rather + // than as "the shape behind those numbers is not on record". + const html = render(); + expect(html).toContain('data-slot="workout-detail-hr-empty"'); + expect(html).toContain("No reading-by-reading heart-rate data"); + expect(html).not.toContain('data-slot="workout-hr-chart"'); + }); + + it("returns null when the session carries no heart rate at all", () => { + // Nothing was measured, so there is no absence to report and an + // empty card would be a shell. + const noHr: WorkoutDetailPayload = { + ...FIXTURE, + avgHr: null, + maxHr: null, + minHr: null, + }; + expect(render()).toBe(""); }); it("renders the card and a provenance chip for a pulse-window series", () => { diff --git a/src/components/insights/workout-detail/hr-section.tsx b/src/components/insights/workout-detail/hr-section.tsx index 7106ae280..37ac56b5b 100644 --- a/src/components/insights/workout-detail/hr-section.tsx +++ b/src/components/insights/workout-detail/hr-section.tsx @@ -37,15 +37,33 @@ export interface WorkoutDetailHrSectionProps { * Heart-rate curve card — mean line + optional min→max envelope + %HRmax * zone bands + average and peak reference lines. A muted provenance chip * discloses when the curve was reconstructed from pulse data around the - * session rather than the workout's own sensor stream. Returns `null` - * when no series is available (hide, don't render empty). + * session rather than the workout's own sensor stream. + * + * Three states, and the middle one is the reason this is not a plain + * hide-when-empty section: + * + * - a curve, when the server resolved one; + * - a named absence, when the session HAS heart-rate figures (the + * stats grid is showing an average, a peak) but no reading-by-reading + * profile behind them. Silence there reads as "this page has no + * heart-rate section", which is wrong — the data exists, the shape of + * it does not, and saying so is the difference between an honest gap + * and an invisible one; + * - nothing at all, when the session carries no heart rate of any kind. + * There is no absence to report where nothing was ever measured, and + * an empty card would be a shell (page contract: hide, don't render + * empty). */ export function WorkoutDetailHrSection({ workout, }: WorkoutDetailHrSectionProps) { const { t } = useTranslations(); const series = workout.hrSeries; - if (!series || series.points.length < 2) return null; + const hasCurve = Boolean(series && series.points.length >= 2); + const hasHeartRateAtAll = + workout.avgHr != null || workout.maxHr != null || workout.minHr != null; + + if (!hasCurve && !hasHeartRateAtAll) return null; return ( @@ -55,7 +73,7 @@ export function WorkoutDetailHrSection({ title={t("insights.workouts.detail.hrChartTitle")} titleAs="h2" /> - {series.source === "pulse_window" ? ( + {hasCurve && series!.source === "pulse_window" ? (

- + {hasCurve ? ( + + ) : ( +

+ {t("insights.workouts.detail.hrNoProfile")} +

+ )}
);