fix(sleep): the hypnogram keeps its timeline when a summary writer joins the night

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 20:30:59 +02:00
1 parent 4493eb2cac
commit 5859072561
3 files changed
+89 -6

No files matched your search

+9 -1
View File
@@ -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 (
@@ -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).
+34 -5
View File
@@ -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<string, number>();
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;