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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:10:44 +02:00
1 parent d1ba01548f
commit 1ce1c786b6
4 files changed
+99 -59

No files matched your search

-1
View File
@@ -661,7 +661,6 @@ async function buildDashboardSummary(
sleepPriorityJson,
),
sleepNeedMinutes(sleepProfile.ageYears),
userTz,
);
const metrics: MetricCard[] = [];
@@ -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
@@ -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);
});
});
+48 -18
View File
@@ -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);
}