diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 62c5997eb..0080f84b5 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -591,7 +591,7 @@ describe("GET /api/dashboard/summary", () => { it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // Ten consecutive 6 h (360 min) nights. Need = 420 (40-year-old default in - // beforeEach) → a 60-min deficit per night, 10 nights ≥ the 7-night floor. + // beforeEach) → a 60-min deficit per night, 10 nights ≥ the night floor. const rows = []; for (let d = 1; d <= 10; d++) { const day = String(d).padStart(2, "0"); @@ -627,12 +627,15 @@ describe("GET /api/dashboard/summary", () => { }; }; const { sleepDebt, chronotype } = body.data.sleepRhythm; - // Debt DTO is the computeSleepDebt result: ready, 10 × 60 = 600 min, need - // forwarded as 420 — proves the route reused the module, not a recompute. + // Debt DTO is the computeSleepDebt result: the rolling balance over the + // 5-night window of 60-min-short nights with no surplus to recover → + // 5 × 60 = 300 min, need forwarded as 420 — proves the route reused the + // module, not a recompute. expect(sleepDebt.state).toBe("ready"); - expect(sleepDebt.debtMinutes).toBe(600); + expect(sleepDebt.debtMinutes).toBe(300); expect(sleepDebt.needMinutes).toBe(420); - expect(sleepDebt.nightsCounted).toBe(10); + expect(sleepDebt.nightsCounted).toBe(5); + expect(sleepDebt.windowNights).toBe(5); expect(sleepDebt.nightsUntilReady).toBe(0); // Chronotype DTO carries every wiring field; the calm learning state holds // until enough free-day nights exist (these are all weekday wake days). diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx index 7d09a89d0..8d26be570 100644 --- a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx +++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx @@ -80,7 +80,7 @@ describe("", () => { expect(html).not.toContain('data-slot="chronotype-error"'); }); - it("renders the large band treatment + midpoint clock on a settled DTO", () => { + it("renders the band value + midpoint clock on a settled DTO", () => { rhythmMock.mockReturnValue({ data: READY_DTO, isLoading: false, @@ -90,8 +90,10 @@ describe("", () => { expect(html).toContain('data-slot="chronotype-band"'); expect(html).toContain("Evening type"); expect(html).toContain("05:41"); - // The section heading carries the chronotype title. - expect(html).toContain('data-slot="section-heading"'); + // v1.19.0 — the duplicate standalone section heading is gone; the single + // chronotype title now lives inside the card, uniform with the debt tile. + expect(html).not.toContain('data-slot="section-heading"'); + expect(html).toContain('data-slot="chronotype-card"'); }); it("renders nothing when disabled", () => { diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index c2c0bc53f..6dfe7a9ca 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -21,16 +21,16 @@ const PARTIAL_DEBT: SleepDebtDto = { debtMinutes: 0, needMinutes: 420, nightsCounted: 3, - windowNights: 14, - nightsUntilReady: 4, + windowNights: 5, + nightsUntilReady: 1, }; const READY_DEBT: SleepDebtDto = { state: "ready", - debtMinutes: 600, + debtMinutes: 60, needMinutes: 420, - nightsCounted: 10, - windowNights: 14, + nightsCounted: 5, + windowNights: 5, nightsUntilReady: 0, }; @@ -65,11 +65,11 @@ describe("", () => { expect(html).not.toContain("short"); }); - it("asserts the cumulative debt in the ready state", () => { + it("asserts the running debt balance in the ready state", () => { const html = render(); - // 600 min = 10h 0m short. - expect(html).toContain("10h 0m short"); - expect(html).toContain("14 nights"); + // 60 min = 1h 0m short — the small, recovering balance a normal sleeper sees. + expect(html).toContain("1h 0m short"); + expect(html).toContain("5 nights"); }); it("inherits the standard Card rhythm (no compact override)", () => { @@ -110,10 +110,11 @@ describe("", () => { expect(html).toContain("04:30"); }); - it("v1.18.7 W-D — renders the band as the large hero label, not a corner readout", () => { + it("v1.19.0 — renders the band as the bold primary value, uniform with the debt tile", () => { const html = render(); - // The reworked bottom treatment leads with a large band label + the - // natural-midpoint headline. The retired top-right corner readout is gone. + // The band is the primary value at the SAME text-2xl scale the debt tile + // uses for its hours figure; the natural-midpoint clock is the secondary + // line. The retired top-right corner readout is gone. expect(html).not.toContain('data-slot="chronotype-corner"'); expect(html).toContain('data-slot="chronotype-band"'); expect(html).toContain("Intermediate type"); diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx index 2d254ed3e..d4af80009 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -42,10 +42,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { const READY_DTO: SleepRhythmDto = { sleepDebt: { state: "ready", - debtMinutes: 600, + debtMinutes: 60, needMinutes: 420, - nightsCounted: 10, - windowNights: 14, + nightsCounted: 5, + windowNights: 5, nightsUntilReady: 0, }, chronotype: { @@ -97,8 +97,8 @@ describe("", () => { isError: false, }); const html = render(); - expect(html).toContain("10h 0m short"); - // The chronotype band lives in the separate bottom section now, never here. + expect(html).toContain("1h 0m short"); + // The chronotype band lives in the separate sibling tile, never here. expect(html).not.toContain("Intermediate type"); expect(html).not.toContain('data-slot="chronotype-band"'); }); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 4918e9813..03bb455e6 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -28,12 +28,16 @@ function hoursMinutes(totalMinutes: number): { /** * v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag). * - * v1.18.7 W-D — reworked from a small corner-label card into the PROMINENT - * bottom treatment of the Sleep view: a LARGE band label is the hero, with the - * natural sleep-midpoint clock beneath it, and an "advanced" disclosure that - * reveals social jetlag + the sleep-debt-corrected MSFsc. While `learning` it - * keeps the calm "still learning your rhythm — N of M nights" copy and asserts - * NO band. Warm, grounded copy; mobile-first. + * v1.19.0 — brought to the SAME tile shape as `` so the two sit + * uniform in the shared grid row: identical card chrome, an icon + `text-base` + * `CardTitle` header, then a `space-y-0.5` value block with a bold primary value + * (`text-2xl font-semibold`) and one secondary `text-xs muted` line. The primary + * value is the chronotype BAND (a label, the slot the debt tile fills with an + * hours figure); the concrete companion — the natural sleep midpoint clock — is + * the secondary line, so both tiles read "bold value + one grounded line" at the + * same scale. The "advanced" disclosure (social jetlag + sleep-debt-corrected + * MSFsc) stays. While `learning` it keeps the calm "still learning your rhythm — + * N of M nights" copy and asserts NO band. Warm, grounded copy; mobile-first. */ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { const { t } = useTranslations(); @@ -78,15 +82,13 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { - {/* The band is the hero of the bottom treatment — a large label the - eye lands on first, with the natural sleep-midpoint clock as the - grounding caption beneath it. */} -
-

- {bandLabel} -

+ {/* Uniform with the sleep-debt tile: a bold primary value (the band) at + the same scale, with the concrete companion (natural sleep midpoint + clock) as the secondary muted line. */} +
+

{bandLabel}

{msfClock && ( -

+

{t("insights.sleep.chronotype.midpointHeadline", { clock: msfClock, })} diff --git a/src/components/insights/sleep/chronotype-section.tsx b/src/components/insights/sleep/chronotype-section.tsx index 39f577634..38d27879d 100644 --- a/src/components/insights/sleep/chronotype-section.tsx +++ b/src/components/insights/sleep/chronotype-section.tsx @@ -1,28 +1,28 @@ "use client"; -import { Clock } from "lucide-react"; - import { useAuth } from "@/hooks/use-auth"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; -import { SectionHeading } from "@/components/insights/section-heading"; import { useSleepRhythm } from "./use-sleep-rhythm"; import { ChronotypeCard } from "./chronotype-card"; /** - * v1.18.7 W-D — chronotype as the prominent bottom block of the Sleep view. + * v1.18.7 W-D — chronotype card on the Sleep view. * - * Split out of `` (which now owns only the sleep-debt - * headline) so the chronotype gets a full-width treatment at the end of the - * page: a large band label, the natural sleep-midpoint clock beneath it, and an - * expandable disclosure for social jetlag + MSFsc. Reads the SAME - * server-authoritative DTO via `useSleepRhythm`, so it shares the + * v1.19.0 — the standalone "Chronotyp" `` was removed: it + * duplicated the heading the `` already carries in its own + * `CardTitle`, so "Chronotype" appeared twice and the tile no longer matched + * its sibling (the sleep-debt tile, which has no section heading). The card now + * stands as a peer of `` inside the shared grid row — same card + * chrome, one heading each, rendered inside the tile. + * + * Reads the SAME server-authoritative DTO via `useSleepRhythm`, so it shares the * `["sleep-rhythm"]` cache the debt section already warmed — no extra * round-trip. Gated on `enabled` so a source-less account never fetches. * * States mirror the debt section: loading → skeleton, error → quiet inline - * notice, settled → the card (which carries its own calm "still learning" - * copy for the no-nights-yet case, since the route always returns a full DTO). + * notice, settled → the card (which carries its own calm "still learning" copy + * for the no-nights-yet case, since the route always returns a full DTO). */ export function ChronotypeSection({ enabled }: { enabled: boolean }) { const { isAuthenticated } = useAuth(); @@ -33,28 +33,26 @@ export function ChronotypeSection({ enabled }: { enabled: boolean }) { if (!enabled) return null; - return ( -

- + {t("insights.sleep.rhythm.loadError")} +
+ ); + } + + if (isLoading || !data) { + return ( + - {isError ? ( -
- {t("insights.sleep.rhythm.loadError")} -
- ) : isLoading || !data ? ( - - ) : ( - - )} - - ); + ); + } + + return ; } diff --git a/src/lib/insights/derived/__tests__/sleep-debt.test.ts b/src/lib/insights/derived/__tests__/sleep-debt.test.ts index 6a70c2305..ada0e10ce 100644 --- a/src/lib/insights/derived/__tests__/sleep-debt.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-debt.test.ts @@ -11,24 +11,56 @@ function nights(asleepPerNight: number[]): SleepDebtNight[] { 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, +describe("computeSleepDebt — rolling balance", () => { + it("a well-rested week reads near-zero debt", () => { + // Seven nights at need exactly → no deficit ever accrues. + const res = computeSleepDebt(nights(Array(7).fill(480)), NEED, { + minNights: 4, }); 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); + expect(res.debtMinutes).toBe(0); }); - it("never mints negative deficit from catch-up sleep", () => { - // A 10 h night (600) is 120 over need — deficit floors at 0, not −120. + it("keeps a modestly-short week bounded by the short window, not a fortnight sum", () => { + // The v1.17.0 bug: ~30 min short every night summed over 14 nights to ~7 h + // of standing debt. The balance model with a 5-night window keeps it small: + // five nights ~30 min short (450) → 5 × 30 = 150 min, never the old 7 h. + const res = computeSleepDebt(nights(Array(7).fill(450)), NEED, { + minNights: 4, + }); + expect(res.debtMinutes).toBe(5 * 30); + expect(res.windowNights).toBe(5); + }); + + it("surplus nights pay the running balance down (recovery)", () => { + // Short night (360 = 120 short) then a long catch-up night (600 = 120 over). + // Deficit adds 120; surplus pays down 0.5 × 120 = 60 → balance 60. + const res = computeSleepDebt(nights([360, 600]), NEED, { minNights: 1 }); + expect(res.perNight.map((n) => n.deltaMinutes)).toEqual([120, -60]); + expect(res.debtMinutes).toBe(60); + }); + + it("a single short night carries only that night's deficit, bounded", () => { + // Four full nights then one 6 h night (360 = 120 short) → balance 120. + const res = computeSleepDebt(nights([480, 480, 480, 480, 360]), NEED, { + minNights: 1, + }); + expect(res.debtMinutes).toBe(120); + }); + + it("recovers to zero after enough catch-up sleep", () => { + // Two short nights (each 120 short = +240 balance) then long catch-up nights + // (each 240 over → pays 0.5 × 240 = 120 down). Two catch-up nights clear it. + const res = computeSleepDebt([...nights([360, 360, 720, 720, 480])], NEED, { + minNights: 1, + maxNightlyDeficitMinutes: 180, + }); + expect(res.debtMinutes).toBe(0); + }); + + it("never mints negative debt — surplus alone floors at zero", () => { 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", () => { @@ -37,54 +69,55 @@ describe("computeSleepDebt", () => { minNights: 1, maxNightlyDeficitMinutes: 180, }); - expect(res.perNight[0].deficitMinutes).toBe(180); + expect(res.perNight[0].deltaMinutes).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, { + it("caps the running balance total", () => { + // Five 0 h nights × 180 cap = 900 raw, clamped to a 600 balance cap. + const res = computeSleepDebt(nights(Array(5).fill(0)), NEED, { minNights: 1, - maxTotalDebtMinutes: 1200, + maxTotalDebtMinutes: 600, }); - expect(res.debtMinutes).toBe(1200); + expect(res.debtMinutes).toBe(600); }); 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, + // 8 nights of 420 (60 short each); window 5 → balance walks 5 × 60 = 300. + const res = computeSleepDebt(nights(Array(8).fill(420)), NEED, { + windowNights: 5, }); - 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"); + expect(res.nightsCounted).toBe(5); + expect(res.debtMinutes).toBe(5 * 60); + expect(res.windowNights).toBe(5); + // Oldest kept night is 2026-06-04 (06-01..06-03 aged out of the 5-window). + expect(res.perNight[0].night).toBe("2026-06-04"); + expect(res.perNight.at(-1)?.night).toBe("2026-06-08"); }); 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 + // One short night then five full ones, window 5 → the short night ages out + // entirely, leaving only full nights inside the window → zero balance. + const res = computeSleepDebt(nights([360, 480, 480, 480, 480, 480]), NEED, { + windowNights: 5, + }); + expect(res.debtMinutes).toBe(0); }); it("returns a calm partial state under the night threshold", () => { const res = computeSleepDebt(nights([300, 300, 300]), NEED, { - minNights: 7, + minNights: 4, }); 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 + expect(res.nightsUntilReady).toBe(1); // 4 − 3 + // It still reports the running balance so the UI can show a soft preview. + expect(res.debtMinutes).toBe(3 * 180); // each night 180 short, no surplus }); it("clears the partial state at or above the threshold", () => { - const res = computeSleepDebt(nights(Array(7).fill(420)), NEED, { - minNights: 7, + const res = computeSleepDebt(nights(Array(4).fill(420)), NEED, { + minNights: 4, }); expect(res.state).toBe("ready"); expect(res.nightsUntilReady).toBe(0); @@ -96,4 +129,12 @@ describe("computeSleepDebt", () => { expect(res.debtMinutes).toBe(0); expect(res.needMinutes).toBe(0); }); + + it("reports minutes (unit correctness) — a 90-min standing deficit is 90, not 1.5", () => { + // One 90-min-short night inside an otherwise-met window. + const res = computeSleepDebt(nights([480, 480, 480, 480, 390]), NEED, { + minNights: 1, + }); + expect(res.debtMinutes).toBe(90); + }); }); diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts index b62a1fc1d..13f1cc29b 100644 --- a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts @@ -101,10 +101,11 @@ describe("buildSleepRhythm", () => { tz: "UTC", now: new Date("2026-06-14T12:00:00Z"), }); - // Partial (< 7 nights) but the cumulative is still computed: 120 + 0. + // Partial (< 4 nights) but the running balance is still computed: a 120-min + // short night then a met night, no surplus to pay down → balance 120. expect(dto.sleepDebt.debtMinutes).toBe(120); expect(dto.sleepDebt.nightsCounted).toBe(2); - expect(dto.sleepDebt.nightsUntilReady).toBe(5); + expect(dto.sleepDebt.nightsUntilReady).toBe(2); // minNights 4 − 2 counted }); it("returns calm states with zero nights", async () => { diff --git a/src/lib/insights/derived/sleep-debt.ts b/src/lib/insights/derived/sleep-debt.ts index e33fde150..c3fcc7137 100644 --- a/src/lib/insights/derived/sleep-debt.ts +++ b/src/lib/insights/derived/sleep-debt.ts @@ -1,31 +1,61 @@ /** - * v1.17.0 — cumulative sleep debt (server-authoritative timing math). + * v1.19.0 — rolling sleep-debt BALANCE (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. + * Sleep debt is the extra sleep the body still needs TONIGHT because of recent + * undersleep. It is a running BALANCE over a short recent window, not a + * fortnight-long sum of every deficit: + * + * - a night below need ADDS its shortfall to the balance, and + * - a night above need PAYS THE BALANCE DOWN (catch-up sleep recovers debt), + * - the balance floors at 0 (you cannot bank surplus into negative debt) and + * caps at a sane maximum (the body cannot carry an unbounded debt). + * + * WHY THE OLD MODEL READ ~7 h FOR A NORMAL SLEEPER + * ------------------------------------------------ + * The v1.17.0 model summed `max(0, need − asleep)` over the trailing 14 nights + * with NO surplus credit inside the window. Catch-up sleep could only help by a + * deficit night AGEING OUT — never by paying the balance down. So a user a + * modest ~30 min short on most nights accrued 14 × 30 ≈ 420 min ≈ 7 h of + * standing "debt" that never recovered while the pattern held, and a single + * 10 h catch-up night did nothing. WHOOP reports ~1 h for the same sleeper + * because WHOOP's sleep debt is the recent need-minus-got balance that recovers + * as you sleep — extra sleep tonight reduces what you owe tomorrow. This module + * now matches that semantics: a short recent window, surplus-credited, floored. + * + * WHY A RUNNING BALANCE AND NOT A SUM + * ----------------------------------- + * A sum of deficits answers "how much sleep did I miss across the window" — a + * historical total that grows with the window and ignores recovery. A running + * balance answers "how much do I still OWE right now" — it shrinks the moment + * you catch up, which is the actionable number a recovery surface wants and the + * one WHOOP shows. Recovery is credited at a partial rate (`recoveryRate`, + * default 0.5) because 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 — one long night recovers some, not all, accumulated deficit. + * + * WHY A 5-NIGHT WINDOW + * -------------------- + * Sleep debt is a SHORT-horizon, recent signal: research on recovery from acute + * restriction shows most of the rebound resolves within a few nights of + * adequate sleep. Five nights is long enough that one rough night does not + * dominate, short enough that a fortnight-old deficit no longer shapes today's + * "what do I owe tonight" — matching the recent-nights horizon WHOOP uses. * * 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 + * 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. + * SINGLE-NIGHT view of `need − actual` expressed 0–100; this is the recent + * recovering BALANCE of the same `need − actual`. Same inputs, two framings — a + * timing/recovery signal, not a second sleep-adequacy score. */ /** One night's canonical asleep total, keyed by its wake day. */ @@ -39,59 +69,68 @@ export interface SleepDebtNight { /** 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. + * Rolling window length in nights. 5 is the recent-nights horizon a recovery + * surface wants: long enough that a single bad night does not dominate, short + * enough that a fortnight-old deficit no longer shapes today's balance. */ 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. + * Minimum tracked nights before the balance is asserted. Below this the + * result is a calm PARTIAL state ("still learning") rather than a confident + * balance off a near-empty window. */ 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. + * Fraction of a night's surplus (asleep − need) that pays the running balance + * DOWN. 0.5 encodes the sub-linear recovery curve (Van Dongen 2003): one long + * night recovers some, not all, accrued deficit — surplus does not bank + * minute-for-minute. Deficits still add at full weight. + */ + recoveryRate?: number; + /** + * Per-night deficit cap (minutes). One catastrophic night (a 2-hour sleep, or + * a missing-stage artefact) must not swamp the window; 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. + * Balance cap (minutes). The body cannot carry an unbounded debt and beyond a + * point more debt is not meaningfully more actionable — the message is the + * same. 600 min (10 h) caps the running balance so the number stays legible + * and a long gap of rough nights cannot mint an absurd total. */ maxTotalDebtMinutes?: number; } const DEFAULTS: Required = { - windowNights: 14, - minNights: 7, + windowNights: 5, + minNights: 4, + recoveryRate: 0.5, maxNightlyDeficitMinutes: 180, - maxTotalDebtMinutes: 1200, + maxTotalDebtMinutes: 600, }; -/** Per-night deficit detail, oldest → newest, for the trend strip / tooltip. */ +/** Per-night 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; + /** + * Signed contribution to the running balance for the night, in minutes, + * after caps: `+deficit` when short of need, `−recoveryRate·surplus` when + * over need. The running balance applies these in order and floors at 0. + */ + deltaMinutes: 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. + * balance. */ state: "partial" | "ready"; - /** Cumulative sleep debt in minutes over the window, after caps. */ + /** Current sleep-debt balance in minutes over the window, after caps. */ debtMinutes: number; /** The sleep need (minutes) used for the deficit — echoed for transparency. */ needMinutes: number; @@ -101,17 +140,22 @@ export interface SleepDebtResult { windowNights: number; /** `minNights` − `nightsCounted` while partial, else 0 — drives the nudge. */ nightsUntilReady: number; - /** Per-night deficits over the window, oldest → newest. */ + /** Per-night signed deltas over the window, oldest → newest. */ perNight: SleepDebtNightDetail[]; } /** - * Compute cumulative sleep debt over the trailing window. Pure. + * Compute the rolling sleep-debt balance 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. + * 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. + * + * The balance walks the window oldest → newest: each night adds its capped + * deficit OR subtracts `recoveryRate` of its surplus, floored at 0 and capped + * at `maxTotalDebtMinutes`. The returned `debtMinutes` is the balance AFTER the + * most recent night — what the sleeper still owes right now. */ export function computeSleepDebt( nights: readonly SleepDebtNight[], @@ -123,26 +167,39 @@ export function computeSleepDebt( 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. + // perNight strip reads oldest → newest like every other trend surface — and + // so the running balance applies nights in chronological order. 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); + let balance = 0; 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); + const gap = safeNeed - n.asleepMinutes; + let deltaMinutes: number; + if (gap > 0) { + // Short of need: add the shortfall, capped so one disaster night cannot + // swamp the window. + deltaMinutes = Math.min(gap, opts.maxNightlyDeficitMinutes); + } else { + // Over need: catch-up sleep pays the balance down at the recovery rate + // (sub-linear — surplus does not bank minute-for-minute). + deltaMinutes = gap * opts.recoveryRate; + } + balance = Math.max( + 0, + Math.min(balance + deltaMinutes, opts.maxTotalDebtMinutes), + ); return { night: n.night, asleepMinutes: n.asleepMinutes, - deficitMinutes, + deltaMinutes, }; }); const nightsCounted = perNight.length; - const rawDebt = perNight.reduce((sum, n) => sum + n.deficitMinutes, 0); - const debtMinutes = Math.min(Math.round(rawDebt), opts.maxTotalDebtMinutes); + const debtMinutes = Math.round(balance); const ready = nightsCounted >= opts.minNights && safeNeed > 0; return { diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index 4553673de..b46039715 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -161,8 +161,8 @@ export interface RhythmNight { * to pass. Without this cap a caller that hands in a year of nights (the * dashboard summary's 365-day read) would average MSF/MSFsc/social-jetlag over * a different sample than a caller passing six weeks (the route), and the two - * surfaces would disagree. `computeSleepDebt` already self-caps to 14 nights, - * so the debt headline is source-window-independent for free; this brings the + * surfaces would disagree. `computeSleepDebt` already self-caps to its rolling + * window, so the debt headline is source-window-independent for free; this brings the * chronotype to the same guarantee. 42 nights (six weeks) gives ~12 weekend * nights for a stable free-day MSF. */