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

+149
View File
@@ -1906,6 +1906,27 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/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:
$ref: "#/components/schemas/SleepRhythmResponse"
"401": *a1
"422": *a3
"429": *a4
/api/workouts:
get:
tags:
@@ -9138,6 +9159,134 @@ components:
additionalProperties: false
description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps
surfaced separately."
SleepRhythmResponse:
type: object
properties:
data:
$ref: "#/components/schemas/SleepRhythmResource"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SleepRhythmResource:
type: object
properties:
sleepDebt:
type: object
properties:
state:
type: string
enum:
- partial
- ready
description: "`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can
assert a cumulative debt."
debtMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
description: Cumulative sleep debt over the window, in minutes, after caps.
needMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
description: Age-based sleep need (minutes) used for the per-night deficit.
nightsCounted:
type: integer
minimum: 0
maximum: 9007199254740991
windowNights:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
description: Rolling window length in nights.
nightsUntilReady:
type: integer
minimum: 0
maximum: 9007199254740991
description: Nights still needed before the debt is asserted (0 when ready).
required:
- state
- debtMinutes
- needMinutes
- nightsCounted
- windowNights
- nightsUntilReady
additionalProperties: false
chronotype:
type: object
properties:
state:
type: string
enum:
- learning
- ready
description: "`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is
large enough."
msfMinutes:
anyOf:
- type: number
- type: "null"
description: Mid-sleep on free days (minutes-of-day), null while learning.
msfScMinutes:
anyOf:
- type: number
- type: "null"
description: Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning.
band:
anyOf:
- type: string
enum:
- extreme_early
- early
- intermediate
- late
- extreme_late
- type: "null"
description: MCTQ chronotype band off MSFsc, null while learning.
socialJetlagMinutes:
anyOf:
- type: number
- type: "null"
description: Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing.
freeNightsCounted:
type: integer
minimum: 0
maximum: 9007199254740991
workNightsCounted:
type: integer
minimum: 0
maximum: 9007199254740991
freeNightsUntilReady:
type: integer
minimum: 0
maximum: 9007199254740991
description: Free-day nights still needed before a band is asserted.
required:
- state
- msfMinutes
- msfScMinutes
- band
- socialJetlagMinutes
- freeNightsCounted
- workNightsCounted
- freeNightsUntilReady
additionalProperties: false
required:
- sleepDebt
- chronotype
additionalProperties: false
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."
ListWorkoutsResponse:
type: object
properties:
+26
View File
@@ -2666,6 +2666,32 @@
"title": "Noch keine Schlafdaten",
"description": "Apple-Health-Sync folgt mit v1.5 und füllt diese Ansicht automatisch.",
"action": "Geräte verwalten"
},
"debt": {
"title": "Schlafschuld",
"value": "{hours} Std. {minutes} Min. zu wenig",
"caption": "Kumuliertes Defizit der letzten {nights} Nächte",
"clearValue": "Ausgeglichen",
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz."
},
"chronotype": {
"title": "Chronotyp",
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
"midSleepCaption": "Schlafmitte an freien Tagen gegen {clock}",
"advancedToggle": "Mehr Details",
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
"msfScValue": "Korrigierte Schlafmitte {clock}",
"msfScCaption": "Bereinigt um den Schlaf, den du am Wochenende nachholst (MSFsc).",
"dayTypeNote": "Freie Tage werden als Wochenenden angenommen, bis eine Tagestyp-Erfassung ergänzt wird.",
"band": {
"extreme_early": "Ausgeprägter Morgentyp",
"early": "Morgentyp",
"intermediate": "Mischtyp",
"late": "Abendtyp",
"extreme_late": "Ausgeprägter Abendtyp"
}
}
},
"correlations": "Korrelationen",
+26
View File
@@ -2666,6 +2666,32 @@
"title": "No sleep data yet",
"description": "Apple Health sync arrives in v1.5 and will fill this in automatically.",
"action": "Manage devices"
},
"debt": {
"title": "Sleep debt",
"value": "{hours}h {minutes}m short",
"caption": "Cumulative shortfall over the last {nights} nights",
"clearValue": "All caught up",
"clearCaption": "No accumulated shortfall over the last {nights} nights",
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance."
},
"chronotype": {
"title": "Chronotype",
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
"midSleepCaption": "Mid-sleep on free days around {clock}",
"advancedToggle": "Advanced",
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
"msfScValue": "Corrected mid-sleep {clock}",
"msfScCaption": "Adjusted for the sleep you catch up on at the weekend (MSFsc).",
"dayTypeNote": "Free days are assumed to be weekends until day-type tracking is added.",
"band": {
"extreme_early": "Strong morning type",
"early": "Morning type",
"intermediate": "Intermediate type",
"late": "Evening type",
"extreme_late": "Strong evening type"
}
}
},
"correlations": "Correlations",
+26
View File
@@ -2666,6 +2666,32 @@
"title": "Aún no hay datos de sueño",
"description": "La sincronización con Apple Health llega con la v1.5 y llenará esta vista automáticamente.",
"action": "Gestionar dispositivos"
},
"debt": {
"title": "Deuda de sueño",
"value": "{hours} h {minutes} min de déficit",
"caption": "Déficit acumulado de las últimas {nights} noches",
"clearValue": "Al día",
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable."
},
"chronotype": {
"title": "Cronotipo",
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
"midSleepCaption": "Punto medio del sueño en días libres en torno a las {clock}",
"advancedToggle": "Avanzado",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
"msfScValue": "Punto medio corregido {clock}",
"msfScCaption": "Ajustado por el sueño que recuperas el fin de semana (MSFsc).",
"dayTypeNote": "Se asume que los días libres son los fines de semana hasta que se añada el registro del tipo de día.",
"band": {
"extreme_early": "Matutino marcado",
"early": "Matutino",
"intermediate": "Intermedio",
"late": "Vespertino",
"extreme_late": "Vespertino marcado"
}
}
},
"correlations": "Correlaciones",
+26
View File
@@ -2666,6 +2666,32 @@
"title": "Pas encore de données de sommeil",
"description": "La synchro Apple Health arrive avec la v1.5 et remplira automatiquement cette vue.",
"action": "Gérer les appareils"
},
"debt": {
"title": "Dette de sommeil",
"value": "{hours} h {minutes} min de déficit",
"caption": "Déficit cumulé sur les {nights} dernières nuits",
"clearValue": "À jour",
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable."
},
"chronotype": {
"title": "Chronotype",
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
"midSleepCaption": "Milieu de sommeil les jours libres vers {clock}",
"advancedToggle": "Avancé",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
"msfScValue": "Milieu de sommeil corrigé {clock}",
"msfScCaption": "Ajusté pour le sommeil rattrapé le week-end (MSFsc).",
"dayTypeNote": "Les jours libres sont supposés être les week-ends jusqu'à l'ajout du suivi du type de jour.",
"band": {
"extreme_early": "Type du matin marqué",
"early": "Type du matin",
"intermediate": "Type intermédiaire",
"late": "Type du soir",
"extreme_late": "Type du soir marqué"
}
}
},
"correlations": "Corrélations",
+26
View File
@@ -2666,6 +2666,32 @@
"title": "Ancora nessun dato sul sonno",
"description": "La sync con Apple Health arriva con v1.5 e riempirà questa vista in automatico.",
"action": "Gestisci dispositivi"
},
"debt": {
"title": "Debito di sonno",
"value": "{hours} h {minutes} min in meno",
"caption": "Deficit cumulato nelle ultime {nights} notti",
"clearValue": "In pari",
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile."
},
"chronotype": {
"title": "Cronotipo",
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
"midSleepCaption": "Metà del sonno nei giorni liberi intorno alle {clock}",
"advancedToggle": "Avanzate",
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
"msfScValue": "Metà del sonno corretta {clock}",
"msfScCaption": "Corretta per il sonno recuperato nel fine settimana (MSFsc).",
"dayTypeNote": "Si presume che i giorni liberi siano i fine settimana finché non verrà aggiunto il tracciamento del tipo di giorno.",
"band": {
"extreme_early": "Tipo mattutino marcato",
"early": "Tipo mattutino",
"intermediate": "Tipo intermedio",
"late": "Tipo serale",
"extreme_late": "Tipo serale marcato"
}
}
},
"correlations": "Correlazioni",
+26
View File
@@ -2666,6 +2666,32 @@
"title": "Brak danych o śnie",
"description": "Synchronizacja Apple Health pojawi się w v1.5 i wypełni ten widok automatycznie.",
"action": "Zarządzaj urządzeniami"
},
"debt": {
"title": "Dług snu",
"value": "{hours} godz. {minutes} min niedoboru",
"caption": "Skumulowany niedobór z ostatnich {nights} nocy",
"clearValue": "Na bieżąco",
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans."
},
"chronotype": {
"title": "Chronotyp",
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
"midSleepCaption": "Środek snu w dni wolne około {clock}",
"advancedToggle": "Zaawansowane",
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
"msfScValue": "Skorygowany środek snu {clock}",
"msfScCaption": "Skorygowany o sen nadrabiany w weekend (MSFsc).",
"dayTypeNote": "Dni wolne przyjmuje się jako weekendy, dopóki nie zostanie dodane śledzenie typu dnia.",
"band": {
"extreme_early": "Wyraźny typ poranny",
"early": "Typ poranny",
"intermediate": "Typ pośredni",
"late": "Typ wieczorny",
"extreme_late": "Wyraźny typ wieczorny"
}
}
},
"correlations": "Korelacje",
@@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({
medicationScheduleRevision: {
groupBy: vi.fn(),
},
// v1.17.0 — the sleep-rhythm block resolves the user's age-based sleep
// need via the baseline profile (`User` row).
user: {
findUnique: vi.fn(),
},
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
@@ -120,6 +125,13 @@ beforeEach(() => {
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
// v1.17.0 — baseline profile read for the sleep-rhythm need. Default to a
// 40-year-old adult (need = 420 min) so the rhythm block resolves.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: null,
heightCm: null,
} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
@@ -515,6 +527,59 @@ describe("GET /api/dashboard/summary", () => {
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
});
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.
const rows = [];
for (let d = 1; d <= 10; d++) {
const day = String(d).padStart(2, "0");
rows.push({
value: 360,
measuredAt: new Date(`2026-06-${day}T06:00:00.000Z`),
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
deviceType: null,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
sleepRhythm: {
sleepDebt: {
state: string;
debtMinutes: number;
needMinutes: number;
nightsCounted: number;
windowNights: number;
nightsUntilReady: number;
};
chronotype: {
state: string;
freeNightsCounted: number;
workNightsCounted: number;
freeNightsUntilReady: number;
};
};
};
};
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.
expect(sleepDebt.state).toBe("ready");
expect(sleepDebt.debtMinutes).toBe(600);
expect(sleepDebt.needMinutes).toBe(420);
expect(sleepDebt.nightsCounted).toBe(10);
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).
expect(chronotype.state).toBe("learning");
expect(typeof chronotype.freeNightsCounted).toBe("number");
expect(typeof chronotype.freeNightsUntilReady).toBe("number");
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
+37 -2
View File
@@ -64,6 +64,15 @@ import {
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
import {
reconstructNights,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import {
computeSleepRhythmFromNights,
type SleepRhythmDto,
} from "@/lib/insights/derived/sleep-rhythm";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -534,8 +543,13 @@ async function buildDashboardSummary(
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
// before the per-night reconstruction sums it.
const sleepPriorityJson = await loadUserSourcePriority(userId);
// before the per-night reconstruction sums it. v1.17.0 — the baseline
// profile rides along (one read) so the sleep-rhythm block resolves the
// age-based sleep need the cumulative-debt deficit uses.
const [sleepPriorityJson, sleepProfile] = await Promise.all([
loadUserSourcePriority(userId),
loadBaselineProfile(prisma, userId),
]);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
@@ -632,6 +646,24 @@ async function buildDashboardSummary(
sleepPriorityJson,
);
// v1.17.0 — server-authoritative sleep-rhythm DTO (sleep-debt + chronotype)
// off the SAME canonical reconstruction the Sleep page + Sleep Score read,
// so iOS renders identical values. Reuses the rows already loaded above —
// `reconstructNights` adapts them into the asleep + wall-clock-midpoint
// shape the foundation modules consume, then `computeSleepRhythmFromNights`
// forwards them to `computeSleepDebt` / `computeChronotype` (the one place
// the math lives). Both signals carry a calm partial / learning state below
// their night thresholds — never an assertion off thin data.
const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights(
reconstructNights(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
),
sleepNeedMinutes(sleepProfile.ageYears),
userTz,
);
const metrics: MetricCard[] = [];
// v1.4.33 maintainer-item-1 — every emitted card now carries
@@ -862,6 +894,9 @@ async function buildDashboardSummary(
},
highlightInsight: null,
metrics,
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
lastUpdated: now.toISOString(),
};
}
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
// `apiHandler` always reads `request.url`; the inner handler ignores it, so we
// bypass the arity check with a cast (same pattern as the dashboard summary
// test).
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function req(): NextRequest {
return new NextRequest("http://localhost/api/sleep/rhythm");
}
/** A bare-ASLEEP night: end = wake instant, asleep = `minutes`. */
function night(wakeIso: string, minutes: number) {
return {
value: minutes,
measuredAt: new Date(wakeIso),
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
deviceType: null,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: null,
heightCm: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/sleep/rhythm", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(req());
expect(res.status).toBe(401);
});
it("returns the sleep-debt + chronotype DTO shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(req());
expect(res.status).toBe(200);
const body = await res.json();
// Sleep-debt DTO fields.
expect(body.data.sleepDebt).toMatchObject({
state: "partial",
debtMinutes: 0,
needMinutes: expect.any(Number),
nightsCounted: 0,
windowNights: expect.any(Number),
nightsUntilReady: expect.any(Number),
});
// Chronotype DTO fields.
expect(body.data.chronotype).toMatchObject({
state: "learning",
msfMinutes: null,
msfScMinutes: null,
band: null,
socialJetlagMinutes: null,
freeNightsCounted: 0,
workNightsCounted: 0,
freeNightsUntilReady: expect.any(Number),
});
});
it("reaches the ready debt state with a full window of short nights", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Ten consecutive nights of 6 h (360 min); need is 420 → 60/night deficit.
const rows = [];
for (let d = 1; d <= 10; d++) {
const day = String(d).padStart(2, "0");
rows.push(night(`2026-06-${day}T06:00:00.000Z`, 360));
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const res = await callGet(req());
const body = await res.json();
expect(body.data.sleepDebt.state).toBe("ready");
expect(body.data.sleepDebt.nightsCounted).toBe(10);
expect(body.data.sleepDebt.nightsUntilReady).toBe(0);
// 10 nights × 60-min deficit = 600 min cumulative debt.
expect(body.data.sleepDebt.debtMinutes).toBe(600);
expect(body.data.sleepDebt.needMinutes).toBe(420);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* GET /api/sleep/rhythm
*
* v1.17.0 — server-authoritative sleep-rhythm read. Returns the two timing
* signals the Sleep page + the iOS client render off the SAME canonical night
* reconstruction the Sleep Score uses:
*
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
* `partial` state under the night threshold.
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
* state until enough free-day nights exist.
*
* The math lives in `sleep-debt.ts` / `chronotype.ts`; this route is the auth +
* read boundary over `buildSleepRhythm`, which owns the DB read, the canonical
* reconstruction, the age-based `needMinutes`, and the weekday/weekend day-type
* default. No schema, no new type — a view over the existing per-stage
* `SLEEP_DURATION` rows.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rhythm = await buildSleepRhythm(user.id);
annotate({
action: { name: "sleep.rhythm" },
meta: {
debt_state: rhythm.sleepDebt.state,
debt_minutes: rhythm.sleepDebt.debtMinutes,
chronotype_state: rhythm.chronotype.state,
chronotype_band: rhythm.chronotype.band,
},
});
return apiSuccess(rhythm);
});
+8
View File
@@ -12,6 +12,7 @@ import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -81,6 +82,13 @@ export default function InsightsSchlafPage() {
>
<SleepOverview />
{/*
v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
(the same DTO iOS renders), gated on the data-bearing branch so a
source-less account never fetches.
*/}
<SleepRhythmSection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SleepDebtCard } from "../sleep-debt-card";
import { ChronotypeCard } from "../chronotype-card";
import type { SleepDebtDto, ChronotypeDto } from "../use-sleep-rhythm";
/**
* v1.17.0 — sleep-debt + chronotype card rendering, with a focus on the calm
* NOT-YET-READY states (partial / learning): both must show a "still learning"
* nudge and NEVER assert a debt total or a chronotype band off thin data.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const PARTIAL_DEBT: SleepDebtDto = {
state: "partial",
debtMinutes: 0,
needMinutes: 420,
nightsCounted: 3,
windowNights: 14,
nightsUntilReady: 4,
};
const READY_DEBT: SleepDebtDto = {
state: "ready",
debtMinutes: 600,
needMinutes: 420,
nightsCounted: 10,
windowNights: 14,
nightsUntilReady: 0,
};
const LEARNING_CHRONO: ChronotypeDto = {
state: "learning",
msfMinutes: null,
msfScMinutes: null,
band: null,
socialJetlagMinutes: null,
freeNightsCounted: 1,
workNightsCounted: 5,
freeNightsUntilReady: 2,
};
const READY_CHRONO: ChronotypeDto = {
state: "ready",
msfMinutes: 4 * 60 + 30, // 04:30
msfScMinutes: 4 * 60, // 04:00
band: "intermediate",
socialJetlagMinutes: 75,
freeNightsCounted: 6,
workNightsCounted: 14,
freeNightsUntilReady: 0,
};
describe("<SleepDebtCard>", () => {
it("shows the learning nudge in the partial state, not a debt total", () => {
const html = render(<SleepDebtCard debt={PARTIAL_DEBT} />);
expect(html).toContain("Still learning your sleep");
expect(html).toContain("3 nights");
// No asserted "short" figure while partial.
expect(html).not.toContain("short");
});
it("asserts the cumulative debt 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");
});
it("shows the caught-up state at zero debt", () => {
const html = render(
<SleepDebtCard debt={{ ...READY_DEBT, debtMinutes: 0 }} />,
);
expect(html).toContain("All caught up");
});
});
describe("<ChronotypeCard>", () => {
it("shows the learning state and asserts NO band off thin data", () => {
const html = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
expect(html).toContain("Still learning your rhythm");
expect(html).toContain("1 of 3 free nights");
// No band label leaks in the learning state — none of the five MCTQ
// class labels appears.
expect(html).not.toContain("Intermediate type");
expect(html).not.toContain("Morning type");
expect(html).not.toContain("Evening type");
});
it("shows the band + mid-sleep clock in the ready state", () => {
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
expect(html).toContain("Intermediate type");
expect(html).toContain("04:30");
});
it("keeps social jetlag + MSFsc behind the advanced disclosure (collapsed by default)", () => {
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
// The toggle is present, the advanced detail is not rendered until opened.
expect(html).toContain("Advanced");
expect(html).not.toContain("social jetlag");
expect(html).not.toContain("Corrected mid-sleep");
});
});
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { ChronotypeDto } from "./use-sleep-rhythm";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
function clockLabel(minutesOfDay: number): string {
const wrapped = ((Math.round(minutesOfDay) % 1440) + 1440) % 1440;
const h = Math.floor(wrapped / 60);
const m = wrapped - h * 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Whole hours + minutes from a minute total, for the social-jetlag readout. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
const t = Math.round(totalMinutes);
return { hours: Math.floor(t / 60), minutes: t - Math.floor(t / 60) * 60 };
}
/**
* v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag).
*
* Progressive disclosure: the band + mid-sleep clock time show by default; the
* social-jetlag detail + the sleep-debt-corrected MSFsc sit behind an
* "advanced" toggle. While `learning` it shows "still learning your rhythm —
* N of M nights" and asserts NO type. Warm, grounded copy; mobile-first.
*/
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const { t } = useTranslations();
const [showAdvanced, setShowAdvanced] = useState(false);
if (chronotype.state === "learning" || chronotype.band == null) {
const need =
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.chronotype.learning", {
counted: chronotype.freeNightsCounted,
total: need,
})}
</p>
</CardContent>
</Card>
);
}
const bandLabel = t(`insights.sleep.chronotype.band.${chronotype.band}`);
const msfClock =
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Badge variant="secondary" className="text-sm">
{bandLabel}
</Badge>
{msfClock && (
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.midSleepCaption", {
clock: msfClock,
})}
</p>
)}
</div>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
>
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{t("insights.sleep.chronotype.advancedToggle")}
</button>
{showAdvanced && (
<div className="space-y-2 border-t pt-3">
{chronotype.socialJetlagMinutes != null &&
(() => {
const { hours, minutes } = hoursMinutes(
chronotype.socialJetlagMinutes,
);
return (
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("insights.sleep.chronotype.socialJetlagValue", {
hours,
minutes,
})}
</p>
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.socialJetlagCaption")}
</p>
</div>
);
})()}
{chronotype.msfScMinutes != null && (
<div className="space-y-0.5">
<p className="text-sm font-medium tabular-nums">
{t("insights.sleep.chronotype.msfScValue", {
clock: clockLabel(chronotype.msfScMinutes),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.msfScCaption")}
</p>
</div>
)}
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.dayTypeNote")}
</p>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,83 @@
"use client";
import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes - hours * 60);
return { hours, minutes };
}
/**
* v1.17.0 — sleep-debt headline.
*
* The cumulative shortfall between sleep need and sleep obtained over the
* rolling window. Under the night threshold it shows the calm `partial`
* state ("still learning — N more nights") and never asserts a total off thin
* data. Mobile-first: a single readable figure + one line of grounded context.
*/
export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
const { t } = useTranslations();
if (debt.state === "partial") {
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.debt.learning", {
counted: debt.nightsCounted,
remaining: debt.nightsUntilReady,
})}
</p>
</CardContent>
</Card>
);
}
const { hours, minutes } = hoursMinutes(debt.debtMinutes);
const isClear = debt.debtMinutes === 0;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
<p className="text-2xl font-semibold tabular-nums">
{isClear
? t("insights.sleep.debt.clearValue")
: t("insights.sleep.debt.value", { hours, minutes })}
</p>
<p className="text-muted-foreground text-xs">
{isClear
? t("insights.sleep.debt.clearCaption", {
nights: debt.windowNights,
})
: t("insights.sleep.debt.caption", {
nights: debt.windowNights,
})}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { SleepDebtCard } from "./sleep-debt-card";
import { ChronotypeCard } from "./chronotype-card";
/**
* v1.17.0 — sleep-rhythm section on the Sleep page.
*
* Reads the server-authoritative sleep-debt + chronotype DTO and renders the
* sleep-debt headline + the chronotype card. Gated on `enabled` so a
* source-less account never fetches. The values are identical to what the iOS
* client renders (one server computation).
*/
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useSleepRhythm(isAuthenticated && enabled);
if (!enabled) return null;
if (isLoading || !data) {
return (
<div className="space-y-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-4">
<SleepDebtCard debt={data.sleepDebt} />
<ChronotypeCard chronotype={data.chronotype} />
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
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 — 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;
}
/**
* Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a
* source-less account never fetches; shares the measurement-write
* invalidation prefix (`["sleep-rhythm"]`).
*/
export function useSleepRhythm(enabled: boolean) {
return useQuery({
queryKey: queryKeys.sleepRhythm(),
queryFn: () => apiGet<SleepRhythmDto>("/api/sleep/rhythm"),
enabled,
staleTime: 60 * 1000,
});
}
@@ -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", …]`