diff --git a/messages/de.json b/messages/de.json index 3dcc80763..4e8cd7244 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index a87949920..5c4c0206d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 99b6d7782..a69ad908c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 85cb525e9..8ca52cec5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 48507ed1d..7608d2186 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c60c651f4..32e690173 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index a04a0ea17..6452cd205 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -127,6 +127,30 @@ describe(" — 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. diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 27b341252..7e6797140 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -58,6 +58,17 @@ export interface SleepHypnogramSession { awakenings: number; stages: Record; 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) { })} ) : 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 ? ( + + {t("insights.sleep.hypnogram.estimateNote")} + + ) : null}