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