From a3b0af577f0de5cbdf90907594c52b88663a16d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 05:24:02 +0200 Subject: [PATCH] fix(sleep): count a re-exported sleep period once A device can write the same period twice: an original envelope and a corrected one, same start, later end, distinct external ids. Both rows belong to the same writer, so the writer collapse keeps both, and the per-stage totals added them. One night served a 10 h 13 min in-bed headline next to a 20 h 17 min in-bed legend on the same screen, and the hypnogram drew the night twice over. Every per-stage total is now the measure of the union of that stage's spans rather than the sum of the rows, and the same merged spans feed the hypnogram segments and the time-asleep headline. Stages merge independently: two stages describing one minute is a contest, and the writer collapse is this file's mechanism for that - arbitrating again would collapse the summary-shaped writers, which stamp every stage total on the sleep end instant and nest by design. stages.IN_BED is now the in-bed envelope itself, the same union inBedMinutes reports, so the legend cannot contradict the headline and a night whose envelope only a losing writer recorded still reports it. --- .../api/sleep/night/__tests__/route.test.ts | 27 ++ .../analytics/__tests__/sleep-night.test.ts | 349 ++++++++++++++++++ src/lib/analytics/sleep-night.ts | 205 ++++++++-- 3 files changed, 554 insertions(+), 27 deletions(-) diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index c8d43feab..1e28ffd85 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -201,4 +201,31 @@ describe("GET /api/sleep/night", () => { const res = await GET(req("date=2026-06-04")); expect(res.status).toBe(200); // never 500 on the reconstruction edge }); + it("cannot serve an in-bed headline that contradicts its stage legend", async () => { + // A device wrote the same night twice: an envelope 16:06 -> 02:09:59 and + // a corrected one 16:06 -> 02:18:59. Raw-summing them served a 613-minute + // headline next to a 1217-minute legend on the same screen. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + stage("2026-07-25T02:09:59.000Z", "IN_BED", 603 + 59 / 60), + stage("2026-07-25T02:18:59.000Z", "IN_BED", 612 + 59 / 60), + stage("2026-07-24T21:55:42.000Z", "CORE", 319.7), + stage("2026-07-24T23:30:36.000Z", "DEEP", 94.9), + stage("2026-07-24T23:37:31.200Z", "AWAKE", 6.92), + stage("2026-07-25T01:53:25.200Z", "REM", 135.9), + ] as never); + const res = await GET(req("date=2026-07-25")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.main.inBedMinutes).toBe(613); + expect(body.data.main.stages.IN_BED).toBe(613); + // One in-bed bar on the hypnogram, not the night drawn twice over. + expect( + body.data.main.segments.filter( + (seg: { stage: string }) => seg.stage === "IN_BED", + ), + ).toHaveLength(1); + expect(body.data.main.stages.CORE).toBe(320); + expect(body.data.main.asleepMinutes).toBe(551); + }); }); diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 7cb83ecb9..4f4bce2c3 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -1252,3 +1252,352 @@ describe("night-level source discrepancy (dashboard path)", () => { expect(latestNight?.sourceDiscrepancy).toBeNull(); }); }); + +/** + * Overlapping spans within ONE writer. A device can write the SAME period + * twice — an original envelope and a corrected one, same start, later end, + * distinct external ids. Both rows belong to the same writer, so the + * writer collapse keeps both, and adding their minute counts reports the + * period twice. + */ +describe("overlapping spans within one writer", () => { + /** 16:06:00Z → 02:09:59Z — the superseded envelope. */ + const IN_BED_ORIGINAL = 603 + 59 / 60; + /** 16:06:00Z → 02:18:59Z — the corrected envelope, same start. */ + const IN_BED_REVISED = 612 + 59 / 60; + + /** + * One reported night: two IN_BED rows sharing a start, four staged + * segments inside the envelope, one AWAKE bout mid-sleep. Raw-summing the + * two envelopes yields 1217 min (20 h 17 min) against a 613 min (10 h 13 + * min) union. + */ + function revisedEnvelopeNight(): SleepStageRow[] { + return [ + srcRow( + "2026-07-25T02:09:59.000Z", + "IN_BED", + IN_BED_ORIGINAL, + "APPLE_HEALTH", + ), + srcRow( + "2026-07-25T02:18:59.000Z", + "IN_BED", + IN_BED_REVISED, + "APPLE_HEALTH", + ), + srcRow("2026-07-24T21:55:42.000Z", "CORE", 319.7, "APPLE_HEALTH"), + srcRow("2026-07-24T23:30:36.000Z", "DEEP", 94.9, "APPLE_HEALTH"), + srcRow("2026-07-24T23:37:31.200Z", "AWAKE", 6.92, "APPLE_HEALTH"), + srcRow("2026-07-25T01:53:25.200Z", "REM", 135.9, "APPLE_HEALTH"), + ]; + } + + it("reports the union, not the sum, when a revised in-bed row supersedes the original", () => { + const sessions = reconstructSleepSessions(revisedEnvelopeNight(), "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + // Headline and legend are ONE number: 613, never 1217. + expect(Math.round(s.inBedMinutes!)).toBe(613); + expect(Math.round(s.stages.IN_BED!)).toBe(613); + expect(s.stages.IN_BED).toBe(s.inBedMinutes); + // The staged totals of that night were never the defect. + expect(Math.round(s.stages.CORE!)).toBe(320); + expect(Math.round(s.stages.DEEP!)).toBe(95); + expect(Math.round(s.stages.REM!)).toBe(136); + expect(Math.round(s.stages.AWAKE!)).toBe(7); + expect(Math.round(s.asleepMinutes)).toBe(551); + expect(s.awakenings).toBe(1); + }); + + it("draws the revised in-bed envelope as ONE hypnogram segment", () => { + const sessions = reconstructSleepSessions(revisedEnvelopeNight(), "UTC"); + const inBed = sessions[0].segments.filter((seg) => seg.stage === "IN_BED"); + expect(inBed).toHaveLength(1); + expect(inBed[0].start.toISOString()).toBe("2026-07-24T16:06:00.000Z"); + expect(inBed[0].end.toISOString()).toBe("2026-07-25T02:18:59.000Z"); + // Five bars, not six: the superseded envelope is gone, not stacked. + expect(sessions[0].segments).toHaveLength(5); + }); + + it("carries the same union onto the night-level headline and legend", () => { + const nights = reconstructSleepNights(revisedEnvelopeNight(), "UTC"); + expect(nights).toHaveLength(1); + expect(Math.round(nights[0].inBedMinutes!)).toBe(613); + expect(nights[0].stages.IN_BED).toBe(nights[0].inBedMinutes); + expect(Math.round(nights[0].stages.CORE!)).toBe(320); + expect(Math.round(nights[0].asleepMinutes)).toBe(551); + }); + + it("merges a re-exported CORE span instead of counting it twice", () => { + // The same correction shape on a sleep stage rather than the envelope: + // 22:00 → 00:00 superseded by 22:00 → 00:30. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:00:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T00:30:00.000Z", "CORE", 150, "APPLE_HEALTH"), + srcRow("2026-06-04T02:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].stages.CORE).toBe(150); + expect(sessions[0].asleepMinutes).toBe(240); + expect(sessions[0].segments.filter((s) => s.stage === "CORE")).toHaveLength( + 1, + ); + }); + + it("keeps two TOUCHING awake bouts separate — touching is not overlap", () => { + // Consecutive AWAKE spans (one ends exactly where the next starts) + // describe two bouts, not one doubled export. Coalescing them would + // silently drop a mid-sleep awakening. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:00:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T00:10:00.000Z", "AWAKE", 10, "APPLE_HEALTH"), + srcRow("2026-06-04T00:20:00.000Z", "AWAKE", 10, "APPLE_HEALTH"), + srcRow("2026-06-04T02:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].stages.AWAKE).toBe(20); + expect( + sessions[0].segments.filter((s) => s.stage === "AWAKE"), + ).toHaveLength(2); + expect(sessions[0].awakenings).toBe(2); + }); + + it("leaves a summary-shaped writer's nested stage spans alone", () => { + // WHOOP stamps every stage total on the sleep END instant, so its spans + // nest inside one another by design. Arbitrating across stages would + // collapse the night to its longest stage — the totals must survive. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T06:00:00.000Z", "CORE", 200, "WHOOP"), + srcRow("2026-06-04T06:00:00.000Z", "DEEP", 60, "WHOOP"), + srcRow("2026-06-04T06:00:00.000Z", "REM", 80, "WHOOP"), + srcRow("2026-06-04T06:00:00.000Z", "AWAKE", 15, "WHOOP"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].stages.CORE).toBe(200); + expect(sessions[0].stages.DEEP).toBe(60); + expect(sessions[0].stages.REM).toBe(80); + expect(sessions[0].asleepMinutes).toBe(340); + }); +}); + +describe("in-bed envelope across writers", () => { + it("unions two partially overlapping writer envelopes into headline AND legend", () => { + const rows: SleepStageRow[] = [ + writerRow( + "2026-06-04T02:00:00.000Z", + "CORE", + 200, + "APPLE_HEALTH", + "watch", + ), + // Watch 22:00 → 04:00, phone 00:00 → 06:00: union 22:00 → 06:00. + writerRow( + "2026-06-04T04:00:00.000Z", + "IN_BED", + 360, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T06:00:00.000Z", + "IN_BED", + 360, + "APPLE_HEALTH", + "phone", + ), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].inBedMinutes).toBe(480); + expect(sessions[0].stages.IN_BED).toBe(480); + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights[0].stages.IN_BED).toBe(nights[0].inBedMinutes); + }); + + it("keeps the legend on the headline when only a LOSING writer knows the envelope", () => { + // The watch wins the night on stage richness and writes no IN_BED row; + // the phone's bedtime detection holds the night's only envelope. The + // legend must report the envelope the headline reports, not nothing. + const rows: SleepStageRow[] = [ + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 120, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T02:30:00.000Z", + "DEEP", + 60, + "APPLE_HEALTH", + "watch", + ), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + writerRow( + "2026-06-04T04:30:00.000Z", + "IN_BED", + 460, + "APPLE_HEALTH", + "phone", + ), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].inBedMinutes).toBe(460); + expect(sessions[0].stages.IN_BED).toBe(460); + }); + + it("sums two DISJOINT in-bed intervals of one session without merging them", () => { + // Two separate in-bed windows less than the session gap apart: the + // envelope is the sum of the two unions, not the span between them. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:00:00.000Z", "IN_BED", 60, "APPLE_HEALTH"), + srcRow("2026-06-04T04:00:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "IN_BED", 60, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].inBedMinutes).toBe(120); + expect(sessions[0].stages.IN_BED).toBe(120); + expect( + sessions[0].segments.filter((s) => s.stage === "IN_BED"), + ).toHaveLength(2); + }); + + it("reports no in-bed signal at all when no IN_BED row exists", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T02:00:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 60, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions[0].inBedMinutes).toBeNull(); + expect(sessions[0].stages.IN_BED).toBeUndefined(); + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights[0].inBedMinutes).toBeNull(); + expect(nights[0].stages.IN_BED).toBeUndefined(); + }); +}); + +/** + * The invariant, checked over generated shapes rather than hand-picked ones: + * every stage total is the MEASURE OF THE UNION of that stage's spans, so no + * stage total can exceed the session's own envelope, and `stages.IN_BED` is + * the same number as `inBedMinutes` by construction. + */ +describe("stage-total invariant over generated sessions", () => { + /** Deterministic 32-bit PRNG — a failing seed is reproducible. */ + function prng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0; + return state / 4_294_967_296; + }; + } + + /** Total minutes covered by the union of a set of [start, end) spans. */ + function unionMinutes(spans: Array<{ start: number; end: number }>): number { + if (spans.length === 0) return 0; + const sorted = [...spans].sort((a, b) => a.start - b.start); + let total = 0; + let curStart = sorted[0].start; + let curEnd = sorted[0].end; + for (const span of sorted.slice(1)) { + if (span.start <= curEnd) { + if (span.end > curEnd) curEnd = span.end; + } else { + total += curEnd - curStart; + curStart = span.start; + curEnd = span.end; + } + } + return (total + (curEnd - curStart)) / 60_000; + } + + const GENERATED_STAGES: SleepStage[] = ["CORE", "DEEP", "REM", "AWAKE"]; + const BASE = Date.parse("2026-06-03T20:00:00.000Z"); + + it("holds for 500 randomly overlapping single-writer sessions", () => { + const rand = prng(20_260_724); + for (let round = 0; round < 500; round++) { + // One IN_BED row spans the whole window so every generated span lands + // in ONE session regardless of the gaps between the staged rows. + const rows: SleepStageRow[] = [ + srcRow( + new Date(BASE + 720 * 60_000).toISOString(), + "IN_BED", + 720, + "APPLE_HEALTH", + ), + ]; + // A second, deliberately overlapping in-bed envelope on most rounds — + // the corrected-export shape. + if (rand() < 0.7) { + const extra = 60 + Math.floor(rand() * 660); + rows.push( + srcRow( + new Date(BASE + (extra + 30) * 60_000).toISOString(), + "IN_BED", + extra, + "APPLE_HEALTH", + ), + ); + } + const spanCount = 2 + Math.floor(rand() * 8); + for (let i = 0; i < spanCount; i++) { + const stage = GENERATED_STAGES[Math.floor(rand() * 4)]; + const start = Math.floor(rand() * 600); + const minutes = 5 + Math.floor(rand() * 175); + rows.push( + srcRow( + new Date(BASE + (start + minutes) * 60_000).toISOString(), + stage, + minutes, + "APPLE_HEALTH", + ), + ); + } + + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + + // Independently recompute the spans of the raw rows. + const spansOf = (predicate: (r: SleepStageRow) => boolean) => + rows.filter(predicate).map((r) => ({ + start: r.measuredAt.getTime() - r.value * 60_000, + end: r.measuredAt.getTime(), + })); + const envelope = unionMinutes(spansOf(() => true)); + + for (const [stage, total] of Object.entries(s.stages)) { + // No stage total exceeds the session's union envelope… + expect(total).toBeLessThanOrEqual(envelope); + // …and each one IS the union measure of that stage's own spans. + expect(total).toBe( + stage === "IN_BED" + ? Math.round( + unionMinutes(spansOf((r) => r.sleepStage === "IN_BED")), + ) + : unionMinutes(spansOf((r) => r.sleepStage === stage)), + ); + } + // The legend never contradicts the headline. + expect(s.stages.IN_BED).toBe(s.inBedMinutes); + expect(s.awakeMinutes).toBe(s.stages.AWAKE ?? null); + // Time asleep is the same merged arithmetic as the legend it sits on. + const granular = ["CORE", "DEEP", "REM"].reduce( + (sum, stage) => sum + (s.stages[stage as SleepStage] ?? 0), + 0, + ); + expect(s.asleepMinutes).toBe(granular); + // Deliberately NOT asserted: that the stage totals SUM to no more than + // the envelope. Two stages describing the same minute is a contest, not + // a doubled export, and the writer collapse is the mechanism that + // settles it — arbitrating again here would collapse every + // summary-shaped writer (WHOOP / Polar stamp all stage totals on the + // sleep END instant, so their spans nest by design) to its longest + // stage. The per-stage bound above is what the merge can honestly + // guarantee, and it is what the contradictory legend needed. + } + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index d3a2d58ea..138e6fc9e 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -93,10 +93,28 @@ * the long IN_BED window — the watch wins the night on richness, and a * winner-only in-bed figure would shrink to nothing exactly when the * stage data is best. Overlapping spans from two writers are merged - * interval-union style so a doubled export never double-counts. The - * asleep total, the `stages` map, and `awakeMinutes` stay winner-only — - * blending the phone's coarse AWAKE blocks into the watch's night was - * the original inflated-awake bug this collapse exists to prevent. + * interval-union style so a doubled export never double-counts. + * `stages.IN_BED` IS that same number — one quantity, one computation — + * so the stage legend can never contradict the headline. The asleep + * total, `awakeMinutes` and every OTHER entry of the `stages` map stay + * winner-only — blending the phone's coarse AWAKE blocks into the + * watch's night was the original inflated-awake bug this collapse + * exists to prevent. + * + * Overlap within one writer + * ------------------------- + * A device can write the SAME period twice: an original envelope and a + * corrected one, same start, later end, distinct external ids. Both rows + * survive the writer collapse (they ARE the same writer), and adding both + * minute counts reports the night twice — the shape that made one night's + * in-bed legend read 20 h against a 10 h headline. Every per-stage total + * is therefore the measure of the UNION of that stage's spans, not the sum + * of the rows (`mergeOverlappingSegments`). Stages merge independently: a + * CORE span overlapping a DEEP span is a contest over what the minute WAS, + * and the writer collapse above is this file's one mechanism for that + * contest. The invariant that falls out and holds for every session: no + * stage total can exceed the union envelope of that stage's own spans, so + * none can exceed the session's envelope either. * * Unit * ---- @@ -166,21 +184,25 @@ function isRedundantBareAsleep( * bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps. * IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's * total only when no granular stage is present. + * + * Each stage contributes the measure of the UNION of its own spans + * (`mergeOverlappingSegments`), so a period a device re-exported under a + * corrected row is counted once — the headline cannot drift away from the + * per-stage legend that sums the same merged spans. */ function asleepMinutesOf(rows: readonly SleepStageRow[]): number { let granular = 0; let fallback = 0; let sawGranular = false; - for (const r of rows) { - const minutes = Number.isFinite(r.value) ? r.value : 0; - const stage = r.sleepStage; + for (const seg of mergeOverlappingSegments(rows.map(segmentOf))) { + const stage = seg.stage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) { - granular += minutes; + granular += seg.minutes; sawGranular = true; } else if (stage === "ASLEEP" || stage == null) { // Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual // row — only used when no granular stage carries the night. - fallback += minutes; + fallback += seg.minutes; } // IN_BED / AWAKE never count toward asleep. } @@ -274,7 +296,13 @@ export interface SleepNight { inBedMinutes: number | null; /** Awake-in-bed minutes when an AWAKE row exists, else null. */ awakeMinutes: number | null; - /** Per-stage minutes for the night (only stages the winning writer reported). */ + /** + * Per-stage minutes for the night. Every entry is the measure of the + * UNION of that stage's spans, never the raw row sum, so a re-exported + * period is counted once. Stages other than IN_BED come from the winning + * writer only; `stages.IN_BED` is the all-writer envelope and therefore + * always equals `inBedMinutes`. + */ stages: Partial>; /** * Non-null when two writer buckets reported clearly different asleep @@ -633,6 +661,10 @@ export function reconstructSleepNights( if (envelope !== null) { inBed += envelope; sawInBed = true; + // ONE quantity, ONE computation: `stages.IN_BED` IS the in-bed + // envelope, so the legend can never contradict the headline. The + // per-stage loop below skips IN_BED entirely for that reason. + stages.IN_BED = (stages.IN_BED ?? 0) + envelope; } // v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular // overnight does not strip a bare-only nap's minutes, and the per-stage @@ -645,20 +677,28 @@ export function reconstructSleepNights( asleep += sessionAsleep; let sessionEnd = Number.NEGATIVE_INFINITY; for (const r of sessionRows) { - const minutes = Number.isFinite(r.value) ? r.value : 0; if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; if (r.measuredAt.getTime() > sessionEnd) sessionEnd = r.measuredAt.getTime(); - const stage = r.sleepStage; - // Drop the redundant bare ASLEEP aggregate (and stage-less rows) from - // the per-stage breakdown when this session has the granular partition, - // so `stages` is never the granular set PLUS its ~equal bare twin. - if (isRedundantBareAsleep(stage, sawGranular)) continue; - if (stage) { - stages[stage] = (stages[stage] ?? 0) + minutes; - } + } + // Per-stage totals come from the OVERLAP-MERGED spans, never from the + // raw row sum: a device that re-exports a period it already wrote (a + // corrected envelope — same start, later end, fresh external id) would + // otherwise have both rows added and report the period twice. The + // granular-over-bare filter runs first, so `stages` is never the + // granular set PLUS its ~equal bare twin. IN_BED is skipped — it is the + // envelope, already carried above from the same union as `inBedMinutes`. + const merged = mergeOverlappingSegments( + sessionRows + .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular)) + .map(segmentOf), + ); + for (const seg of merged) { + const stage = seg.stage; + if (stage == null || stage === "IN_BED") continue; + stages[stage] = (stages[stage] ?? 0) + seg.minutes; if (stage === "AWAKE") { - awake += minutes; + awake += seg.minutes; sawAwake = true; } } @@ -730,7 +770,13 @@ export interface SleepSession { inBedMinutes: number | null; /** Awake-in-bed minutes when an AWAKE segment exists, else null. */ awakeMinutes: number | null; - /** Per-stage minutes for the session (only stages the device reported). */ + /** + * Per-stage minutes for the session. Every entry is the measure of the + * UNION of that stage's spans, never the raw row sum, so a corrected + * re-export of a period is counted once. Stages other than IN_BED come + * from the canonical writer only; `stages.IN_BED` is the all-writer + * envelope and therefore always equals `inBedMinutes`. + */ stages: Partial>; /** * Count of AWAKE segments between the first and last asleep segment — @@ -739,7 +785,11 @@ export interface SleepSession { * canonical source's segments only. */ awakenings: number; - /** The canonical source's segments, sorted by start, for the hypnogram. */ + /** + * The canonical source's segments, sorted by start, for the hypnogram. + * Overlapping spans of the same stage are merged, so a corrected + * re-export never draws a second bar across the night it revises. + */ segments: SleepSegment[]; /** * `true` when the winning writer's stage ORDER is synthesised rather than @@ -770,6 +820,96 @@ function segmentOf(r: SleepStageRow): SleepSegment { return { stage: r.sleepStage, start, end, minutes }; } +/** Map key for stage-less (`null`) spans — they group among themselves. */ +const NULL_STAGE_KEY = "__null_stage__"; + +/** + * Merge the spans of ONE stage that genuinely OVERLAP into their interval + * union. A device re-exporting a period it already wrote (a corrected + * envelope: same start, later end, fresh external id) lands as a SECOND row + * describing the SAME minutes, and adding both minute counts reports the + * night twice. The union is the only defensible total for two spans that + * describe one period, and it needs no arbitration — both rows say the same + * thing about the contested minute. + * + * Overlap is STRICT (`start < runningEnd`): two spans that merely TOUCH + * (`start === runningEnd`) describe consecutive periods, so they stay two + * segments — their union already equals their sum, and coalescing them would + * silently drop one mid-sleep awakening from `countAwakenings`. + * + * A group of exactly one span is returned VERBATIM, so a well-formed night + * (no overlap anywhere) keeps the stored per-row minutes bit-for-bit and only + * a genuinely doubled export is rewritten. + */ +function mergeStageSpans(spans: readonly SleepSegment[]): SleepSegment[] { + if (spans.length <= 1) return [...spans]; + const sorted = [...spans].sort( + (a, b) => + a.start.getTime() - b.start.getTime() || + a.end.getTime() - b.end.getTime(), + ); + const out: SleepSegment[] = []; + let group: SleepSegment[] = [sorted[0]]; + let groupEnd = sorted[0].end.getTime(); + const flush = (): void => { + if (group.length === 1) { + out.push(group[0]); + return; + } + const start = group[0].start; + out.push({ + stage: group[0].stage, + start, + end: new Date(groupEnd), + minutes: (groupEnd - start.getTime()) / 60_000, + }); + }; + for (let i = 1; i < sorted.length; i++) { + const span = sorted[i]; + if (span.start.getTime() < groupEnd) { + group.push(span); + if (span.end.getTime() > groupEnd) groupEnd = span.end.getTime(); + } else { + flush(); + group = [span]; + groupEnd = span.end.getTime(); + } + } + flush(); + return out; +} + +/** + * Collapse a segment list so no two segments of the SAME stage overlap, + * returning the result sorted by start. Stages are merged INDEPENDENTLY — + * a CORE span overlapping a DEEP span is left alone, because that is not a + * doubled export but a contest over what the minute WAS, and the file already + * owns one mechanism for that contest: `pickSessionWriter` collapses the + * session to a single writer before anything is summed. Arbitrating across + * stages here would also destroy the summary-shaped writers on purpose: + * WHOOP / Polar stamp every stage total on the sleep END instant, so their + * stage spans nest inside one another by design and their union is one + * stage's length, not the night. + * + * The consequence is the invariant this function exists to guarantee: every + * stage total is the MEASURE OF THE UNION of that stage's spans, so no stage + * total can exceed the session's own envelope. + */ +function mergeOverlappingSegments( + segments: readonly SleepSegment[], +): SleepSegment[] { + const byStage = new Map(); + for (const seg of segments) { + const key = seg.stage ?? NULL_STAGE_KEY; + const list = byStage.get(key) ?? []; + list.push(seg); + byStage.set(key, list); + } + const out: SleepSegment[] = []; + for (const list of byStage.values()) out.push(...mergeStageSpans(list)); + return out.sort((a, b) => a.start.getTime() - b.start.getTime()); +} + /** * Count mid-sleep awakenings from a session's segments. An AWAKE segment * counts only when it sits BETWEEN the first and last asleep segment — a @@ -854,10 +994,16 @@ export function reconstructSleepSessions( // granular stages and the `stages` map is the granular set OR the bare // aggregate, never both. const sawGranular = sawGranularStage(pool); - const segments = pool - .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular)) - .map(segmentOf) - .sort((a, b) => a.start.getTime() - b.start.getTime()); + // Overlapping spans of ONE stage merge into their union before anything + // is drawn or summed: a corrected re-export (same start, later end, fresh + // external id) must not paint a second bar over the night it revises, nor + // add its minutes on top of the row it supersedes. Returned sorted by + // start, as the hypnogram expects. + const segments = mergeOverlappingSegments( + pool + .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular)) + .map(segmentOf), + ); // The granular-over-bare filter can empty a non-empty pool (a session whose // only rows are the redundant bare ASLEEP aggregate / stage-less twins of a @@ -877,7 +1023,11 @@ export function reconstructSleepSessions( if (seg.start.getTime() < earliest.getTime()) earliest = seg.start; if (seg.end.getTime() > latest.getTime()) latest = seg.end; const stage = seg.stage; - if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes; + // IN_BED is skipped here — it is the session ENVELOPE, filled in below + // from the very computation `inBedMinutes` reports, so the two can never + // disagree. + if (stage == null || stage === "IN_BED") continue; + stages[stage] = (stages[stage] ?? 0) + seg.minutes; if (stage === "AWAKE") { awake += seg.minutes; sawAwake = true; @@ -889,6 +1039,7 @@ export function reconstructSleepSessions( // only in-bed window (the phone's bedtime detection). The hypnogram // `segments` + `stages` stay winner-only. const inBedEnvelope = inBedEnvelopeMinutes(session); + if (inBedEnvelope !== null) stages.IN_BED = inBedEnvelope; const asleep = asleepMinutesOf(pool); const canonicalSource = sourceOfWriterKey(canonical); sessions.push({