Merge pull request #721 from MBombeck/feat/workout-hr-profile

Show the whole workout heart-rate profile
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 19:01:51 +02:00
commit 1f4b8f43dc
14 files changed
+775 -86

No files matched your search

+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
@@ -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();
});
});
@@ -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),
];
}
+82 -63
View File
@@ -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 (
<div
@@ -137,7 +125,7 @@ export function WorkoutHrChart({
unit=""
/>
<YAxis
domain={["dataMin - 10", "dataMax + 10"]}
domain={yDomain ?? ["dataMin - 10", "dataMax + 10"]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
@@ -154,37 +142,41 @@ export function WorkoutHrChart({
labelFormatter={(tSec) =>
`${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 ? (
<>
<Area
type="monotone"
dataKey="lo"
stackId="band"
stroke="none"
fill="transparent"
connectNulls={false}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="hi"
stackId="band"
stroke="none"
fill="var(--chart-1)"
fillOpacity={0.15}
connectNulls={false}
isAnimationActive={false}
/>
</>
<Area
type="monotone"
dataKey="band"
stroke="none"
fill="var(--chart-1)"
fillOpacity={0.15}
connectNulls={false}
isAnimationActive={false}
/>
) : null}
{avgHr != null ? (
<ReferenceLine
@@ -192,6 +184,33 @@ export function WorkoutHrChart({
stroke="var(--muted-foreground)"
strokeDasharray="5 5"
strokeOpacity={0.7}
label={{
value: t("insights.workouts.detail.hrAvgMarker", {
bpm: String(Math.round(avgHr)),
}),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 11,
}}
/>
) : 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 ? (
<ReferenceLine
y={maxHr}
stroke="var(--chart-4)"
strokeDasharray="4 4"
strokeOpacity={0.6}
label={{
value: t("insights.workouts.detail.hrMaxMarker", {
bpm: String(Math.round(maxHr)),
}),
position: "insideBottomLeft",
fill: "var(--muted-foreground)",
fontSize: 11,
}}
/>
) : null}
<Line
@@ -207,8 +207,27 @@ describe("<WorkoutDetailRoute>", () => {
});
describe("<WorkoutDetailHrSection>", () => {
it("returns null without an HR series", () => {
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).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(<WorkoutDetailHrSection workout={FIXTURE} />);
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(<WorkoutDetailHrSection workout={noHr} />)).toBe("");
});
it("renders the card and a provenance chip for a pulse-window series", () => {
@@ -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 (
<Card data-slot="workout-detail-hr">
@@ -55,7 +73,7 @@ export function WorkoutDetailHrSection({
title={t("insights.workouts.detail.hrChartTitle")}
titleAs="h2"
/>
{series.source === "pulse_window" ? (
{hasCurve && series!.source === "pulse_window" ? (
<p
data-slot="workout-detail-hr-provenance"
className="text-muted-foreground text-xs"
@@ -65,13 +83,23 @@ export function WorkoutDetailHrSection({
) : null}
</CardHeader>
<CardContent>
<WorkoutHrChart
points={series.points}
bucketSec={series.bucketSec}
envelope={series.envelope}
avgHr={workout.avgHr}
zones={workout.zones?.zones ?? null}
/>
{hasCurve ? (
<WorkoutHrChart
points={series!.points}
bucketSec={series!.bucketSec}
envelope={series!.envelope}
avgHr={workout.avgHr}
maxHr={workout.maxHr}
zones={workout.zones?.zones ?? null}
/>
) : (
<p
data-slot="workout-detail-hr-empty"
className="text-muted-foreground text-sm"
>
{t("insights.workouts.detail.hrNoProfile")}
</p>
)}
</CardContent>
</Card>
);
@@ -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));
});
});
+41 -9
View File
@@ -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,
);
@@ -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<string> {
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<typeof pulseRow>[];
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<WorkoutDetailBody> {
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);
});
});