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

@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
// `apiHandler` always reads `request.url`; the inner handler ignores it, so we
// bypass the arity check with a cast (same pattern as the dashboard summary
// test).
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function req(): NextRequest {
return new NextRequest("http://localhost/api/sleep/rhythm");
}
/** A bare-ASLEEP night: end = wake instant, asleep = `minutes`. */
function night(wakeIso: string, minutes: number) {
return {
value: minutes,
measuredAt: new Date(wakeIso),
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
deviceType: null,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: null,
heightCm: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/sleep/rhythm", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(req());
expect(res.status).toBe(401);
});
it("returns the sleep-debt + chronotype DTO shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(req());
expect(res.status).toBe(200);
const body = await res.json();
// Sleep-debt DTO fields.
expect(body.data.sleepDebt).toMatchObject({
state: "partial",
debtMinutes: 0,
needMinutes: expect.any(Number),
nightsCounted: 0,
windowNights: expect.any(Number),
nightsUntilReady: expect.any(Number),
});
// Chronotype DTO fields.
expect(body.data.chronotype).toMatchObject({
state: "learning",
msfMinutes: null,
msfScMinutes: null,
band: null,
socialJetlagMinutes: null,
freeNightsCounted: 0,
workNightsCounted: 0,
freeNightsUntilReady: expect.any(Number),
});
});
it("reaches the ready debt state with a full window of short nights", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Ten consecutive nights of 6 h (360 min); need is 420 → 60/night deficit.
const rows = [];
for (let d = 1; d <= 10; d++) {
const day = String(d).padStart(2, "0");
rows.push(night(`2026-06-${day}T06:00:00.000Z`, 360));
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const res = await callGet(req());
const body = await res.json();
expect(body.data.sleepDebt.state).toBe("ready");
expect(body.data.sleepDebt.nightsCounted).toBe(10);
expect(body.data.sleepDebt.nightsUntilReady).toBe(0);
// 10 nights × 60-min deficit = 600 min cumulative debt.
expect(body.data.sleepDebt.debtMinutes).toBe(600);
expect(body.data.sleepDebt.needMinutes).toBe(420);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* GET /api/sleep/rhythm
*
* v1.17.0 — server-authoritative sleep-rhythm read. Returns the two timing
* signals the Sleep page + the iOS client render off the SAME canonical night
* reconstruction the Sleep Score uses:
*
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
* `partial` state under the night threshold.
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
* state until enough free-day nights exist.
*
* The math lives in `sleep-debt.ts` / `chronotype.ts`; this route is the auth +
* read boundary over `buildSleepRhythm`, which owns the DB read, the canonical
* reconstruction, the age-based `needMinutes`, and the weekday/weekend day-type
* default. No schema, no new type — a view over the existing per-stage
* `SLEEP_DURATION` rows.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rhythm = await buildSleepRhythm(user.id);
annotate({
action: { name: "sleep.rhythm" },
meta: {
debt_state: rhythm.sleepDebt.state,
debt_minutes: rhythm.sleepDebt.debtMinutes,
chronotype_state: rhythm.chronotype.state,
chronotype_band: rhythm.chronotype.band,
},
});
return apiSuccess(rhythm);
});