refactor(dashboard): remove the orphaned verdict resolver

The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.

Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.

- Delete the resolver, its ladder suite, and the client-safety import walk
  that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
  existed only so a client component could read them without dragging the
  server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
  Today hero actually reads, so it still fails on the assembly it was
  written for.
- Repoint the clinical-floors notes off the retired surface.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 02:32:24 +02:00
1 parent 817886e145
commit 86f0e5d2cb
8 files changed
+53 -1299

No files matched your search

+7 -7
View File
@@ -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);
});
@@ -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 () => {
+18 -18
View File
@@ -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). */
@@ -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<string>;
specifiers: Set<string>;
} {
const visited = new Set<string>();
const specifiers = new Set<string>();
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([]);
});
});
-808
View File
@@ -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>): 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> = {}): MedsTodayBlock {
return {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
...partial,
};
}
function baseSnapshot(
overrides: Partial<DashboardSnapshot> = {},
): 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<string, { lastSeenAt: string; daysAgo: number } | null>,
): 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");
});
});
-287
View File
@@ -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<string, string | number>;
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 };
}
-19
View File
@@ -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;
+4 -12
View File
@@ -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. */