diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5190ca81e..bae350852 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 6bfdd2954..cdc207dd8 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -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, diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 11a8c39db..36f1e9506 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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; diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index a2bd8b53f..9761a8ba3 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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> { - 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; diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index b46247982..66458f54e 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 # const callGet = GET as unknown as () => Promise; +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; + }; + }; + }; + 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({ diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 692b7ed70..5896c6322 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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 { @@ -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({ diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 0a8fe9a33..964ede10a 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -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 diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 50bcc52cd..06c7f3317 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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, }; } diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts new file mode 100644 index 000000000..ec0266f46 --- /dev/null +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -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 = {}, +): Record { + 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 = {}, +): Record { + 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>; +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 | 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; + 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(); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 991e3dcc8..ef9da94e1 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -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. diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts new file mode 100644 index 000000000..61b32560f --- /dev/null +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -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 { + 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 { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + ...partial, + }; +} + +function baseSnapshot( + overrides: Partial = {}, +): 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, + ): 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 }); + }); +}); diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts new file mode 100644 index 000000000..4f501cb3f --- /dev/null +++ b/src/lib/dashboard/meds-today.ts @@ -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 { + 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( + latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), + ); + const resolvedSlotsByMedId = new Map(); + 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(); + 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, + }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 945ee4f14..bb633d37d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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 { + now: Date, + time: (label: string, fn: () => Promise) => Promise, +): 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, diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts new file mode 100644 index 000000000..f38f439ef --- /dev/null +++ b/src/lib/dashboard/verdict.ts @@ -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; + 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 }; +} diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index 83d68356d..edc57407a 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -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(),