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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:22:39 +02:00
1 parent b79bd8370d
commit 6328117bd2
2 files changed
+23 -5

No files matched your search

@@ -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[] = [
+8 -5
View File
@@ -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;
}