From 6328117bd2512024145a5b33ab5eafcdac147e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 04:22:39 +0200 Subject: [PATCH] fix(sleep): let a zero-minute granular row leave the bare night alone A REM row of no length counted as the granular partition and stripped the bare aggregate beside it, so a six-hour night read as no sleep while the SQL page cut of the list counted it as a night. --- src/lib/analytics/__tests__/sleep-night.test.ts | 15 +++++++++++++++ src/lib/analytics/sleep-night.ts | 13 ++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index d5ba7fe52..f78c4cc5f 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -655,6 +655,21 @@ describe("the one nap rule", () => { }); }); +describe("a zero-minute granular row", () => { + it("does not strip the bare aggregate that carries the night", () => { + // A writer's bare 6-hour night beside a REM row of no length: the REM + // row partitions nothing, so the night is the aggregate's six hours. + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "ASLEEP", 360), + row("2026-06-04T06:00:00.000Z", "REM", 0), + ]; + const [session] = reconstructSleepSessions(rows, "UTC"); + expect(session.asleepMinutes).toBe(360); + expect(session.stages.ASLEEP).toBe(360); + expect(reconstructSleepNights(rows, "UTC")[0].asleepMinutes).toBe(360); + }); +}); + describe("summarizeSleepNights", () => { it("summarises per-night totals, not per-stage rows", () => { const rows: SleepStageRow[] = [ diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 6cb81ddcb..732424e68 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -177,7 +177,10 @@ export const ASLEEP_STAGES: readonly SleepStage[] = [ function sawGranularStage(rows: readonly SleepStageRow[]): boolean { for (const r of rows) { const stage = r.sleepStage; - if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) return true; + // A zero-minute granular row partitions nothing: it must not strip the + // bare aggregate that does carry the night. + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage) && r.value > 0) + return true; } return false; } @@ -214,7 +217,7 @@ function asleepMinutesOf(rows: readonly SleepStageRow[]): number { const stage = seg.stage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) { granular += seg.minutes; - sawGranular = true; + if (seg.minutes > 0) 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. @@ -350,12 +353,12 @@ export interface SleepStageRow { */ source?: MeasurementSource | null; /** - * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25 - * W8c). Distinguishes WRITERS behind one source: several HealthKit apps + * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25). + * Distinguishes WRITERS behind one source: several HealthKit apps * write sleep under `source = APPLE_HEALTH` (watch granular stages, phone * coarse in-bed, vendor-app re-exports), and collapsing on source alone * blends them. Optional — callers that don't select the column (and every - * row stored before W8c) collapse per source exactly as before. + * row stored before v1.4.25) collapse per source exactly as before. */ deviceType?: string | null; }