fix(insights): keep a synthesised in-bed denominator for the sleep score

Restore the efficiency denominator the pre-unification score derived when a
night carries asleep and awake time but no explicit in-bed record — a common
Apple Health shape. The canonical reconstruction returns a null in-bed figure
for those nights, which dropped the efficiency sub-score and silently
reweighted the composite. Fall back to asleep plus awake minutes when no real
in-bed record exists, keep the recorded figure when present, and leave the
denominator null only when neither signal exists, so the multi-source dedup
is retained without shifting historical scores for that class of night.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:40:59 +02:00
commit a201e49edd
2 files changed
+68 -2

No files matched your search

@@ -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
+20 -2
View File
@@ -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,
};