feat(dashboard): snapshot carries the day's dose state, score and verdict inputs

The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 09:02:58 +02:00
1 parent 2718930e8d
commit ad33fa46ed
15 files changed
+2103 -113

No files matched your search

+72
View File
@@ -11713,6 +11713,76 @@ components:
- glucoseByContext
additionalProperties: false
- type: "null"
medsToday:
type: object
properties:
activeCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
scheduledToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
takenToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
skippedToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
nextDueAt:
anyOf:
- type: string
- type: "null"
nextDueOverdue:
type: boolean
nextDueMedicationName:
anyOf:
- type: string
- type: "null"
required:
- activeCount
- scheduledToday
- takenToday
- skippedToday
- nextDueAt
- nextDueOverdue
- nextDueMedicationName
additionalProperties: false
description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and
the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot
(anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a
`nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built —
render the plain day summary, never an overdue state."
healthScore:
anyOf:
- type: object
properties:
score:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
band:
type: string
enum:
- green
- yellow
- red
delta:
anyOf:
- type: number
- type: "null"
required:
- score
- band
- delta
additionalProperties: false
- type: "null"
description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a
rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable.
Component breakdown is deliberately not serialised here.
briefing:
anyOf:
- type: object
@@ -11745,6 +11815,8 @@ components:
- metricStates
- tiles
- extras
- medsToday
- healthScore
- briefing
- briefingState
- briefingUpdatedAt
+10
View File
@@ -144,6 +144,16 @@ export function buildMockSnapshot(
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
},
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
@@ -14,12 +14,19 @@ vi.mock("@/lib/db", () => ({
// Dashboard compliance tile leaves "Heute nichts geplant" the
// moment a daily med is configured.
createMany: vi.fn(),
// Shared meds-today builder — latest non-skipped intake per
// medication (next-due re-anchor for rolling cadences).
groupBy: vi.fn(),
},
medication: {
// v1.4.39 W-SERVER-FIX-2 — projection source for the today's-
// intakes backfill.
findMany: vi.fn(),
},
// Shared meds-today builder — current-era floor per medication.
medicationScheduleRevision: {
groupBy: vi.fn(),
},
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
@@ -105,6 +112,14 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 0,
} as never);
// Shared meds-today builder feeder reads — default to empty so the
// legacy tests keep their "no medications" branch.
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
@@ -620,25 +635,25 @@ describe("GET /api/dashboard/summary", () => {
],
},
] as never);
// The dashboard route now issues three findMany calls in the
// today path:
// 1. existence probe (no `OR`) → empty (no rows yet)
// 2. final read after backfill (no `OR`) → one minted row
// 3. streak fetch (with `OR`) → empty
// The shared meds-today builder issues four findMany shapes:
// 1. projector existence probe (no `OR`, selects medicationId +
// scheduledFor) → empty (no rows yet)
// 2. today tally read after backfill (no `OR`, selects takenAt +
// skipped) → one minted row
// 3. resolved-slot read + streak fetch (with `OR`) → empty
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as {
where: { OR?: unknown };
select?: { id?: boolean };
select?: { medicationId?: boolean };
};
if (a.where.OR) return Promise.resolve([]) as never;
// No `id` field on the existence-probe `select` → the route
// only asked for `(medicationId, scheduledFor)`.
if (!a.select?.id) return Promise.resolve([]) as never;
// The existence probe asks for `(medicationId, scheduledFor)`;
// the tally read asks for `(takenAt, skipped)`.
if (a.select?.medicationId) return Promise.resolve([]) as never;
return Promise.resolve([
{
id: "e-new",
takenAt: null,
skipped: false,
},
@@ -712,14 +727,9 @@ describe("GET /api/dashboard/summary", () => {
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) return Promise.resolve([]) as never;
return Promise.resolve([]) as never;
}) as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 2,
} as never);
@@ -783,18 +793,17 @@ describe("GET /api/dashboard/summary", () => {
) => {
const a = args as {
where: { OR?: unknown };
select?: { id?: boolean };
select?: { medicationId?: boolean };
};
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) {
if (a.select?.medicationId) {
// Existence probe — the projected slot already exists.
return Promise.resolve([
{ medicationId: "med-ramipril", scheduledFor },
]) as never;
}
return Promise.resolve([
{ id: "e-existing", takenAt: null, skipped: false },
]) as never;
// Today tally read — the pre-existing pending row.
return Promise.resolve([{ takenAt: null, skipped: false }]) as never;
}) as never);
const res = await callGet(makeReq());
@@ -809,14 +818,16 @@ describe("GET /api/dashboard/summary", () => {
it("computes intake compliance for today", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// No active medications (default) → the projector early-outs, so
// the only no-`OR` read is the meds-today tally over the window.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown } };
if (!a.where.OR) {
return Promise.resolve([
{ id: "e1", takenAt: new Date(), skipped: false },
{ id: "e2", takenAt: null, skipped: false },
{ takenAt: new Date(), skipped: false },
{ takenAt: null, skipped: false },
]) as never;
}
return Promise.resolve([]) as never;
+15 -65
View File
@@ -56,7 +56,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -211,20 +211,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
interface StreakInfo {
currentDays: number;
longest: number;
@@ -373,41 +359,6 @@ function buildContext(
return { greetingName, locale };
}
/**
* v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently
* backfill any missing `MedicationIntakeEvent` rows via the shared
* helper, then re-read the today-window so the caller sees both
* pre-existing rows + the freshly minted ones.
*
* Mirrors the behaviour of `/api/medications/intake?scope=today` (same
* helper) so the iOS Dashboard tile (fed by this route) and the iOS
* Erfassen sheet (fed by the intake route) converge on the same row
* set the moment a daily med becomes active.
*/
async function projectAndReadTodaysIntakes(
userId: string,
userTz: string,
todayStart: Date,
todayEnd: Date,
): Promise<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd,
});
return prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from the today tile.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
});
}
async function buildDashboardSummary(
userId: string,
userTz: string,
@@ -421,8 +372,6 @@ async function buildDashboardSummary(
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
@@ -453,14 +402,15 @@ async function buildDashboardSummary(
// REG-11: taken via ROW_NUMBER window, no calendar
// filter, so a 60-day-old metric still paints a chart)
// - allTimeAggregate: ≤ N metric types (unchanged)
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
// - medsToday: today-window tally via the shared projector
// (≤ daily intake schedule count, unchanged semantics)
// - streakActivity: ≤ daily intake count × 365 (unchanged)
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
const [
latestEver,
sparkBuckets,
allTimeAggregate,
todaysIntakes,
medsToday,
streakActivity,
measurementStreakDays,
sleepStageRows,
@@ -513,13 +463,12 @@ async function buildDashboardSummary(
// reminder worker entered the RED phase at the end of the dose
// window, so the iOS Dashboard tile fell to "Heute nichts geplant"
// even when the intake route (post-`expandTodayIntakes` fix) was
// already projecting + backfilling correctly. Re-uses the same
// helper + idempotent `createMany` (with `skipDuplicates: true`
// so a concurrent intake-route hit can't race a duplicate row in
// before the existence probe converges).
time("todaysIntakes", () =>
projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd),
),
// already projecting + backfilling correctly. The shared
// `buildMedsTodayBlock` runs the same idempotent projection
// (`skipDuplicates: true` so a concurrent intake-route hit can't
// race a duplicate row in) before its today-window tally, so this
// route and the dashboard snapshot count the identical row set.
time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)),
time("streakIntakes", () =>
prisma.medicationIntakeEvent.findMany({
where: {
@@ -887,10 +836,11 @@ async function buildDashboardSummary(
});
}
const scheduledToday = todaysIntakes.length;
const takenToday = todaysIntakes.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
// Shared builder carries the exact tally semantics this tile always
// had (scheduled = every today-window row; taken = `takenAt` set and
// not skipped). The wire shape of `compliance` is locked for iOS.
const scheduledToday = medsToday.scheduledToday;
const takenToday = medsToday.takenToday;
const t = getServerTranslator(ctx.locale).t;
@@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to true for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+22 -5
View File
@@ -115,6 +115,12 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
@@ -258,21 +264,32 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save.
// wiped here on a subsequent layout save. `heroVisible` rides the
// same preserve-when-absent contract — an older client's layout
// save must not reset the hero toggle. One stored-layout read
// covers both fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
if (mergedChartOverlayPrefs === undefined) {
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
mergedChartOverlayPrefs =
resolveDashboardLayout(existing?.dashboardWidgetsJson)
.chartOverlayPrefs ?? {};
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible !== false;
}
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
} as DashboardLayout);
await prisma.user.update({
@@ -377,6 +377,65 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
});
});
/**
* Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs
* on the layout blob like the B8 comparison baseline. Resolver contract:
* anything that is not the literal `false` clamps back to `true`
* (default-on for legacy blobs; a stale client cannot poison the field
* with a non-boolean). Serializer persists the resolved boolean
* explicitly so a re-read never has to guess the default.
*/
describe("resolveDashboardLayout() — heroVisible", () => {
it("defaults to true for legacy layouts (no field saved)", () => {
const legacy = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(resolveDashboardLayout(legacy).heroVisible).toBe(true);
});
it("respects an explicit heroVisible: false", () => {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: false,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(false);
});
it("clamps non-boolean values back to true", () => {
for (const poisoned of ["false", 0, null, 1, {}]) {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: poisoned,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(true);
}
});
it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true);
});
it("preserves heroVisible: false through serialize → resolve round-trip", () => {
const layout: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: false,
};
const resolved = resolveDashboardLayout(serializeDashboardLayout(layout));
expect(resolved.heroVisible).toBe(false);
});
it("serializer derives true when the field is missing on input", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(serializeDashboardLayout(layout).heroVisible).toBe(true);
});
});
/**
* v1.7.0 — full 27-id widget catalogue for the iOS cold-launch seed.
* The catalogue is a pure superset of the server-known ids; the
+20
View File
@@ -321,6 +321,16 @@ export interface DashboardLayout {
comparisonBaseline?: ComparisonBaseline;
/** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */
chartOverlayPrefs?: ChartOverlayPrefsMap;
/**
* Dashboard hero (daily verdict) visibility. Piggy-backs on the
* layout blob like the B8 comparison baseline — a UI affordance, not
* an analytical attribute, so no Prisma migration. The resolver
* clamps anything that is not the literal `false` back to `true`
* (legacy blobs without the field render the hero by default; a
* stale client cannot poison it with a non-boolean), and the
* serializer persists the resolved boolean explicitly.
*/
heroVisible?: boolean;
}
const DASHBOARD_LAYOUT_VERSION = 1;
@@ -332,6 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1;
*/
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
// Hero (daily verdict) is on by default; users opt out via
// Settings → Dashboard.
heroVisible: true,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -449,6 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// client cannot poison the dashboard with values the renderer
// doesn't know how to draw.
chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs),
// Hero visibility — anything that is not the literal `false`
// clamps to `true` (default-on for legacy blobs and malformed
// values alike).
heroVisible: candidate.heroVisible !== false,
};
}
@@ -476,5 +493,8 @@ export function serializeDashboardLayout(
// v1.4.18 — persist per-chart overlay prefs verbatim through the
// same coercion the resolver runs so the wire shape is stable.
chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs),
// Persist hero visibility explicitly (same clamp as the resolver)
// so a re-read never has to guess the default.
heroVisible: layout.heroVisible !== false,
};
}
@@ -0,0 +1,327 @@
/**
* `buildMedsTodayBlock` — integration-style unit tests with a mocked
* Prisma client and a mocked projector.
*
* Pinned contracts:
* - the idempotent projection runs BEFORE the today-window read (so
* the tally always sees the freshly minted pending rows) with the
* user's local-day bounds;
* - the tally semantics match the summary route's compliance tile
* exactly (taken = `takenAt` set and not skipped);
* - the next-due pick is the EARLIEST display-due across active
* medications (real recurrence engine, no engine mocks);
* - an open overdue slot surfaces with `nextDueOverdue: true` and a
* resolved slot at the same anchor advances to the next future one;
* - the today window is computed in the USER's timezone (asserted on
* the where-clause bounds for a non-European zone).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const projectTodayIntakesAndRecompute = vi.fn();
vi.mock("@/lib/medications/scheduling/project-today-intakes", () => ({
projectTodayIntakesAndRecompute: (...a: unknown[]) =>
projectTodayIntakesAndRecompute(...a),
}));
import { buildMedsTodayBlock } from "../meds-today";
/** Noon Berlin (CEST) on a fixed summer day. */
const NOW = new Date("2026-06-10T10:00:00.000Z");
const BERLIN = "Europe/Berlin";
interface FakeIntakeRow {
medicationId?: string;
scheduledFor?: Date;
takenAt: Date | null;
skipped: boolean;
}
function dailySchedule(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id: "sched-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
...overrides,
};
}
function medication(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id: "med-1",
name: "Testmittel",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [dailySchedule()],
...overrides,
};
}
/** Call-order ledger so the projection-before-read contract is assertable. */
let callOrder: string[];
let medications: Array<Record<string, unknown>>;
let todayRows: FakeIntakeRow[];
let resolvedRows: Array<{ medicationId: string; scheduledFor: Date }>;
let latestIntakeGroups: Array<{
medicationId: string;
_max: { takenAt: Date | null };
}>;
let eraFloorGroups: Array<{
medicationId: string;
_max: { validUntil: Date | null };
}>;
let capturedTodayWhere: Record<string, unknown> | null;
const fakePrisma = {
medication: {
findMany: vi.fn(async () => {
callOrder.push("medication.findMany");
return medications;
}),
},
medicationIntakeEvent: {
findMany: vi.fn(
async (args: { where: { OR?: unknown; scheduledFor?: unknown } }) => {
if (args.where.OR) {
callOrder.push("resolved.findMany");
return resolvedRows;
}
callOrder.push("today.findMany");
capturedTodayWhere = args.where as Record<string, unknown>;
return todayRows;
},
),
groupBy: vi.fn(async () => {
callOrder.push("latest.groupBy");
return latestIntakeGroups;
}),
},
medicationScheduleRevision: {
groupBy: vi.fn(async () => {
callOrder.push("era.groupBy");
return eraFloorGroups;
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
beforeEach(() => {
vi.clearAllMocks();
callOrder = [];
medications = [];
todayRows = [];
resolvedRows = [];
latestIntakeGroups = [];
eraFloorGroups = [];
capturedTodayWhere = null;
projectTodayIntakesAndRecompute.mockImplementation(async () => {
callOrder.push("project");
return { projected: 0, backfilled: 0 };
});
});
describe("buildMedsTodayBlock — projection contract", () => {
it("projects the user's local today BEFORE every read (idempotency seam)", async () => {
await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(projectTodayIntakesAndRecompute).toHaveBeenCalledTimes(1);
const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as {
userId: string;
userTz: string;
todayStart: Date;
todayEnd: Date;
};
expect(arg.userId).toBe("user-1");
expect(arg.userTz).toBe(BERLIN);
// Berlin local midnight (CEST = UTC+2) → 22:00Z the prior evening.
expect(arg.todayStart.toISOString()).toBe("2026-06-09T22:00:00.000Z");
expect(arg.todayEnd.toISOString()).toBe("2026-06-10T22:00:00.000Z");
// Projection strictly first — the tally must see the minted rows.
expect(callOrder[0]).toBe("project");
expect(callOrder.slice(1)).toContain("today.findMany");
});
it("computes the today window in the user's zone, not the server's", async () => {
// Pacific/Auckland (UTC+12, no DST in June): 10:00Z = 22:00 local,
// so local-today spans [2026-06-09T12:00Z, 2026-06-10T12:00Z).
await buildMedsTodayBlock(fakePrisma, "user-1", "Pacific/Auckland", NOW);
const where = capturedTodayWhere as {
scheduledFor: { gte: Date; lt: Date };
deletedAt: null;
};
expect(where.scheduledFor.gte.toISOString()).toBe(
"2026-06-09T12:00:00.000Z",
);
expect(where.scheduledFor.lt.toISOString()).toBe(
"2026-06-10T12:00:00.000Z",
);
expect(where.deletedAt).toBeNull();
});
});
describe("buildMedsTodayBlock — tally", () => {
it("counts scheduled / taken / skipped with the summary-tile semantics", async () => {
todayRows = [
{ takenAt: new Date("2026-06-10T06:05:00.000Z"), skipped: false },
{ takenAt: new Date("2026-06-10T08:00:00.000Z"), skipped: false },
{ takenAt: null, skipped: true }, // deliberate skip
{ takenAt: null, skipped: false }, // still-open pending
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.scheduledToday).toBe(4);
expect(block.takenToday).toBe(2);
expect(block.skippedToday).toBe(1);
});
it("returns a zeroed block for an account with no medications", async () => {
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block).toEqual({
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
});
it("reads only ACTIVE medications", async () => {
await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
const arg = fakePrisma.medication.findMany.mock.calls[0][0] as {
where: { userId: string; active: boolean };
};
expect(arg.where).toEqual({ userId: "user-1", active: true });
});
});
describe("buildMedsTodayBlock — next due (real engine)", () => {
it("picks the EARLIEST future slot across medications, with its name", async () => {
// 18:00 Berlin = 16:00Z; 20:00 Berlin = 18:00Z. Yesterday's slots
// are past their catch-up bands (daily tail 4 h), so the engine
// lands on today's anchors.
medications = [
medication({
id: "med-late",
name: "Spätmittel",
schedules: [
dailySchedule({
id: "sched-late",
medicationId: "med-late",
windowStart: "20:00",
windowEnd: "21:00",
timesOfDay: ["20:00"],
}),
],
}),
medication({
id: "med-evening",
name: "Abendmittel",
schedules: [
dailySchedule({
id: "sched-evening",
medicationId: "med-evening",
windowStart: "18:00",
windowEnd: "19:00",
timesOfDay: ["18:00"],
}),
],
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.activeCount).toBe(2);
expect(block.nextDueAt).toBe("2026-06-10T16:00:00.000Z");
expect(block.nextDueOverdue).toBe(false);
expect(block.nextDueMedicationName).toBe("Abendmittel");
});
it("surfaces an open overdue slot (anchor passed, band still open)", async () => {
// 11:00 Berlin anchor = 09:00Z; now is 12:00 Berlin — one hour past
// the anchor, well inside the daily 4 h catch-up band.
medications = [
medication({
name: "Mittagsmittel",
schedules: [
dailySchedule({
windowStart: "11:00",
windowEnd: "12:00",
timesOfDay: ["11:00"],
}),
],
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z");
expect(block.nextDueOverdue).toBe(true);
expect(block.nextDueMedicationName).toBe("Mittagsmittel");
});
it("advances past a RESOLVED slot to the next future anchor", async () => {
medications = [
medication({
name: "Mittagsmittel",
schedules: [
dailySchedule({
windowStart: "11:00",
windowEnd: "12:00",
timesOfDay: ["11:00"],
}),
],
}),
];
// The 11:00-Berlin slot was acted on — taken row at the anchor.
resolvedRows = [
{
medicationId: "med-1",
scheduledFor: new Date("2026-06-10T09:00:00.000Z"),
},
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
// Tomorrow's 11:00 Berlin anchor.
expect(block.nextDueAt).toBe("2026-06-11T09:00:00.000Z");
expect(block.nextDueOverdue).toBe(false);
});
it("returns null next-due when no schedule has an upcoming slot", async () => {
// Course ended last month — the engine yields nothing.
medications = [
medication({
name: "Altmittel",
endsOn: new Date("2026-05-01T00:00:00.000Z"),
}),
];
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
expect(block.activeCount).toBe(1);
expect(block.nextDueAt).toBeNull();
expect(block.nextDueOverdue).toBe(false);
expect(block.nextDueMedicationName).toBeNull();
});
});
@@ -21,6 +21,8 @@ const ensureUserMoodRollupsFresh = vi.fn();
const computeBpInTargetFastPath = vi.fn();
const getAssistantFlags = vi.fn();
const hasAnyConfiguredProvider = vi.fn();
const buildMedsTodayBlock = vi.fn();
const computeUserHealthScoreFastPath = vi.fn();
vi.mock("@/lib/analytics/summaries-slice", () => ({
computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a),
@@ -46,6 +48,15 @@ vi.mock("@/lib/feature-flags", () => ({
vi.mock("@/lib/ai/provider", () => ({
hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a),
}));
// Meds-today + health-score builders are tested in their own suites;
// here only the assembly contract (fast vs warm phase) is pinned.
vi.mock("@/lib/dashboard/meds-today", () => ({
buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: (...a: unknown[]) =>
computeUserHealthScoreFastPath(...a),
}));
import {
buildDashboardSnapshot,
@@ -112,6 +123,16 @@ beforeEach(() => {
fakePrisma.measurement.findMany.mockResolvedValue([]);
fakePrisma.moodEntry.findMany.mockResolvedValue([]);
hasAnyConfiguredProvider.mockResolvedValue(true);
buildMedsTodayBlock.mockResolvedValue({
activeCount: 1,
scheduledToday: 2,
takenToday: 1,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
});
describe("buildDashboardSnapshot — envelope shape", () => {
@@ -195,6 +216,104 @@ describe("buildDashboardSnapshot — two-phase null extras", () => {
});
});
describe("buildDashboardSnapshot — medsToday (fast phase)", () => {
it("rides the fast phase and is present even on a rollup-coverage miss", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
isFullyCovered.mockReturnValue(false);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.medsToday).toEqual({
activeCount: 1,
scheduledToday: 2,
takenToday: 1,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
// Built from the user's id + timezone, not from any body field.
expect(buildMedsTodayBlock).toHaveBeenCalledWith(
fakePrisma,
"user-1",
"Europe/Berlin",
expect.any(Date),
);
});
});
describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
it("is null on a rollup-coverage miss and the fast path never runs", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
isFullyCovered.mockReturnValue(false);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.healthScore).toBeNull();
expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled();
});
it("serialises score + band + delta — and ONLY those — when warm", async () => {
const coverageMap = new Map([["WEIGHT", true]]);
probeRollupCoverage.mockResolvedValue(coverageMap);
isFullyCovered.mockReturnValue(true);
computeBpInTargetFastPath.mockResolvedValue({
last7Days: { pct: 70 },
last30Days: { pct: 80 },
allTime: { pct: 75 },
priorMonth: { pct: 60 },
priorYear: { pct: 50 },
gradedScore: 88,
});
computeUserHealthScoreFastPath.mockResolvedValue({
score: 72,
band: "yellow",
delta: -12,
components: { bp: {}, weight: {}, mood: {}, compliance: {} },
});
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
// Score + band + delta only — components never reach the wire.
expect(snap.healthScore).toEqual({ score: 72, band: "yellow", delta: -12 });
// The fast path reuses the BP windows + coverage map already
// computed for `extras` (no re-probe, graded score threaded).
expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1);
const arg = computeUserHealthScoreFastPath.mock.calls[0][0] as {
userId: string;
bpInTargetPct: number | null;
bpGradedScore: number | null;
heightCm: number | null;
coverage: unknown;
};
expect(arg.userId).toBe("user-1");
expect(arg.bpInTargetPct).toBe(80);
expect(arg.bpGradedScore).toBe(88);
expect(arg.heightCm).toBe(180);
expect(arg.coverage).toBe(coverageMap);
});
it("is null when warm but no pillar is computable (fast path returns null)", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
isFullyCovered.mockReturnValue(true);
computeBpInTargetFastPath.mockResolvedValue({
last7Days: null,
last30Days: null,
allTime: null,
priorMonth: null,
priorYear: null,
gradedScore: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.extras).not.toBeNull();
expect(snap.healthScore).toBeNull();
});
});
describe("buildDashboardSnapshot — briefingState matrix", () => {
beforeEach(() => {
probeRollupCoverage.mockResolvedValue(new Map());
@@ -507,6 +626,8 @@ describe("buildDashboardSnapshot — additive proof", () => {
expect(snap).toHaveProperty("metricStates");
expect(snap).toHaveProperty("layoutCatalogue");
expect(snap).toHaveProperty("briefingStale");
expect(snap).toHaveProperty("medsToday");
expect(snap).toHaveProperty("healthScore");
// The web-consumed `layout` block is the resolved per-user layout —
// NOT replaced by the 35-id catalogue.
+679
View File
@@ -0,0 +1,679 @@
/**
* 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) and only on a fresh reading;
* - 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 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,
...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: {},
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 1 — 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("wins over a simultaneously critical BP reading (precedence)", () => {
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("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 2 — 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 diastolic floor 110 alone", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 110, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
expect(verdict.values).toEqual({ sys: 150, dia: 110 });
});
it("does NOT fire at dia 109 (boundary)", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 109, 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,
): 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;
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 is stored (no derivable range)", () => {
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 },
});
}
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 });
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 });
});
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 },
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 },
briefing: {
paragraph: "Busy day.",
keyFindings: [WATCH_FINDING],
},
briefingState: "ready",
briefingStale: false,
}),
NOW,
);
expect(verdict.variant).toBe("scoreDrop");
expect(verdict.values).toEqual({ points: 15 });
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Today's medication block for the dashboard hero + summary tally.
*
* `buildMedsTodayBlock` is the one shared builder behind every surface
* that answers "how is the medication day going?":
*
* 1. **Projection** — `projectTodayIntakesAndRecompute` idempotently
* mints the day's pending `MedicationIntakeEvent` rows for active
* schedules (same helper the intake route + dashboard summary use),
* so the tally below never under-counts a daily med whose rows the
* reminder worker has not minted yet.
* 2. **Tally** — one today-window event read produces
* `scheduledToday` / `takenToday` / `skippedToday` with the exact
* semantics the summary route's compliance tile always had
* (taken = `takenAt !== null && !skipped`; tombstones excluded).
* 3. **Next due** — the same four feeder reads +
* `computeDisplayDue` loop the medications list route runs
* (`src/app/api/medications/route.ts`), restricted to ACTIVE
* medications: latest non-skipped intake per med (rolling cadences
* re-anchor on it), resolved slots over the overdue-lookback
* horizon, and the current-era floor per med. The block surfaces
* the EARLIEST display-due across medications — an open overdue
* slot sits in the past, so it naturally wins over any future slot.
*
* The feeder reads are not lifted out of the medications list route
* because that route reuses the same rows for its own response fields
* (`lastTakenAt`, `todayEventCount`, per-card next-due); the clean
* shared seam is `computeDisplayDue` itself, which both call sites go
* through.
*
* Consumer contract (snapshot caching): `nextDueAt` / `nextDueOverdue`
* are computed at build time and may be served from a cache. A
* `nextDueAt` in the past with `nextDueOverdue: false` means the slot's
* anchor passed AFTER the block was built — render the plain day
* summary, never an overdue state, until a fresh block arrives.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
export interface MedsTodayBlock {
/** Active medications (paused courses excluded). */
activeCount: number;
/** Intake-event rows scheduled in the user's local today (all states). */
scheduledToday: number;
/** Rows with `takenAt` set and not skipped. */
takenToday: number;
/** Rows the user deliberately skipped. */
skippedToday: number;
/**
* Earliest display-due instant across active medications (ISO8601),
* or null when nothing is due (no schedules, course ended, all PRN).
* May lie outside today — the consumer gates on the local day.
*/
nextDueAt: string | null;
/**
* True when `nextDueAt` is an OPEN overdue slot (anchor passed, still
* inside its catch-up band, unresolved). A cached `nextDueAt` in the
* past with `nextDueOverdue: false` must render as the plain summary,
* never as overdue — see the module doc.
*/
nextDueOverdue: boolean;
/** Name of the medication carrying `nextDueAt`; null when none due. */
nextDueMedicationName: string | null;
}
export async function buildMedsTodayBlock(
prisma: PrismaClient,
userId: string,
userTz: string,
now: Date,
): Promise<MedsTodayBlock> {
const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz);
// The projector + tally read use an exclusive upper bound (`lt`),
// matching the summary route's window convention.
const todayEndExclusive = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
// Projection FIRST so the tally read below sees the freshly minted
// pending rows. Idempotent — `skipDuplicates` + the
// `(userId, medicationId, scheduledFor, source)` unique index make a
// concurrent intake-route hit safe.
await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd: todayEndExclusive,
});
// Resolved-slot window — same horizon the medications list route uses:
// back as far as the widest band tail (so a long-resolved past slot
// cannot resurface as overdue), forward two days for the lookahead.
const resolvedWindowStart = new Date(
todayStart.getTime() - OVERDUE_LOOKBACK_MS,
);
const resolvedWindowEnd = new Date(
todayEnd.getTime() + 2 * 24 * 60 * 60 * 1000,
);
const [medications, todayEvents, latestIntakes, resolvedEvents, eraFloors] =
await Promise.all([
prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
orderBy: { createdAt: "desc" },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEndExclusive },
},
select: { takenAt: true, skipped: true },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId,
deletedAt: null,
skipped: false,
takenAt: { not: null },
},
_max: { takenAt: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
OR: [
{ takenAt: { not: null } },
{ skipped: true },
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true },
}),
// Current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid; the
// open-overdue search must not reach past it into a previous
// era's cadence.
prisma.medicationScheduleRevision.groupBy({
by: ["medicationId"],
where: { medication: { userId }, supersededByRevisionId: null },
_max: { validUntil: true },
}),
]);
const takenToday = todayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const skippedToday = todayEvents.filter((e) => e.skipped).length;
const lastTakenAtByMedId = new Map<string, Date | null>(
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
);
const resolvedSlotsByMedId = new Map<string, Date[]>();
for (const e of resolvedEvents) {
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(e.scheduledFor);
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
}
const eraStartByMedId = new Map<string, Date>();
for (const f of eraFloors) {
if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil);
}
// Earliest display-due across active medications. An open overdue
// slot's anchor is in the past, so the minimum naturally prefers it
// over any future slot.
let nextDueAt: Date | null = null;
let nextDueOverdue = false;
let nextDueMedicationName: string | null = null;
for (const m of medications) {
const display = computeDisplayDue({
medication: {
id: m.id,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
},
schedules: m.schedules,
now,
userTz,
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
if (!display) continue;
if (nextDueAt === null || display.at.getTime() < nextDueAt.getTime()) {
nextDueAt = display.at;
nextDueOverdue = display.overdue;
nextDueMedicationName = m.name;
}
}
return {
activeCount: medications.length,
scheduledToday: todayEvents.length,
takenToday,
skippedToday,
nextDueAt: nextDueAt ? nextDueAt.toISOString() : null,
nextDueOverdue,
nextDueMedicationName,
};
}
+96 -18
View File
@@ -66,6 +66,12 @@ import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema";
import { getAssistantFlags } from "@/lib/feature-flags";
import { hasAnyConfiguredProvider } from "@/lib/ai/provider";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
buildMedsTodayBlock,
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import type { HealthScoreBand } from "@/lib/analytics/health-score";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -213,6 +219,18 @@ export interface DashboardLayoutCatalogueEntry {
order: number;
}
/**
* Health-score summary for the hero — score + band + week-over-week
* delta ONLY. The per-pillar component breakdown is deliberately NOT
* serialised here; the analytics route remains the one surface that
* exposes components.
*/
export interface DashboardSnapshotHealthScore {
score: number;
band: HealthScoreBand;
delta: number | null;
}
export interface DashboardSnapshot {
user: DashboardSnapshotUser;
layout: DashboardLayout;
@@ -241,6 +259,24 @@ export interface DashboardSnapshot {
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
/**
* Fast phase — today's medication block (projection-backed tally +
* earliest next-due across active medications).
*
* Caching contract: the snapshot is served read-through a cache, so
* `medsToday.nextDueAt` can sit in the past with
* `medsToday.nextDueOverdue: false` when the slot's anchor passed
* AFTER the snapshot was built. Consumers MUST render that state as
* the plain day summary — never as overdue — until a fresh snapshot
* carries `nextDueOverdue: true` from the band model itself.
*/
medsToday: MedsTodayBlock;
/**
* Warm-phase health score (score + band + delta only — no
* components). `null` on a rollup-coverage miss (the score rides the
* thick phase alongside `extras`) and when no pillar is computable.
*/
healthScore: DashboardSnapshotHealthScore | null;
briefing: DailyBriefing | null;
briefingState: BriefingState;
briefingUpdatedAt: string | null;
@@ -377,29 +413,40 @@ async function buildMoodBlock(
}
/**
* Thick slice — BD-Zielbereich + per-context glucose. Only called by
* the builder when the rollup tier is warm (`isFullyCovered`); the BP
* fast-path then stays on the sub-second rollup branch and never drops
* into the multi-second live fallback that would make the whole strip
* wait. On a coverage miss the builder skips this entirely and emits
* `extras: null` (per-tile shimmer until the boot backfill converges).
* Thick slice — BD-Zielbereich + per-context glucose + health score.
* Only called by the builder when the rollup tier is warm
* (`isFullyCovered`); the BP fast-path then stays on the sub-second
* rollup branch and never drops into the multi-second live fallback
* that would make the whole strip wait. On a coverage miss the builder
* skips this entirely and emits `extras: null` + `healthScore: null`
* (per-tile shimmer until the boot backfill converges).
*
* The health score rides this warm phase because it reuses the BP
* windows already computed here (`gradedScore` + `last30Days.pct`) and
* the same coverage map, so the score's weight pillar also stays on
* the rollup branch.
*/
async function buildExtras(
prisma: PrismaClient,
user: SnapshotUserInput,
userTz: string,
coverage: RollupCoverageMap,
): Promise<DashboardSnapshotExtras> {
now: Date,
time: <T>(label: string, fn: () => Promise<T>) => Promise<T>,
): Promise<{
extras: DashboardSnapshotExtras;
healthScore: DashboardSnapshotHealthScore | null;
}> {
let bpInTargetPct: number | null = null;
let bpInTargetPct7d: number | null = null;
let bpInTargetPct30d: number | null = null;
let bpInTargetPctAllTime: number | null = null;
let bpInTargetPctPriorMonth: number | null = null;
let bpInTargetPctPriorYear: number | null = null;
let bpGradedScore: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
const windows = await computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
@@ -413,8 +460,30 @@ async function buildExtras(
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
bpGradedScore = windows.gradedScore ?? null;
}
// Health score — reuses the BP windows captured above plus the
// already-probed coverage map (no second probe). Score + band +
// delta only; the component breakdown stays off this wire.
const scoreResult = await time("healthScore", () =>
computeUserHealthScoreFastPath({
userId: user.id,
bpInTargetPct,
bpGradedScore,
heightCm: user.heightCm,
now,
coverage,
}),
);
const healthScore: DashboardSnapshotHealthScore | null = scoreResult
? {
score: scoreResult.score,
band: scoreResult.band,
delta: scoreResult.delta,
}
: null;
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseRows = await prisma.measurement.findMany({
where: {
@@ -440,13 +509,16 @@ async function buildExtras(
}
return {
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
bpInTargetPctAllTime,
bpInTargetPctPriorMonth,
bpInTargetPctPriorYear,
glucoseByContext,
extras: {
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
bpInTargetPctAllTime,
bpInTargetPctPriorMonth,
bpInTargetPctPriorYear,
glucoseByContext,
},
healthScore,
};
}
@@ -646,15 +718,19 @@ export async function buildDashboardSnapshot(
const coverage = await time("coverage", () => probeRollupCoverage(user.id));
const warm = isFullyCovered(coverage);
const [slim, mood, extras, flags] = await Promise.all([
const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
time("summaries", () => computeSummariesSlice(user.id, coverage)),
time("mood", () => buildMoodBlock(prisma, user.id)),
warm
? time("extras", () => buildExtras(prisma, user, userTz, coverage))
? time("extras", () =>
buildExtras(prisma, user, userTz, coverage, now, time),
)
: Promise.resolve(null),
time("flags", () => getAssistantFlags()),
// Fast phase — projection-backed today tally + earliest next-due.
time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)),
]);
const layout = resolveDashboardLayout(user.dashboardWidgetsJson);
@@ -689,7 +765,9 @@ export async function buildDashboardSnapshot(
entries: mood.entries,
},
},
extras,
extras: extrasResult?.extras ?? null,
medsToday,
healthScore: extrasResult?.healthScore ?? null,
briefing: briefing.briefing,
briefingState: briefing.briefingState,
briefingUpdatedAt: briefing.briefingUpdatedAt,
+281
View File
@@ -0,0 +1,281 @@
/**
* 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:
* 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.
*
* 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 1 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";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Fixed clinical floors for the BP-critical rung (ESC/ESH grade-3 /
* hypertensive-crisis territory). Deliberately NOT the user's
* personal targets — a user-relaxed threshold must never silence a
* 180/110 reading on the hero.
*/
const BP_CRITICAL_SYS_FLOOR = 180;
const BP_CRITICAL_DIA_FLOOR = 110;
/** 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;
}
/** YYYY-MM-DD key of `d`'s calendar day in `tz`. */
function dayKeyInTz(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
/** 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 · 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" },
};
}
// ── 2 · bpCritical ─────────────────────────────────────────────────
// Fixed floors (sys ≥ 180 OR dia ≥ 110), never user thresholds, and
// only while the reading is fresh (≤ 1 day, re-derived against the
// injected `now`).
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" },
};
}
// ── 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 &&
dayKeyInTz(new Date(dueMs), tz) === dayKeyInTz(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 BMI-derived green band than the 30-day mean by at least the
// coach-nudge drift threshold. No stored height → no range → skip.
const weight = summaries.WEIGHT;
if (
snapshot.user.heightCm !== null &&
weight &&
weight.avg7 !== null &&
weight.avg30 !== null
) {
const range = buildWeightRangeFromHeight(snapshot.user.heightCm);
const dist = (x: number): number =>
Math.max(0, range.greenMin - x, x - range.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.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",
values: { headline: picked.headline },
cta: { kind: "link", href: "/insights" },
};
}
// ── 9 · allQuiet ───────────────────────────────────────────────────
return { variant: "allQuiet", values: {}, cta: null };
}
+29
View File
@@ -606,6 +606,35 @@ const dashboardSnapshotResponse = z
glucoseByContext: dataSummaryRecord,
})
.nullable(),
// Dashboard hero — today's medication block (fast phase, always
// present). Projection-backed tally + earliest next-due across
// active medications.
medsToday: z
.object({
activeCount: z.number().int(),
scheduledToday: z.number().int(),
takenToday: z.number().int(),
skippedToday: z.number().int(),
nextDueAt: z.string().nullable(),
nextDueOverdue: z.boolean(),
nextDueMedicationName: z.string().nullable(),
})
.describe(
"Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.",
),
// Dashboard hero — health score (warm phase, nullable on a
// rollup-coverage miss). Score + band + delta only; the per-pillar
// component breakdown stays on the analytics route.
healthScore: z
.object({
score: z.number().int(),
band: z.enum(["green", "yellow", "red"]),
delta: z.number().nullable(),
})
.nullable()
.describe(
"Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.",
),
briefing: z.record(z.string(), z.unknown()).nullable(),
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
briefingUpdatedAt: z.string().nullable(),