mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(sleep): surface sleep debt and chronotype
Assemble a sleep-rhythm read on the canonical per-night reconstruction — the same midpoints and asleep totals the sleep score uses — that reports a rolling sleep debt against the age-based need and an MCTQ chronotype with its mid-sleep, social jetlag and band. A shared assembler caps the chronotype window so the dashboard and the dedicated read return identical values, and both withhold a confident reading behind a learning state until enough nights accrue. The sleep page gains a debt headline and a chronotype card with the deeper detail behind an advanced disclosure; the sleep score is left untouched so debt reframes the sufficiency gap without double-counting it.
This commit is contained in:
commit
a970ba8ab0
22 files changed
+1529
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -2671,6 +2671,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",
|
||||
|
||||
@@ -2671,6 +2671,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",
|
||||
|
||||
@@ -2671,6 +2671,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",
|
||||
|
||||
@@ -2671,6 +2671,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",
|
||||
|
||||
@@ -2671,6 +2671,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",
|
||||
|
||||
@@ -2671,6 +2671,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
|
||||
|
||||
@@ -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,23 @@ 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),
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — every emitted card now carries
|
||||
@@ -862,6 +893,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,31 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
// v1.17.0 — the wire shape IS the server DTO. Import the type directly (erased
|
||||
// at build, so no server code reaches the client bundle) rather than keeping a
|
||||
// second hand-mirrored copy that silently drifts when the server shape changes.
|
||||
import type {
|
||||
SleepDebtDto,
|
||||
ChronotypeDto,
|
||||
SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto };
|
||||
|
||||
/**
|
||||
* 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,147 @@
|
||||
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,
|
||||
computeSleepRhythmFromNights,
|
||||
defaultDayType,
|
||||
type RhythmNight,
|
||||
} 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 from the date key alone", () => {
|
||||
// 2026-06-13 is a Saturday, 2026-06-14 a Sunday, 2026-06-15 a Monday.
|
||||
expect(defaultDayType("2026-06-13")).toBe("free");
|
||||
expect(defaultDayType("2026-06-14")).toBe("free");
|
||||
expect(defaultDayType("2026-06-15")).toBe("work");
|
||||
});
|
||||
|
||||
it("reads the weekday off the local date digits — correct for far-east zones", () => {
|
||||
// The wake-day key is already local; the weekday must not shift for a
|
||||
// UTC+14 user (the old noon-UTC anchor mislabelled a Friday as Saturday).
|
||||
expect(defaultDayType("2026-06-12")).toBe("work"); // Friday
|
||||
expect(defaultDayType("2026-06-13")).toBe("free"); // Saturday
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepRhythmFromNights window cap", () => {
|
||||
/** Build N trailing nights ending 2026-06-30, midpoint 04:00, asleep 420. */
|
||||
function trailingNights(count: number): RhythmNight[] {
|
||||
const out: RhythmNight[] = [];
|
||||
const end = Date.UTC(2026, 5, 30); // 2026-06-30
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const d = new Date(end - i * 86_400_000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
out.push({ night: key, asleepMinutes: 420, midpoint: 4 * 60 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("yields the same chronotype DTO whether fed 42 or 365 nights (source-window-independent)", () => {
|
||||
const need = 420;
|
||||
const sixWeeks = computeSleepRhythmFromNights(trailingNights(42), need);
|
||||
const oneYear = computeSleepRhythmFromNights(trailingNights(365), need);
|
||||
// The most-recent 42 nights are identical in both inputs, so the chronotype
|
||||
// (which the helper caps to the trailing window) must match exactly. This
|
||||
// is the invariant the dashboard summary (365-day read) + the /api/sleep/
|
||||
// rhythm route (42-day read) both depend on to render identical values.
|
||||
expect(oneYear.chronotype).toEqual(sixWeeks.chronotype);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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 { 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: Saturday / Sunday → "free",
|
||||
* else "work". The documented calendar default (no work calendar exists).
|
||||
*
|
||||
* `night` is ALREADY the YYYY-MM-DD wake-day key in the user's timezone (the
|
||||
* canonical engine keyed it with `userDayKey`), so its weekday is a pure
|
||||
* property of those calendar digits — there is no second tz conversion to do.
|
||||
* Parsing the digits and taking `getUTCDay()` is timezone-independent: a UTC
|
||||
* instant at midnight of the same Y-M-D reads back the identical weekday in
|
||||
* every zone, so this is correct for far-east (UTC+13/+14) users too — the
|
||||
* earlier "noon-UTC instant + wallClockInTz" form shifted the weekday by a day
|
||||
* there.
|
||||
*/
|
||||
export function defaultDayType(night: string): "work" | "free" {
|
||||
const [y, m, d] = night.split("-").map(Number);
|
||||
if (!y || !m || !d) return "work";
|
||||
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun … 6 = Sat
|
||||
return weekday === 0 || weekday === 6 ? "free" : "work";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chronotype look-back in nights. The helper caps its OWN chronotype input to
|
||||
* the trailing `CHRONOTYPE_WINDOW_NIGHTS` so the result is a pure function of
|
||||
* the most-recent N nights — NOT of however many nights a given caller happened
|
||||
* to pass. Without this cap a caller that hands in a year of nights (the
|
||||
* dashboard summary's 365-day read) would average MSF/MSFsc/social-jetlag over
|
||||
* a different sample than a caller passing six weeks (the route), and the two
|
||||
* surfaces would disagree. `computeSleepDebt` already self-caps to 14 nights,
|
||||
* so the debt headline is source-window-independent for free; this brings the
|
||||
* chronotype to the same guarantee. 42 nights (six weeks) gives ~12 weekend
|
||||
* nights for a stable free-day MSF.
|
||||
*/
|
||||
const CHRONOTYPE_WINDOW_NIGHTS = 42;
|
||||
|
||||
/**
|
||||
* Pure DTO assembler from already-reconstructed nights. The route reads + runs
|
||||
* the canonical reconstruction; the dashboard summary reuses its own loaded
|
||||
* rows. Both feed the SAME foundation modules here so the values are identical
|
||||
* REGARDLESS of how many nights each caller passes — the helper owns the
|
||||
* chronotype window cap, so a 42-night and a 365-night caller get the same DTO.
|
||||
* This is the one place the window cap + day-type default + the two `compute*`
|
||||
* calls live.
|
||||
*
|
||||
* `nights` must already be the canonical engine's output (asleep + midpoint);
|
||||
* this function does NOT reconstruct. `needMinutes` is the age-resolved need.
|
||||
*/
|
||||
export function computeSleepRhythmFromNights(
|
||||
nights: readonly RhythmNight[],
|
||||
needMinutes: number,
|
||||
): SleepRhythmDto {
|
||||
// Sort ascending by wake-day key so "trailing N" is well-defined regardless
|
||||
// of the caller's input order, then keep only scorable nights.
|
||||
const scorable = [...nights]
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0));
|
||||
|
||||
// Debt self-caps to its own 14-night window internally; pass every scorable
|
||||
// night and let the module slice.
|
||||
const debtNights: SleepDebtNight[] = scorable.map((n) => ({
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
}));
|
||||
const sleepDebt = computeSleepDebt(debtNights, needMinutes);
|
||||
|
||||
// Chronotype has no internal window — cap HERE to the trailing N nights so
|
||||
// the value is a function of the recent rhythm, not the caller's read span.
|
||||
const chronoNights: ChronotypeNight[] = scorable
|
||||
.filter((n) => n.midpoint != null)
|
||||
.slice(-CHRONOTYPE_WINDOW_NIGHTS)
|
||||
.map((n) => ({
|
||||
night: n.night,
|
||||
midpointMinutes: n.midpoint as number,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
dayType: defaultDayType(n.night),
|
||||
}));
|
||||
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. The helper owns the
|
||||
// scorable-night filter + the window cap, so we hand it the raw output.
|
||||
const nights = reconstructNights(rows, tz, priorityJson);
|
||||
|
||||
const needMinutes = sleepNeedMinutes(profile.ageYears);
|
||||
return computeSleepRhythmFromNights(nights, needMinutes);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", …]`
|
||||
|
||||
Reference in new issue
Block a user