From 6d9002bb716c900d9573fd1ab2241e123b52b303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 12:31:18 +0200 Subject: [PATCH 1/2] fix(sleep): compute sleep debt as a recovering rolling balance The cumulative model summed every nightly deficit over a 14-night window with no credit for catch-up sleep, so a sleeper a modest amount short on most nights carried hours of standing debt that never recovered while the pattern held. Replace it with a running balance over a 5-night window: short nights add their capped shortfall, nights above need pay the balance down at a sub-linear recovery rate, floored at zero. The headline now reflects what is still owed tonight and shrinks as the sleeper catches up. --- .../dashboard/summary/__tests__/route.test.ts | 13 +- .../derived/__tests__/sleep-debt.test.ts | 117 +++++++++---- .../derived/__tests__/sleep-rhythm.test.ts | 5 +- src/lib/insights/derived/sleep-debt.ts | 165 ++++++++++++------ src/lib/insights/derived/sleep-rhythm.ts | 4 +- 5 files changed, 203 insertions(+), 101 deletions(-) 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/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. */ From 26230542ce00e8b2bd42d44d86440a986a461993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 12:31:26 +0200 Subject: [PATCH 2/2] fix(sleep): make the chronotype tile uniform with the sleep-debt tile The chronotype card carried its own section heading on top of the title it already renders, so 'Chronotype' appeared twice and the tile did not match its sibling. Drop the standalone heading and bring the card to the same shape as the sleep-debt tile: identical chrome and a bold primary value with one secondary line. The chronotype band is the primary value at the same scale the debt tile uses for its hours figure, with the natural sleep- midpoint clock as the companion line. --- .../__tests__/chronotype-section.test.tsx | 8 ++- .../__tests__/sleep-rhythm-cards.test.tsx | 25 +++---- .../__tests__/sleep-rhythm-section.test.tsx | 10 +-- .../insights/sleep/chronotype-card.tsx | 30 +++++---- .../insights/sleep/chronotype-section.tsx | 66 +++++++++---------- 5 files changed, 71 insertions(+), 68 deletions(-) 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 ; }