diff --git a/src/lib/__tests__/clinical-floors.test.ts b/src/lib/__tests__/clinical-floors.test.ts index c22b4e4b8..bab1f0534 100644 --- a/src/lib/__tests__/clinical-floors.test.ts +++ b/src/lib/__tests__/clinical-floors.test.ts @@ -2,11 +2,11 @@ * D3-H1 coverage — the clinical safety floors are ONE source of truth. * * Before v1.21.0 the "is this BP a crisis?" floors lived in three modules with - * a divergent diastolic value (notification engine + Coach 180/120, dashboard - * hero 180/110), so a 170/112 reading lit the hero banner yet never tripped the - * alarm. This suite pins that every consumer now resolves the SAME canonical - * constants from `@/lib/clinical-floors`, so the three surfaces can never drift - * apart again without a test failure. + * a divergent diastolic value (notification engine + Coach 180/120, a third + * surface 180/110), so a 170/112 reading read as a crisis in one place and + * never tripped the alarm. This suite pins that every consumer now resolves the + * SAME canonical constants from `@/lib/clinical-floors`, so the surfaces can + * never drift apart again without a test failure. */ import { describe, it, expect } from "vitest"; @@ -33,8 +33,8 @@ import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; describe("clinical-floors — single source of truth", () => { it("uses the guideline-correct hypertensive-crisis floors (180/120)", () => { - // ACC/AHA hypertensive-urgency floor: the diastolic floor is 120, NOT the - // former hero-only 110 that fired the banner on calmer readings. + // ACC/AHA hypertensive-urgency floor: the diastolic floor is 120, not the + // 110 one surface once used to raise a banner on calmer readings. expect(BP_SYS_CRITICAL).toBe(180); expect(BP_DIA_CRITICAL).toBe(120); }); diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 3e067b8e8..940dae220 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -19,9 +19,13 @@ * → `computeUserHealthScore` (the real reader, real pillars) * → `computeAndRecordUserHealthScore` (the real write path) * → `buildDashboardSnapshot` (the real snapshot mapping) - * → `resolveDashboardVerdict` (the real `scoreDrop` rung) * → `loadDailyDigest` → `buildDailyDigest` (the real morning line) * + * The digest is the last stop before a person: the Today hero renders the + * delta chip on `delta !== null && deltaReason === null` and the morning + * push sends `line` verbatim, so those two fields are asserted rather than + * any intermediate the renderer does not read. + * * Only the IO around that chain is faked. A version of this test that * built a `DashboardSnapshot` literal by hand would have been green * through a release where the assembly between the two ends dropped the @@ -233,7 +237,6 @@ vi.mock("@/lib/insights/derived/baseline", () => ({ import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot"; import type { SnapshotUserInput } from "@/lib/dashboard/snapshot"; -import { resolveDashboardVerdict } from "@/lib/dashboard/verdict"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; @@ -322,7 +325,6 @@ async function runChain(healthScoreConfigJson: unknown) { fakeDb as never, user(healthScoreConfigJson), ); - const verdict = resolveDashboardVerdict(snapshot, NOW); readDashboardSnapshotCached.mockResolvedValue({ body: { ...snapshot, @@ -341,7 +343,7 @@ async function runChain(healthScoreConfigJson: unknown) { } as unknown as User, NOW, ); - return { snapshot, verdict, digest, record: writtenRecords[0] ?? null }; + return { snapshot, digest, record: writtenRecords[0] ?? null }; } beforeEach(() => { @@ -424,7 +426,7 @@ const JUST_CHANGED = recipe(new Date(NOW.getTime() - 3 * DAY_MS)); describe("a settings change is never narrated as a health event", () => { it("shows a real ten-point-plus drop when no recipe changed in the window", async () => { - const { snapshot, verdict, digest } = await runChain(SETTLED); + const { snapshot, digest } = await runChain(SETTLED); // If this ever stops holding, every assertion in the next test is // vacuous: an account with no drop to suppress would pass it while @@ -433,19 +435,27 @@ describe("a settings change is never narrated as a health event", () => { expect(snapshot.healthScore!.deltaReason).toBeNull(); expect(snapshot.healthScore!.delta).not.toBeNull(); expect(snapshot.healthScore!.delta!).toBeLessThanOrEqual(-10); - expect(verdict.variant).toBe("scoreDrop"); - expect(digest.score?.delta).toBe(snapshot.healthScore!.delta); + // And it survives the assembly to the field the hero reads, at the + // full size — a drop that arrived rounded to nothing would leave the + // suppression below with nothing to prove. expect(digest.score?.deltaReason).toBeNull(); + expect(digest.score?.delta).toBe(snapshot.healthScore!.delta); + expect(digest.score!.delta!).toBeLessThanOrEqual(-10); }); - it("fires no scoreDrop verdict when the recipe changed inside the window", async () => { - // The verdict first and on its own line: this is the sentence the - // person would read, and it is what has to go red when the guard - // goes missing. Asserting the reason before it would fail one step - // earlier and never exercise the rung at all. - const { verdict } = await runChain(JUST_CHANGED); + it("renders no delta chip when the recipe changed inside the window", async () => { + // The chip first and on its own line: the hero paints it on + // `delta !== null && deltaReason === null`, so that pair is what a + // person actually sees and what has to go red when the guard goes + // missing. Asserting the reason before it would fail one step + // earlier and never exercise the render condition at all. + const { digest } = await runChain(JUST_CHANGED); - expect(verdict.variant).not.toBe("scoreDrop"); + expect( + digest.score !== null && + digest.score.delta !== null && + digest.score.deltaReason === null, + ).toBe(false); }); it("suppresses the delta with the reason that names the act", async () => { diff --git a/src/lib/clinical-floors.ts b/src/lib/clinical-floors.ts index c31282d56..15e107f0a 100644 --- a/src/lib/clinical-floors.ts +++ b/src/lib/clinical-floors.ts @@ -3,25 +3,25 @@ * * Before this module the "is this a crisis?" thresholds were defined in three * places with a divergent diastolic floor: the safety-floor notification engine - * and the Coach acute clause used 180/120, but the dashboard hero's `bpCritical` - * rung used 180/110 — so a reading like 170/112 lit the red "critical BP" hero - * banner yet never tripped the notification alarm or the Coach's stated acute - * number. One surface said crisis, the other two stayed calm on the same row. + * and the Coach acute clause used 180/120, while a third surface used 180/110 — + * so a reading like 170/112 read as a crisis in one place and stayed calm in + * the other two on the same row. * * This leaf holds the absolute, guideline-backed floors as plain constants and * every consumer imports from here: * - `src/lib/illness/safety-floors.ts` (the confirm-before-alarm engine), - * - `src/lib/dashboard/verdict.ts` (the hero `bpCritical` rung), * - `src/lib/insights/metric-status-registry.ts` (the fever band line), - * - and (left to the prompt agent) the `safetyAcute` / `safetyGlp1` Coach - * contracts, which must bind their prose numbers to these exports rather - * than hardcoding literals — see the comment at each constant. + * - `src/lib/signals/registry.ts` (the signal severity bands), + * - and the `safetyAcute` / `safetyGlp1` Coach contracts in + * `src/lib/ai/prompts/shared-contracts.ts`, which bind their prose numbers + * to these exports rather than hardcoding literals — see the comment at + * each constant. * - * CLIENT-SAFE: dependency-free literals only. `verdict.ts` runs inside the - * `"use client"` dashboard hero, and its import-graph client-safety test - * (`verdict-client-safety.test.ts`) walks every VALUE import reachable from - * `verdict.ts`. Do NOT add any import to this file — no prisma, no `node:` - * builtins, no server graph. Constants only. + * CLIENT-SAFE: dependency-free literals only. The fever band reaches the + * browser through `metric-status-registry.ts`, which the `"use client"` metric + * status card renders, so a server import added here would land in the client + * bundle. Do NOT add any import to this file — no prisma, no `node:` builtins, + * no server graph. Constants only. * * Units: mmHg (BP), mg/dL (glucose), °C (temperature) — HealthLog canonical * store units throughout. @@ -30,9 +30,9 @@ * - BP ≥ 180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute * Care Setting" 2024. The number is identical for emergency vs non- * emergency — symptoms are the differentiator, not the value. The crisis - * DIASTOLIC floor is 120, NOT the old hero 110: 120 is the hypertensive- - * urgency standard, so the wider 110 net is dropped to keep the three - * surfaces telling one story (D3-H1). + * DIASTOLIC floor is 120, not the 110 one surface once used: 120 is the + * hypertensive-urgency standard, so the wider 110 net is dropped to keep + * every surface telling one story (D3-H1). * - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension — * StatPearls/NIH 2024. * - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL, @@ -55,8 +55,8 @@ export const BP_SYS_CRITICAL = 180; /** * Hypertensive-crisis floor: diastolic ≥ this (mmHg). 120 (ACC/AHA), the - * single crisis diastolic floor across the hero, the notification engine, and - * the Coach acute clause (D3-H1 — was 110 on the hero only). + * single crisis diastolic floor across the notification engine, the signal + * bands, and the Coach acute clause (D3-H1 — one surface once used 110). */ export const BP_DIA_CRITICAL = 120; /** Symmetric low-BP cautionary floor: systolic < this (mmHg). */ diff --git a/src/lib/dashboard/__tests__/verdict-client-safety.test.ts b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts deleted file mode 100644 index 199f80bb8..000000000 --- a/src/lib/dashboard/__tests__/verdict-client-safety.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync, existsSync, statSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; - -/** - * Client-safety pin for the verdict resolver's import graph. - * - * `resolveDashboardVerdict` runs inside the `"use client"` dashboard - * hero, so every VALUE import reachable from `verdict.ts` must stay off - * the server graph: no prisma, no `node:` builtins, no pg-boss, no - * `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull - * `node:async_hooks` — the exact regression class the dose-window - * editor hit when it imported `DOSE_WINDOW_DEFAULTS` from - * `@/lib/analytics/compliance`; see - * `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared - * nudge thresholds therefore live in the dependency-free - * `@/lib/jobs/coach-nudge-thresholds` leaf. - * - * `import type` statements are erased by the compiler AND the bundler, - * so the walker skips them — the `DashboardSnapshot` type import from - * `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly - * as long as it stays type-only. If someone drops the `type` keyword, - * the walker traverses into the snapshot builder and this suite fails. - */ - -const SRC = join(process.cwd(), "src"); -const ENTRY = join(SRC, "lib/dashboard/verdict.ts"); - -/** Specifier patterns that mark a server-only dependency. */ -const BANNED_SPECIFIERS: RegExp[] = [ - /^node:/, - /^@\/generated\//, - /^@\/lib\/db$/, - /^@prisma\//, - /^pg-boss$/, - /^server-only$/, - /^@\/lib\/jobs\/coach-nudge$/, -]; - -/** - * Extract VALUE-import specifiers from a module. Type-only statements - * (`import type … from`, `export type … from`) are erased at build - * time and skipped. Side-effect imports (`import "x"`) are included. - */ -function valueImportSpecifiers(source: string): string[] { - const out: string[] = []; - // Statement-anchored (`^import` / `^export`) and semicolon-bounded - // (`[^;]*?`) so the lazy scan can never swallow across statements or - // latch onto a `from "…"` inside an unrelated string / comment. - const stmt = - /^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; - let m: RegExpExecArray | null; - while ((m = stmt.exec(source)) !== null) { - if (m[4] !== undefined) { - out.push(m[4]); // bare side-effect import - continue; - } - if (m[2] !== undefined) continue; // `import type` / `export type` — erased - out.push(m[3]); - } - return out; -} - -/** Resolve `@/…` and relative specifiers to a file inside `src/`. */ -function resolveLocal(spec: string, fromFile: string): string | null { - let base: string; - if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); - else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); - else return null; // external package - for (const candidate of [ - base, - `${base}.ts`, - `${base}.tsx`, - join(base, "index.ts"), - join(base, "index.tsx"), - ]) { - if (existsSync(candidate) && statSync(candidate).isFile()) { - return candidate; - } - } - throw new Error(`unresolvable local import "${spec}" from ${fromFile}`); -} - -function walkGraph(entry: string): { - visited: Set; - specifiers: Set; -} { - const visited = new Set(); - const specifiers = new Set(); - const queue = [entry]; - while (queue.length > 0) { - const file = queue.pop()!; - if (visited.has(file)) continue; - visited.add(file); - for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) { - specifiers.add(spec); - const local = resolveLocal(spec, file); - if (local !== null) queue.push(local); - } - } - return { visited, specifiers }; -} - -describe("verdict.ts — client-safe import graph", () => { - const { visited, specifiers } = walkGraph(ENTRY); - - it("carries no server-only specifier anywhere in the value-import graph", () => { - const offenders = [...specifiers].filter((spec) => - BANNED_SPECIFIERS.some((re) => re.test(spec)), - ); - expect(offenders).toEqual([]); - }); - - it("never traverses into the snapshot builder (type-only boundary holds)", () => { - const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts"); - expect(visited.has(snapshotModule)).toBe(false); - }); - - it("reads the nudge thresholds from the dependency-free leaf", () => { - expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true); - const leaf = readFileSync( - join(SRC, "lib/jobs/coach-nudge-thresholds.ts"), - "utf8", - ); - expect(valueImportSpecifiers(leaf)).toEqual([]); - }); - - it("the whole graph stays local — no external runtime package rides in", () => { - const external = [...specifiers].filter( - (spec) => !spec.startsWith("@/") && !spec.startsWith("."), - ); - expect(external).toEqual([]); - }); -}); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts deleted file mode 100644 index b14bc7e47..000000000 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ /dev/null @@ -1,808 +0,0 @@ -/** - * Dashboard hero — verdict-ladder unit tests. - * - * `resolveDashboardVerdict` is pure (snapshot in, verdict out, `now` - * injected), so the suite pins every rung, the first-hit-wins - * precedence, and the exact boundaries the spec locked: - * - bpCritical fires at sys 180, not 179 (fixed floors, never user - * thresholds), only on a fresh reading, and outranks an overdue - * dose (a crisis-level reading is rung 1); - * - weightDrift fires at a 0.50 kg distance delta, not 0.49; - * - silence fires at 7 days, not 6; - * - scoreDrop fires at −10, not −9; - * - doseUpcoming is timezone-correct (next anchor must be today in - * the USER's zone) and never fires on a stale past `nextDueAt` - * with `nextDueOverdue: false` (the cached-snapshot defensive - * contract). - */ -import { describe, it, expect } from "vitest"; - -import { resolveDashboardVerdict } from "../verdict"; -import { buildTargetBands } from "../snapshot"; -import type { DashboardSnapshot } from "../snapshot"; -import type { MedsTodayBlock } from "../meds-today"; -import type { DataSummary } from "@/lib/analytics/trends"; - -/** Noon Berlin (CEST) on a fixed summer day. */ -const NOW = new Date("2026-06-10T10:00:00.000Z"); - -function summary(partial: Partial): DataSummary { - return { - count: 0, - latest: null, - min: null, - max: null, - mean: null, - median: null, - avg7: null, - avg30: null, - slope7: null, - slope30: null, - slope90: null, - anomalyCount: 0, - ...partial, - }; -} - -function medsToday(partial: Partial = {}): MedsTodayBlock { - return { - activeCount: 0, - scheduledToday: 0, - takenToday: 0, - skippedToday: 0, - nextDueAt: null, - nextDueOverdue: false, - nextDueMedicationName: null, - nextDueMedicationId: null, - ...partial, - }; -} - -function baseSnapshot( - overrides: Partial = {}, -): DashboardSnapshot { - return { - user: { - username: "tester", - timezone: "Europe/Berlin", - heightCm: null, - dateOfBirth: null, - gender: null, - glucoseUnit: null, - onboardingTourCompleted: true, - greetingHour: 12, - }, - layout: { version: 1, widgets: [] }, - layoutCatalogue: [], - metricStates: {}, - targetBands: buildTargetBands({ - dateOfBirth: null, - gender: null, - heightCm: null, - weightTargetOverride: null, - }), - tiles: { - summaries: {}, - lastSeenByType: {}, - mood: { summary: null, entries: [] }, - }, - extras: null, - medsToday: medsToday(), - healthScore: null, - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: null, - briefingStale: false, - generatedAt: NOW.toISOString(), - ...overrides, - }; -} - -function isoHoursAgo(hours: number, now: Date = NOW): string { - return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString(); -} - -const FRESH_FINDING = { - tone: "info" as const, - headline: "Steps held steady this week.", - detail: "Daily totals stayed within the usual range.", - delta: null, - sourceWindow: "7d" as const, - sourceMetric: "steps" as const, -}; - -const WATCH_FINDING = { - tone: "watch" as const, - headline: "Systolic crept up over seven days.", - detail: "The weekly mean sits above the prior week.", - delta: "+4 mmHg", - sourceWindow: "7d" as const, - sourceMetric: "bp" as const, -}; - -describe("rung 2 — doseOverdue", () => { - it("fires on nextDueOverdue with the medication name and a quick-entry CTA", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - nextDueAt: isoHoursAgo(1), - nextDueOverdue: true, - nextDueMedicationName: "Metformin", - }), - }), - NOW, - ); - expect(verdict.variant).toBe("doseOverdue"); - expect(verdict.values).toEqual({ name: "Metformin" }); - expect(verdict.cta).toEqual({ - kind: "quickEntry", - target: "medicationIntake", - }); - }); - - it("yields to a simultaneously critical fresh BP reading (precedence)", () => { - // A crisis-level reading (≥ 180 / ≥ 110, ≤ 1 day old) is rung 1 — - // it must not hide behind a routine medication prompt. - const verdict = resolveDashboardVerdict( - baseSnapshot({ - medsToday: medsToday({ - nextDueOverdue: true, - nextDueMedicationName: "Metformin", - }), - tiles: { - summaries: { - BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }), - BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }), - }, - lastSeenByType: { - BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }), - NOW, - ); - expect(verdict.variant).toBe("bpCritical"); - }); - - it("wins over the BP rung once the critical reading has aged past a day", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - medsToday: medsToday({ - nextDueOverdue: true, - nextDueMedicationName: "Metformin", - }), - tiles: { - summaries: { - BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }), - BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }), - }, - lastSeenByType: { - BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(3 * 24), daysAgo: 3 }, - }, - mood: { summary: null, entries: [] }, - }, - }), - NOW, - ); - expect(verdict.variant).toBe("doseOverdue"); - }); - - it("never renders overdue from a stale past nextDueAt with the flag false", () => { - // Cached-snapshot defensive contract: the anchor passed AFTER the - // snapshot was built, so the flag is still false — plain summary. - const verdict = resolveDashboardVerdict( - baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 1, - nextDueAt: isoHoursAgo(1), - nextDueOverdue: false, - nextDueMedicationName: "Metformin", - }), - }), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); -}); - -describe("rung 1 — bpCritical", () => { - function bpSnapshot( - sys: number | null, - dia: number | null, - lastSeenAt: string, - ): DashboardSnapshot { - return baseSnapshot({ - tiles: { - summaries: { - ...(sys !== null && { - BLOOD_PRESSURE_SYS: summary({ latest: sys, count: 5 }), - }), - ...(dia !== null && { - BLOOD_PRESSURE_DIA: summary({ latest: dia, count: 5 }), - }), - }, - lastSeenByType: { - BLOOD_PRESSURE_SYS: { lastSeenAt, daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }); - } - - it("fires at the fixed systolic floor 180 with a fresh reading", () => { - const verdict = resolveDashboardVerdict( - bpSnapshot(180, 95, isoHoursAgo(2)), - NOW, - ); - expect(verdict.variant).toBe("bpCritical"); - expect(verdict.values).toEqual({ sys: 180, dia: 95 }); - expect(verdict.cta).toEqual({ - kind: "link", - href: "/insights/blood-pressure", - }); - }); - - it("does NOT fire at 179 (boundary)", () => { - const verdict = resolveDashboardVerdict( - bpSnapshot(179, 95, isoHoursAgo(2)), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("fires on the canonical diastolic crisis floor 120 alone", () => { - // D3-H1: the hero's diastolic crisis floor is now 120 (ACC/AHA), bound to - // `@/lib/clinical-floors`, so the hero, the notification engine, and the - // Coach acute clause agree on the same reading. - const verdict = resolveDashboardVerdict( - bpSnapshot(150, 120, isoHoursAgo(2)), - NOW, - ); - expect(verdict.variant).toBe("bpCritical"); - expect(verdict.values).toEqual({ sys: 150, dia: 120 }); - }); - - it("does NOT fire at dia 119 (boundary)", () => { - const verdict = resolveDashboardVerdict( - bpSnapshot(150, 119, isoHoursAgo(2)), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("stays quiet at dia 112 — the former 110 floor no longer fires alone", () => { - // The cross-surface contradiction this fix closes: 170/112 used to light - // the hero banner yet never tripped the alarm or the Coach acute number. - const verdict = resolveDashboardVerdict( - bpSnapshot(170, 112, isoHoursAgo(2)), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips a critical-valued reading older than one day", () => { - const verdict = resolveDashboardVerdict( - bpSnapshot(190, 120, isoHoursAgo(3 * 24)), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("still fires inside the one-day freshness window (daysAgo floor 1)", () => { - const verdict = resolveDashboardVerdict( - bpSnapshot(190, 120, isoHoursAgo(36)), - NOW, - ); - expect(verdict.variant).toBe("bpCritical"); - }); - - it("re-derives freshness from the injected now, not the snapshot daysAgo", () => { - // The snapshot claims daysAgo: 0 (computed at build time), but the - // injected `now` sits three days later — the rung must skip. - const snap = bpSnapshot(190, 120, isoHoursAgo(1)); - const later = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000); - expect(resolveDashboardVerdict(snap, later).variant).toBe("allQuiet"); - // Same snapshot evaluated at its build instant fires. - expect(resolveDashboardVerdict(snap, NOW).variant).toBe("bpCritical"); - }); -}); - -describe("rung 3 — doseUpcoming", () => { - function upcomingSnapshot( - nextDueAt: string, - tz = "Europe/Berlin", - ): DashboardSnapshot { - const snap = baseSnapshot({ - medsToday: medsToday({ - scheduledToday: 2, - takenToday: 1, - skippedToday: 0, - nextDueAt, - nextDueOverdue: false, - nextDueMedicationName: "Ramipril", - }), - }); - snap.user.timezone = tz; - return snap; - } - - it("fires for a dose due within two hours today, with local HH:mm", () => { - // 11:30 UTC = 13:30 Berlin (CEST). - const verdict = resolveDashboardVerdict( - upcomingSnapshot("2026-06-10T11:30:00.000Z"), - NOW, - ); - expect(verdict.variant).toBe("doseUpcoming"); - expect(verdict.values).toEqual({ time: "13:30", name: "Ramipril" }); - expect(verdict.cta).toEqual({ - kind: "quickEntry", - target: "medicationIntake", - }); - }); - - it("does NOT fire when the dose is more than two hours away", () => { - const verdict = resolveDashboardVerdict( - upcomingSnapshot("2026-06-10T12:30:00.000Z"), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("fires exactly at the two-hour boundary", () => { - const verdict = resolveDashboardVerdict( - upcomingSnapshot("2026-06-10T12:00:00.000Z"), - NOW, - ); - expect(verdict.variant).toBe("doseUpcoming"); - }); - - it("does NOT fire when the next anchor falls on tomorrow in the user's zone", () => { - // 23:30 Berlin: a dose 1 h away sits at 00:30 LOCAL tomorrow even - // though it is still 2026-06-10 in UTC terms of distance. - const lateNow = new Date("2026-06-10T21:30:00.000Z"); // 23:30 Berlin - const verdict = resolveDashboardVerdict( - upcomingSnapshot("2026-06-10T22:30:00.000Z"), - lateNow, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("respects a non-European zone for the same instants", () => { - // Pacific/Auckland (UTC+12, no DST in June): 10:00 UTC = 22:00 - // local; a dose at 11:30 UTC = 23:30 local is still TODAY there. - const verdict = resolveDashboardVerdict( - upcomingSnapshot("2026-06-10T11:30:00.000Z", "Pacific/Auckland"), - NOW, - ); - expect(verdict.variant).toBe("doseUpcoming"); - expect(verdict.values).toEqual({ time: "23:30", name: "Ramipril" }); - }); - - it("does NOT fire when every scheduled dose is already resolved", () => { - const snap = upcomingSnapshot("2026-06-10T11:30:00.000Z"); - snap.medsToday.takenToday = 1; - snap.medsToday.skippedToday = 1; // 2 scheduled, 2 resolved - expect(resolveDashboardVerdict(snap, NOW).variant).toBe("allQuiet"); - }); -}); - -describe("rung 4 — weightDrift", () => { - // height 180 cm → green band [59.94, 80.676] kg. - const GREEN_MAX_180 = 24.9 * 1.8 * 1.8; - - function weightSnapshot( - avg7: number | null, - avg30: number | null, - heightCm: number | null = 180, - weightTargetOverride: { min: number; max: number } | null = null, - ): DashboardSnapshot { - const snap = baseSnapshot({ - tiles: { - summaries: { - WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }), - }, - lastSeenByType: { - WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }); - snap.user.heightCm = heightCm; - // v1.34 — the rung reads the snapshot's RESOLVED band, so the fixture - // resolves it the same way the builder does. - snap.targetBands = buildTargetBands({ - dateOfBirth: null, - gender: null, - heightCm, - weightTargetOverride, - }); - return snap; - } - - it("fires at exactly the 0.50 kg drift threshold", () => { - // avg30 inside the band (distance 0), avg7 exactly 0.5 kg outside. - const verdict = resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 0.5, GREEN_MAX_180 - 1), - NOW, - ); - expect(verdict.variant).toBe("weightDrift"); - expect(verdict.cta).toEqual({ kind: "link", href: "/insights/weight" }); - }); - - it("does NOT fire at 0.49 kg drift (boundary)", () => { - const verdict = resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 0.49, GREEN_MAX_180 - 1), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips entirely when no height and no target (no band at all)", () => { - const verdict = resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips when avg7 or avg30 is null", () => { - expect(resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant).toBe( - "allQuiet", - ); - expect( - resolveDashboardVerdict(weightSnapshot(GREEN_MAX_180 + 5, null), NOW) - .variant, - ).toBe("allQuiet"); - }); - - it("stays silent while converging back toward the range", () => { - // avg7 closer to the band than avg30 — drift is negative. - const verdict = resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 1, GREEN_MAX_180 + 3), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); -}); - -describe("rung 5 — shortNights", () => { - function sleepSnapshot( - avg7: number | null, - count: number, - ): DashboardSnapshot { - return baseSnapshot({ - tiles: { - summaries: { - SLEEP_DURATION: summary({ avg7, count, latest: avg7 }), - }, - lastSeenByType: { - SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }); - } - - it("fires under the clear-margin floor (6.5 h) with enough readings", () => { - const verdict = resolveDashboardVerdict(sleepSnapshot(360, 7), NOW); - expect(verdict.variant).toBe("shortNights"); - expect(verdict.values).toEqual({ hours: 6 }); - expect(verdict.cta).toEqual({ kind: "link", href: "/insights/sleep" }); - }); - - it("fires just under the 390-minute floor, not at it (boundary)", () => { - expect(resolveDashboardVerdict(sleepSnapshot(389, 7), NOW).variant).toBe( - "shortNights", - ); - expect(resolveDashboardVerdict(sleepSnapshot(390, 7), NOW).variant).toBe( - "allQuiet", - ); - }); - - it("stays silent on a sparse week (fewer than 5 readings)", () => { - expect(resolveDashboardVerdict(sleepSnapshot(300, 4), NOW).variant).toBe( - "allQuiet", - ); - }); -}); - -describe("rung 6 — silence", () => { - function silenceSnapshot( - seen: Record, - ): DashboardSnapshot { - return baseSnapshot({ - tiles: { - summaries: {}, - lastSeenByType: seen, - mood: { summary: null, entries: [] }, - }, - }); - } - - it("fires when every logged-ever core vital is silent for ≥ 7 days", () => { - const verdict = resolveDashboardVerdict( - silenceSnapshot({ - WEIGHT: { lastSeenAt: isoHoursAgo(8 * 24), daysAgo: 8 }, - BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(9 * 24), daysAgo: 9 }, - // glucose never logged — excluded from the min. - }), - NOW, - ); - expect(verdict.variant).toBe("silence"); - expect(verdict.values).toEqual({ days: 8 }); - expect(verdict.cta).toEqual({ kind: "quickEntry", target: "measurement" }); - }); - - it("does NOT fire at 6 days (boundary), fires at exactly 7", () => { - expect( - resolveDashboardVerdict( - silenceSnapshot({ - WEIGHT: { lastSeenAt: isoHoursAgo(6 * 24), daysAgo: 6 }, - }), - NOW, - ).variant, - ).toBe("allQuiet"); - expect( - resolveDashboardVerdict( - silenceSnapshot({ - WEIGHT: { lastSeenAt: isoHoursAgo(7 * 24), daysAgo: 7 }, - }), - NOW, - ).variant, - ).toBe("silence"); - }); - - it("a single fresh vital silences the rung (min, not max)", () => { - const verdict = resolveDashboardVerdict( - silenceSnapshot({ - WEIGHT: { lastSeenAt: isoHoursAgo(30 * 24), daysAgo: 30 }, - BLOOD_GLUCOSE: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, - }), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips when none of the three vitals was ever logged", () => { - expect(resolveDashboardVerdict(silenceSnapshot({}), NOW).variant).toBe( - "allQuiet", - ); - }); -}); - -describe("rung 7 — scoreDrop", () => { - function scoreSnapshot(delta: number | null): DashboardSnapshot { - return baseSnapshot({ - healthScore: { - score: 60, - band: "yellow", - delta, - deltaReason: null, - }, - }); - } - - it("fires at a −10 delta (boundary)", () => { - const verdict = resolveDashboardVerdict(scoreSnapshot(-10), NOW); - expect(verdict.variant).toBe("scoreDrop"); - expect(verdict.values).toEqual({ points: 10 }); - expect(verdict.cta).toEqual({ kind: "link", href: "/insights" }); - }); - - it("does NOT fire at −9 (boundary)", () => { - expect(resolveDashboardVerdict(scoreSnapshot(-9), NOW).variant).toBe( - "allQuiet", - ); - }); - - it("skips a null delta and a null score block", () => { - expect(resolveDashboardVerdict(scoreSnapshot(null), NOW).variant).toBe( - "allQuiet", - ); - expect( - resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW).variant, - ).toBe("allQuiet"); - }); -}); - -describe("rung 8 — briefing", () => { - it("renders the first watch-tone headline verbatim from a fresh briefing", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - briefing: { - paragraph: "Calm day overall.", - keyFindings: [FRESH_FINDING, WATCH_FINDING], - }, - briefingState: "ready", - briefingStale: false, - briefingUpdatedAt: NOW.toISOString(), - }), - NOW, - ); - expect(verdict.variant).toBe("briefing"); - expect(verdict.values).toEqual({ - headline: WATCH_FINDING.headline, - sourceMetric: WATCH_FINDING.sourceMetric, - }); - expect(verdict.cta).toEqual({ kind: "link", href: "/insights" }); - }); - - it("falls back to the first finding when no watch tone exists", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - briefing: { - paragraph: "Calm day overall.", - keyFindings: [FRESH_FINDING], - }, - briefingState: "ready", - briefingStale: false, - }), - NOW, - ); - expect(verdict.variant).toBe("briefing"); - expect(verdict.values).toEqual({ - headline: FRESH_FINDING.headline, - sourceMetric: FRESH_FINDING.sourceMetric, - }); - }); - - it("skips a STALE briefing even in ready-shaped delivery", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - briefing: { - paragraph: "Yesterday's text.", - keyFindings: [WATCH_FINDING], - }, - briefingState: "preparing", - briefingStale: true, - }), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips ready + stale (belt and braces on the two flags)", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - briefing: { - paragraph: "Old text.", - keyFindings: [WATCH_FINDING], - }, - briefingState: "ready", - briefingStale: true, - }), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); - - it("skips an empty keyFindings array", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - briefing: { paragraph: "Flat data day.", keyFindings: [] }, - briefingState: "ready", - briefingStale: false, - }), - NOW, - ); - expect(verdict.variant).toBe("allQuiet"); - }); -}); - -describe("rung 9 — allQuiet + full-ladder precedence", () => { - it("returns allQuiet with no CTA on an empty snapshot", () => { - const verdict = resolveDashboardVerdict(baseSnapshot(), NOW); - expect(verdict).toEqual({ variant: "allQuiet", values: {}, cta: null }); - }); - - it("bpCritical outranks every lower rung when stacked", () => { - // BP critical + short nights + score drop + fresh briefing — the - // ladder must stop at rung 2. - const verdict = resolveDashboardVerdict( - baseSnapshot({ - tiles: { - summaries: { - BLOOD_PRESSURE_SYS: summary({ latest: 185, count: 5 }), - BLOOD_PRESSURE_DIA: summary({ latest: 100, count: 5 }), - SLEEP_DURATION: summary({ avg7: 300, count: 7 }), - }, - lastSeenByType: { - BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 }, - SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - healthScore: { - score: 40, - band: "red", - delta: -20, - deltaReason: null, - }, - briefing: { - paragraph: "Busy day.", - keyFindings: [WATCH_FINDING], - }, - briefingState: "ready", - briefingStale: false, - }), - NOW, - ); - expect(verdict.variant).toBe("bpCritical"); - }); - - it("scoreDrop outranks the briefing teaser", () => { - const verdict = resolveDashboardVerdict( - baseSnapshot({ - healthScore: { - score: 50, - band: "yellow", - delta: -15, - deltaReason: null, - }, - briefing: { - paragraph: "Busy day.", - keyFindings: [WATCH_FINDING], - }, - briefingState: "ready", - briefingStale: false, - }), - NOW, - ); - expect(verdict.variant).toBe("scoreDrop"); - expect(verdict.values).toEqual({ points: 15 }); - }); -}); - -/** - * v1.34 — the weight-drift rung measures against the band the user actually - * reads. It used to recompute the height-derived BMI band inline, so it kept - * nudging about drift away from a band an explicit target had replaced. - */ -describe("rung 4 — weightDrift honours an explicit target", () => { - function driftSnapshot( - avg7: number, - avg30: number, - weightTargetOverride: { min: number; max: number } | null, - ): DashboardSnapshot { - const snap = baseSnapshot({ - tiles: { - summaries: { - WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }), - }, - lastSeenByType: { - WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, - }, - mood: { summary: null, entries: [] }, - }, - }); - snap.user.heightCm = 180; - snap.targetBands = buildTargetBands({ - dateOfBirth: null, - gender: null, - heightCm: 180, - weightTargetOverride, - }); - return snap; - } - - it("stays quiet inside the user's own band even when the BMI band would fire", () => { - // 82 / 81 kg is above the height-derived green max (80.68) and drifting - // away from it, but sits inside a self-set 78-85 kg band. - expect( - resolveDashboardVerdict(driftSnapshot(82, 81, null), NOW).variant, - ).toBe("weightDrift"); - expect( - resolveDashboardVerdict(driftSnapshot(82, 81, { min: 78, max: 85 }), NOW) - .variant, - ).toBe("allQuiet"); - }); - - it("fires on drift away from the user's own band", () => { - expect( - resolveDashboardVerdict(driftSnapshot(72, 74, { min: 78, max: 85 }), NOW) - .variant, - ).toBe("weightDrift"); - }); -}); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts deleted file mode 100644 index 6ee7cb96e..000000000 --- a/src/lib/dashboard/verdict.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Dashboard hero — daily-verdict resolver. - * - * Pure function over an already-built `DashboardSnapshot`: no DB, no - * network, no clock read (`now` is injected so the resolver is - * deterministic under test and a cached snapshot can be re-evaluated - * against the current instant). - * - * Nine rungs, first hit wins. The ordering is a severity ladder: - * the fixed-floor BP emergency first (a fresh crisis-level reading - * outranks an overdue dose), then the actionable medication states, - * then trend nudges, then the briefing teaser, then the all-quiet - * fallback. Threshold constants are imported from the client-safe - * coach-nudge-thresholds leaf where one exists so the hero and the - * nudge cron can never disagree on what "drift" or "deficit" means — - * and so this module never pulls the cron's server graph into a - * client bundle (this resolver runs inside `"use client"` code). - * - * Freshness is re-derived from the snapshot's ISO timestamps against - * the injected `now` (not the snapshot's own `daysAgo` fields) so a - * snapshot served from cache ages honestly. - * - * Defensive contract (cache staleness): a `medsToday.nextDueAt` in the - * past with `nextDueOverdue: false` means the slot's anchor passed - * after the snapshot was built. Rung 2 keys ONLY on the server-computed - * `nextDueOverdue` flag and rung 3 requires `nextDueAt >= now`, so that - * state falls through to the plain summary — never renders as overdue. - */ -import { - COACH_NUDGE_WEIGHT_DRIFT_KG, - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, -} from "@/lib/jobs/coach-nudge-thresholds"; -import { BP_SYS_CRITICAL, BP_DIA_CRITICAL } from "@/lib/clinical-floors"; -import { userDayKey } from "@/lib/tz/format"; -import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; - -const DAY_MS = 24 * 60 * 60 * 1000; - -/** - * Fixed clinical floors for the BP-critical rung — the canonical - * hypertensive-crisis floors (sys ≥ 180 OR dia ≥ 120), imported from the - * one source of truth `@/lib/clinical-floors` so the hero, the safety-floor - * notification engine, and the Coach acute clause can never disagree on the - * same reading (D3-H1). The diastolic floor is 120 (ACC/AHA), NOT the former - * local 110: the wider 110 net lit the hero on readings the notification - * engine left calm. Deliberately NOT the user's personal targets — a - * user-relaxed threshold must never silence a crisis reading on the hero. - */ -const BP_CRITICAL_SYS_FLOOR = BP_SYS_CRITICAL; -const BP_CRITICAL_DIA_FLOOR = BP_DIA_CRITICAL; -/** A critical reading older than this many days is history, not an alert. */ -const BP_CRITICAL_MAX_DAYS_AGO = 1; - -/** Rung 3 — surface a dose at most this far before its anchor. */ -const DOSE_UPCOMING_WINDOW_MS = 2 * 60 * 60 * 1000; - -/** Rung 5 — nightly floor (hours) the short-nights rung measures against. */ -const SHORT_NIGHTS_FLOOR_H = 7; -/** Rung 5 — minimum readings in the summary window before the rung fires. */ -const SHORT_NIGHTS_MIN_COUNT = 5; - -/** Rung 6 — every logged-ever core vital silent for at least this long. */ -const SILENCE_MIN_DAYS = 7; -/** Rung 6 — the core vitals the silence rung watches. */ -const SILENCE_TYPES = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_GLUCOSE", -] as const; - -/** Rung 7 — week-over-week score drop (points) that fires. */ -const SCORE_DROP_MIN_POINTS = 10; - -export type DashboardVerdictVariant = - | "doseOverdue" - | "bpCritical" - | "doseUpcoming" - | "weightDrift" - | "shortNights" - | "silence" - | "scoreDrop" - | "briefing" - | "allQuiet"; - -export type DashboardVerdictCta = - | { kind: "quickEntry"; target: "medicationIntake" | "measurement" } - | { kind: "link"; href: string }; - -export interface DashboardVerdict { - variant: DashboardVerdictVariant; - /** - * Interpolation values for the variant's i18n message. `briefing` - * carries the model-authored headline VERBATIM under `headline`; - * renderers must emit it as plain text children (no HTML, no - * markdown) per the repo-wide no-markdown-renderer rule. - */ - values: Record; - cta: DashboardVerdictCta | null; -} - -/** HH:mm wall-clock of `d` in `tz`. */ -function hmInTz(d: Date, tz: string): string { - const parts = new Intl.DateTimeFormat("en-GB", { - timeZone: tz, - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).format(d); - // Some ICU builds emit "24:xx" for midnight under hour12: false. - return parts.startsWith("24") ? `00${parts.slice(2)}` : parts; -} - -/** Whole days between an ISO instant and `now` (floor; negative-safe). */ -function daysSince(iso: string, now: Date): number { - return Math.floor((now.getTime() - Date.parse(iso)) / DAY_MS); -} - -export function resolveDashboardVerdict( - snapshot: DashboardSnapshot, - now: Date, -): DashboardVerdict { - const meds = snapshot.medsToday; - const tz = snapshot.user.timezone; - const summaries = snapshot.tiles.summaries; - const lastSeen = snapshot.tiles.lastSeenByType; - - // ── 1 · bpCritical ───────────────────────────────────────────────── - // Fixed floors (sys ≥ 180 OR dia ≥ 120, from clinical-floors), never user - // thresholds, and - // only while the reading is fresh (≤ 1 day, re-derived against the - // injected `now`). Sits ABOVE the overdue dose: a fresh crisis-level - // reading is the one item that must not hide behind a routine - // medication prompt. - const sysLatest = summaries.BLOOD_PRESSURE_SYS?.latest ?? null; - const diaLatest = summaries.BLOOD_PRESSURE_DIA?.latest ?? null; - const sysSeen = lastSeen.BLOOD_PRESSURE_SYS; - const bpCriticalValue = - (sysLatest !== null && sysLatest >= BP_CRITICAL_SYS_FLOOR) || - (diaLatest !== null && diaLatest >= BP_CRITICAL_DIA_FLOOR); - if ( - bpCriticalValue && - sysSeen && - daysSince(sysSeen.lastSeenAt, now) <= BP_CRITICAL_MAX_DAYS_AGO - ) { - return { - variant: "bpCritical", - values: { sys: sysLatest ?? 0, dia: diaLatest ?? 0 }, - cta: { kind: "link", href: "/insights/blood-pressure" }, - }; - } - - // ── 2 · doseOverdue ──────────────────────────────────────────────── - // Keys ONLY on the server-computed band-model flag. A stale cached - // `nextDueAt` in the past with `nextDueOverdue: false` falls through - // (defensive contract — see module doc). - if (meds.nextDueOverdue === true) { - return { - variant: "doseOverdue", - values: { name: meds.nextDueMedicationName ?? "" }, - cta: { kind: "quickEntry", target: "medicationIntake" }, - }; - } - - // ── 3 · doseUpcoming ─────────────────────────────────────────────── - // Unresolved doses remain today AND the next anchor sits within the - // next two hours on the user's local calendar day. - const unresolvedToday = - meds.scheduledToday > meds.takenToday + meds.skippedToday; - if (unresolvedToday && meds.nextDueAt !== null) { - const dueMs = Date.parse(meds.nextDueAt); - const untilDue = dueMs - now.getTime(); - if ( - untilDue >= 0 && - untilDue <= DOSE_UPCOMING_WINDOW_MS && - userDayKey(new Date(dueMs), tz) === userDayKey(now, tz) - ) { - return { - variant: "doseUpcoming", - values: { - time: hmInTz(new Date(dueMs), tz), - name: meds.nextDueMedicationName ?? "", - }, - cta: { kind: "quickEntry", target: "medicationIntake" }, - }; - } - } - - // ── 4 · weightDrift ──────────────────────────────────────────────── - // Distance-to-green-range drift: the 7-day mean sits further from the - // green band than the 30-day mean by at least the coach-nudge drift - // threshold. - // - // v1.34 — the band comes off the snapshot's resolved `targetBands`, which - // honours the user's own weight target when they set one. The rung used to - // recompute the height-derived BMI band inline, so it nudged about drifting - // away from a band the user had explicitly replaced. No band on the snapshot - // (no height and no target) → skip. - const weight = summaries.WEIGHT; - const weightRange = snapshot.targetBands.weightRange; - if (weightRange && weight && weight.avg7 !== null && weight.avg30 !== null) { - const dist = (x: number): number => - Math.max(0, weightRange.greenMin - x, x - weightRange.greenMax); - if (dist(weight.avg7) - dist(weight.avg30) >= COACH_NUDGE_WEIGHT_DRIFT_KG) { - return { - variant: "weightDrift", - values: {}, - cta: { kind: "link", href: "/insights/weight" }, - }; - } - } - - // ── 5 · shortNights ──────────────────────────────────────────────── - // 7-day mean sleep (stored in minutes) clearly under the floor, with - // enough readings that a sparse week stays silent. The clear-margin - // is the same constant the coach-nudge sleep trigger uses. - const sleep = summaries.SLEEP_DURATION; - if (sleep && sleep.avg7 !== null && sleep.count >= SHORT_NIGHTS_MIN_COUNT) { - const floorMinutes = - (SHORT_NIGHTS_FLOOR_H - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H) * 60; - if (sleep.avg7 < floorMinutes) { - return { - variant: "shortNights", - values: { hours: Math.round((sleep.avg7 / 60) * 10) / 10 }, - cta: { kind: "link", href: "/insights/sleep" }, - }; - } - } - - // ── 6 · silence ──────────────────────────────────────────────────── - // EVERY logged-ever core vital has been silent for ≥ 7 days (min over - // the per-type ages). Types the user never logged don't count — a - // BP-only account is judged on BP alone. - let minDaysAgo: number | null = null; - for (const type of SILENCE_TYPES) { - const seen = lastSeen[type]; - if (!seen) continue; - const days = daysSince(seen.lastSeenAt, now); - if (minDaysAgo === null || days < minDaysAgo) minDaysAgo = days; - } - if (minDaysAgo !== null && minDaysAgo >= SILENCE_MIN_DAYS) { - return { - variant: "silence", - values: { days: minDaysAgo }, - cta: { kind: "quickEntry", target: "measurement" }, - }; - } - - // ── 7 · scoreDrop ────────────────────────────────────────────────── - const score = snapshot.healthScore; - if ( - score !== null && - score.delta !== null && - score.deltaReason === null && - score.delta <= -SCORE_DROP_MIN_POINTS - ) { - return { - variant: "scoreDrop", - values: { points: Math.round(Math.abs(score.delta)) }, - cta: { kind: "link", href: "/insights" }, - }; - } - - // ── 8 · briefing ─────────────────────────────────────────────────── - // Fresh briefing only (`ready` + not stale) with at least one key - // finding. Prefer a "watch"-tone finding; the headline rides verbatim - // as plain text — see the `values` doc. - if ( - snapshot.briefingState === "ready" && - !snapshot.briefingStale && - snapshot.briefing !== null && - snapshot.briefing.keyFindings.length > 0 - ) { - const findings = snapshot.briefing.keyFindings; - const picked = findings.find((f) => f.tone === "watch") ?? findings[0]; - return { - variant: "briefing", - // `sourceMetric` rides along so the hero can deep-link the - // sentence to the picked finding's metric sub-page (METRIC_HREF); - // renderers that don't know the field ignore it. - values: { headline: picked.headline, sourceMetric: picked.sourceMetric }, - cta: { kind: "link", href: "/insights" }, - }; - } - - // ── 9 · allQuiet ─────────────────────────────────────────────────── - return { variant: "allQuiet", values: {}, cta: null }; -} diff --git a/src/lib/jobs/coach-nudge-thresholds.ts b/src/lib/jobs/coach-nudge-thresholds.ts deleted file mode 100644 index 8cf107bb9..000000000 --- a/src/lib/jobs/coach-nudge-thresholds.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Coach-nudge trend thresholds shared with client-reachable code. - * - * Kept in a leaf module — free of any server-only import — because the - * dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads - * these to stay numerically identical to the nudge cron, and that - * resolver runs inside a `"use client"` component. Importing them from - * `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server - * graph into the browser bundle (the same regression class the - * dose-window defaults hit — see - * `src/lib/medications/scheduling/dose-window-defaults.ts`). - * `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep - * their path. - */ - -/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ -export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; -/** Sleep trigger: a night must undershoot the floor by this margin (h). */ -export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 217b5895a..3dd46e799 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -105,18 +105,6 @@ import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, type HealthProfileAiSection, } from "@/lib/validations/health-profile-facts"; -// Trend thresholds shared with the dashboard hero's verdict resolver -// live in a client-safe leaf module; re-exported below so server-side -// imports keep their path. -import { - COACH_NUDGE_WEIGHT_DRIFT_KG, - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, -} from "@/lib/jobs/coach-nudge-thresholds"; - -export { - COACH_NUDGE_WEIGHT_DRIFT_KG, - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, -} from "@/lib/jobs/coach-nudge-thresholds"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const SELF_CONTEXT_AI_SECTIONS = [ @@ -155,8 +143,12 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; /** Weight trigger: minimum readings per weekly window. */ export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; /** Sleep trigger: minimum recorded nights in the 7-day window. */ export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; /** Sleep trigger: nights under the floor required to fire. */ export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; /** Gap trigger: distinct active days required in the prior 3 weeks. */