Merge w11b/sleepcard into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 12:31:58 +02:00
commit 72809280cb
10 files changed
+274 -169

No files matched your search

@@ -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).
@@ -80,7 +80,7 @@ describe("<ChronotypeSection>", () => {
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("<ChronotypeSection>", () => {
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", () => {
@@ -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("<SleepDebtCard>", () => {
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(<SleepDebtCard debt={READY_DEBT} />);
// 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("<ChronotypeCard>", () => {
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(<ChronotypeCard chronotype={READY_CHRONO} />);
// 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");
@@ -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("<SleepRhythmSection>", () => {
isError: false,
});
const html = render(<SleepRhythmSection enabled />);
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"');
});
@@ -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 `<SleepDebtCard>` 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 }) {
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 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. */}
<div className="space-y-1" data-slot="chronotype-band">
<p className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{bandLabel}
</p>
{/* 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. */}
<div className="space-y-0.5" data-slot="chronotype-band">
<p className="text-2xl font-semibold">{bandLabel}</p>
{msfClock && (
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.midpointHeadline", {
clock: msfClock,
})}
@@ -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 `<SleepRhythmSection>` (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" `<SectionHeading>` was removed: it
* duplicated the heading the `<ChronotypeCard>` 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 `<SleepDebtCard>` 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 (
<section data-slot="chronotype-section" className="space-y-3">
<SectionHeading
icon={Clock}
title={t("insights.sleep.chronotype.title")}
if (isError) {
return (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<Skeleton
data-slot="chronotype-loading"
className="h-32 w-full rounded-xl"
/>
{isError ? (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
) : isLoading || !data ? (
<Skeleton
data-slot="chronotype-loading"
className="h-32 w-full rounded-xl"
/>
) : (
<ChronotypeCard chronotype={data.chronotype} />
)}
</section>
);
);
}
return <ChronotypeCard chronotype={data.chronotype} />;
}
@@ -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);
});
});
@@ -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 () => {
+111 -54
View File
@@ -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<SleepDebtOptions> = {
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 {
+2 -2
View File
@@ -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.
*/