From 58590725614e45fb74c504ac629a81ed02af4db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 20:30:59 +0200 Subject: [PATCH] fix(sleep): the hypnogram keeps its timeline when a summary writer joins the night MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A writer whose stage rows all share one timestamp carries night totals, not segments — reconstructing them as segments paints every stage flush against the night's right edge. The session picker now prefers a writer with genuinely timed rows among the granular candidates, so a stage summary backfilled for a night an actually-timed writer also recorded no longer evicts the real timeline; summary-only nights still resolve. The hypnogram itself degrades a summary-shaped session to the stage breakdown instead of drawing the false skyline. --- src/components/insights/sleep-hypnogram.tsx | 10 +++- .../analytics/__tests__/sleep-night.test.ts | 46 +++++++++++++++++++ src/lib/analytics/sleep-night.ts | 39 ++++++++++++++-- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index c61f3fa66..c337e25c5 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -209,7 +209,15 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { // need any positive per-stage minutes. A legacy stage-summary night (no // segment spans) still shows its breakdown rather than collapsing the // whole card. "Unavailable" is reserved for a truly empty session. - const hasTrack = spans.length > 0; + // Spans must also carry more than one distinct end instant: a summary- + // shaped writer (per-stage night totals all stamped on the sleep end) + // reconstructs into bars that all touch the night's right edge — that + // is a breakdown wearing a timeline costume, so paint it as the + // breakdown footer instead. + const hasTrack = + spans.length > 0 && + (spans.length === 1 || + new Set(spans.map((s) => s.x2)).size > 1); const hasBreakdown = breakdown.length > 0; return ( diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 93fc874ec..17b32973c 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -403,6 +403,52 @@ describe("reconstructSleepSessions", () => { expect(s.inBedMinutes).toBe(510); }); + it("prefers a per-segment timeline writer over a summary-shaped writer (v1.16.11)", () => { + // WHOOP's real wire shape: the v2 API exposes only stage_summary, so + // every stage row of a night lands with ONE shared measuredAt — the + // sleep END instant. Reconstructing those as segments yields five + // spans all touching the right edge of the night. When Apple Health + // carries genuinely timed per-segment rows for the same night, the + // timed writer must win even though WHOOP ties (or wins) on distinct + // stage count and outranks Apple on the default ladder. + const end = "2026-06-04T05:00:00.000Z"; + const rows: SleepStageRow[] = [ + // WHOOP: full-house stage summary, all stamped on the sleep end. + srcRow(end, "IN_BED", 510, "WHOOP"), + srcRow(end, "AWAKE", 30, "WHOOP"), + srcRow(end, "CORE", 240, "WHOOP"), + srcRow(end, "DEEP", 120, "WHOOP"), + srcRow(end, "REM", 120, "WHOOP"), + // Apple Health: a partial but genuinely timed timeline (2 stages). + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("APPLE_HEALTH"); + // The surviving segments carry a real timeline: more than one + // distinct end instant. + const ends = new Set( + sessions[0].segments.map((seg) => seg.end.getTime()), + ); + expect(ends.size).toBeGreaterThan(1); + }); + + it("keeps a summary-shaped writer when nobody else covers the night (v1.16.11)", () => { + // WHOOP-only night — the stage summary is all there is, so it must + // still resolve (the timeline gate is conditional, not absolute). + const end = "2026-06-04T05:00:00.000Z"; + const rows: SleepStageRow[] = [ + srcRow(end, "CORE", 240, "WHOOP"), + srcRow(end, "DEEP", 120, "WHOOP"), + srcRow(end, "REM", 120, "WHOOP"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + expect(sessions[0].asleepMinutes).toBe(480); + }); + it("keeps the bare ASLEEP segment when NO granular stage exists", () => { // Legacy ASLEEP-only night — the bare aggregate IS the timeline, so it // must survive as a segment (otherwise the hypnogram would have nothing). diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 3b33034fd..590361df7 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -360,10 +360,12 @@ function pickSessionWriter( asleepByWriter.size > 0 ? [...asleepByWriter.keys()] : [...rowsByWriter.keys()]; - // Richness gate: keep only the writers tied for the MOST distinct granular - // stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse - // ASLEEP-only export; coarse writers survive only when NO writer carries a - // granular stage for the session. + // Three gates, in this order: + // 1. granular PRESENCE — writers carrying any granular stage eliminate + // coarse ASLEEP-only writers (a phone aggregate must not mask a + // watch's hypnogram), coarse survives only when nobody is granular; + // 2. timeline shape — see below; + // 3. max distinct-stage COUNT among the survivors. const richnessOf = new Map(); let maxRichness = 0; for (const key of writers) { @@ -372,7 +374,34 @@ function pickSessionWriter( if (score > maxRichness) maxRichness = score; } if (maxRichness > 0) { - writers = writers.filter((key) => richnessOf.get(key) === maxRichness); + writers = writers.filter((key) => (richnessOf.get(key) ?? 0) > 0); + } + // Timeline gate — BETWEEN granular presence and the max-richness count. + // A writer whose stage rows carry ≥2 distinct measuredAt instants has + // real per-segment timing; a summary-shaped writer stamps every stage + // total on one instant (WHOOP's stage_summary stamps everything on the + // sleep END), and the hypnogram would reconstruct its "segments" as + // five spans all touching the night's right edge. When at least one + // timeline writer covers the session, summary-only writers are out of + // the running BEFORE the richness count — WHOOP's guaranteed full-house + // stage count must not eliminate a partial but genuinely-timed Apple + // night. A summary-only writer still wins nights nobody else recorded. + const distinctInstantsOf = (writerRows: readonly SleepStageRow[]): number => + new Set(writerRows.map((r) => r.measuredAt.getTime())).size; + const timelineWriters = writers.filter( + (key) => distinctInstantsOf(rowsByWriter.get(key) ?? []) > 1, + ); + if (timelineWriters.length > 0) { + writers = timelineWriters; + } + // Richness gate among the survivors: most distinct granular stages wins. + let maxTimedRichness = 0; + for (const key of writers) { + const score = richnessOf.get(key) ?? 0; + if (score > maxTimedRichness) maxTimedRichness = score; + } + if (maxTimedRichness > 0) { + writers = writers.filter((key) => richnessOf.get(key) === maxTimedRichness); } if (writers.length <= 1) return writers[0] ?? NO_SOURCE;