mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge w11b/sleepcard into release/v1.19.0
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user