From cea330bf70aff9dc3dfb90e71c809aa744b16fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:51:13 +0200 Subject: [PATCH] Say when a session has heart-rate figures but no profile behind them The heart-rate card hid itself whenever the server resolved no curve. That is right for a session with no heart rate at all, but wrong for the common case it also covered: a ride logged from a chest strap through a provider that reports an average and a peak and nothing in between. The stats grid shows those two numbers, the card next to them renders nothing, and the page reads as if it has no heart-rate section rather than as if the shape behind the numbers is not on record. Three states now. A curve when there is one; a named absence when the session carries heart-rate figures without a profile; nothing at all when nothing was ever measured, since there is no absence to report there and an empty card would be a shell. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/workout-detail.test.tsx | 23 ++++++++- .../insights/workout-detail/hr-section.tsx | 51 ++++++++++++++----- 8 files changed, 66 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index f4414e635..fd827a575 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3114,6 +3114,7 @@ "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 dfcbd366f..c27ca78b6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3114,6 +3114,7 @@ "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 900b2fb4d..a4c12b689 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3114,6 +3114,7 @@ "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 d87e1247a..e33fbc113 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3114,6 +3114,7 @@ "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 5cf1d6856..6f61e22e6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3114,6 +3114,7 @@ "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 3d3f2c64e..2b303a47f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3114,6 +3114,7 @@ "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/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index aa6306131..a7916eaaa 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -207,8 +207,27 @@ describe("", () => { }); 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 7106ae280..37ac56b5b 100644 --- a/src/components/insights/workout-detail/hr-section.tsx +++ b/src/components/insights/workout-detail/hr-section.tsx @@ -37,15 +37,33 @@ export interface WorkoutDetailHrSectionProps { * Heart-rate curve card — mean line + optional min→max envelope + %HRmax * 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")} +

+ )}
);