diff --git a/src/lib/insights/derived/__tests__/chronotype.test.ts b/src/lib/insights/derived/__tests__/chronotype.test.ts new file mode 100644 index 000000000..b607ddd4a --- /dev/null +++ b/src/lib/insights/derived/__tests__/chronotype.test.ts @@ -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"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-debt.test.ts b/src/lib/insights/derived/__tests__/sleep-debt.test.ts new file mode 100644 index 000000000..5ff39fbf1 --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-debt.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/derived/chronotype.ts b/src/lib/insights/derived/chronotype.ts new file mode 100644 index 000000000..11d2fa052 --- /dev/null +++ b/src/lib/insights/derived/chronotype.ts @@ -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 = { + 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, + }; +} diff --git a/src/lib/insights/derived/sleep-debt.ts b/src/lib/insights/derived/sleep-debt.ts new file mode 100644 index 000000000..573bc4232 --- /dev/null +++ b/src/lib/insights/derived/sleep-debt.ts @@ -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 = { + 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, + }; +}