mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user