Keep the workout heart-rate axis on the readings it plots

The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.

The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.

The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.

Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 14:50:03 +02:00
1 parent 2f14379066
commit 0c99c27bfc
10 files changed
+328 -64

No files matched your search

+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Workout nicht gefunden.",
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
"hrAxisMinutes": "Min.",
"hrAvgMarker": "Ø {bpm}",
"hrMaxMarker": "Max. {bpm}",
"hrRangeValue": "{low} bis {high} bpm",
"hrRangeLabel": "Spanne",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
"zonesTitle": "Belastungszonen",
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Workout not found.",
"hrFromPulse": "From your heart-rate data around this session",
"hrAxisMinutes": "min",
"hrAvgMarker": "Avg {bpm}",
"hrMaxMarker": "Peak {bpm}",
"hrRangeValue": "{low} to {high} bpm",
"hrRangeLabel": "Range",
"sportAverageLabel": "Your recent average:",
"zonesTitle": "Effort zones",
"zonesCaption": "How your effort was distributed across heart-rate zones.",
+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Entrenamiento no encontrado.",
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
"hrAxisMinutes": "min",
"hrAvgMarker": "Media {bpm}",
"hrMaxMarker": "Máx. {bpm}",
"hrRangeValue": "{low} a {high} bpm",
"hrRangeLabel": "Rango",
"sportAverageLabel": "Tu promedio reciente:",
"zonesTitle": "Zonas de esfuerzo",
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Entraînement introuvable.",
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
"hrAxisMinutes": "min",
"hrAvgMarker": "Moy. {bpm}",
"hrMaxMarker": "Max. {bpm}",
"hrRangeValue": "{low} à {high} bpm",
"hrRangeLabel": "Plage",
"sportAverageLabel": "Votre moyenne récente :",
"zonesTitle": "Zones d'effort",
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Allenamento non trovato.",
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
"hrAxisMinutes": "min",
"hrAvgMarker": "Media {bpm}",
"hrMaxMarker": "Max {bpm}",
"hrRangeValue": "da {low} a {high} bpm",
"hrRangeLabel": "Intervallo",
"sportAverageLabel": "La tua media recente:",
"zonesTitle": "Zone di sforzo",
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
+4
View File
@@ -3110,6 +3110,10 @@
"notFound": "Nie znaleziono treningu.",
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
"hrAxisMinutes": "min",
"hrAvgMarker": "Śr. {bpm}",
"hrMaxMarker": "Maks. {bpm}",
"hrRangeValue": "{low} do {high} bpm",
"hrRangeLabel": "Zakres",
"sportAverageLabel": "Twoja ostatnia średnia:",
"zonesTitle": "Strefy wysiłku",
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
@@ -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
@@ -35,7 +35,7 @@ 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).
@@ -70,6 +70,7 @@ export function WorkoutDetailHrSection({
bucketSec={series.bucketSec}
envelope={series.envelope}
avgHr={workout.avgHr}
maxHr={workout.maxHr}
zones={workout.zones?.zones ?? null}
/>
</CardContent>