mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(coach): let the coach reference sleep debt and chronotype
Add a sleep-rhythm block to the coach snapshot, built from the same assembler the sleep page and the dashboard use over a pinned window, so the coach reasons from the same sleep-debt and chronotype values the rest of the app shows rather than a second computation. The prompt documents the new fields and keeps the learning-gate honesty — a chronotype band is offered only once it is calibrated.
This commit is contained in:
commit
c88ec7269e
3 files changed
+239
-1
No files matched your search
@@ -32,6 +32,11 @@ vi.mock("../memory-snapshot", () => ({
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import { computeSleepRhythmFromNights } from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
@@ -404,6 +409,119 @@ describe("buildCoachSnapshot", () => {
|
||||
// Two distinct window keys → two cache slots → two Prisma reads.
|
||||
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
|
||||
//
|
||||
// The coach reads the SAME computed values the Sleep page + dashboard
|
||||
// show: it hands the canonical per-night reconstruction to the SAME
|
||||
// assembler (`computeSleepRhythmFromNights`) the dashboard route uses,
|
||||
// never recomputing sleep-debt or chronotype inline. These tests pin
|
||||
// the reuse contract + the learning-gate honesty.
|
||||
|
||||
/**
|
||||
* One night's bare-ASLEEP block, wake instant in UTC. `reconstructNights`
|
||||
* reads asleep = `asleepMinutes` and a midpoint at wake − asleep/2 — the
|
||||
* same fixture shape the sleep-rhythm unit suite uses.
|
||||
*/
|
||||
function nightRow(wakeIso: string, asleepMinutes: number) {
|
||||
return {
|
||||
type: "SLEEP_DURATION",
|
||||
value: asleepMinutes,
|
||||
measuredAt: new Date(wakeIso),
|
||||
sleepStage: "ASLEEP" as const,
|
||||
source: null,
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("emits a ready sleepRhythm block equal to the assembler's DTO", async () => {
|
||||
// 14 consecutive nights across two weekends → debt ready (≥7 nights)
|
||||
// and chronotype ready (≥3 free/weekend nights). 2026-06-01 is a Monday.
|
||||
const rows = Array.from({ length: 14 }, (_, i) => {
|
||||
const day = 1 + i; // 2026-06-01 .. 2026-06-14
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return nightRow(`2026-06-${dd}T06:00:00Z`, 360 + (i % 3) * 20);
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue(rows);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
|
||||
// Independently run the SAME engine over the SAME rows + need — the
|
||||
// snapshot must equal it field-for-field (reuse, never recompute).
|
||||
const expected = computeSleepRhythmFromNights(
|
||||
reconstructNights(rows, "UTC", null),
|
||||
sleepNeedMinutes(40),
|
||||
);
|
||||
expect(expected.sleepDebt.state).toBe("ready");
|
||||
expect(expected.chronotype.state).toBe("ready");
|
||||
expect(expected.chronotype.band).not.toBeNull();
|
||||
|
||||
expect(parsed.sleepRhythm.sleepDebt).toEqual({
|
||||
state: expected.sleepDebt.state,
|
||||
debtMinutes: expected.sleepDebt.debtMinutes,
|
||||
needMinutes: expected.sleepDebt.needMinutes,
|
||||
});
|
||||
expect(parsed.sleepRhythm.chronotype).toEqual({
|
||||
state: expected.chronotype.state,
|
||||
band: expected.chronotype.band,
|
||||
socialJetlagMinutes: expected.chronotype.socialJetlagMinutes,
|
||||
});
|
||||
});
|
||||
|
||||
it("never asserts a band for a learning chronotype", async () => {
|
||||
// 8 weekday nights (2026-06-15 Mon .. 2026-06-22 Mon, weekends 06-20/21
|
||||
// are the only free days) — enough for debt to be ready but the
|
||||
// chronotype stays learning until it has ≥3 free-day nights. With only
|
||||
// two weekend nights here, the band must NOT be asserted.
|
||||
const weekdayRows = [15, 16, 17, 18, 19, 22].map((d) =>
|
||||
nightRow(`2026-06-${String(d).padStart(2, "0")}T06:00:00Z`, 380),
|
||||
);
|
||||
prismaMock.measurement.findMany.mockResolvedValue(weekdayRows);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
|
||||
const expected = computeSleepRhythmFromNights(
|
||||
reconstructNights(weekdayRows, "UTC", null),
|
||||
sleepNeedMinutes(40),
|
||||
);
|
||||
expect(expected.chronotype.state).toBe("learning");
|
||||
|
||||
expect(parsed.sleepRhythm.chronotype.state).toBe("learning");
|
||||
// A learning chronotype carries no band the data supports.
|
||||
expect(parsed.sleepRhythm.chronotype.band).toBeNull();
|
||||
expect(parsed.sleepRhythm.chronotype.socialJetlagMinutes).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the sleepRhythm block when no sleep rows exist", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.sleepRhythm).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import {
|
||||
computeSleepRhythmFromNights,
|
||||
type SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
@@ -90,6 +98,18 @@ const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
|
||||
*/
|
||||
const GLUCOSE_CLINICAL_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed
|
||||
* trailing-window artifact, identical across the Sleep page, the dashboard
|
||||
* summary, and (here) the coach. Pinned independently of the coach's variable
|
||||
* narration window (7/30/90/365) so the coach's debt + chronotype band always
|
||||
* equal what the page renders. Mirrors `DEFAULT_WINDOW_DAYS` in
|
||||
* `sleep-rhythm.ts`: 42 days gives the 14-night debt window full coverage and
|
||||
* ~12 weekend nights for a stable MSF — the assembler self-caps each signal to
|
||||
* its own window, so feeding the same 42-day rows yields the page's DTO.
|
||||
*/
|
||||
const SLEEP_RHYTHM_WINDOW_DAYS = 42;
|
||||
|
||||
/**
|
||||
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
|
||||
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
|
||||
@@ -841,6 +861,37 @@ async function buildCoachSnapshotImpl(
|
||||
},
|
||||
})
|
||||
: null;
|
||||
// v1.17.0 — sleep-rhythm rows. The sleep-debt + chronotype DTO is a fixed
|
||||
// trailing-42-day artifact (the Sleep page + dashboard read the same window),
|
||||
// so it must NOT ride the coach's variable narration window or the
|
||||
// multi-cluster timeline cap, or the coach would quote a debt / chronotype
|
||||
// band the page never shows. Read the rhythm's own trailing-42-day rows
|
||||
// directly (one indexed query, only when the sleep cluster is active) and
|
||||
// hand them to the SAME assembler the dashboard route uses. The per-stage
|
||||
// narration timeline above keeps using the coach-window `sleepRows`.
|
||||
const sleepRhythmCutoff = new Date(
|
||||
now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const sleepRhythmRowsPromise = sources.has("sleep")
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION" as never,
|
||||
measuredAt: { gte: sleepRhythmCutoff },
|
||||
deletedAt: null,
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const workoutRowsPromise = sources.has("workouts")
|
||||
? prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: additiveCutoff("workouts") } },
|
||||
@@ -911,6 +962,7 @@ async function buildCoachSnapshotImpl(
|
||||
moodRows,
|
||||
complianceMeds,
|
||||
sleepRows,
|
||||
sleepRhythmRows,
|
||||
workoutRows,
|
||||
glucoseClinicalRows,
|
||||
glp1Block,
|
||||
@@ -922,6 +974,7 @@ async function buildCoachSnapshotImpl(
|
||||
moodRowsPromise,
|
||||
complianceMedsPromise,
|
||||
sleepRowsPromise,
|
||||
sleepRhythmRowsPromise,
|
||||
workoutRowsPromise,
|
||||
glucoseClinicalRowsPromise,
|
||||
glp1BlockPromise,
|
||||
@@ -1327,6 +1380,56 @@ async function buildCoachSnapshotImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
|
||||
//
|
||||
// The two server-authoritative timing signals the Sleep page + the
|
||||
// dashboard summary render — cumulative sleep debt and the MCTQ
|
||||
// chronotype band + social jetlag. Built from the SAME assembler the
|
||||
// dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
|
||||
// over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
|
||||
// exact debt + band the page shows regardless of the coach's narration
|
||||
// window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline —
|
||||
// the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the
|
||||
// assembler. The `needMinutes` is the SAME age-resolved need the derived
|
||||
// block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the
|
||||
// `sourcePriorityJson` is the one already loaded for the per-stage sleep
|
||||
// block above — no extra read beyond the rhythm rows.
|
||||
//
|
||||
// LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning`
|
||||
// state below their night thresholds. The chronotype `band` is emitted ONLY
|
||||
// when the state is `ready` — a learning chronotype is surfaced as
|
||||
// "still calibrating", never asserted as a band the data can't support.
|
||||
if (sources.has("sleep") && sleepRhythmRows && sleepRhythmRows.length > 0) {
|
||||
const rhythm: SleepRhythmDto = computeSleepRhythmFromNights(
|
||||
reconstructNights(
|
||||
sleepRhythmRows as SleepStageRow[],
|
||||
userTz,
|
||||
prefsRow?.sourcePriorityJson ?? null,
|
||||
),
|
||||
sleepNeedMinutes(derivedProfile.ageYears),
|
||||
);
|
||||
const chronotypeReady = rhythm.chronotype.state === "ready";
|
||||
snapshot.sleepRhythm = {
|
||||
sleepDebt: {
|
||||
state: rhythm.sleepDebt.state,
|
||||
debtMinutes: rhythm.sleepDebt.debtMinutes,
|
||||
needMinutes: rhythm.sleepDebt.needMinutes,
|
||||
},
|
||||
chronotype: {
|
||||
state: rhythm.chronotype.state,
|
||||
// Only assert a band + social jetlag once the chronotype is `ready`.
|
||||
// A `learning` chronotype carries no band the data supports — the
|
||||
// model treats it as "still calibrating", never a typed assertion.
|
||||
band: chronotypeReady ? rhythm.chronotype.band : null,
|
||||
socialJetlagMinutes: chronotypeReady
|
||||
? rhythm.chronotype.socialJetlagMinutes
|
||||
: null,
|
||||
},
|
||||
};
|
||||
metrics.add("sleep");
|
||||
registerBlock("sleepRhythm", "sleep");
|
||||
}
|
||||
|
||||
// ── v1.7.0 glucose block (per-context daily means) ────────────────
|
||||
//
|
||||
// Glucose is summarised per `GlucoseContext` so the Coach can tell
|
||||
|
||||
@@ -231,6 +231,22 @@ ISO-week means.
|
||||
not clinical assessments or diagnoses — never frame a band as a
|
||||
medical finding, and lean on lower-confidence / few-day entries
|
||||
cautiously. Never recompute or second-guess the number.
|
||||
- The SNAPSHOT MAY carry a "sleepRhythm" block with two server-computed
|
||||
timing signals — the SAME values the Sleep page and dashboard show, so
|
||||
cite them as the user sees them and never recompute. It holds:
|
||||
- "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is
|
||||
the cumulative deficit against the age-based nightly need over the
|
||||
trailing window, "needMinutes" the target per night — both in
|
||||
MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h
|
||||
20m of sleep debt"). When "state" is "partial" the window is still
|
||||
short: treat the figure as still calibrating, not a firm number.
|
||||
- "chronotype": { state, band, socialJetlagMinutes }. "band" is one of
|
||||
extreme_early / early / intermediate / late / extreme_late and
|
||||
"socialJetlagMinutes" is the free-day vs work-day midpoint gap in
|
||||
MINUTES. Cite the band plainly ("your rhythm reads intermediate").
|
||||
When "state" is "learning" the band is null — say the chronotype is
|
||||
still calibrating and DO NOT assert any band or social-jetlag figure.
|
||||
These are DESCRIPTIVE timing signals, not a sleep disorder finding.
|
||||
- The SNAPSHOT's "memory" block MAY carry a "facts" list — durable
|
||||
things you have learned about this user across conversations (stable
|
||||
preferences, conditions they have told you about, goals, constraints,
|
||||
@@ -258,7 +274,8 @@ Rules:
|
||||
or a day-pin ("Tue 6 May").
|
||||
- "value" is a pre-formatted display string ("138/85", "84.2",
|
||||
"4.1"). Format mood as N/5 when numeric.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (sleep
|
||||
debt / social jetlag).
|
||||
- "window" is one of last7days, last30days, last90days, allTime;
|
||||
omit it for day-level pins.
|
||||
- Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)
|
||||
|
||||
Reference in new issue
Block a user