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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 14:51:13 +02:00
1 parent 0c99c27bfc
commit cea330bf70
8 files changed
+66 -14

No files matched your search

+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -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", () => {
@@ -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 (
<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,14 +83,23 @@ export function WorkoutDetailHrSection({
) : null}
</CardHeader>
<CardContent>
<WorkoutHrChart
points={series.points}
bucketSec={series.bucketSec}
envelope={series.envelope}
avgHr={workout.avgHr}
maxHr={workout.maxHr}
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>
);