mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge pull request #680 from MBombeck/work/dashboard-verdict
Retire the dashboard verdict resolver
This commit is contained in:
8 files changed
+53
-1299
No files matched your search
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user