From c80450dfac1998639c98bd384db93fa17c83943b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:40:29 +0200 Subject: [PATCH] fix(sleep): synthesise efficiency denominator when no IN_BED row Nights with AWAKE rows but no IN_BED row (a common Apple Health shape) lost their efficiency sub-score after the canonical reconstructSleepNights migration, silently reweighting the composite and shifting historical Sleep Scores. Restore the synthesised asleep + awake fallback on top of the deduped per-night totals: keep the real IN_BED figure when present, synthesise when only asleep + awake exist, stay null when neither. --- .../derived/__tests__/sleep-score.test.ts | 48 +++++++++++++++++++ src/lib/insights/derived/sleep-score.ts | 22 ++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index 3eaf40a68..add3ddad7 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -197,6 +197,54 @@ describe("reconstructNights", () => { expect(nights[0].midpoint).toBe(8 * 60 + 30); }); + it("synthesises the efficiency denominator from asleep + awake when no IN_BED row exists", () => { + // A common Apple-Health shape: granular asleep stages + an AWAKE row but + // NO IN_BED row. The canonical engine leaves `inBedMinutes` null; the + // adapter must synthesise `asleep + awake` so the efficiency sub-score + // survives and historical scores for this night class are preserved. + const rows = night("2026-06-02", [ + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ["AWAKE", 30, "05:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(390); + // 390 asleep + 30 awake = 420 synthesised in-bed denominator. + expect(nights[0].inBedMinutes).toBe(420); + // The efficiency sub-score is non-null and stable (390 / 420 ≈ 93 %). + expect(scoreEfficiency(nights[0].asleepMinutes, nights[0].inBedMinutes)).toBe( + 93, + ); + }); + + it("keeps the real IN_BED figure when present (synthesis does not override)", () => { + // An explicit IN_BED row coexists with an AWAKE row — the real envelope + // wins, NOT the synthesised asleep + awake. + const rows = night("2026-06-02", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ["AWAKE", 30, "05:00"], + ]); + const nights = reconstructNights(rows); + expect(nights[0].inBedMinutes).toBe(480); + }); + + it("leaves in-bed null when neither an IN_BED row nor awake minutes exist", () => { + // Asleep stages only, no IN_BED and no AWAKE row — no honest efficiency + // denominator, so it stays null and the sub-score drops as before. + const rows = night("2026-06-02", [ + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ]); + const nights = reconstructNights(rows); + expect(nights[0].inBedMinutes).toBeNull(); + expect(scoreEfficiency(nights[0].asleepMinutes, nights[0].inBedMinutes)).toBeNull(); + }); + it("deduplicates a multi-source night to ONE canonical total (no double-count)", () => { // The SAME physical night written by both Apple Health AND WHOOP. The // divergent UTC-day reconstructor used to SUM both writers (~doubling the diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 01fcf2314..7c27a048a 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -364,6 +364,24 @@ export function reconstructNights( const deep = n.stages.DEEP ?? 0; const core = n.stages.CORE ?? 0; const hasStageBreakdown = rem > 0 || deep > 0 || core > 0; + const awakeMinutes = n.awakeMinutes ?? 0; + // Efficiency denominator ("in bed"). The canonical engine sets + // `inBedMinutes` to null unless a real IN_BED row exists anywhere in the + // night — but a very common Apple-Health shape carries AWAKE rows and NO + // IN_BED row, for which the legacy reconstructor synthesised a denominator + // as `asleep + awake`. Taking the canonical null straight through silently + // dropped the efficiency sub-score for that whole class of night, which + // reweights the composite and shifts historical Sleep Scores. Restore the + // synthesised fallback ON TOP of the (correctly deduped) canonical totals: + // keep the real IN_BED figure when present, else synthesise `asleep + awake` + // when both are positive, else keep null (neither signal — no honest + // efficiency). + const inBedMinutes = + n.inBedMinutes != null + ? n.inBedMinutes + : n.asleepMinutes > 0 && awakeMinutes > 0 + ? n.asleepMinutes + awakeMinutes + : null; // Midpoint = centre of the asleep span. The canonical night's `measuredAt` // is the wake instant (latest stage END); the asleep span is // `asleepMinutes` long ending there, so its centre is wake − asleep/2. @@ -377,10 +395,10 @@ export function reconstructNights( return { night: n.night, asleepMinutes: n.asleepMinutes, - awakeMinutes: n.awakeMinutes ?? 0, + awakeMinutes, remMinutes: rem, deepMinutes: deep, - inBedMinutes: n.inBedMinutes, + inBedMinutes, hasStageBreakdown, midpoint, };