Surface sleep-debt and chronotype on the sleep page

Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.

Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:03:11 +02:00
1 parent 4884f019b7
commit d1ba01548f
22 files changed
+1489 -2

No files matched your search

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the DB so the builder is hermetic — we control the rows.
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } },
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("../baseline", () => ({
// 40-year-old adult → sleepNeedMinutes returns 420 (7 h).
loadBaselineProfile: vi.fn(async () => ({
ageYears: 40,
sex: null,
heightCm: null,
})),
}));
// Spy on the foundation modules WITHOUT replacing the math — assert reuse.
import * as sleepDebtMod from "../sleep-debt";
import * as chronotypeMod from "../chronotype";
import { buildSleepRhythm, defaultDayType } from "../sleep-rhythm";
/**
* Build per-stage rows for one night: a single bare-ASLEEP block whose END is
* the wake instant, so `reconstructNights` reads asleep = `asleepMinutes` and a
* midpoint at wake − asleep/2. `wakeIso` is the wake instant (UTC).
*/
function nightRows(wakeIso: string, asleepMinutes: number) {
return [
{
value: asleepMinutes,
measuredAt: new Date(wakeIso),
sleepStage: "ASLEEP" as const,
source: null,
deviceType: null,
},
];
}
describe("defaultDayType", () => {
it("tags weekend wake days free and weekdays work (UTC)", () => {
// 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");
});
});
describe("buildSleepRhythm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reuses the foundation modules — calls computeSleepDebt + computeChronotype", async () => {
const debtSpy = vi.spyOn(sleepDebtMod, "computeSleepDebt");
const chronoSpy = vi.spyOn(chronotypeMod, "computeChronotype");
// Two scorable nights — enough to exercise the call, not the ready gate.
findMany.mockResolvedValue([
...nightRows("2026-06-13T06:00:00Z", 360),
...nightRows("2026-06-14T06:00:00Z", 400),
]);
const dto = await buildSleepRhythm("user-1", {
tz: "UTC",
now: new Date("2026-06-14T12:00:00Z"),
});
expect(debtSpy).toHaveBeenCalledTimes(1);
expect(chronoSpy).toHaveBeenCalledTimes(1);
// The need forwarded to the debt module is the age-resolved 420 (7 h).
expect(debtSpy.mock.calls[0][1]).toBe(420);
expect(dto.sleepDebt.needMinutes).toBe(420);
// Below the 7-night floor → calm partial / learning, never an assertion.
expect(dto.sleepDebt.state).toBe("partial");
expect(dto.chronotype.state).toBe("learning");
});
it("forwards the canonical asleep totals into the debt deficit", async () => {
// need 420; a 300-min night is 120 short, a 420-min night exactly meets it.
findMany.mockResolvedValue([
...nightRows("2026-06-13T06:00:00Z", 300),
...nightRows("2026-06-14T06:00:00Z", 420),
]);
const dto = await buildSleepRhythm("user-1", {
tz: "UTC",
now: new Date("2026-06-14T12:00:00Z"),
});
// Partial (< 7 nights) but the cumulative is still computed: 120 + 0.
expect(dto.sleepDebt.debtMinutes).toBe(120);
expect(dto.sleepDebt.nightsCounted).toBe(2);
expect(dto.sleepDebt.nightsUntilReady).toBe(5);
});
it("returns calm states with zero nights", async () => {
findMany.mockResolvedValue([]);
const dto = await buildSleepRhythm("user-1", {
tz: "UTC",
now: new Date("2026-06-14T12:00:00Z"),
});
expect(dto.sleepDebt.state).toBe("partial");
expect(dto.sleepDebt.nightsCounted).toBe(0);
expect(dto.chronotype.state).toBe("learning");
expect(dto.chronotype.band).toBeNull();
expect(dto.chronotype.msfMinutes).toBeNull();
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* v1.17.0 — sleep-rhythm assembler (server-authoritative).
*
* The one place that turns a user's raw `SLEEP_DURATION` rows into the two
* timing signals the Sleep page + the iOS client + the dashboard render:
*
* - cumulative SLEEP DEBT over the rolling window (`computeSleepDebt`), and
* - MCTQ CHRONOTYPE + social jetlag (`computeChronotype`).
*
* ONE ENGINE, NO DUPLICATION
* --------------------------
* The per-night `asleepMinutes` and `midpointMinutes` come from the SAME
* canonical reconstruction the Sleep Score reads — `reconstructNights`
* (`sleep-score.ts`), which itself adapts `reconstructSleepNights`
* (`sleep-night.ts`). This module does NOT re-cluster sessions, re-dedup
* writers, or re-derive a midpoint; it maps the canonical night output onto
* the two foundation modules' inputs and forwards the resolved `needMinutes`.
* The math lives entirely in `sleep-debt.ts` / `chronotype.ts` — this file is
* the adapter + DB read + day-type resolver, nothing else. Because the inputs
* are the canonical totals, the debt headline and the chronotype band can
* never contradict the dashboard / hypnogram / Sleep Score for the same night.
*
* DAY-TYPE SIGNAL (free vs work)
* ------------------------------
* Chronotype's MSF needs each night tagged free or work, and the app has no
* work calendar. The documented DEFAULT is calendar-based: a night whose WAKE
* day falls on Saturday or Sunday (in the user's timezone) is "free"; every
* other night is "work". This mirrors the MCTQ assumption that weekends are
* the alarm-free days for a standard work week — surfaced as a modelling
* assumption, not a hidden constant. When a future per-night logged day-type
* exists it overrides this default; until then weekday/weekend is the floor.
*
* Server-only — reads raw `SLEEP_DURATION` rows via Prisma, bounded to the
* window. The DTO is the shared wire shape the route, the dashboard summary,
* and the iOS serializer all emit identically.
*/
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";
import {
reconstructNights,
sleepNeedMinutes,
} from "./sleep-score";
import {
computeSleepDebt,
type SleepDebtNight,
type SleepDebtResult,
} from "./sleep-debt";
import {
computeChronotype,
type ChronotypeNight,
type ChronotypeResult,
} from "./chronotype";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Trailing read window in days. The sleep-debt rolling window is 14 nights and
* chronotype wants a few weeks of free-day samples; 42 days (six weeks) gives
* the debt window full coverage and ~12 weekend nights for a stable MSF while
* staying a bounded per-stage read.
*/
const DEFAULT_WINDOW_DAYS = 42;
/** Wire DTO for the cumulative sleep debt — see `SleepDebtResult`. */
export interface SleepDebtDto {
state: "partial" | "ready";
debtMinutes: number;
needMinutes: number;
nightsCounted: number;
windowNights: number;
nightsUntilReady: number;
}
/** Wire DTO for the MCTQ chronotype — see `ChronotypeResult`. */
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;
}
/** The combined sleep-rhythm DTO — both signals in one read. */
export interface SleepRhythmDto {
sleepDebt: SleepDebtDto;
chronotype: ChronotypeDto;
}
/**
* 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.
*/
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
return weekday === 0 || weekday === 6 ? "free" : "work";
}
function toDebtDto(r: SleepDebtResult): SleepDebtDto {
return {
state: r.state,
debtMinutes: r.debtMinutes,
needMinutes: r.needMinutes,
nightsCounted: r.nightsCounted,
windowNights: r.windowNights,
nightsUntilReady: r.nightsUntilReady,
};
}
function toChronotypeDto(r: ChronotypeResult): ChronotypeDto {
return {
state: r.state,
msfMinutes: r.msfMinutes,
msfScMinutes: r.msfScMinutes,
band: r.band,
socialJetlagMinutes: r.socialJetlagMinutes,
freeNightsCounted: r.freeNightsCounted,
workNightsCounted: r.workNightsCounted,
freeNightsUntilReady: r.freeNightsUntilReady,
};
}
export interface SleepRhythmOpts {
windowDays?: number;
now?: Date;
/** Pin the IANA zone in tests; omit to resolve the user's stored zone. */
tz?: string;
}
/** A reconstructed night carrying the canonical asleep total + wall-clock midpoint. */
export interface RhythmNight {
night: string;
asleepMinutes: number;
/** Sleep midpoint as minutes-of-day (0..1439), or null when undefined. */
midpoint: number | null;
}
/**
* 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.
*
* `nights` must already be the canonical engine's output (asleep + midpoint);
* this function does NOT reconstruct. `needMinutes` is the age-resolved need.
*/
export function computeSleepRhythmFromNights(
nights: readonly RhythmNight[],
needMinutes: number,
tz: string,
): SleepRhythmDto {
const scorable = nights.filter((n) => n.asleepMinutes > 0);
const debtNights: SleepDebtNight[] = scorable.map((n) => ({
night: n.night,
asleepMinutes: n.asleepMinutes,
}));
const sleepDebt = computeSleepDebt(debtNights, needMinutes);
const chronoNights: ChronotypeNight[] = scorable
.filter((n) => n.midpoint != null)
.map((n) => ({
night: n.night,
midpointMinutes: n.midpoint as number,
asleepMinutes: n.asleepMinutes,
dayType: defaultDayType(n.night, tz),
}));
const chronotype = computeChronotype(chronoNights);
return {
sleepDebt: toDebtDto(sleepDebt),
chronotype: toChronotypeDto(chronotype),
};
}
/**
* Read raw sleep rows, reconstruct nights via the canonical engine, and
* compute the sleep-debt + chronotype DTO. Returns the calm partial/learning
* states (never asserts off thin data) when the window is short. The midpoint
* + asleep totals are the canonical engine's — no second reconstruction.
*/
export async function buildSleepRhythm(
userId: string,
opts: SleepRhythmOpts = {},
): Promise<SleepRhythmDto> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = opts.now ?? new Date();
const tz = opts.tz ?? (await resolveUserTimezone(userId));
const [priorityJson, profile] = await Promise.all([
loadUserSourcePriority(userId),
loadBaselineProfile(prisma, userId),
]);
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" satisfies MeasurementType,
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
// source + deviceType feed the canonical writer-dedup so a multi-source
// night is counted ONCE, matching every other sleep surface.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
});
// 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,
);
const needMinutes = sleepNeedMinutes(profile.ageYears);
return computeSleepRhythmFromNights(nights, needMinutes, tz);
}
+107
View File
@@ -216,6 +216,94 @@ const sleepNightResponse = z
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
});
// ── Sleep rhythm (sleep-debt + chronotype, v1.17.0) ──────────────────
//
// Server-authoritative timing signals computed off the SAME canonical night
// reconstruction the Sleep Score reads. Both carry a calm not-yet-ready state
// (`partial` / `learning`) below their night thresholds — they never assert a
// total / band off thin data.
const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
needMinutes: z
.number()
.int()
.nonnegative()
.describe("Age-based sleep need (minutes) used for the per-night deficit."),
nightsCounted: z.number().int().nonnegative(),
windowNights: z
.number()
.int()
.positive()
.describe("Rolling window length in nights."),
nightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Nights still needed before the debt is asserted (0 when ready)."),
});
const chronotypeBandEnum = z.enum([
"extreme_early",
"early",
"intermediate",
"late",
"extreme_late",
]);
const chronotypeResource = z.object({
state: z
.enum(["learning", "ready"])
.describe(
"`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is large enough.",
),
msfMinutes: z
.number()
.nullable()
.describe("Mid-sleep on free days (minutes-of-day), null while learning."),
msfScMinutes: z
.number()
.nullable()
.describe(
"Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning.",
),
band: chronotypeBandEnum
.nullable()
.describe("MCTQ chronotype band off MSFsc, null while learning."),
socialJetlagMinutes: z
.number()
.nullable()
.describe(
"Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing.",
),
freeNightsCounted: z.number().int().nonnegative(),
workNightsCounted: z.number().int().nonnegative(),
freeNightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Free-day nights still needed before a band is asserted."),
});
const sleepRhythmResponse = z
.object({
sleepDebt: sleepDebtResource,
chronotype: chronotypeResource,
})
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
//
// v1.16.16 — documents the long-shipped `GET /api/measurements/series`
@@ -511,4 +599,23 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/sleep/rhythm": {
get: {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
content: {
"application/json": {
schema: dataEnvelope(sleepRhythmResponse, "SleepRhythmResponse"),
},
},
},
...stdResponses,
},
},
},
};
+2
View File
@@ -67,6 +67,8 @@ export const measurementDependentKeys = [
["trend-series"] as const,
// v1.11.5 — refresh the last-night hypnogram when sleep rows change.
["sleep-night"] as const,
// v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change.
["sleep-rhythm"] as const,
];
/**
+8
View File
@@ -45,6 +45,14 @@ export const measurementKeys = {
*/
sleepNight: (date?: string) => ["sleep-night", date ?? "latest"] as const,
/**
* v1.17.0 — sleep-rhythm read (`GET /api/sleep/rhythm`): the
* server-authoritative sleep-debt + chronotype DTO. Reads the same raw
* SLEEP_DURATION rows the night view does, so a sleep write invalidates
* it through the shared measurement-dependent set.
*/
sleepRhythm: () => ["sleep-rhythm"] as const,
/**
* v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard
* + insights chart row. Pre-fix the key was bare `["chart-data", …]`