mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): add sleep-debt and MCTQ chronotype math
This commit is contained in:
1 parent
dd8ffffd91
commit
edaa345f4e
4 files changed
+632
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeChronotype,
|
||||
bandForMSFsc,
|
||||
type ChronotypeNight,
|
||||
} from "../chronotype";
|
||||
|
||||
/** Minutes-of-day for HH:MM. */
|
||||
function hm(h: number, m = 0): number {
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function freeNight(
|
||||
day: number,
|
||||
midpointMinutes: number,
|
||||
asleepMinutes: number,
|
||||
): ChronotypeNight {
|
||||
return {
|
||||
night: `2026-06-${String(day).padStart(2, "0")}`,
|
||||
midpointMinutes,
|
||||
asleepMinutes,
|
||||
dayType: "free",
|
||||
};
|
||||
}
|
||||
|
||||
function workNight(
|
||||
day: number,
|
||||
midpointMinutes: number,
|
||||
asleepMinutes: number,
|
||||
): ChronotypeNight {
|
||||
return {
|
||||
night: `2026-06-${String(day).padStart(2, "0")}`,
|
||||
midpointMinutes,
|
||||
asleepMinutes,
|
||||
dayType: "work",
|
||||
};
|
||||
}
|
||||
|
||||
describe("bandForMSFsc", () => {
|
||||
it("bands MSFsc minutes-of-day into MCTQ classes", () => {
|
||||
expect(bandForMSFsc(hm(2, 0))).toBe("extreme_early"); // 02:00
|
||||
expect(bandForMSFsc(hm(3, 0))).toBe("early"); // 03:00
|
||||
expect(bandForMSFsc(hm(4, 0))).toBe("intermediate"); // 04:00
|
||||
expect(bandForMSFsc(hm(5, 30))).toBe("late"); // 05:30
|
||||
expect(bandForMSFsc(hm(7, 0))).toBe("extreme_late"); // 07:00
|
||||
});
|
||||
|
||||
it("normalises a midnight-straddling / corrected value onto the clock", () => {
|
||||
expect(bandForMSFsc(hm(24, 0) + hm(1, 0))).toBe("extreme_early"); // 25:00→01:00
|
||||
expect(bandForMSFsc(-30)).toBe("extreme_late"); // −0:30 → 23:30
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeChronotype", () => {
|
||||
it("computes MSF + band from a free-day mid-sleep set (no debt correction)", () => {
|
||||
// Three free nights all centred at 04:00, equal-length → SDf == SDweek →
|
||||
// MSFsc == MSF == 240 → intermediate.
|
||||
const res = computeChronotype([
|
||||
freeNight(1, hm(4), 480),
|
||||
freeNight(2, hm(4), 480),
|
||||
freeNight(3, hm(4), 480),
|
||||
]);
|
||||
expect(res.state).toBe("ready");
|
||||
expect(res.msfMinutes).toBeCloseTo(240, 6);
|
||||
expect(res.msfScMinutes).toBeCloseTo(240, 6);
|
||||
expect(res.band).toBe("intermediate");
|
||||
});
|
||||
|
||||
it("applies the MSFsc sleep-debt correction when free days oversleep", () => {
|
||||
// 3 free nights @ 04:00 / 9 h (540) + 3 work nights @ 03:00 / 6 h (360).
|
||||
// SDf = 540; SDweek = (540·3 + 360·3)/6 = 450; oversleep = 90.
|
||||
// MSFsc = 240 − 0.5·90 = 195 → "early" (< 03:30).
|
||||
const res = computeChronotype([
|
||||
freeNight(1, hm(4), 540),
|
||||
freeNight(2, hm(4), 540),
|
||||
freeNight(3, hm(4), 540),
|
||||
workNight(4, hm(3), 360),
|
||||
workNight(5, hm(3), 360),
|
||||
workNight(6, hm(3), 360),
|
||||
]);
|
||||
expect(res.msfMinutes).toBeCloseTo(240, 6);
|
||||
expect(res.msfScMinutes).toBeCloseTo(195, 6);
|
||||
expect(res.band).toBe("early"); // 195 → early (< 03:30)
|
||||
});
|
||||
|
||||
it("computes social jetlag as circular |MSF_work − MSF_free|", () => {
|
||||
// Free mid-sleep 04:00 (240), work mid-sleep 03:00 (180) → SJL = 60.
|
||||
const res = computeChronotype([
|
||||
freeNight(1, hm(4), 480),
|
||||
freeNight(2, hm(4), 480),
|
||||
freeNight(3, hm(4), 480),
|
||||
workNight(4, hm(3), 480),
|
||||
workNight(5, hm(3), 480),
|
||||
]);
|
||||
expect(res.socialJetlagMinutes).toBeCloseTo(60, 6);
|
||||
expect(res.workNightsCounted).toBe(2);
|
||||
expect(res.freeNightsCounted).toBe(3);
|
||||
});
|
||||
|
||||
it("measures social jetlag across midnight by the shorter arc", () => {
|
||||
// Work mid-sleep 23:30 (1410), free mid-sleep 00:30 (30) → circular 60,
|
||||
// not 1380 — the midnight-straddle handling from circularMinuteDistance.
|
||||
const res = computeChronotype([
|
||||
freeNight(1, hm(0, 30), 480),
|
||||
freeNight(2, hm(0, 30), 480),
|
||||
freeNight(3, hm(0, 30), 480),
|
||||
workNight(4, hm(23, 30), 480),
|
||||
]);
|
||||
expect(res.socialJetlagMinutes).toBeCloseTo(60, 6);
|
||||
});
|
||||
|
||||
it("fires the learning gate under the free-night threshold", () => {
|
||||
const res = computeChronotype(
|
||||
[freeNight(1, hm(4), 480), freeNight(2, hm(4), 480)],
|
||||
{ minFreeNights: 3 },
|
||||
);
|
||||
expect(res.state).toBe("learning");
|
||||
expect(res.band).toBeNull();
|
||||
expect(res.msfMinutes).toBeNull();
|
||||
expect(res.msfScMinutes).toBeNull();
|
||||
expect(res.freeNightsCounted).toBe(2);
|
||||
expect(res.freeNightsUntilReady).toBe(1); // 3 − 2
|
||||
});
|
||||
|
||||
it("clears the learning gate at the threshold", () => {
|
||||
const res = computeChronotype(
|
||||
[
|
||||
freeNight(1, hm(4), 480),
|
||||
freeNight(2, hm(4), 480),
|
||||
freeNight(3, hm(4), 480),
|
||||
],
|
||||
{ minFreeNights: 3 },
|
||||
);
|
||||
expect(res.state).toBe("ready");
|
||||
expect(res.band).not.toBeNull();
|
||||
});
|
||||
|
||||
it("work-only data stays learning and reports no social jetlag", () => {
|
||||
const res = computeChronotype([
|
||||
workNight(1, hm(3), 480),
|
||||
workNight(2, hm(3), 480),
|
||||
workNight(3, hm(3), 480),
|
||||
]);
|
||||
expect(res.state).toBe("learning");
|
||||
expect(res.socialJetlagMinutes).toBeNull(); // no free side to compare
|
||||
expect(res.freeNightsCounted).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores nights with zero asleep minutes", () => {
|
||||
const res = computeChronotype([
|
||||
freeNight(1, hm(4), 480),
|
||||
freeNight(2, hm(4), 0), // dropped
|
||||
freeNight(3, hm(4), 480),
|
||||
]);
|
||||
expect(res.freeNightsCounted).toBe(2);
|
||||
expect(res.state).toBe("learning");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeSleepDebt,
|
||||
type SleepDebtNight,
|
||||
} from "../sleep-debt";
|
||||
|
||||
/** Build a contiguous run of nights ending YYYY-06-?? with given asleep mins. */
|
||||
function nights(asleepPerNight: number[]): SleepDebtNight[] {
|
||||
return asleepPerNight.map((asleepMinutes, i) => ({
|
||||
night: `2026-06-${String(i + 1).padStart(2, "0")}`,
|
||||
asleepMinutes,
|
||||
}));
|
||||
}
|
||||
|
||||
const NEED = 480; // 8 h need, pinned so the deficit math is exact.
|
||||
|
||||
describe("computeSleepDebt", () => {
|
||||
it("sums a deficit sequence into the exact cumulative debt", () => {
|
||||
// need 480; nights of 420 / 360 / 480 / 450 → deficits 60 / 120 / 0 / 30.
|
||||
const res = computeSleepDebt(nights([420, 360, 480, 450]), NEED, {
|
||||
minNights: 1,
|
||||
});
|
||||
expect(res.state).toBe("ready");
|
||||
expect(res.debtMinutes).toBe(60 + 120 + 0 + 30); // 210
|
||||
expect(res.perNight.map((n) => n.deficitMinutes)).toEqual([60, 120, 0, 30]);
|
||||
expect(res.nightsCounted).toBe(4);
|
||||
expect(res.needMinutes).toBe(NEED);
|
||||
});
|
||||
|
||||
it("never mints negative deficit from catch-up sleep", () => {
|
||||
// A 10 h night (600) is 120 over need — deficit floors at 0, not −120.
|
||||
const res = computeSleepDebt(nights([600, 600]), NEED, { minNights: 1 });
|
||||
expect(res.debtMinutes).toBe(0);
|
||||
expect(res.perNight.every((n) => n.deficitMinutes === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps a single catastrophic night's deficit", () => {
|
||||
// A 1 h night (60) is 420 short; the per-night cap (180) holds.
|
||||
const res = computeSleepDebt(nights([60]), NEED, {
|
||||
minNights: 1,
|
||||
maxNightlyDeficitMinutes: 180,
|
||||
});
|
||||
expect(res.perNight[0].deficitMinutes).toBe(180);
|
||||
expect(res.debtMinutes).toBe(180);
|
||||
});
|
||||
|
||||
it("caps the cumulative total", () => {
|
||||
// Ten 0 h nights × 180 cap = 1800 raw, clamped to 1200 total cap.
|
||||
const res = computeSleepDebt(nights(Array(10).fill(0)), NEED, {
|
||||
minNights: 1,
|
||||
maxTotalDebtMinutes: 1200,
|
||||
});
|
||||
expect(res.debtMinutes).toBe(1200);
|
||||
});
|
||||
|
||||
it("takes only the most recent windowNights and sorts oldest→newest", () => {
|
||||
// 16 nights of 420 (60 short each); window 14 → 14 × 60 = 840.
|
||||
const res = computeSleepDebt(nights(Array(16).fill(420)), NEED, {
|
||||
windowNights: 14,
|
||||
});
|
||||
expect(res.nightsCounted).toBe(14);
|
||||
expect(res.debtMinutes).toBe(14 * 60);
|
||||
expect(res.windowNights).toBe(14);
|
||||
// Oldest kept night is 2026-06-03 (06-01 and 06-02 aged out).
|
||||
expect(res.perNight[0].night).toBe("2026-06-03");
|
||||
expect(res.perNight.at(-1)?.night).toBe("2026-06-16");
|
||||
});
|
||||
|
||||
it("debt drains as deficit nights age out of the rolling window", () => {
|
||||
// Two short nights then twelve full ones, window 14 → only the short
|
||||
// nights inside the window count; thirteen full + one short → drains.
|
||||
const fourteen = nights([360, ...Array(13).fill(480)]); // 1 short, 13 full
|
||||
const res = computeSleepDebt(fourteen, NEED, { windowNights: 14 });
|
||||
expect(res.debtMinutes).toBe(120); // only the 360 night carries debt
|
||||
});
|
||||
|
||||
it("returns a calm partial state under the night threshold", () => {
|
||||
const res = computeSleepDebt(nights([300, 300, 300]), NEED, {
|
||||
minNights: 7,
|
||||
});
|
||||
expect(res.state).toBe("partial");
|
||||
expect(res.nightsCounted).toBe(3);
|
||||
expect(res.nightsUntilReady).toBe(4); // 7 − 3
|
||||
// It still reports the running figure so the UI can show a soft preview.
|
||||
expect(res.debtMinutes).toBe(3 * 180); // each night 180 short
|
||||
});
|
||||
|
||||
it("clears the partial state at or above the threshold", () => {
|
||||
const res = computeSleepDebt(nights(Array(7).fill(420)), NEED, {
|
||||
minNights: 7,
|
||||
});
|
||||
expect(res.state).toBe("ready");
|
||||
expect(res.nightsUntilReady).toBe(0);
|
||||
});
|
||||
|
||||
it("guards a non-positive need (no fabricated debt)", () => {
|
||||
const res = computeSleepDebt(nights([300, 300]), 0, { minNights: 1 });
|
||||
expect(res.state).toBe("partial");
|
||||
expect(res.debtMinutes).toBe(0);
|
||||
expect(res.needMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* v1.17.0 — MCTQ chronotype + social jetlag (server-authoritative).
|
||||
*
|
||||
* Implements the Munich ChronoType Questionnaire derivation from observed
|
||||
* sleep, not a survey: Roenneberg, Wirz-Justice & Merrow (2003), *J Biol
|
||||
* Rhythms* 18(1):80-90; Roenneberg et al. (2007), *Sleep Med Rev* 11(6):429-
|
||||
* 438. The chronotype marker is MID-SLEEP ON FREE DAYS (MSF) — the clock time
|
||||
* halfway between sleep onset and wake on days without an alarm — corrected
|
||||
* for the sleep debt people pay off by sleeping in on free days (MSFsc).
|
||||
*
|
||||
* MSF = circular mean of free-day sleep midpoints (minutes-of-day).
|
||||
* MSFsc = MSF − 0.5·(SDf − SDweek) when SDf > SDweek, else MSF
|
||||
* where SDf = average free-day sleep DURATION,
|
||||
* SDweek = average sleep duration across work + free days.
|
||||
* SJL = social jetlag = circular |MSF_work − MSF_free|.
|
||||
*
|
||||
* The MSFsc correction (Roenneberg 2007) removes the morning "oversleep" that
|
||||
* a sleep-debt rebound adds to the free-day midpoint, so a workweek-deprived
|
||||
* sleeper is not mislabelled later than their true phase.
|
||||
*
|
||||
* ONE MIDPOINT ESTIMATOR
|
||||
* ----------------------
|
||||
* The per-night `midpointMinutes` is the SAME minutes-of-day midpoint the
|
||||
* Sleep Score's Timing sub-score reads (centre of the canonical asleep span,
|
||||
* in the user's wall clock). The caller supplies it; chronotype does NOT
|
||||
* re-derive a second midpoint — and it reuses `circularMeanMinutes` /
|
||||
* `circularMinuteDistance` from the Sleep Score so the midnight-straddle / DST
|
||||
* handling is identical. Pure — no Prisma, no I/O; route, Coach, PDF, and the
|
||||
* iOS serializer all reuse it.
|
||||
*
|
||||
* LEARNING GATE
|
||||
* -------------
|
||||
* Chronotype is a stable trait and a thin sample is noise. Below
|
||||
* `minFreeNights` free-day nights the result is a calm "still learning — N of
|
||||
* M nights" state and asserts NO type. This mirrors the Sleep Score's
|
||||
* "< 3 nights → null" timing gate; the band only appears once the free-day
|
||||
* sample is large enough to mean something.
|
||||
*
|
||||
* FREE vs WORK
|
||||
* ------------
|
||||
* The app has no work calendar, so the day-type signal is an INPUT per night
|
||||
* (the caller maps weekday/weekend or a future shift schedule). A modelling
|
||||
* assumption, surfaced as such — not hardcoded here.
|
||||
*/
|
||||
import {
|
||||
circularMeanMinutes,
|
||||
circularMinuteDistance,
|
||||
} from "./sleep-score";
|
||||
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
|
||||
/** One night fed to the chronotype estimator. */
|
||||
export interface ChronotypeNight {
|
||||
/** Wake-day key (YYYY-MM-DD), user timezone. */
|
||||
night: string;
|
||||
/**
|
||||
* Sleep midpoint as minutes-of-day (0..1439) in the user's wall clock — the
|
||||
* SAME estimator the Sleep Score Timing sub-score uses. The caller passes it;
|
||||
* this module does not re-derive a midpoint.
|
||||
*/
|
||||
midpointMinutes: number;
|
||||
/** Canonical time-asleep minutes (`SleepNight.asleepMinutes`) for SD math. */
|
||||
asleepMinutes: number;
|
||||
/** Day type — caller-supplied (no work calendar is hardcoded here). */
|
||||
dayType: "work" | "free";
|
||||
}
|
||||
|
||||
export interface ChronotypeOptions {
|
||||
/**
|
||||
* Minimum free-day nights before a chronotype band is asserted. MCTQ needs a
|
||||
* stable free-day estimate; below this the result stays in the calm learning
|
||||
* state. 3 mirrors the Sleep Score's timing/consistency floor — enough to
|
||||
* dampen a single anomalous lie-in without demanding a month of data.
|
||||
*/
|
||||
minFreeNights?: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<ChronotypeOptions> = {
|
||||
minFreeNights: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* MCTQ chronotype band off MSFsc (minutes-of-day). The cut points follow the
|
||||
* Roenneberg population percentiles (extreme-early through extreme-late around
|
||||
* a ~04:00 mid-sleep mode). One banding helper — never re-thresholded per
|
||||
* surface.
|
||||
*/
|
||||
export type ChronotypeBand =
|
||||
| "extreme_early"
|
||||
| "early"
|
||||
| "intermediate"
|
||||
| "late"
|
||||
| "extreme_late";
|
||||
|
||||
/**
|
||||
* Band a corrected mid-sleep (MSFsc, minutes-of-day) into the MCTQ chronotype
|
||||
* classes. Cut points (local clock): < 02:30 extreme-early, < 03:30 early,
|
||||
* < 05:00 intermediate, < 06:00 late, else extreme-late — the standard MCTQ
|
||||
* MSFsc deciles centred on the ~04:00 population mode.
|
||||
*/
|
||||
export function bandForMSFsc(msfscMinutes: number): ChronotypeBand {
|
||||
// Normalise onto [0,1440) so a value that the correction pushed below 0 or a
|
||||
// late-night midpoint past midnight still bands on the wall clock.
|
||||
const m = ((msfscMinutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
|
||||
MINUTES_PER_DAY;
|
||||
if (m < 150) return "extreme_early"; // < 02:30
|
||||
if (m < 210) return "early"; // < 03:30
|
||||
if (m < 300) return "intermediate"; // < 05:00
|
||||
if (m < 360) return "late"; // < 06:00
|
||||
return "extreme_late";
|
||||
}
|
||||
|
||||
export interface ChronotypeResult {
|
||||
/**
|
||||
* `learning` until `minFreeNights` free-day nights exist — no band asserted.
|
||||
* `ready` once the free-day sample is large enough.
|
||||
*/
|
||||
state: "learning" | "ready";
|
||||
/** Mid-sleep on free days (minutes-of-day), or null while learning. */
|
||||
msfMinutes: number | null;
|
||||
/** Sleep-debt-corrected mid-sleep on free days (minutes-of-day), or null. */
|
||||
msfScMinutes: number | null;
|
||||
/** Chronotype band off MSFsc, or null while learning. */
|
||||
band: ChronotypeBand | null;
|
||||
/**
|
||||
* Social jetlag in minutes = circular |MSF_work − MSF_free|. Null when there
|
||||
* is no work-day OR no free-day midpoint to compare (one side missing).
|
||||
*/
|
||||
socialJetlagMinutes: number | null;
|
||||
/** Free-day nights counted. */
|
||||
freeNightsCounted: number;
|
||||
/** Work-day nights counted. */
|
||||
workNightsCounted: number;
|
||||
/** `minFreeNights` − `freeNightsCounted` while learning, else 0. */
|
||||
freeNightsUntilReady: number;
|
||||
}
|
||||
|
||||
function mean(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((s, v) => s + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive MCTQ chronotype, MSFsc, and social jetlag from per-night midpoints +
|
||||
* day types. Pure. Holds the learning gate until enough free-day nights exist.
|
||||
*/
|
||||
export function computeChronotype(
|
||||
nights: readonly ChronotypeNight[],
|
||||
options: ChronotypeOptions = {},
|
||||
): ChronotypeResult {
|
||||
const opts = { ...DEFAULTS, ...options };
|
||||
const usable = nights.filter(
|
||||
(n) =>
|
||||
Number.isFinite(n.midpointMinutes) &&
|
||||
Number.isFinite(n.asleepMinutes) &&
|
||||
n.asleepMinutes > 0,
|
||||
);
|
||||
const free = usable.filter((n) => n.dayType === "free");
|
||||
const work = usable.filter((n) => n.dayType === "work");
|
||||
|
||||
const freeNightsCounted = free.length;
|
||||
const workNightsCounted = work.length;
|
||||
|
||||
// MSF = circular mean of free-day midpoints (handles the midnight straddle).
|
||||
const msfMinutes = circularMeanMinutes(free.map((n) => n.midpointMinutes));
|
||||
// MSW = circular mean of work-day midpoints — only used for social jetlag.
|
||||
const mswMinutes = circularMeanMinutes(work.map((n) => n.midpointMinutes));
|
||||
|
||||
// Social jetlag = circular distance between the work and free mid-sleeps.
|
||||
// Both sides must exist; otherwise the offset is undefined (not zero).
|
||||
const socialJetlagMinutes =
|
||||
msfMinutes != null && mswMinutes != null
|
||||
? circularMinuteDistance(mswMinutes, msfMinutes)
|
||||
: null;
|
||||
|
||||
const ready = freeNightsCounted >= opts.minFreeNights && msfMinutes != null;
|
||||
if (!ready) {
|
||||
return {
|
||||
state: "learning",
|
||||
msfMinutes: null,
|
||||
msfScMinutes: null,
|
||||
band: null,
|
||||
socialJetlagMinutes,
|
||||
freeNightsCounted,
|
||||
workNightsCounted,
|
||||
freeNightsUntilReady: Math.max(
|
||||
0,
|
||||
opts.minFreeNights - freeNightsCounted,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// MSFsc correction (Roenneberg 2007): subtract half the free-day oversleep
|
||||
// (free-day duration minus the week-average duration), applied only when
|
||||
// people sleep LONGER on free days (paying off workweek debt). When SDf ≤
|
||||
// SDweek there is no debt rebound, so MSFsc = MSF.
|
||||
const sdFree = mean(free.map((n) => n.asleepMinutes)) ?? 0;
|
||||
const sdWeek = mean(usable.map((n) => n.asleepMinutes)) ?? sdFree;
|
||||
const oversleep = Math.max(0, sdFree - sdWeek);
|
||||
const msfScRaw = msfMinutes - 0.5 * oversleep;
|
||||
const msfScMinutes =
|
||||
((msfScRaw % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
||||
|
||||
return {
|
||||
state: "ready",
|
||||
msfMinutes,
|
||||
msfScMinutes,
|
||||
band: bandForMSFsc(msfScMinutes),
|
||||
socialJetlagMinutes,
|
||||
freeNightsCounted,
|
||||
workNightsCounted,
|
||||
freeNightsUntilReady: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* v1.17.0 — cumulative sleep debt (server-authoritative timing math).
|
||||
*
|
||||
* Sleep debt is the running shortfall between how much sleep a person NEEDS
|
||||
* and how much they actually got, accumulated over a rolling window. Per
|
||||
* night the deficit is `max(0, need − asleep)` — only undersleep accrues
|
||||
* debt; a night of catch-up sleep does NOT mint a negative deficit that
|
||||
* silently erases yesterday's shortfall (the body does not bank surplus sleep
|
||||
* minute-for-minute; Van Dongen et al. 2003, *Sleep* 26(2):117-126 show the
|
||||
* recovery curve is sub-linear). Catch-up instead pays the running debt DOWN
|
||||
* through the rolling window: as a deficit night ages out of the window it
|
||||
* stops contributing, so a stretch of full nights drains the debt toward zero.
|
||||
*
|
||||
* ONE ENGINE, NO DUPLICATION
|
||||
* --------------------------
|
||||
* This module is PURE — it consumes the canonical per-night `asleepMinutes`
|
||||
* the shared sleep engine (`reconstructSleepNights`) already produces and
|
||||
* takes the sleep `need` as a parameter (the caller resolves it via the one
|
||||
* existing `sleepNeedMinutes(ageYears)` table — there is no second need table
|
||||
* here). "actual" MUST be the canonical asleep total, never a divergent
|
||||
* reconstruction, so this figure can never contradict the dashboard / Sleep
|
||||
* Score / hypnogram for the same night. No Prisma, no I/O — the route, Coach
|
||||
* snapshot, doctor PDF, and iOS serializer all reuse it.
|
||||
*
|
||||
* Composition with the Sleep Score: Sufficiency (`scoreSufficiency`) is the
|
||||
* SINGLE-NIGHT view of `need − actual` expressed 0–100; this is the ROLLING
|
||||
* CUMULATIVE of the same `need − actual`. Same inputs, two framings — a
|
||||
* timing/trend signal, not a second sleep-adequacy score.
|
||||
*/
|
||||
|
||||
/** One night's canonical asleep total, keyed by its wake day. */
|
||||
export interface SleepDebtNight {
|
||||
/** Wake-day key (YYYY-MM-DD), user timezone — from the canonical engine. */
|
||||
night: string;
|
||||
/** Canonical time-asleep minutes for the night (`SleepNight.asleepMinutes`). */
|
||||
asleepMinutes: number;
|
||||
}
|
||||
|
||||
/** Tunable knobs; every default is documented inline. */
|
||||
export interface SleepDebtOptions {
|
||||
/**
|
||||
* Rolling window length in nights. 14 is the standard two-week sleep-debt
|
||||
* horizon used across the consumer-sleep literature — long enough that a
|
||||
* single bad night does not dominate, short enough that month-old debt no
|
||||
* longer shapes today's signal.
|
||||
*/
|
||||
windowNights?: number;
|
||||
/**
|
||||
* Minimum tracked nights before the cumulative debt is asserted. Below this
|
||||
* the result is a calm PARTIAL state ("still learning") rather than a
|
||||
* confident total off a near-empty window — a one-night window would read a
|
||||
* single rough night as a fortnight of debt.
|
||||
*/
|
||||
minNights?: number;
|
||||
/**
|
||||
* Per-night deficit cap (minutes). One catastrophic night (a 2-hour sleep,
|
||||
* or a missing-stage artefact) must not swamp a fortnight of otherwise-fine
|
||||
* sleep; 180 min (3 h) is the largest single-night shortfall that still
|
||||
* reads as "a rough night" rather than a data glitch.
|
||||
*/
|
||||
maxNightlyDeficitMinutes?: number;
|
||||
/**
|
||||
* Cumulative-debt cap (minutes). Beyond a point more debt is not
|
||||
* meaningfully more actionable — the message is the same ("you are deeply
|
||||
* under-slept"). 1200 min (20 h) caps the headline so the bar / number
|
||||
* stays legible and a long gap of untracked-then-rough nights cannot mint
|
||||
* an absurd total.
|
||||
*/
|
||||
maxTotalDebtMinutes?: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<SleepDebtOptions> = {
|
||||
windowNights: 14,
|
||||
minNights: 7,
|
||||
maxNightlyDeficitMinutes: 180,
|
||||
maxTotalDebtMinutes: 1200,
|
||||
};
|
||||
|
||||
/** Per-night deficit detail, oldest → newest, for the trend strip / tooltip. */
|
||||
export interface SleepDebtNightDetail {
|
||||
night: string;
|
||||
asleepMinutes: number;
|
||||
/** `max(0, need − asleep)` after the per-night cap, in minutes. */
|
||||
deficitMinutes: number;
|
||||
}
|
||||
|
||||
export interface SleepDebtResult {
|
||||
/**
|
||||
* `partial` until `minNights` tracked nights exist — the calm "still
|
||||
* learning" state. `ready` once the window has enough nights to assert a
|
||||
* cumulative debt.
|
||||
*/
|
||||
state: "partial" | "ready";
|
||||
/** Cumulative sleep debt in minutes over the window, after caps. */
|
||||
debtMinutes: number;
|
||||
/** The sleep need (minutes) used for the deficit — echoed for transparency. */
|
||||
needMinutes: number;
|
||||
/** Nights actually counted (≤ windowNights). */
|
||||
nightsCounted: number;
|
||||
/** The configured rolling window length in nights. */
|
||||
windowNights: number;
|
||||
/** `minNights` − `nightsCounted` while partial, else 0 — drives the nudge. */
|
||||
nightsUntilReady: number;
|
||||
/** Per-night deficits over the window, oldest → newest. */
|
||||
perNight: SleepDebtNightDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cumulative sleep debt over the trailing window. Pure.
|
||||
*
|
||||
* `nights` may arrive in any order and span more than the window — the most
|
||||
* recent `windowNights` (by wake-day key) are taken. `needMinutes` is the
|
||||
* caller-resolved age-based need (`sleepNeedMinutes`); passing it in keeps
|
||||
* this module free of the need table and lets a test pin an exact value.
|
||||
*/
|
||||
export function computeSleepDebt(
|
||||
nights: readonly SleepDebtNight[],
|
||||
needMinutes: number,
|
||||
options: SleepDebtOptions = {},
|
||||
): SleepDebtResult {
|
||||
const opts = { ...DEFAULTS, ...options };
|
||||
const safeNeed = Number.isFinite(needMinutes) && needMinutes > 0
|
||||
? needMinutes
|
||||
: 0;
|
||||
|
||||
// Take the most recent `windowNights` by wake-day key. Sort ascending so the
|
||||
// perNight strip reads oldest → newest like every other trend surface.
|
||||
const windowed = [...nights]
|
||||
.filter((n) => Number.isFinite(n.asleepMinutes) && n.asleepMinutes >= 0)
|
||||
.sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0))
|
||||
.slice(-opts.windowNights);
|
||||
|
||||
const perNight: SleepDebtNightDetail[] = windowed.map((n) => {
|
||||
const rawDeficit = Math.max(0, safeNeed - n.asleepMinutes);
|
||||
// Cap one catastrophic night so it cannot swamp the fortnight.
|
||||
const deficitMinutes = Math.min(rawDeficit, opts.maxNightlyDeficitMinutes);
|
||||
return {
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
deficitMinutes,
|
||||
};
|
||||
});
|
||||
|
||||
const nightsCounted = perNight.length;
|
||||
const rawDebt = perNight.reduce((sum, n) => sum + n.deficitMinutes, 0);
|
||||
const debtMinutes = Math.min(Math.round(rawDebt), opts.maxTotalDebtMinutes);
|
||||
|
||||
const ready = nightsCounted >= opts.minNights && safeNeed > 0;
|
||||
return {
|
||||
state: ready ? "ready" : "partial",
|
||||
debtMinutes,
|
||||
needMinutes: safeNeed,
|
||||
nightsCounted,
|
||||
windowNights: opts.windowNights,
|
||||
nightsUntilReady: ready ? 0 : Math.max(0, opts.minNights - nightsCounted),
|
||||
perNight,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user