diff --git a/messages/de.json b/messages/de.json index 722d99e3b..bd178a38a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3110,6 +3110,11 @@ "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", + "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 7afbc0765..62a606b7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3110,6 +3110,11 @@ "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", + "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 8f6a2f6b1..95921a55c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3110,6 +3110,11 @@ "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", + "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 f26b406aa..60ceddd0d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3110,6 +3110,11 @@ "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", + "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 7175278a8..fac161835 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3110,6 +3110,11 @@ "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", + "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 972357d82..8e8b9e4bd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3110,6 +3110,11 @@ "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", + "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/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} ", () => { }); 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 1ba60a4c4..37ac56b5b 100644 --- a/src/components/insights/workout-detail/hr-section.tsx +++ b/src/components/insights/workout-detail/hr-section.tsx @@ -35,17 +35,35 @@ export interface WorkoutDetailHrSectionProps { /** * Heart-rate curve card — mean line + optional min→max envelope + %HRmax - * zone bands + an average-HR reference line. A muted provenance chip + * 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")} +

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