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

@@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({
medicationScheduleRevision: {
groupBy: vi.fn(),
},
// v1.17.0 — the sleep-rhythm block resolves the user's age-based sleep
// need via the baseline profile (`User` row).
user: {
findUnique: vi.fn(),
},
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
@@ -120,6 +125,13 @@ beforeEach(() => {
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
// v1.17.0 — baseline profile read for the sleep-rhythm need. Default to a
// 40-year-old adult (need = 420 min) so the rhythm block resolves.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: null,
heightCm: null,
} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
@@ -515,6 +527,59 @@ describe("GET /api/dashboard/summary", () => {
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
});
it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Ten consecutive 6 h (360 min) nights. Need = 420 (40-year-old default in
// beforeEach) → a 60-min deficit per night, 10 nights ≥ the 7-night floor.
const rows = [];
for (let d = 1; d <= 10; d++) {
const day = String(d).padStart(2, "0");
rows.push({
value: 360,
measuredAt: new Date(`2026-06-${day}T06:00:00.000Z`),
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
deviceType: null,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
sleepRhythm: {
sleepDebt: {
state: string;
debtMinutes: number;
needMinutes: number;
nightsCounted: number;
windowNights: number;
nightsUntilReady: number;
};
chronotype: {
state: string;
freeNightsCounted: number;
workNightsCounted: number;
freeNightsUntilReady: number;
};
};
};
};
const { sleepDebt, chronotype } = body.data.sleepRhythm;
// Debt DTO is the computeSleepDebt result: ready, 10 × 60 = 600 min, need
// forwarded as 420 — proves the route reused the module, not a recompute.
expect(sleepDebt.state).toBe("ready");
expect(sleepDebt.debtMinutes).toBe(600);
expect(sleepDebt.needMinutes).toBe(420);
expect(sleepDebt.nightsCounted).toBe(10);
expect(sleepDebt.nightsUntilReady).toBe(0);
// Chronotype DTO carries every wiring field; the calm learning state holds
// until enough free-day nights exist (these are all weekday wake days).
expect(chronotype.state).toBe("learning");
expect(typeof chronotype.freeNightsCounted).toBe("number");
expect(typeof chronotype.freeNightsUntilReady).toBe("number");
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
+37 -2
View File
@@ -64,6 +64,15 @@ import {
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
import {
reconstructNights,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import {
computeSleepRhythmFromNights,
type SleepRhythmDto,
} from "@/lib/insights/derived/sleep-rhythm";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -534,8 +543,13 @@ async function buildDashboardSummary(
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
// before the per-night reconstruction sums it.
const sleepPriorityJson = await loadUserSourcePriority(userId);
// before the per-night reconstruction sums it. v1.17.0 — the baseline
// profile rides along (one read) so the sleep-rhythm block resolves the
// age-based sleep need the cumulative-debt deficit uses.
const [sleepPriorityJson, sleepProfile] = await Promise.all([
loadUserSourcePriority(userId),
loadBaselineProfile(prisma, userId),
]);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
@@ -632,6 +646,24 @@ async function buildDashboardSummary(
sleepPriorityJson,
);
// v1.17.0 — server-authoritative sleep-rhythm DTO (sleep-debt + chronotype)
// off the SAME canonical reconstruction the Sleep page + Sleep Score read,
// so iOS renders identical values. Reuses the rows already loaded above —
// `reconstructNights` adapts them into the asleep + wall-clock-midpoint
// shape the foundation modules consume, then `computeSleepRhythmFromNights`
// forwards them to `computeSleepDebt` / `computeChronotype` (the one place
// the math lives). Both signals carry a calm partial / learning state below
// their night thresholds — never an assertion off thin data.
const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights(
reconstructNights(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
),
sleepNeedMinutes(sleepProfile.ageYears),
userTz,
);
const metrics: MetricCard[] = [];
// v1.4.33 maintainer-item-1 — every emitted card now carries
@@ -862,6 +894,9 @@ async function buildDashboardSummary(
},
highlightInsight: null,
metrics,
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
lastUpdated: now.toISOString(),
};
}