Surface sleep-debt and chronotype on the sleep page

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

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

No files matched your search

+107
View File
@@ -216,6 +216,94 @@ const sleepNightResponse = z
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
});
// ── Sleep rhythm (sleep-debt + chronotype, v1.17.0) ──────────────────
//
// Server-authoritative timing signals computed off the SAME canonical night
// reconstruction the Sleep Score reads. Both carry a calm not-yet-ready state
// (`partial` / `learning`) below their night thresholds — they never assert a
// total / band off thin data.
const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
needMinutes: z
.number()
.int()
.nonnegative()
.describe("Age-based sleep need (minutes) used for the per-night deficit."),
nightsCounted: z.number().int().nonnegative(),
windowNights: z
.number()
.int()
.positive()
.describe("Rolling window length in nights."),
nightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Nights still needed before the debt is asserted (0 when ready)."),
});
const chronotypeBandEnum = z.enum([
"extreme_early",
"early",
"intermediate",
"late",
"extreme_late",
]);
const chronotypeResource = z.object({
state: z
.enum(["learning", "ready"])
.describe(
"`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is large enough.",
),
msfMinutes: z
.number()
.nullable()
.describe("Mid-sleep on free days (minutes-of-day), null while learning."),
msfScMinutes: z
.number()
.nullable()
.describe(
"Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning.",
),
band: chronotypeBandEnum
.nullable()
.describe("MCTQ chronotype band off MSFsc, null while learning."),
socialJetlagMinutes: z
.number()
.nullable()
.describe(
"Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing.",
),
freeNightsCounted: z.number().int().nonnegative(),
workNightsCounted: z.number().int().nonnegative(),
freeNightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Free-day nights still needed before a band is asserted."),
});
const sleepRhythmResponse = z
.object({
sleepDebt: sleepDebtResource,
chronotype: chronotypeResource,
})
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
//
// v1.16.16 — documents the long-shipped `GET /api/measurements/series`
@@ -511,4 +599,23 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/sleep/rhythm": {
get: {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
content: {
"application/json": {
schema: dataEnvelope(sleepRhythmResponse, "SleepRhythmResponse"),
},
},
},
...stdResponses,
},
},
},
};