From 1ce1c786b64f85729be07ba98b40234e372160bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:10:44 +0200 Subject: [PATCH] Cap chronotype window in the shared assembler Make computeSleepRhythmFromNights own the chronotype look-back so the dashboard summary (a year of nights) and GET /api/sleep/rhythm (six weeks) return the same band, mid-sleep, and social-jetlag values rather than averaging different samples. Derive the free/work day-type from the local wake-day digits so the classification holds in far-east timezones, and read the shared sleep-rhythm DTO type into the client hook instead of a hand-mirrored copy. --- src/app/api/dashboard/summary/route.ts | 1 - .../insights/sleep/use-sleep-rhythm.ts | 44 +++---------- .../derived/__tests__/sleep-rhythm.test.ts | 47 +++++++++++-- src/lib/insights/derived/sleep-rhythm.ts | 66 ++++++++++++++----- 4 files changed, 99 insertions(+), 59 deletions(-) diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 772d46996..d9241ce6a 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -661,7 +661,6 @@ async function buildDashboardSummary( sleepPriorityJson, ), sleepNeedMinutes(sleepProfile.ageYears), - userTz, ); const metrics: MetricCard[] = []; diff --git a/src/components/insights/sleep/use-sleep-rhythm.ts b/src/components/insights/sleep/use-sleep-rhythm.ts index 6dc0dc9a6..64a66213d 100644 --- a/src/components/insights/sleep/use-sleep-rhythm.ts +++ b/src/components/insights/sleep/use-sleep-rhythm.ts @@ -5,42 +5,16 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; -/** - * v1.17.0 — sleep-debt DTO (mirrors the server `SleepDebtDto`). `partial` is - * the calm "still learning" state below the night threshold; `ready` asserts - * the cumulative deficit. - */ -export interface SleepDebtDto { - state: "partial" | "ready"; - debtMinutes: number; - needMinutes: number; - nightsCounted: number; - windowNights: number; - nightsUntilReady: number; -} +// v1.17.0 — the wire shape IS the server DTO. Import the type directly (erased +// at build, so no server code reaches the client bundle) rather than keeping a +// second hand-mirrored copy that silently drifts when the server shape changes. +import type { + SleepDebtDto, + ChronotypeDto, + SleepRhythmDto, +} from "@/lib/insights/derived/sleep-rhythm"; -/** v1.17.0 — chronotype DTO (mirrors the server `ChronotypeDto`). */ -export interface ChronotypeDto { - state: "learning" | "ready"; - msfMinutes: number | null; - msfScMinutes: number | null; - band: - | "extreme_early" - | "early" - | "intermediate" - | "late" - | "extreme_late" - | null; - socialJetlagMinutes: number | null; - freeNightsCounted: number; - workNightsCounted: number; - freeNightsUntilReady: number; -} - -export interface SleepRhythmDto { - sleepDebt: SleepDebtDto; - chronotype: ChronotypeDto; -} +export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto }; /** * Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts index 0ec06d0ab..b62a1fc1d 100644 --- a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts @@ -21,7 +21,12 @@ vi.mock("../baseline", () => ({ import * as sleepDebtMod from "../sleep-debt"; import * as chronotypeMod from "../chronotype"; -import { buildSleepRhythm, defaultDayType } from "../sleep-rhythm"; +import { + buildSleepRhythm, + computeSleepRhythmFromNights, + defaultDayType, + type RhythmNight, +} from "../sleep-rhythm"; /** * Build per-stage rows for one night: a single bare-ASLEEP block whose END is @@ -41,11 +46,18 @@ function nightRows(wakeIso: string, asleepMinutes: number) { } describe("defaultDayType", () => { - it("tags weekend wake days free and weekdays work (UTC)", () => { + it("tags weekend wake days free and weekdays work from the date key alone", () => { // 2026-06-13 is a Saturday, 2026-06-14 a Sunday, 2026-06-15 a Monday. - expect(defaultDayType("2026-06-13", "UTC")).toBe("free"); - expect(defaultDayType("2026-06-14", "UTC")).toBe("free"); - expect(defaultDayType("2026-06-15", "UTC")).toBe("work"); + expect(defaultDayType("2026-06-13")).toBe("free"); + expect(defaultDayType("2026-06-14")).toBe("free"); + expect(defaultDayType("2026-06-15")).toBe("work"); + }); + + it("reads the weekday off the local date digits — correct for far-east zones", () => { + // The wake-day key is already local; the weekday must not shift for a + // UTC+14 user (the old noon-UTC anchor mislabelled a Friday as Saturday). + expect(defaultDayType("2026-06-12")).toBe("work"); // Friday + expect(defaultDayType("2026-06-13")).toBe("free"); // Saturday }); }); @@ -108,3 +120,28 @@ describe("buildSleepRhythm", () => { expect(dto.chronotype.msfMinutes).toBeNull(); }); }); + +describe("computeSleepRhythmFromNights window cap", () => { + /** Build N trailing nights ending 2026-06-30, midpoint 04:00, asleep 420. */ + function trailingNights(count: number): RhythmNight[] { + const out: RhythmNight[] = []; + const end = Date.UTC(2026, 5, 30); // 2026-06-30 + for (let i = count - 1; i >= 0; i--) { + const d = new Date(end - i * 86_400_000); + const key = d.toISOString().slice(0, 10); + out.push({ night: key, asleepMinutes: 420, midpoint: 4 * 60 }); + } + return out; + } + + it("yields the same chronotype DTO whether fed 42 or 365 nights (source-window-independent)", () => { + const need = 420; + const sixWeeks = computeSleepRhythmFromNights(trailingNights(42), need); + const oneYear = computeSleepRhythmFromNights(trailingNights(365), need); + // The most-recent 42 nights are identical in both inputs, so the chronotype + // (which the helper caps to the trailing window) must match exactly. This + // is the invariant the dashboard summary (365-day read) + the /api/sleep/ + // rhythm route (42-day read) both depend on to render identical values. + expect(oneYear.chronotype).toEqual(sixWeeks.chronotype); + }); +}); diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index 32330dfd0..8b329db18 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -36,7 +36,6 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { loadBaselineProfile } from "./baseline"; @@ -100,15 +99,22 @@ export interface SleepRhythmDto { } /** - * Day type of a night from its WAKE day's weekday in the user's timezone: - * Saturday / Sunday → "free", else "work". The documented calendar default - * (no work calendar exists). `night` is the YYYY-MM-DD wake-day key. + * Day type of a night from its WAKE day's weekday: Saturday / Sunday → "free", + * else "work". The documented calendar default (no work calendar exists). + * + * `night` is ALREADY the YYYY-MM-DD wake-day key in the user's timezone (the + * canonical engine keyed it with `userDayKey`), so its weekday is a pure + * property of those calendar digits — there is no second tz conversion to do. + * Parsing the digits and taking `getUTCDay()` is timezone-independent: a UTC + * instant at midnight of the same Y-M-D reads back the identical weekday in + * every zone, so this is correct for far-east (UTC+13/+14) users too — the + * earlier "noon-UTC instant + wallClockInTz" form shifted the weekday by a day + * there. */ -export function defaultDayType(night: string, tz: string): "work" | "free" { - // Anchor at local noon of the wake day so the weekday read is immune to the - // tz offset and DST — noon never crosses a day boundary in any zone. - const noon = new Date(`${night}T12:00:00Z`); - const weekday = wallClockInTz(noon, tz).weekday; // 0 = Sun … 6 = Sat +export function defaultDayType(night: string): "work" | "free" { + const [y, m, d] = night.split("-").map(Number); + if (!y || !m || !d) return "work"; + const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun … 6 = Sat return weekday === 0 || weekday === 6 ? "free" : "work"; } @@ -151,11 +157,28 @@ export interface RhythmNight { midpoint: number | null; } +/** + * Chronotype look-back in nights. The helper caps its OWN chronotype input to + * the trailing `CHRONOTYPE_WINDOW_NIGHTS` so the result is a pure function of + * the most-recent N nights — NOT of however many nights a given caller happened + * 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 + * chronotype to the same guarantee. 42 nights (six weeks) gives ~12 weekend + * nights for a stable free-day MSF. + */ +const CHRONOTYPE_WINDOW_NIGHTS = 42; + /** * Pure DTO assembler from already-reconstructed nights. The route reads + runs * the canonical reconstruction; the dashboard summary reuses its own loaded * rows. Both feed the SAME foundation modules here so the values are identical - * — this is the one place the day-type default + the two `compute*` calls live. + * REGARDLESS of how many nights each caller passes — the helper owns the + * chronotype window cap, so a 42-night and a 365-night caller get the same DTO. + * This is the one place the window cap + day-type default + the two `compute*` + * calls live. * * `nights` must already be the canonical engine's output (asleep + midpoint); * this function does NOT reconstruct. `needMinutes` is the age-resolved need. @@ -163,23 +186,31 @@ export interface RhythmNight { export function computeSleepRhythmFromNights( nights: readonly RhythmNight[], needMinutes: number, - tz: string, ): SleepRhythmDto { - const scorable = nights.filter((n) => n.asleepMinutes > 0); + // Sort ascending by wake-day key so "trailing N" is well-defined regardless + // of the caller's input order, then keep only scorable nights. + const scorable = [...nights] + .filter((n) => n.asleepMinutes > 0) + .sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0)); + // Debt self-caps to its own 14-night window internally; pass every scorable + // night and let the module slice. const debtNights: SleepDebtNight[] = scorable.map((n) => ({ night: n.night, asleepMinutes: n.asleepMinutes, })); const sleepDebt = computeSleepDebt(debtNights, needMinutes); + // Chronotype has no internal window — cap HERE to the trailing N nights so + // the value is a function of the recent rhythm, not the caller's read span. const chronoNights: ChronotypeNight[] = scorable .filter((n) => n.midpoint != null) + .slice(-CHRONOTYPE_WINDOW_NIGHTS) .map((n) => ({ night: n.night, midpointMinutes: n.midpoint as number, asleepMinutes: n.asleepMinutes, - dayType: defaultDayType(n.night, tz), + dayType: defaultDayType(n.night), })); const chronotype = computeChronotype(chronoNights); @@ -229,11 +260,10 @@ export async function buildSleepRhythm( // Canonical reconstruction (session-clustering, wake-day keying, writer // dedup) → per-night asleep total + wall-clock midpoint. Same engine as the - // Sleep Score; we never re-derive a midpoint here. - const nights = reconstructNights(rows, tz, priorityJson).filter( - (n) => n.asleepMinutes > 0, - ); + // Sleep Score; we never re-derive a midpoint here. The helper owns the + // scorable-night filter + the window cap, so we hand it the raw output. + const nights = reconstructNights(rows, tz, priorityJson); const needMinutes = sleepNeedMinutes(profile.ageYears); - return computeSleepRhythmFromNights(nights, needMinutes, tz); + return computeSleepRhythmFromNights(nights, needMinutes); }