diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..f4414e635 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3110,6 +3110,10 @@ "notFound": "Workout nicht gefunden.", "hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit", "hrAxisMinutes": "Min.", + "hrAvgMarker": "Ø {bpm}", + "hrMaxMarker": "Max. {bpm}", + "hrRangeValue": "{low} bis {high} bpm", + "hrRangeLabel": "Spanne", "sportAverageLabel": "Dein aktueller Durchschnitt:", "zonesTitle": "Belastungszonen", "zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..dfcbd366f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3110,6 +3110,10 @@ "notFound": "Workout not found.", "hrFromPulse": "From your heart-rate data around this session", "hrAxisMinutes": "min", + "hrAvgMarker": "Avg {bpm}", + "hrMaxMarker": "Peak {bpm}", + "hrRangeValue": "{low} to {high} bpm", + "hrRangeLabel": "Range", "sportAverageLabel": "Your recent average:", "zonesTitle": "Effort zones", "zonesCaption": "How your effort was distributed across heart-rate zones.", diff --git a/messages/es.json b/messages/es.json index a800a3727..900b2fb4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3110,6 +3110,10 @@ "notFound": "Entrenamiento no encontrado.", "hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión", "hrAxisMinutes": "min", + "hrAvgMarker": "Media {bpm}", + "hrMaxMarker": "Máx. {bpm}", + "hrRangeValue": "{low} a {high} bpm", + "hrRangeLabel": "Rango", "sportAverageLabel": "Tu promedio reciente:", "zonesTitle": "Zonas de esfuerzo", "zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..d87e1247a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3110,6 +3110,10 @@ "notFound": "Entraînement introuvable.", "hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance", "hrAxisMinutes": "min", + "hrAvgMarker": "Moy. {bpm}", + "hrMaxMarker": "Max. {bpm}", + "hrRangeValue": "{low} à {high} bpm", + "hrRangeLabel": "Plage", "sportAverageLabel": "Votre moyenne récente :", "zonesTitle": "Zones d'effort", "zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..5cf1d6856 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3110,6 +3110,10 @@ "notFound": "Allenamento non trovato.", "hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione", "hrAxisMinutes": "min", + "hrAvgMarker": "Media {bpm}", + "hrMaxMarker": "Max {bpm}", + "hrRangeValue": "da {low} a {high} bpm", + "hrRangeLabel": "Intervallo", "sportAverageLabel": "La tua media recente:", "zonesTitle": "Zone di sforzo", "zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..3d3f2c64e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3110,6 +3110,10 @@ "notFound": "Nie znaleziono treningu.", "hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji", "hrAxisMinutes": "min", + "hrAvgMarker": "Śr. {bpm}", + "hrMaxMarker": "Maks. {bpm}", + "hrRangeValue": "{low} do {high} bpm", + "hrRangeLabel": "Zakres", "sportAverageLabel": "Twoja ostatnia średnia:", "zonesTitle": "Strefy wysiłku", "zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.", diff --git a/src/components/charts/__tests__/workout-hr-chart-data.test.ts b/src/components/charts/__tests__/workout-hr-chart-data.test.ts new file mode 100644 index 000000000..a4911e729 --- /dev/null +++ b/src/components/charts/__tests__/workout-hr-chart-data.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { + buildWorkoutHrChartData, + workoutHrAxisDomain, + type WorkoutHrCurvePoint, +} from "@/components/charts/workout-hr-chart-data"; + +/** + * The workout heart-rate curve's data mapping. The assertions that matter + * are the two that decide whether the rendered curve tells the truth: + * every value the axis sees has to be a bpm reading (so the domain lands + * on the band the session was actually in), and a hole in the recording + * has to stay a hole. + */ + +const BUCKET = 10; + +/** A dense stretch with a two-bucket hole at 20 s and 30 s. */ +const POINTS: WorkoutHrCurvePoint[] = [ + { tSec: 0, mean: 128, min: 122, max: 134 }, + { tSec: 10, mean: 141, min: 133, max: 149 }, + { tSec: 40, mean: 166, min: 158, max: 174 }, +]; + +describe("buildWorkoutHrChartData", () => { + it("lays the buckets on a complete elapsed grid", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data.map((d) => d.tSec)).toEqual([0, 10, 20, 30, 40]); + }); + + it("keeps a hole in the recording as a hole", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data[2]).toEqual({ tSec: 20, mean: null, band: null }); + expect(data[3]).toEqual({ tSec: 30, mean: null, band: null }); + }); + + it("carries the envelope as the bucket's own [min, max] pair", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + expect(data[0].band).toEqual([122, 134]); + expect(data[4].band).toEqual([158, 174]); + }); + + /** + * The regression this mapping exists to prevent. The band used to ship + * as a stacked pair — a base at `min` plus a `max - min` delta — and a + * stack is measured from zero, so the axis domain collapsed to include + * 0 and the curve was drawn squashed against the top of the plot. Every + * number handed to the chart must be a bpm reading in the session's own + * range; a spread posing as a value is what broke it. + */ + it("hands the axis nothing but bpm readings — no spread, no zero baseline", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + const lowestReading = Math.min(...POINTS.map((p) => p.min)); + const highestReading = Math.max(...POINTS.map((p) => p.max)); + + const values = data.flatMap((d) => [ + ...(d.mean == null ? [] : [d.mean]), + ...(d.band ?? []), + ]); + expect(values.length).toBeGreaterThan(0); + for (const value of values) { + expect(value).toBeGreaterThanOrEqual(lowestReading); + expect(value).toBeLessThanOrEqual(highestReading); + } + }); + + it("drops the band when the server judged the buckets too sparse for one", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, false); + expect(data.every((d) => d.band === null)).toBe(true); + expect(data[0].mean).toBe(128); + }); + + it("maps an empty or unusable series to no data at all", () => { + expect(buildWorkoutHrChartData([], BUCKET, true)).toEqual([]); + expect(buildWorkoutHrChartData(POINTS, 0, true)).toEqual([]); + }); +}); + +describe("workoutHrAxisDomain", () => { + const data = buildWorkoutHrChartData(POINTS, BUCKET, true); + + it("covers the plotted readings with breathing room", () => { + expect(workoutHrAxisDomain(data)).toEqual([112, 184]); + }); + + /** + * A reference line outside the axis domain is discarded by recharts, + * not clamped — so a peak above the curve's own highest bucket mean + * would simply never be drawn. The reported peak is a single reading + * and the curve plots bucket means, so that is the common case, not + * the edge one. + */ + it("stretches to hold a peak the bucketed curve never reaches", () => { + const domain = workoutHrAxisDomain(data, [145, 191]); + expect(domain).not.toBeNull(); + expect(domain![1]).toBeGreaterThanOrEqual(191); + }); + + it("stretches downwards for an average below the curve too", () => { + const domain = workoutHrAxisDomain(data, [96, null]); + expect(domain![0]).toBeLessThanOrEqual(96); + }); + + it("ignores markers that are absent or not numbers", () => { + expect(workoutHrAxisDomain(data, [null, undefined, Number.NaN])).toEqual( + workoutHrAxisDomain(data), + ); + }); + + it("leaves the axis alone when there is nothing plotted", () => { + expect(workoutHrAxisDomain([], [140])).toBeNull(); + }); +}); diff --git a/src/components/charts/workout-hr-chart-data.ts b/src/components/charts/workout-hr-chart-data.ts new file mode 100644 index 000000000..e61ea1ba0 --- /dev/null +++ b/src/components/charts/workout-hr-chart-data.ts @@ -0,0 +1,106 @@ +/** + * Data mapping for the per-workout heart-rate curve. + * + * Pure and separate from the chart component so the shape the axis reads + * can be asserted without mounting recharts — the y-axis domain is + * computed from these values, and getting them wrong flattens the curve + * rather than erroring. + */ + +export interface WorkoutHrCurvePoint { + /** Elapsed seconds from the workout start (bucket left edge). */ + tSec: number; + mean: number; + min: number; + max: number; +} + +export interface WorkoutHrChartDatum { + tSec: number; + /** Bucket mean, or null where the recording has a hole. */ + mean: number | null; + /** + * The min→max ribbon as an explicit `[low, high]` pair — recharts reads + * an array `dataKey` as a range area and takes its baseline from the + * first element. + * + * This deliberately is NOT a stacked pair of `min` and `max - min`. + * Stacked series are measured from zero: recharts derives the axis + * domain for a stack from the stacked offsets, whose baseline is 0 + * (`getDomainOfStackGroups` over `stackOffsetNone`). A chart asking for + * `dataMin - 10` then resolves to −10 instead of to the bottom of the + * band, and a session spent between 120 and 170 bpm gets drawn on a + * −10…180 axis — the shape of the effort, which is the whole point of + * the curve, squashes into the top quarter. A range area keeps the + * domain on the bpm values that are actually in the data. + */ + band: [number, number] | null; +} + +/** + * Lay the bucket points onto a complete elapsed-time grid. + * + * A missing bucket becomes an explicit null rather than being skipped, so + * a hole in the recording breaks the line (`connectNulls` false) instead + * of being drawn across as if the reading had continued. Absence reads as + * absence. + * + * `envelope` is the server's density verdict: below its threshold the + * per-bucket min→max spread is noise rather than signal, and the chart + * draws the mean line alone. + */ +export function buildWorkoutHrChartData( + points: readonly WorkoutHrCurvePoint[], + bucketSec: number, + envelope: boolean, +): WorkoutHrChartDatum[] { + if (points.length === 0 || bucketSec <= 0) return []; + const byT = new Map(points.map((p) => [p.tSec, p])); + const maxT = points[points.length - 1].tSec; + const grid: WorkoutHrChartDatum[] = []; + for (let tSec = 0; tSec <= maxT; tSec += bucketSec) { + const p = byT.get(tSec); + grid.push({ + tSec, + mean: p ? p.mean : null, + band: p && envelope ? [p.min, p.max] : null, + }); + } + return grid; +} + +/** Breathing room above and below the plotted readings, in bpm. */ +const AXIS_PAD_BPM = 10; + +/** + * The bpm range the y-axis has to cover: every plotted reading plus every + * reference line drawn over them. + * + * The markers have to be in the domain or they are not drawn at all — + * recharts discards an out-of-range `ReferenceLine` rather than clamping + * it. The session's reported peak is routinely above the curve's own + * highest point, because the curve plots bucket means and a peak is a + * single reading, so a domain derived from the data alone drops the peak + * marker on exactly the sessions that have one. Silently: no warning, no + * gap, just a line that never appears. + * + * Returns null for an empty series, leaving the axis to recharts. + */ +export function workoutHrAxisDomain( + data: readonly WorkoutHrChartDatum[], + markers: readonly (number | null | undefined)[] = [], +): [number, number] | null { + const values: number[] = []; + for (const datum of data) { + if (datum.mean != null) values.push(datum.mean); + if (datum.band) values.push(datum.band[0], datum.band[1]); + } + if (values.length === 0) return null; + for (const marker of markers) { + if (marker != null && Number.isFinite(marker)) values.push(marker); + } + return [ + Math.floor(Math.min(...values) - AXIS_PAD_BPM), + Math.ceil(Math.max(...values) + AXIS_PAD_BPM), + ]; +} diff --git a/src/components/charts/workout-hr-chart.tsx b/src/components/charts/workout-hr-chart.tsx index 9a5fe387e..78731cee0 100644 --- a/src/components/charts/workout-hr-chart.tsx +++ b/src/components/charts/workout-hr-chart.tsx @@ -15,6 +15,11 @@ import { } from "recharts"; import { useTranslations } from "@/lib/i18n/context"; +import { + buildWorkoutHrChartData, + workoutHrAxisDomain, + type WorkoutHrCurvePoint, +} from "./workout-hr-chart-data"; /** * Per-workout heart-rate curve. Rendered ONLY through the shared @@ -22,20 +27,15 @@ import { useTranslations } from "@/lib/i18n/context"; * library stays a single shared chunk — never import this file directly * at a call site. * - * `ComposedChart`: an `Area` for the min→max envelope (drawn only when - * the series is dense enough to be honest), a `Line` for the bucket - * mean, optional %HRmax zone bands behind them, and a dashed reference - * line at the workout's average HR. Gaps stay gaps (`connectNulls` + * `ComposedChart`: a range `Area` for the min→max envelope (drawn only + * when the series is dense enough to be honest), a `Line` for the bucket + * mean, optional %HRmax zone bands behind them, and dashed reference + * lines at the session's average and peak. Gaps stay gaps (`connectNulls` * false) — a hole in the recording never reads as an interpolated * curve. */ -export interface WorkoutHrChartPoint { - tSec: number; - mean: number; - min: number; - max: number; -} +export type WorkoutHrChartPoint = WorkoutHrCurvePoint; export interface WorkoutHrZoneBand { zone: number; @@ -50,6 +50,8 @@ export interface WorkoutHrChartProps { envelope: boolean; /** Workout average HR for the reference line. */ avgHr: number | null; + /** Workout peak HR for the reference line. */ + maxHr?: number | null; /** Optional %HRmax zone bands for the shaded background. */ zones?: WorkoutHrZoneBand[] | null; } @@ -71,33 +73,19 @@ export function WorkoutHrChart({ bucketSec, envelope, avgHr, + maxHr, zones, }: WorkoutHrChartProps) { const { t } = useTranslations(); - // Build a full elapsed-time grid so a gap of missing buckets breaks - // the line rather than interpolating across it. - const data = useMemo(() => { - if (points.length === 0) return []; - const byT = new Map(points.map((p) => [p.tSec, p])); - const maxT = points[points.length - 1].tSec; - const grid: Array<{ - tSec: number; - mean: number | null; - lo: number | null; - hi: number | null; - }> = []; - for (let tSec = 0; tSec <= maxT; tSec += bucketSec) { - const p = byT.get(tSec); - grid.push({ - tSec, - mean: p ? p.mean : null, - lo: p ? p.min : null, - hi: p && envelope ? p.max - p.min : null, - }); - } - return grid; - }, [points, bucketSec, envelope]); + const data = useMemo( + () => buildWorkoutHrChartData(points, bucketSec, envelope), + [points, bucketSec, envelope], + ); + const yDomain = useMemo( + () => workoutHrAxisDomain(data, [avgHr, maxHr]), + [data, avgHr, maxHr], + ); return (