mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user