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({