fix(sleep): drop the misleading last-night timeline for estimated stages

The last-night card drew a stepped stage timeline for every source. For a
source that reports per-stage totals without measured onsets (WHOOP / Polar),
the server lays the segments out in a fixed physiological order, so the bar
invents a phase progression that was never recorded — it reads as one Core
block then one Deep block, not a real hypnogram.

Suppress the timeline when the night is reconstructed and keep the per-stage
breakdown + totals, which are real. A calm note explains that stage timing is
estimated for that source. Real-series nights (Apple Health, Withings, Fitbit)
keep the measured timeline unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:11:18 +02:00
1 parent ae3091df9e
commit c69e6deb67
8 files changed
+67 -8

No files matched your search

+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Geschlafen {value}",
"inBedTotal": "Im Bett {value}",
"awakenings": "{count} Wachphasen",
"source": "Quelle: {source}"
"source": "Quelle: {source}",
"estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf."
},
"stages": {
"deep": "Tief",
+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Asleep {value}",
"inBedTotal": "In bed {value}",
"awakenings": "{count} awakenings",
"source": "Source: {source}"
"source": "Source: {source}",
"estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline."
},
"stages": {
"deep": "Deep",
+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Dormido {value}",
"inBedTotal": "En la cama {value}",
"awakenings": "{count} despertares",
"source": "Fuente: {source}"
"source": "Fuente: {source}",
"estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida."
},
"stages": {
"deep": "Profundo",
+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sommeil {value}",
"inBedTotal": "Au lit {value}",
"awakenings": "{count} réveils",
"source": "Source : {source}"
"source": "Source : {source}",
"estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée."
},
"stages": {
"deep": "Profond",
+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sonno {value}",
"inBedTotal": "A letto {value}",
"awakenings": "{count} risvegli",
"source": "Fonte: {source}"
"source": "Fonte: {source}",
"estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata."
},
"stages": {
"deep": "Profondo",
+2 -1
View File
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sen {value}",
"inBedTotal": "W łóżku {value}",
"awakenings": "{count} przebudzeń",
"source": "Źródło: {source}"
"source": "Źródło: {source}",
"estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg."
},
"stages": {
"deep": "Głęboki",
@@ -127,6 +127,30 @@ describe("<SleepHypnogram> — timeline bar visibility", () => {
expect(render(session)).toContain('role="img"');
});
it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => {
// WHOOP / Polar report per-stage TOTALS and the server lays the segments
// out in a fixed physiological order — the timeline would invent a phase
// progression that was never measured. So even with distinct, well-timed
// spans, a `reconstructed` night drops the bar and keeps the breakdown.
const session: SleepHypnogramSession = {
...baseSession,
source: "WHOOP",
reconstructed: true,
};
const html = render(session);
expect(html).not.toContain('role="img"');
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"');
});
it("keeps the timeline bar for a measured (non-reconstructed) night", () => {
// An Apple Health night carries real per-stage onsets, so the stepped bar
// is genuine and stays. The estimate note never appears.
const html = render({ ...baseSession, reconstructed: false });
expect(html).toContain('role="img"');
expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"');
});
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
// Every segment stamped on ONE instant — no real timeline, so only the
// breakdown footer, no bar.
+31 -2
View File
@@ -58,6 +58,17 @@ export interface SleepHypnogramSession {
awakenings: number;
stages: Record<string, number>;
segments: SleepHypnogramSegment[];
/**
* `true` when the winning source has no measured per-stage onset
* timestamps and the server synthesised a contiguous timeline in a fixed
* physiological order (WHOOP / Polar). The "timeline" of such a night is
* not a real hypnogram — it would read as one long Core block followed by
* one Deep block, never the phase-to-phase progression a measured night
* shows — so we never draw the misleading stepped bar for it and fall back
* to the per-stage breakdown alone. A real-series source (Apple Health,
* Withings, Fitbit) is `false` and keeps the timeline.
*/
reconstructed?: boolean;
}
export interface SleepHypnogramProps {
@@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// breakdown footer. (Gating on the END instant instead hid the bar for an
// ordinary multi-stage night whose segments happened to share a right
// edge — the regression this restores.)
// A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
// server laid its segments out in a fixed physiological order, so drawing
// the stepped bar would invent a phase progression that was never recorded
// (the "Core block then Deep block, no real breakdown" the bar reads as).
// For those we keep the per-stage breakdown + totals, which ARE real, and
// drop the misleading timeline entirely. Real-series sources keep the bar.
const hasTrack =
session.reconstructed !== true &&
spans.length > 0 &&
(spans.length === 1 ||
new Set(spans.map((s) => s.x1)).size > 1);
(spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
@@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
})}
</span>
) : null}
{/* When the source reports stage TOTALS but no measured onsets
(WHOOP / Polar) the stepped timeline is suppressed — drawing one
would invent a phase order that was never recorded. Surface a calm
note so the breakdown below doesn't read as a missing chart. */}
{session.reconstructed === true && hasBreakdown ? (
<span
data-slot="sleep-hypnogram-estimate-note"
className="text-muted-foreground/70 text-xs"
>
{t("insights.sleep.hypnogram.estimateNote")}
</span>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">