mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
2718930e8d
commit
ad33fa46ed
15 files changed
+2103
-113
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user