diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5a859419a..4c1f9600a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index cfea1b804..34e9dc3fa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..58d202c7a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..1a6f6b74f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..000f3f8fd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 04730edac..e0b84e5fb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..4e6b5c97c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 36f1e9506..3b90d33f4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 9761a8ba3..772d46996 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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(), }; } diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts new file mode 100644 index 000000000..85d94cdc5 --- /dev/null +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -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(); + 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(); + 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; +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); + }); +}); diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts new file mode 100644 index 000000000..8eb6c1502 --- /dev/null +++ b/src/app/api/sleep/rhythm/route.ts @@ -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); +}); diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 9266068e7..514ac15a1 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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() { > + {/* + 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. + */} + + {/* diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx new file mode 100644 index 000000000..9e313557f --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -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( + {node}, + ); +} + +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("", () => { + it("shows the learning nudge in the partial state, not a debt total", () => { + const html = render(); + 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(); + // 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( + , + ); + expect(html).toContain("All caught up"); + }); +}); + +describe("", () => { + it("shows the learning state and asserts NO band off thin data", () => { + const html = render(); + 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(); + 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(); + // 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"); + }); +}); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx new file mode 100644 index 000000000..7af1f9a26 --- /dev/null +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -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 ( + + +
+ + + {t("insights.sleep.chronotype.title")} + +
+
+ +

+ {t("insights.sleep.chronotype.learning", { + counted: chronotype.freeNightsCounted, + total: need, + })} +

+
+
+ ); + } + + const bandLabel = t(`insights.sleep.chronotype.band.${chronotype.band}`); + const msfClock = + chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null; + + return ( + + +
+ + + {t("insights.sleep.chronotype.title")} + +
+
+ +
+ + {bandLabel} + + {msfClock && ( +

+ {t("insights.sleep.chronotype.midSleepCaption", { + clock: msfClock, + })} +

+ )} +
+ + + + {showAdvanced && ( +
+ {chronotype.socialJetlagMinutes != null && + (() => { + const { hours, minutes } = hoursMinutes( + chronotype.socialJetlagMinutes, + ); + return ( +
+

+ {t("insights.sleep.chronotype.socialJetlagValue", { + hours, + minutes, + })} +

+

+ {t("insights.sleep.chronotype.socialJetlagCaption")} +

+
+ ); + })()} + {chronotype.msfScMinutes != null && ( +
+

+ {t("insights.sleep.chronotype.msfScValue", { + clock: clockLabel(chronotype.msfScMinutes), + })} +

+

+ {t("insights.sleep.chronotype.msfScCaption")} +

+
+ )} +

+ {t("insights.sleep.chronotype.dayTypeNote")} +

+
+ )} +
+
+ ); +} diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx new file mode 100644 index 000000000..d0454adf9 --- /dev/null +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -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 ( + + +
+ + + {t("insights.sleep.debt.title")} + +
+
+ +

+ {t("insights.sleep.debt.learning", { + counted: debt.nightsCounted, + remaining: debt.nightsUntilReady, + })} +

+
+
+ ); + } + + const { hours, minutes } = hoursMinutes(debt.debtMinutes); + const isClear = debt.debtMinutes === 0; + + return ( + + +
+ + + {t("insights.sleep.debt.title")} + +
+
+ +
+

+ {isClear + ? t("insights.sleep.debt.clearValue") + : t("insights.sleep.debt.value", { hours, minutes })} +

+

+ {isClear + ? t("insights.sleep.debt.clearCaption", { + nights: debt.windowNights, + }) + : t("insights.sleep.debt.caption", { + nights: debt.windowNights, + })} +

+
+
+
+ ); +} diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx new file mode 100644 index 000000000..6e0ef9c6e --- /dev/null +++ b/src/components/insights/sleep/sleep-rhythm-section.tsx @@ -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 ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/components/insights/sleep/use-sleep-rhythm.ts b/src/components/insights/sleep/use-sleep-rhythm.ts new file mode 100644 index 000000000..6dc0dc9a6 --- /dev/null +++ b/src/components/insights/sleep/use-sleep-rhythm.ts @@ -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("/api/sleep/rhythm"), + enabled, + staleTime: 60 * 1000, + }); +} diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts new file mode 100644 index 000000000..0ec06d0ab --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts @@ -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(); + }); +}); diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts new file mode 100644 index 000000000..32330dfd0 --- /dev/null +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -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 { + 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); +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 8316a6c25..15de359a7 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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 = { }, }, }, + "/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, + }, + }, + }, }; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index e98e8d0a5..d864a42bd 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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, ]; /** diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index e4081bc0a..a8e9265ad 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -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", …]`