mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
4884f019b7
commit
d1ba01548f
22 files changed
+1489
-2
No files matched your search
@@ -0,0 +1,110 @@
|
||||
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, defaultDayType } 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 (UTC)", () => {
|
||||
// 2026-06-13 is a Saturday, 2026-06-14 a Sunday, 2026-06-15 a Monday.
|
||||
expect(defaultDayType("2026-06-13", "UTC")).toBe("free");
|
||||
expect(defaultDayType("2026-06-14", "UTC")).toBe("free");
|
||||
expect(defaultDayType("2026-06-15", "UTC")).toBe("work");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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 { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
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 in the user's timezone:
|
||||
* Saturday / Sunday → "free", else "work". The documented calendar default
|
||||
* (no work calendar exists). `night` is the YYYY-MM-DD wake-day key.
|
||||
*/
|
||||
export function defaultDayType(night: string, tz: string): "work" | "free" {
|
||||
// Anchor at local noon of the wake day so the weekday read is immune to the
|
||||
// tz offset and DST — noon never crosses a day boundary in any zone.
|
||||
const noon = new Date(`${night}T12:00:00Z`);
|
||||
const weekday = wallClockInTz(noon, tz).weekday; // 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* — this is the one place the 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,
|
||||
tz: string,
|
||||
): SleepRhythmDto {
|
||||
const scorable = nights.filter((n) => n.asleepMinutes > 0);
|
||||
|
||||
const debtNights: SleepDebtNight[] = scorable.map((n) => ({
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
}));
|
||||
const sleepDebt = computeSleepDebt(debtNights, needMinutes);
|
||||
|
||||
const chronoNights: ChronotypeNight[] = scorable
|
||||
.filter((n) => n.midpoint != null)
|
||||
.map((n) => ({
|
||||
night: n.night,
|
||||
midpointMinutes: n.midpoint as number,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
dayType: defaultDayType(n.night, tz),
|
||||
}));
|
||||
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.
|
||||
const nights = reconstructNights(rows, tz, priorityJson).filter(
|
||||
(n) => n.asleepMinutes > 0,
|
||||
);
|
||||
|
||||
const needMinutes = sleepNeedMinutes(profile.ageYears);
|
||||
return computeSleepRhythmFromNights(nights, needMinutes, tz);
|
||||
}
|
||||
@@ -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