mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user