feat(dashboard): open the day with a verdict

The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 09:26:55 +02:00
1 parent ad33fa46ed
commit ea29e9cf1b
20 files changed
+1451 -94

No files matched your search

@@ -0,0 +1,134 @@
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([]);
});
});
+6 -4
View File
@@ -9,9 +9,11 @@
* Nine rungs, first hit wins. The ordering is a severity ladder:
* actionable medication states first, then a fixed-floor BP emergency,
* then trend nudges, then the briefing teaser, then the all-quiet
* fallback. Threshold constants are imported from the coach-nudge
* module where one exists so the hero and the nudge cron can never
* disagree on what "drift" or "deficit" means.
* 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
@@ -26,7 +28,7 @@
import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge";
} from "@/lib/jobs/coach-nudge-thresholds";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
+19
View File
@@ -0,0 +1,19 @@
/**
* 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;
+12 -4
View File
@@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
// 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;
@@ -109,14 +121,10 @@ 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: nights under the floor required to fire. */
export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4;
/** Sleep trigger: a night must undershoot the floor by this margin (h). */
export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5;
/** Gap trigger: distinct active days required in the prior 3 weeks. */
export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10;
/** Gap trigger: length of the silent window that fires (days). */