From 2718930e8d10e06d4d1047ba51b27d008d4a4042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 08:34:10 +0200 Subject: [PATCH 1/7] fix(whoop): trim pasted credentials and honour the derived redirect fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing space from the portal's copy button reached WHOOP verbatim and answered as an unknown client; the credential schema trims both fields. The redirect override fell through nullish coalescing, so the empty string the compose whitelist materialises for an unset var was sent as the literal redirect target — empty now derives the callback from the app URL. --- src/lib/validations/whoop.ts | 6 ++++-- src/lib/whoop/client.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/validations/whoop.ts b/src/lib/validations/whoop.ts index ea33fe786..f547569d7 100644 --- a/src/lib/validations/whoop.ts +++ b/src/lib/validations/whoop.ts @@ -7,8 +7,10 @@ import { z } from "zod/v4"; * multi-operator product). Stored encrypted on `User`. */ export const whoopCredentialsSchema = z.object({ - clientId: z.string().min(1).max(200), - clientSecret: z.string().min(1).max(200), + // Trimmed: a trailing space or newline from the portal's copy button + // reaches WHOOP verbatim and answers as "unknown client". + clientId: z.string().trim().min(1).max(200), + clientSecret: z.string().trim().min(1).max(200), }); export type WhoopCredentialsInput = z.infer; diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 7eb91358e..90cf76fd2 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -34,8 +34,10 @@ export interface WhoopCredentials { } function getRedirectUri(): string { + // `||`, not `??`: the compose whitelist materialises the var as an empty + // string when unset, which must still fall through to the derived URI. return ( - process.env.WHOOP_REDIRECT_URI ?? + process.env.WHOOP_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback` ); } From ad33fa46ed1f986faa72f56f790e4f52eb29f100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 09:02:58 +0200 Subject: [PATCH 2/7] feat(dashboard): snapshot carries the day's dose state, score and verdict inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 72 ++ e2e/utils/mock-dashboard-snapshot.ts | 10 + .../dashboard/summary/__tests__/route.test.ts | 61 +- src/app/api/dashboard/summary/route.ts | 80 +-- .../dashboard/widgets/__tests__/route.test.ts | 126 ++++ src/app/api/dashboard/widgets/route.ts | 27 +- src/lib/__tests__/dashboard-layout.test.ts | 59 ++ src/lib/dashboard-layout.ts | 20 + .../dashboard/__tests__/meds-today.test.ts | 327 +++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 121 ++++ src/lib/dashboard/__tests__/verdict.test.ts | 679 ++++++++++++++++++ src/lib/dashboard/meds-today.ts | 210 ++++++ src/lib/dashboard/snapshot.ts | 114 ++- src/lib/dashboard/verdict.ts | 281 ++++++++ src/lib/openapi/routes/insights.ts | 29 + 15 files changed, 2103 insertions(+), 113 deletions(-) create mode 100644 src/lib/dashboard/__tests__/meds-today.test.ts create mode 100644 src/lib/dashboard/__tests__/verdict.test.ts create mode 100644 src/lib/dashboard/meds-today.ts create mode 100644 src/lib/dashboard/verdict.ts 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(), From ea29e9cf1bdd2cce2b8b29319a553d47ffcc1d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 09:26:55 +0200 Subject: [PATCH 3/7] feat(dashboard): open the day with a verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard now leads with a daily band: the greeting, the one item that matters right now — an overdue or upcoming dose, a very high blood-pressure reading, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring on the right. The verdict resolves from a fixed nine-rung ladder on the client with the thresholds shared from the nudge engine through a dependency-free module, the blood-pressure rung uses fixed crisis floors rather than personal target ranges, and a cached next-due in the past never renders as overdue. The band reserves its exact footprint from the first paint, swaps in with the tiles in one pass, respects the hour-format preference, and can be turned off in the dashboard settings; the greeting moves out of the page header into the band. --- messages/de.json | 30 ++ messages/en.json | 30 ++ messages/es.json | 30 ++ messages/fr.json | 30 ++ messages/it.json | 30 ++ messages/pl.json | 30 ++ .../__tests__/dashboard-hero-wiring.test.ts | 82 +++ .../dashboard-suspense-boundaries.test.ts | 17 +- src/app/page.tsx | 97 ++-- src/components/dashboard/dashboard-header.tsx | 33 +- .../dashboard-hero-skeleton.test.tsx | 48 ++ .../hero/__tests__/dashboard-hero.test.tsx | 479 ++++++++++++++++++ .../hero/dashboard-hero-skeleton.tsx | 45 ++ .../dashboard/hero/dashboard-hero.tsx | 274 ++++++++++ .../dashboard-layout-section.test.tsx | 72 +++ .../settings/dashboard-layout-section.tsx | 39 ++ .../__tests__/verdict-client-safety.test.ts | 134 +++++ src/lib/dashboard/verdict.ts | 10 +- src/lib/jobs/coach-nudge-thresholds.ts | 19 + src/lib/jobs/coach-nudge.ts | 16 +- 20 files changed, 1451 insertions(+), 94 deletions(-) create mode 100644 src/app/__tests__/dashboard-hero-wiring.test.ts create mode 100644 src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx create mode 100644 src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx create mode 100644 src/components/dashboard/hero/dashboard-hero-skeleton.tsx create mode 100644 src/components/dashboard/hero/dashboard-hero.tsx create mode 100644 src/lib/dashboard/__tests__/verdict-client-safety.test.ts create mode 100644 src/lib/jobs/coach-nudge-thresholds.ts diff --git a/messages/de.json b/messages/de.json index 7b5c36cd2..5d5fab0d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, willkommen zurück.", "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Noch nicht genug Daten", + "doses": { + "summary": "{taken} von {scheduled} Dosen genommen", + "nextAt": "Nächste um {time}", + "allDone": "Alle Dosen für heute erledigt", + "none": "Heute keine Dosen geplant" + }, + "verdict": { + "doseOverdue": "Eine Dosis {name} ist überfällig.", + "bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.", + "doseUpcoming": "Um {time} steht {name} an.", + "weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + "shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.", + "silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.", + "scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.", + "allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht." + }, + "action": { + "takeDose": "Jetzt eintragen", + "viewBp": "Blutdruck ansehen", + "viewWeight": "Gewicht ansehen", + "viewSleep": "Schlaf ansehen", + "logMeasurement": "Messung erfassen", + "viewInsights": "Insights öffnen" + } + }, "greetingSalutation": "Hallo, {name}", "streakLabel": "Tage in Folge", "weight": "Gewicht", @@ -323,6 +351,8 @@ "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", + "heroToggleLabel": "Tagesüberblick", + "heroToggleDescription": "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", diff --git a/messages/en.json b/messages/en.json index d50aa6490..8f1d22f32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, welcome back.", "welcomeBackWithName": "{greeting} {name}, welcome back.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Not enough data yet", + "doses": { + "summary": "{taken} of {scheduled} doses taken", + "nextAt": "Next at {time}", + "allDone": "All doses done for today", + "none": "No doses scheduled today" + }, + "verdict": { + "doseOverdue": "A dose of {name} is overdue.", + "bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.", + "doseUpcoming": "{name} is due at {time}.", + "weightDrift": "Your weight is moving away from the target range this week.", + "shortNights": "Your nights have been short lately — {hours} h on average.", + "silence": "No measurement for {days} days. A quick entry keeps your picture current.", + "scoreDrop": "Your score sits {points} points below last week.", + "allQuiet": "All quiet — nothing needs your attention today." + }, + "action": { + "takeDose": "Log it now", + "viewBp": "View blood pressure", + "viewWeight": "View weight", + "viewSleep": "View sleep", + "logMeasurement": "Log measurement", + "viewInsights": "Open Insights" + } + }, "greetingSalutation": "Hi, {name}", "streakLabel": "days in a row", "weight": "Weight", @@ -323,6 +351,8 @@ "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", + "heroToggleLabel": "Daily overview", + "heroToggleDescription": "Shows your score, daily focus and dose status at the top of the dashboard.", "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", diff --git a/messages/es.json b/messages/es.json index 593d43f7b..35af7d077 100644 --- a/messages/es.json +++ b/messages/es.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, te damos la bienvenida de nuevo.", "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Aún no hay datos suficientes", + "doses": { + "summary": "{taken} de {scheduled} dosis tomadas", + "nextAt": "Próxima a las {time}", + "allDone": "Todas las dosis de hoy completadas", + "none": "Hoy no hay dosis programadas" + }, + "verdict": { + "doseOverdue": "Una dosis de {name} está pendiente desde hace rato.", + "bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.", + "doseUpcoming": "A las {time} toca {name}.", + "weightDrift": "Tu peso se está alejando del rango objetivo esta semana.", + "shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.", + "silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.", + "scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.", + "allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención." + }, + "action": { + "takeDose": "Registrar ahora", + "viewBp": "Ver tensión arterial", + "viewWeight": "Ver peso", + "viewSleep": "Ver sueño", + "logMeasurement": "Registrar medición", + "viewInsights": "Abrir Insights" + } + }, "greetingSalutation": "Hola, {name}", "streakLabel": "días seguidos", "weight": "Peso", @@ -323,6 +351,8 @@ "compliance7d": "Cumplimiento (7 días)", "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", + "heroToggleLabel": "Resumen del día", + "heroToggleDescription": "Muestra la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", diff --git a/messages/fr.json b/messages/fr.json index ea85d0e79..98c1c3f2b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, content de te revoir.", "welcomeBackWithName": "{greeting} {name}, content de te revoir.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Pas encore assez de données", + "doses": { + "summary": "{taken} dose(s) prise(s) sur {scheduled}", + "nextAt": "Prochaine à {time}", + "allDone": "Toutes les doses du jour sont prises", + "none": "Aucune dose prévue aujourd’hui" + }, + "verdict": { + "doseOverdue": "Une dose de {name} est en retard.", + "bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.", + "doseUpcoming": "{name} est prévu à {time}.", + "weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.", + "shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.", + "silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.", + "scoreDrop": "Votre score est {points} points sous la semaine dernière.", + "allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui." + }, + "action": { + "takeDose": "Enregistrer maintenant", + "viewBp": "Voir la tension", + "viewWeight": "Voir le poids", + "viewSleep": "Voir le sommeil", + "logMeasurement": "Saisir une mesure", + "viewInsights": "Ouvrir Insights" + } + }, "greetingSalutation": "Bonjour, {name}", "streakLabel": "jours d’affilée", "weight": "Poids", @@ -323,6 +351,8 @@ "compliance7d": "Observance (7 jours)", "mood": "Humeur", "customizeTitle": "Mise en page du tableau", + "heroToggleLabel": "Aperçu du jour", + "heroToggleDescription": "Affiche le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", diff --git a/messages/it.json b/messages/it.json index ef90af625..2a2466464 100644 --- a/messages/it.json +++ b/messages/it.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, bentornato.", "welcomeBackWithName": "{greeting} {name}, bentornato.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Dati ancora insufficienti", + "doses": { + "summary": "{taken} dosi assunte su {scheduled}", + "nextAt": "Prossima alle {time}", + "allDone": "Tutte le dosi di oggi completate", + "none": "Nessuna dose in programma oggi" + }, + "verdict": { + "doseOverdue": "Una dose di {name} è in ritardo.", + "bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.", + "doseUpcoming": "Alle {time} è prevista {name}.", + "weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.", + "shortNights": "Le tue notti sono state corte di recente — in media {hours} h.", + "silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.", + "scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.", + "allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione." + }, + "action": { + "takeDose": "Registra ora", + "viewBp": "Vedi pressione", + "viewWeight": "Vedi peso", + "viewSleep": "Vedi sonno", + "logMeasurement": "Registra misurazione", + "viewInsights": "Apri Insights" + } + }, "greetingSalutation": "Ciao, {name}", "streakLabel": "giorni di fila", "weight": "Peso", @@ -323,6 +351,8 @@ "compliance7d": "Aderenza (7 giorni)", "mood": "Umore", "customizeTitle": "Layout della dashboard", + "heroToggleLabel": "Panoramica del giorno", + "heroToggleDescription": "Mostra punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", diff --git a/messages/pl.json b/messages/pl.json index 84f135b09..2c8b818c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -274,6 +274,34 @@ }, "welcomeBack": "{greeting}, witaj ponownie.", "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Za mało danych", + "doses": { + "summary": "Przyjęto {taken} z {scheduled} dawek", + "nextAt": "Następna o {time}", + "allDone": "Wszystkie dzisiejsze dawki przyjęte", + "none": "Brak zaplanowanych dawek na dziś" + }, + "verdict": { + "doseOverdue": "Dawka {name} jest zaległa.", + "bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.", + "doseUpcoming": "O {time} pora na {name}.", + "weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.", + "shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.", + "silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.", + "scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.", + "allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi." + }, + "action": { + "takeDose": "Zapisz teraz", + "viewBp": "Zobacz ciśnienie", + "viewWeight": "Zobacz wagę", + "viewSleep": "Zobacz sen", + "logMeasurement": "Zapisz pomiar", + "viewInsights": "Otwórz Insights" + } + }, "greetingSalutation": "Cześć, {name}", "streakLabel": "dni z rzędu", "weight": "Waga", @@ -323,6 +351,8 @@ "compliance7d": "Przestrzeganie (7 dni)", "mood": "Nastrój", "customizeTitle": "Układ panelu", + "heroToggleLabel": "Przegląd dnia", + "heroToggleDescription": "Pokazuje wynik, fokus dnia i status dawek na górze pulpitu.", "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts new file mode 100644 index 000000000..9b2cf45d5 --- /dev/null +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Dashboard hero — page + header wiring pins. + * + * The hero band mounts between the page header and the tile strip: + * + * - it gates on the layout's `heroVisible` flag (anything but the + * literal `false` renders — the resolver's clamp) AND on snapshot + * mode (the verdict needs the snapshot payload); + * - while `primaryLoading` is true (same `!mounted ||` pin the tile + * silhouettes use) a footprint-identical skeleton holds the band, + * so the hero and the tiles swap to content in the SAME render + * pass; + * - the greeting paragraph left the header for the hero. The header + * renders no name-bearing text at all any more, so the SSR pass + * stays name-free by construction (the hero itself only mounts + * once the snapshot resolved — post-hydration). + */ +const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +const HEADER_PATH = join( + process.cwd(), + "src/components/dashboard/dashboard-header.tsx", +); + +describe("dashboard hero — page wiring", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + + it("gates the hero on snapshot mode + the layout's heroVisible flag", () => { + expect(src).toMatch( + /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/, + ); + }); + + it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => { + const block = src.match( + /\{heroVisible\s*&&[\s\S]*?/, + ); + expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}"); + }); + + it("derives primaryLoading once in the component body (shared with the tile gate)", () => { + const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? []; + expect(matches).toHaveLength(1); + }); + + it("the hero sits between the header and the strip", () => { + const headerIdx = src.indexOf(" { + expect(src).not.toContain("welcomeText"); + expect(src).not.toContain("welcomeBackWithName"); + expect(src).not.toContain("getHourForTimeZone"); + }); +}); + +describe("dashboard hero — header handoff", () => { + const header = readFileSync(HEADER_PATH, "utf8"); + + it("the header dropped the greeting paragraph and its reservation", () => { + expect(header).not.toContain("welcomeText"); + expect(header).not.toContain("min-h-5"); + }); + + it("the header keeps the title + customize + add actions", () => { + expect(header).toContain('t("dashboard.title")'); + expect(header).toContain("dashboard-customize-shortcut"); + expect(header).toContain("dashboard-quick-add"); + }); +}); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 9db552cbe..30c4dee8d 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); }); - it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => { + it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = load(PAGE_PATH); - // The greeting hour only changes when the user's timezone changes - // — a `useMemo` keyed on the lifted `userTimezone` local keeps the - // `Intl.DateTimeFormat` instantiation off the per-render hot path. - // Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone` - // local one line above the `useMemo` so the dep array stays stable. - expect(src).toMatch( - /const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/, - ); + // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin + // retired with the greeting itself: the hero band derives the + // daypart from the snapshot's server-computed `greetingHour` + // (`dashboard-hero.tsx`), so the page no longer instantiates any + // `Intl.DateTimeFormat` for the greeting — per render or otherwise. + expect(src).not.toContain("getHourForTimeZone"); + expect(src).not.toContain("welcomeText"); }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0e665bbd1..16c68c38b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -36,12 +36,13 @@ import { useDashboardChartReveal, } from "@/components/dashboard/chart-reveal"; import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero"; +import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton"; import { QuickEntrySheets, type QuickEntryDialog, } from "@/components/dashboard/quick-entry-sheets"; import { - getHourForTimeZone, getRangeColorClass, getRangeHint, toHoursSummary, @@ -684,42 +685,49 @@ export default function DashboardPage() { upperBound: 55, }, ); - // v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat` - // instantiation (audit-H4). The greeting derived from the hour only - // changes when the user's timezone changes; recomputing it every - // render churned the parts-array and walked the format ICU twice per - // render across a 1 400-line component body. - // Gated on `mounted`: `user` comes from the auth query, which can - // resolve before this boundary hydrates — the greeting text must - // match the name-less SSR output during hydration (React #418) and - // may only personalise from the first client re-render. - const userTimezone = mounted ? user?.timezone : undefined; - const hour = useMemo( - () => (userTimezone ? getHourForTimeZone(userTimezone) : null), - [userTimezone], - ); - const timeGreeting = - hour == null - ? t("dashboard.greeting.day") - : hour >= 5 && hour < 12 - ? t("dashboard.greeting.morning") - : hour >= 12 && hour < 18 - ? t("dashboard.greeting.day") - : t("dashboard.greeting.evening"); - const welcomeText = - mounted && user?.username && user.username.trim().length > 0 - ? t("dashboard.welcomeBackWithName", { - greeting: timeGreeting, - name: user.username, - }) - : t("dashboard.welcomeBack", { greeting: timeGreeting }); + // v1.7.0 — the primary data source differs by flag. In snapshot + // mode `analyticsSlimQuery` is `enabled: false`, and a disabled + // TanStack query reports `fetchStatus: "idle"` → `isLoading` is + // always `false`. Gating the skeleton on the slim query then + // never fires under snapshot mode, so the empty-state branch + // below flashes for the whole snapshot fetch. Key the loading + // flag off whichever query is actually driving the tiles. + // v1.16.4 — `!mounted` pins the SSR pass AND the hydration + // render to the skeleton branch. Server-side the data queries + // are `enabled: false` (no auth) → `isLoading: false`, which + // used to select the empty state; a late-hydrating boundary + // then saw `isAuthenticated` already resolved and rendered the + // skeleton instead — React #418. See `useMounted`. + // v1.16.9 — hoisted out of the strip IIFE: the hero band below + // shares the same gate so its skeleton and the tile silhouettes + // swap to content in the same render pass. + const primaryLoading = + !mounted || + (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + + // v1.16.9 — the greeting paragraph moved off the header into the + // hero band (it derives from the snapshot's server-computed + // `greetingHour`, so the old client-side Intl hour walk retired + // with it). The hero gates on the layout's `heroVisible` flag + // (default on; anything but the literal `false` renders) and holds + // a footprint-identical skeleton while the snapshot is in flight, + // so the band swaps in place — no growth, no greeting flash. + const heroVisible = snapshotEnabled && layout.heroVisible !== false; + const heroSnapshot = snapshotQuery.data; return (
- + + + {heroVisible && + (primaryLoading || !heroSnapshot ? ( + + ) : ( + + ))} {/* v1.4.15 Phase B5 — spotlight tour for first-time users. * Self-gates on `user.onboardingTourCompleted` (DB flag) plus @@ -1403,24 +1411,9 @@ export default function DashboardPage() { // strip tile) and undercounted `bp` (one widget id, two tiles), // so the silhouettes reshuffled when the data landed. const configuredTileCount = resolveConfiguredTileCount(layout); - // v1.7.0 — the primary data source differs by flag. In snapshot - // mode `analyticsSlimQuery` is `enabled: false`, and a disabled - // TanStack query reports `fetchStatus: "idle"` → `isLoading` is - // always `false`. Gating the skeleton on the slim query then - // never fires under snapshot mode, so the empty-state branch - // below flashes for the whole snapshot fetch. Key the loading - // flag off whichever query is actually driving the tiles. - // v1.16.4 — `!mounted` pins the SSR pass AND the hydration - // render to the skeleton branch. Server-side the data queries - // are `enabled: false` (no auth) → `isLoading: false`, which - // used to select the empty state; a late-hydrating boundary - // then saw `isAuthenticated` already resolved and rendered the - // skeleton instead — React #418. See `useMounted`. - const primaryLoading = - !mounted || - (snapshotEnabled - ? snapshotQuery.isLoading - : analyticsSlimQuery.isLoading); + // `primaryLoading` is derived once in the component body (above + // the return) — the hero band and this strip share the same + // gate so both surfaces swap in the same render pass. // v1.16.8 — chart-row reservation while the snapshot is in // flight. The chart visibility gates above need snapshot data // (`count > 0`), so the cold chart row only carried the diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index a8e7cb5c1..79b0a9589 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -1,8 +1,10 @@ "use client"; /** - * Dashboard page header: title + greeting line, the customize shortcut, - * and the quick-add dropdown that opens the quick-entry sheets. + * Dashboard page header: title, the customize shortcut, and the + * quick-add dropdown that opens the quick-entry sheets. The greeting + * line moved into the hero band (``), which derives it + * from the snapshot's server-computed `greetingHour`. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. @@ -20,35 +22,16 @@ import { useTranslations } from "@/lib/i18n/context"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ - welcomeText, onQuickEntry, }: { - welcomeText: string; onQuickEntry: (dialog: NonNullable) => void; }) { const { t } = useTranslations(); return ( - /* v1.4.37 W4a item 7 — centre-align the Hinzufügen button - against the 2-line title block on mobile (< sm). The - `welcomeText` line wraps under the title at < 380 px so the - button used to float at the top of the row without a - baseline anchor; `items-center sm:items-start` keeps the - mobile vertical centre while preserving the original - top-aligned posture on sm+ (where the title is one line). */ -
-
-

- {t("dashboard.title")} -

- {/* v1.16.8 — `min-h-5` reserves the greeting's line box from the - SSR pass on: the text personalises (name appended) on the - first client re-render after hydration, and the reserved - line keeps the header from collapsing/growing around that - swap. */} -

- {welcomeText} -

-
+
+

+ {t("dashboard.title")} +

{/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx new file mode 100644 index 000000000..0084eb4c6 --- /dev/null +++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton"; + +/** + * Structural pin for the hero loading silhouette: the skeleton must + * occupy the EXACT final footprint of `` (same min-h + * floors, same md two-column split, same fixed 120 px ring circle) so + * the loaded band swaps in place with zero layout shift — and it must + * stay invisible to assistive tech with no focusable content. + */ +describe("", () => { + const html = renderToStaticMarkup(); + + it("carries the data-slot and aria-hidden contract", () => { + expect(html).toContain('data-slot="dashboard-hero-skeleton"'); + const root = html.match( + /]*data-slot="dashboard-hero-skeleton"[^>]*>/, + ); + expect(root).not.toBeNull(); + expect(root![0]).toContain('aria-hidden="true"'); + }); + + it("reserves the hero's exact footprint classes", () => { + for (const cls of [ + "min-h-[8.75rem]", + "md:min-h-[9.5rem]", + "hero-gradient", + "rounded-xl", + ]) { + expect(html).toContain(cls); + } + }); + + it("mirrors the two-column md:flex-row split with the fixed ring circle", () => { + expect(html).toContain("md:flex-row"); + expect(html).toContain("size-[120px]"); + expect(html).toContain("rounded-full"); + }); + + it("contains no focusable elements", () => { + expect(html).not.toContain("` contract: + * + * - every verdict variant renders its sentence + the single CTA the + * ladder assigns (link variants carry an href, quick-entry variants + * a button; allQuiet carries none); + * - the briefing rung renders the model headline VERBATIM as plain + * text (no i18n key); + * - the dose row prints the day tally with next-at / all-done / none + * detail, and the documented stale-cache state (past `nextDueAt`, + * `nextDueOverdue: false`) renders as the plain summary — never as + * overdue; + * - the greeting derives from the snapshot's server-computed + * `greetingHour` and personalises from the snapshot username; + * - the right column always paints the `sm` ScoreRing — provisional + * (never "0") when the score is null. + * + * The component reads the wall clock (`new Date()`) to age the cached + * snapshot honestly, so the suite pins the system time; the snapshot's + * own timezone (`Europe/Berlin`) keeps every assertion independent of + * the host zone. + */ + +const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +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 isoHoursFromNow(hours: number): string { + return new Date(NOW.getTime() + hours * 60 * 60 * 1000).toISOString(); +} + +function render( + snapshot: DashboardSnapshot, + opts: { locale?: "en" | "de"; onQuickEntry?: () => void } = {}, +) { + return renderToStaticMarkup( + + undefined)} + /> + , + ); +} + +describe(" — verdict variants", () => { + it("doseOverdue: sentence with the medication name + quick-entry CTA button", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursFromNow(-1), + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain('data-verdict-variant="doseOverdue"'); + expect(html).toContain("Eine Dosis Metformin ist überfällig."); + expect(html).toContain('data-slot="dashboard-hero-cta"'); + expect(html).toContain("Jetzt eintragen"); + // Quick-entry CTA is a real button, not a link. + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { + lastSeenAt: isoHoursFromNow(-2), + daysAgo: 0, + }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="bpCritical"'); + expect(html).toContain("sehr hoch (184/96)"); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain("Blutdruck ansehen"); + }); + + it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => { + const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt, + nextDueMedicationName: "Jardiance", + }), + }), + ); + const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( + nextDueAt, + ); + expect(html).toContain('data-verdict-variant="doseUpcoming"'); + expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`); + expect(html).toContain("Jetzt eintragen"); + // The dose row repeats the same instant as "Nächste um …". + expect(html).toContain(`Nächste um ${expectedTime}`); + }); + + it("weightDrift: sentence + link to the weight insight", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, heightCm: 180 }, + tiles: { + summaries: { + // Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg + // further outside than the 30d mean. + WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }), + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="weightDrift"'); + expect(html).toContain( + "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + ); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain("Gewicht ansehen"); + }); + + it("shortNights: locale-formatted hours + link to the sleep insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + // 372 min = 6.2 h — clearly under the 6.5 h fire line. + SLEEP_DURATION: summary({ avg7: 372, count: 7 }), + }, + lastSeenByType: { + SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="shortNights"'); + // German decimal comma via the locale number formatter. + expect(html).toContain("im Schnitt 6,2 h."); + expect(html).toContain('href="/insights/sleep"'); + expect(html).toContain("Schlaf ansehen"); + }); + + it("silence: day count + quick-entry measurement CTA", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: {}, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="silence"'); + expect(html).toContain("Seit 9 Tagen keine Messung."); + expect(html).toContain("Messung erfassen"); + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("scoreDrop: point delta + link to /insights", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 58, band: "yellow", delta: -14 }, + }), + ); + expect(html).toContain('data-verdict-variant="scoreDrop"'); + expect(html).toContain("liegt 14 Punkte unter der Vorwoche."); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("briefing: renders the model headline verbatim as plain text + Insights link", () => { + const headline = "Systolic crept up over seven days."; + const html = render( + baseSnapshot({ + briefingState: "ready", + briefingStale: false, + briefingUpdatedAt: NOW.toISOString(), + briefing: { + paragraph: "p", + keyFindings: [ + { + tone: "watch", + headline, + detail: "detail", + delta: null, + sourceWindow: "7d", + sourceMetric: "bp", + }, + ], + } as DashboardSnapshot["briefing"], + }), + ); + expect(html).toContain('data-verdict-variant="briefing"'); + expect(html).toContain(headline); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("allQuiet: sentence with NO CTA in the band", () => { + const html = render(baseSnapshot()); + expect(html).toContain('data-verdict-variant="allQuiet"'); + expect(html).toContain("Alles ruhig"); + expect(html).not.toContain('data-slot="dashboard-hero-cta"'); + }); + + it("exactly one CTA renders for an actionable verdict", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 1, + nextDueOverdue: true, + nextDueAt: isoHoursFromNow(-1), + nextDueMedicationName: "Metformin", + }), + healthScore: { score: 50, band: "yellow", delta: -20 }, + }), + ); + const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? []; + expect(ctas).toHaveLength(1); + }); +}); + +describe(" — dose row", () => { + it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), + }), + ); + expect(html).toContain("1 von 3 Dosen genommen"); + const doseRow = html.match( + /]*data-slot="dashboard-hero-doses"[^>]*>/, + ); + expect(doseRow).not.toBeNull(); + for (const cls of [ + "bg-card/65", + "border-border/60", + "rounded-xl", + "shadow-sm", + "backdrop-blur-sm", + ]) { + expect(doseRow![0]).toContain(cls); + } + }); + + it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + skippedToday: 1, + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).toContain("Alle Dosen für heute erledigt"); + expect(html).not.toContain("Nächste um"); + }); + + it("no doses scheduled → 'Heute keine Dosen geplant'", () => { + const html = render(baseSnapshot()); + expect(html).toContain("Heute keine Dosen geplant"); + expect(html).not.toContain("Dosen genommen"); + }); + + it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build + nextDueOverdue: false, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + expect(html).not.toContain("überfällig"); + expect(html).toContain('data-verdict-variant="allQuiet"'); + }); + + it("a next-due anchor on TOMORROW's local day stays off the dose row", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + }); +}); + +describe(" — greeting", () => { + it("personalises from the snapshot username with the server greeting hour", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: "Sam", greetingHour: 8 }, + }), + ); + expect(html).toContain("Guten Morgen Sam, willkommen zurück."); + }); + + it("falls back to the name-less line when the username is blank", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: " ", greetingHour: 20 }, + }), + ); + expect(html).toContain("Guten Abend, willkommen zurück."); + }); + + it("derives the daypart from greetingHour without any client Intl walk", () => { + const day = render( + baseSnapshot({ + user: { ...baseSnapshot().user, greetingHour: 13 }, + }), + ); + expect(day).toContain("Hallo tester, willkommen zurück."); + }); +}); + +describe(" — score column", () => { + it("renders the sm ScoreRing with the score + band", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + }), + ); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-band="green"'); + expect(html).toContain("Health Score"); + }); + + it("null score renders the provisional ring (not 0) at identical geometry", () => { + const html = render(baseSnapshot({ healthScore: null })); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain("Noch nicht genug Daten"); + // The ring announces "not enough data", never a zero value. + const aria = html.match( + /]*data-slot="score-ring"[^>]*aria-label="([^"]+)"/, + ); + expect(aria).not.toBeNull(); + expect(aria![1]).not.toMatch(/\b0\b/); + // Same fixed footprint as the scored state — the column never + // collapses (the 120 px sm geometry rides an inline style). + expect(html).toContain("width:120px"); + }); +}); + +describe(" — source pins", () => { + const src = readFileSync( + join(process.cwd(), "src/components/dashboard/hero/dashboard-hero.tsx"), + "utf8", + ); + + it("quick-entry CTA wires the verdict's target into onQuickEntry", () => { + expect(src).toMatch(/onClick=\{\(\) => onQuickEntry\(cta\.target\)\}/); + }); + + it("re-derives the verdict with a fresh now per snapshot", () => { + expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/); + }); + + it("uses the hero-gradient chrome WITHOUT glow-purple", () => { + expect(src).toContain("hero-gradient"); + expect(src).not.toContain("glow-purple"); + }); +}); diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx new file mode 100644 index 000000000..3b85873cc --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading silhouette for the dashboard hero band. + * + * Mirrors the real `` footprint exactly — same + * `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]` + * floor, same two-column `md:flex-row` split with the fixed 120 px ring + * circle on the right — so the swap to the loaded hero happens in place + * with zero layout shift. The left column reserves the greeting line, + * the verdict sentence + CTA row, and the dose-row pill. + * + * Always `aria-hidden` with no focusable content: the tile-strip + * skeleton alongside carries the page's loading semantics, and a second + * announcement here would double up for screen readers. Reduced motion + * is honoured inside the `` primitive + * (`motion-reduce:animate-none`). + */ +export function DashboardHeroSkeleton() { + return ( + + ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx new file mode 100644 index 000000000..a746951d2 --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -0,0 +1,274 @@ +"use client"; + +/** + * Dashboard hero — the daily-verdict band between the page header and + * the tile strip. + * + * Purely presentational: every input rides in on the already-resolved + * `DashboardSnapshot`, the verdict is re-derived on render with a fresh + * `now` (so a cached snapshot ages honestly), and the only callback is + * the quick-entry opener the page owns. Three blocks: + * + * - left column: greeting line (absorbed from the old header + * paragraph — the snapshot's server-computed `greetingHour` keeps + * the daypart free of any client `Intl` work), the verdict sentence + * with its single CTA button, and the dose row; + * - right column: the shared `` at `sm` geometry. A null + * score renders the ring's provisional state at the identical + * 120 px footprint, so the column never collapses. + * + * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the + * past with `nextDueOverdue: false` means the slot's anchor passed + * after the snapshot was built. The verdict resolver already falls + * through (rung 1 keys on the flag, rung 3 requires a future anchor), + * and the dose row below only prints "next at" for a FUTURE anchor on + * the user's local today — the stale state renders as the plain day + * summary, never as overdue. + * + * Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a + * plain React text child — no i18n key, no HTML, no markdown — per the + * repo-wide no-markdown-renderer rule. + * + * The page h1 stays in ``; this band is supporting + * content, so it renders non-heading text only (matching the Insights + * hero strip, which also carries no heading element). + */ +import { useMemo } from "react"; +import Link from "next/link"; +import { Pill } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScoreRing } from "@/components/insights/derived/score-ring"; +import { + resolveDashboardVerdict, + type DashboardVerdictVariant, +} from "@/lib/dashboard/verdict"; +import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; +import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; +import { cn } from "@/lib/utils"; + +/** + * Variant → message key. `briefing` is deliberately absent — its + * sentence is the model headline rendered verbatim, not a translation. + * The bpCritical rung surfaces under the `bpHigh` copy key. + */ +const VERDICT_MESSAGE_KEY: Record< + Exclude, + string +> = { + doseOverdue: "dashboard.hero.verdict.doseOverdue", + bpCritical: "dashboard.hero.verdict.bpHigh", + doseUpcoming: "dashboard.hero.verdict.doseUpcoming", + weightDrift: "dashboard.hero.verdict.weightDrift", + shortNights: "dashboard.hero.verdict.shortNights", + silence: "dashboard.hero.verdict.silence", + scoreDrop: "dashboard.hero.verdict.scoreDrop", + allQuiet: "dashboard.hero.verdict.allQuiet", +}; + +/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */ +const CTA_LABEL_KEY: Partial> = { + doseOverdue: "dashboard.hero.action.takeDose", + doseUpcoming: "dashboard.hero.action.takeDose", + bpCritical: "dashboard.hero.action.viewBp", + weightDrift: "dashboard.hero.action.viewWeight", + shortNights: "dashboard.hero.action.viewSleep", + silence: "dashboard.hero.action.logMeasurement", + scoreDrop: "dashboard.hero.action.viewInsights", + briefing: "dashboard.hero.action.viewInsights", +}; + +/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */ +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); +} + +export function DashboardHero({ + snapshot, + onQuickEntry, +}: { + snapshot: DashboardSnapshot; + onQuickEntry: (dialog: NonNullable) => void; +}) { + const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); + // Times format in the USER's timezone from the snapshot (the shared + // `useFormatters()` hook falls back to the display default), honouring + // the per-user hour-cycle preference like the medication cards do. + // Only ever invoked post-snapshot, so no pre-paint Intl work. + const fmt = useMemo( + () => makeFormatters(locale, snapshot.user.timezone, timeFormat), + [locale, snapshot.user.timezone, timeFormat], + ); + + // Fresh `now` per snapshot evaluation: the resolver re-derives every + // freshness window against the current instant, so a snapshot served + // from cache ages honestly between refetches. + const verdict = useMemo( + () => resolveDashboardVerdict(snapshot, new Date()), + [snapshot], + ); + + // ── Greeting — absorbed from the old header paragraph ────────────── + // The server-computed wall-clock hour keeps the daypart stable and + // Intl-free; the hero only mounts once the snapshot resolved (the + // skeleton carries the footprint before that), so the personalised + // line can never disagree with SSR output. + const hour = snapshot.user.greetingHour; + const timeGreeting = + hour >= 5 && hour < 12 + ? t("dashboard.greeting.morning") + : hour >= 12 && hour < 18 + ? t("dashboard.greeting.day") + : t("dashboard.greeting.evening"); + const name = snapshot.user.username.trim(); + const welcomeText = + name.length > 0 + ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name }) + : t("dashboard.welcomeBack", { greeting: timeGreeting }); + + // ── Verdict sentence + CTA ────────────────────────────────────────── + const sentence = + verdict.variant === "briefing" + ? // Model headline VERBATIM as a plain text child — no key. + String(verdict.values.headline ?? "") + : t(VERDICT_MESSAGE_KEY[verdict.variant], { + ...verdict.values, + // The resolver emits a fixed 24 h wall-clock string; re-format + // the underlying instant through the user's hour-cycle + // preference when we still hold it. + ...(verdict.variant === "doseUpcoming" && + snapshot.medsToday.nextDueAt !== null + ? { time: fmt.time(snapshot.medsToday.nextDueAt) } + : {}), + ...(verdict.variant === "shortNights" && + typeof verdict.values.hours === "number" + ? { hours: fmt.number(verdict.values.hours, 1) } + : {}), + }); + const cta = verdict.cta; + const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + + // ── Dose row ──────────────────────────────────────────────────────── + const meds = snapshot.medsToday; + const now = new Date(); + const tz = snapshot.user.timezone; + const unresolvedRemain = + meds.scheduledToday > meds.takenToday + meds.skippedToday; + const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN; + // "Next at" only for a FUTURE anchor on the user's local today. A past + // anchor with `nextDueOverdue: false` is the documented stale-cache + // state and must render as the plain summary — never as overdue. + const showNextAt = + unresolvedRemain && + Number.isFinite(nextDueMs) && + nextDueMs >= now.getTime() && + dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz); + const doseDetail = + meds.scheduledToday > 0 + ? !unresolvedRemain + ? t("dashboard.hero.doses.allDone") + : showNextAt + ? t("dashboard.hero.doses.nextAt", { + time: fmt.time(new Date(nextDueMs)), + }) + : null + : null; + + return ( +
+
+
+

+ {welcomeText} +

+
+

+ {sentence} +

+ {cta !== null && ctaLabelKey ? ( + cta.kind === "link" ? ( + + ) : ( + + ) + ) : null} +
+
+
+
+ {/* Right column — fixed 120 px ring footprint. A null score + renders the ring's provisional state (aria announces "not + enough data", never 0) at identical geometry, so the column + NEVER collapses. */} +
+ +
+
+
+ ); +} diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index d6d740bde..71121b0b2 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { DEFAULT_DASHBOARD_LAYOUT, @@ -269,6 +271,76 @@ describe(" — iOS-only id skip (v1.7.0)", () => { }); }); +/** + * Dashboard hero (Tagesüberblick) visibility switch — one toggle above + * the widget list, persisted as `heroVisible` on the layout blob through + * the SAME PUT mutation the widget rows use (the Save button flushes the + * draft). SSR smoke assertions + source pins, matching the rest of this + * suite (no DOM runtime for state mutation). + */ +describe(" — hero visibility switch", () => { + it("renders one hero switch above the widget list", () => { + const html = render(); + const switches = html.match(/data-slot="hero-visible-switch"/g) ?? []; + expect(switches).toHaveLength(1); + // Above the list: the hero switch markup precedes the first widget row. + expect(html.indexOf('data-slot="hero-visible-switch"')).toBeLessThan( + html.indexOf('data-slot="widget-row"'), + ); + }); + + it("labels the switch with the localised copy + description", () => { + const de = render(, "de"); + expect(de).toContain("Tagesüberblick"); + expect(de).toContain( + "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + ); + const en = render(); + expect(en).toContain("Daily overview"); + }); + + it("reflects heroVisible: default layout → checked, false → unchecked", () => { + const checkedHtml = render( + , + ); + const checked = checkedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(checked).not.toBeNull(); + expect(checked![0]).toContain('data-state="checked"'); + + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false }; + const uncheckedHtml = render( + , + ); + const unchecked = uncheckedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(unchecked).not.toBeNull(); + expect(unchecked![0]).toContain('data-state="unchecked"'); + }); + + it("persists through the existing PUT draft flow (source pins)", () => { + const src = readFileSync( + join( + process.cwd(), + "src/components/settings/dashboard-layout-section.tsx", + ), + "utf8", + ); + // The toggle writes the flag onto the layout draft… + expect(src).toMatch( + /setDraft\(\{\s*\.\.\.layout,\s*heroVisible:\s*value\s*\}\)/, + ); + // …which the Save button flushes via the one save mutation + // (`apiPut("/api/dashboard/widgets", …)`) every other control uses. + expect(src).toMatch(/onCheckedChange=\{\(v\) => setHeroVisible\(v\)\}/); + expect(src).toMatch( + /apiPut\("\/api\/dashboard\/widgets", next\)/, + ); + }); +}); + /** * v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the * iOS pin PUT validates them) but have NO web render path, so the web diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 304cbcfe0..0699eb892 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -153,6 +153,8 @@ export function DashboardLayoutSection({ id }: { id: string }) { // tooltip. One hint paragraph is rendered once at the bottom of the list // and referenced by every drag handle in this section. const dragHintId = useId(); + // Label ↔ switch hookup for the hero (Tagesüberblick) toggle below. + const heroToggleId = useId(); // v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space // + arrow-key reordering. The KeyboardSensor still works for users who @@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, comparisonBaseline: value }); } + /** + * Dashboard hero (daily verdict) visibility. Rides the layout blob + * (`heroVisible`, default on) and persists through the same PUT the + * widget toggles use — the Save button flushes it. + */ + function setHeroVisible(value: boolean) { + if (!layout) return; + setDraft({ ...layout, heroVisible: value }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {
)} + {/* Hero (Tagesüberblick) visibility — one switch above the widget + list because the band sits above every widget on the dashboard. + Persists as `heroVisible` on the layout blob through the same + PUT mutation the rows below use (Save flushes the draft). */} + {layout && ( +
+
+ +

+ {t("dashboard.heroToggleDescription")} +

+
+ setHeroVisible(v)} + disabled={saveMutation.isPending} + data-slot="hero-visible-switch" + /> +
+ )} + {isLoading || !layout ? (
diff --git a/src/lib/dashboard/__tests__/verdict-client-safety.test.ts b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts new file mode 100644 index 000000000..199f80bb8 --- /dev/null +++ b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** + * Client-safety pin for the verdict resolver's import graph. + * + * `resolveDashboardVerdict` runs inside the `"use client"` dashboard + * hero, so every VALUE import reachable from `verdict.ts` must stay off + * the server graph: no prisma, no `node:` builtins, no pg-boss, no + * `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull + * `node:async_hooks` — the exact regression class the dose-window + * editor hit when it imported `DOSE_WINDOW_DEFAULTS` from + * `@/lib/analytics/compliance`; see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared + * nudge thresholds therefore live in the dependency-free + * `@/lib/jobs/coach-nudge-thresholds` leaf. + * + * `import type` statements are erased by the compiler AND the bundler, + * so the walker skips them — the `DashboardSnapshot` type import from + * `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly + * as long as it stays type-only. If someone drops the `type` keyword, + * the walker traverses into the snapshot builder and this suite fails. + */ + +const SRC = join(process.cwd(), "src"); +const ENTRY = join(SRC, "lib/dashboard/verdict.ts"); + +/** Specifier patterns that mark a server-only dependency. */ +const BANNED_SPECIFIERS: RegExp[] = [ + /^node:/, + /^@\/generated\//, + /^@\/lib\/db$/, + /^@prisma\//, + /^pg-boss$/, + /^server-only$/, + /^@\/lib\/jobs\/coach-nudge$/, +]; + +/** + * Extract VALUE-import specifiers from a module. Type-only statements + * (`import type … from`, `export type … from`) are erased at build + * time and skipped. Side-effect imports (`import "x"`) are included. + */ +function valueImportSpecifiers(source: string): string[] { + const out: string[] = []; + // Statement-anchored (`^import` / `^export`) and semicolon-bounded + // (`[^;]*?`) so the lazy scan can never swallow across statements or + // latch onto a `from "…"` inside an unrelated string / comment. + const stmt = + /^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; + let m: RegExpExecArray | null; + while ((m = stmt.exec(source)) !== null) { + if (m[4] !== undefined) { + out.push(m[4]); // bare side-effect import + continue; + } + if (m[2] !== undefined) continue; // `import type` / `export type` — erased + out.push(m[3]); + } + return out; +} + +/** Resolve `@/…` and relative specifiers to a file inside `src/`. */ +function resolveLocal(spec: string, fromFile: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; // external package + for (const candidate of [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`unresolvable local import "${spec}" from ${fromFile}`); +} + +function walkGraph(entry: string): { + visited: Set; + specifiers: Set; +} { + const visited = new Set(); + const specifiers = new Set(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) { + specifiers.add(spec); + const local = resolveLocal(spec, file); + if (local !== null) queue.push(local); + } + } + return { visited, specifiers }; +} + +describe("verdict.ts — client-safe import graph", () => { + const { visited, specifiers } = walkGraph(ENTRY); + + it("carries no server-only specifier anywhere in the value-import graph", () => { + const offenders = [...specifiers].filter((spec) => + BANNED_SPECIFIERS.some((re) => re.test(spec)), + ); + expect(offenders).toEqual([]); + }); + + it("never traverses into the snapshot builder (type-only boundary holds)", () => { + const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts"); + expect(visited.has(snapshotModule)).toBe(false); + }); + + it("reads the nudge thresholds from the dependency-free leaf", () => { + expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true); + const leaf = readFileSync( + join(SRC, "lib/jobs/coach-nudge-thresholds.ts"), + "utf8", + ); + expect(valueImportSpecifiers(leaf)).toEqual([]); + }); + + it("the whole graph stays local — no external runtime package rides in", () => { + const external = [...specifiers].filter( + (spec) => !spec.startsWith("@/") && !spec.startsWith("."), + ); + expect(external).toEqual([]); + }); +}); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index f38f439ef..ae6c56cef 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -9,9 +9,11 @@ * Nine rungs, first hit wins. The ordering is a severity ladder: * actionable medication states first, then a fixed-floor BP emergency, * then trend nudges, then the briefing teaser, then the all-quiet - * fallback. Threshold constants are imported from the coach-nudge - * module where one exists so the hero and the nudge cron can never - * disagree on what "drift" or "deficit" means. + * fallback. Threshold constants are imported from the client-safe + * coach-nudge-thresholds leaf where one exists so the hero and the + * nudge cron can never disagree on what "drift" or "deficit" means — + * and so this module never pulls the cron's server graph into a + * client bundle (this resolver runs inside `"use client"` code). * * Freshness is re-derived from the snapshot's ISO timestamps against * the injected `now` (not the snapshot's own `daysAgo` fields) so a @@ -26,7 +28,7 @@ import { COACH_NUDGE_WEIGHT_DRIFT_KG, COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, -} from "@/lib/jobs/coach-nudge"; +} from "@/lib/jobs/coach-nudge-thresholds"; import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; diff --git a/src/lib/jobs/coach-nudge-thresholds.ts b/src/lib/jobs/coach-nudge-thresholds.ts new file mode 100644 index 000000000..8cf107bb9 --- /dev/null +++ b/src/lib/jobs/coach-nudge-thresholds.ts @@ -0,0 +1,19 @@ +/** + * Coach-nudge trend thresholds shared with client-reachable code. + * + * Kept in a leaf module — free of any server-only import — because the + * dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads + * these to stay numerically identical to the nudge cron, and that + * resolver runs inside a `"use client"` component. Importing them from + * `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server + * graph into the browser bundle (the same regression class the + * dose-window defaults hit — see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). + * `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep + * their path. + */ + +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 5b4c4281a..d931e1733 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; +// Trend thresholds shared with the dashboard hero's verdict resolver +// live in a client-safe leaf module; re-exported below so server-side +// imports keep their path. +import { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; + +export { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -109,14 +121,10 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; /** Weight trigger: minimum readings per weekly window. */ export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; -/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ -export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; /** Sleep trigger: minimum recorded nights in the 7-day window. */ export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; /** Sleep trigger: nights under the floor required to fire. */ export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; -/** Sleep trigger: a night must undershoot the floor by this margin (h). */ -export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; /** Gap trigger: distinct active days required in the prior 3 weeks. */ export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10; /** Gap trigger: length of the silent window that fires (days). */ From aaecd141eeaed456b942cc70a5537e2886ce7c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 10:43:32 +0200 Subject: [PATCH 4/7] fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external ingest endpoint was the one intake writer that never attributed to the minted band: it stored a bare row beside the pending slot, so an early take through the API showed as the last intake while the slot kept demanding a dose, the ledger recorded an extra plus a miss, and the day tally counted both. It now resolves the band and converges onto the pending row like every other path, the Telegram confirm and skip buttons do the same preferring the reminder's own slot, and the nightly convergence pass heals the pairs this has already produced — one band match, one live anchor row, both days' rollups recomputed. The cache layer fences in-flight builders on invalidation so a read can neither join a pre-write build nor have a background refresh re-cache a pre-write body as fresh. Ad-hoc rows no longer resolve a different slot through the six-hour radius, a bounded early grace credits a take up to an hour before the window without crossing the previous band, day-scale cadences taken early downgrade the slot-day prompt instead of inviting a double dose, the overdue pill ignores the projector's pending mints, created intakes carry the same plausibility bounds as edits, slot moves keep the recorded injection site and dose, both affected days recompute on a moved toggle, deliberate slot-less skips converge instead of orphaning, the coach reads adherence from the ledger, the cards and quick-add run in the user's timezone with the served next-due as the gate, and schedule-revision, wipe and restore writes invalidate the caches they feed. --- docs/api/openapi.yaml | 7 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../api/admin/backups/[id]/restore/route.ts | 5 + src/app/api/admin/data/route.ts | 5 + .../ingest/medication/__tests__/route.test.ts | 159 ++++++++++++ src/app/api/ingest/medication/route.ts | 87 +++++-- .../[id]/intake/[eventId]/route.ts | 5 + src/app/api/medications/[id]/intake/route.ts | 31 ++- src/app/api/medications/[id]/route.ts | 7 +- .../schedule-revisions/[revisionId]/route.ts | 9 + .../[id]/schedule-revisions/route.ts | 5 + .../api/medications/__tests__/route.test.ts | 60 ++++- src/app/api/medications/intake/bulk/route.ts | 23 +- src/app/api/medications/intake/route.ts | 21 +- src/app/api/medications/route.ts | 22 +- src/app/api/settings/data/route.ts | 5 + .../telegram/webhook/__tests__/route.test.ts | 215 ++++++++++++++++- src/app/api/telegram/webhook/route.ts | 221 +++++++++++++++-- .../medication-intake-quick-add.test.tsx | 59 +++++ .../dashboard/medication-intake-quick-add.tsx | 21 +- .../card-parts/medication-card-body.tsx | 14 +- .../card-parts/medication-status-pill.tsx | 24 ++ .../medications/glp1-medication-card.tsx | 12 +- .../medications/medication-card.tsx | 17 +- .../coach/__tests__/snapshot-budget.test.ts | 54 ++++- .../__tests__/snapshot-new-metrics.test.ts | 3 + src/lib/ai/coach/__tests__/snapshot.test.ts | 3 + src/lib/ai/coach/snapshot.ts | 122 ++++++++-- src/lib/auth/profile-update.ts | 13 +- src/lib/cache/__tests__/server-cache.test.ts | 159 ++++++++++++ src/lib/cache/invalidate.ts | 32 +++ src/lib/cache/server-cache.ts | 122 ++++++++-- .../dashboard/__tests__/meds-today.test.ts | 8 +- src/lib/dashboard/meds-today.ts | 13 +- .../prompt-soft-delete-filter.test.ts | 5 +- .../intake-slot-dedup-converge.test.ts | 226 +++++++++++++++++ .../__tests__/window-status.test.ts | 92 +++++++ src/lib/medications/intake-slot-dedup.ts | 227 +++++++++++++++++- .../scheduling/__tests__/attribution.test.ts | 83 ++++++- .../scheduling/__tests__/next-due.test.ts | 154 +++++++++++- src/lib/medications/scheduling/attribution.ts | 37 ++- src/lib/medications/scheduling/next-due.ts | 82 +++++-- src/lib/medications/scheduling/recurrence.ts | 12 + src/lib/medications/scheduling/slot-upsert.ts | 9 +- src/lib/medications/window-status.ts | 128 +++++++++- src/lib/openapi/routes/medications.ts | 4 +- .../__tests__/medication-intake-event.test.ts | 73 +++++- src/lib/validations/medication.ts | 51 ++-- 53 files changed, 2550 insertions(+), 202 deletions(-) create mode 100644 src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bae350852..b781a8621 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2118,7 +2118,7 @@ paths: summary: List medications for the calling user description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the - count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per + count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache. responses: "200": @@ -4890,6 +4890,7 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. + Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ @@ -8362,7 +8363,9 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - description: Number of intake events scheduled for today (user-local day window). + description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an + explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this + against the passed-dose count, and a pending mint must not read as covered." required: - id - name diff --git a/messages/de.json b/messages/de.json index 5d5fab0d6..bea30b511 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,7 @@ "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", + "takenEarly": "Bereits vorzeitig eingenommen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", "compliance7d": "Therapietreue (7 Tage)", diff --git a/messages/en.json b/messages/en.json index 8f1d22f32..2a398b384 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,7 @@ "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", + "takenEarly": "Already taken early", "overdue": "Overdue", "veryOverdue": "Very overdue", "compliance7d": "Adherence (7 days)", diff --git a/messages/es.json b/messages/es.json index 35af7d077..3d0217e3a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -760,6 +760,7 @@ "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", + "takenEarly": "Ya tomado con antelación", "overdue": "Vencida", "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", diff --git a/messages/fr.json b/messages/fr.json index 98c1c3f2b..a4bdb7f90 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -760,6 +760,7 @@ "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", + "takenEarly": "Déjà pris en avance", "overdue": "En retard", "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", diff --git a/messages/it.json b/messages/it.json index 2a2466464..5358627c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -760,6 +760,7 @@ "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", + "takenEarly": "Già assunto in anticipo", "overdue": "In ritardo", "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", diff --git a/messages/pl.json b/messages/pl.json index 2c8b818c3..72127650e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -760,6 +760,7 @@ "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", + "takenEarly": "Już przyjęto wcześniej", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e8171168f..5299cc133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -536,6 +537,10 @@ const handler = apiHandler( }, }); + // v1.16.9 — the restore replaced every row the owner's cached + // payloads were built on; hard-evict all of that user's buckets. + invalidateUserData(ownerId); + const response: RestoreResponse = { restored: true, summary, diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index 444965d80..06e90123d 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; +import { invalidateAllCaches } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { details: result, }); + // v1.16.9 — the wipe touched every user's rows; clear every cache + // bucket so no per-user payload survives the reset. + invalidateAllCaches(); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index cc2deb55d..629b8c247 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({ }, medicationIntakeEvent: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), + update: vi.fn(), }, medication: { findFirst: vi.fn(), @@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -55,6 +60,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; function postReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/ingest/medication", { @@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => { + // The maintainer's live bug: a daily 09:00 med, the worker pre-minted the + // pending REMINDER row at 09:00 Berlin, the user ingested the take at + // 08:42 Berlin via the external API. The bare create anchored a SECOND + // row at 08:42 and left the 09:00 pending row open — reminder kept + // firing, ledger showed ad-hoc + later missed, today feed said "due". + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + function wireHappyPath() { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + // First findFirst (by name) returns the bare medication; the slot + // resolver's findFirst (selects schedules) returns the projection. + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + where: Record; + }) => { + if (args.where && "name" in args.where) { + return Promise.resolve({ id: "med-1", name: "Metformin" }); + } + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [scheduleRow], + scheduleRevisions: [], + }); + }) as never); + // Idempotency probe → no replay. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); + // The slot's live rows: the worker-minted pending REMINDER row. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + where: { id: string }; + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + } + + it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => { + wireHappyPath(); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-1", + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { id: string } }; + // The pending REMINDER row was updated in place — never a bare create. + expect(body.data.id).toBe("evt-pending-0900"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + // A recorded take clears a prior cron auto-miss. + expect(update.data.autoMissed).toBe(false); + }); + + it("hard-evicts the user's medication caches after the write", async () => { + wireHappyPath(); + await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-2", + }), + ); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("keeps the standalone ad-hoc contract for an off-window take", async () => { + wireHappyPath(); + // 14:30 Berlin — outside the 09:00 band and its late tail. + const offWindow = new Date("2026-06-10T12:30:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "evt-adhoc", + scheduledFor: offWindow, + takenAt: offWindow, + skipped: false, + source: "API", + } as never); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: offWindow.toISOString(), + idempotencyKey: "ha-ingest-3", + }), + ); + expect(res.status).toBe(201); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + const create = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } }; + // Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`). + expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime()); + expect(create.data.takenAt.getTime()).toBe(offWindow.getTime()); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index fe75ffda1..dfeafa5f4 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { NextRequest, NextResponse } from "next/server"; /** @@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API endpoint for this medication is disabled", 403); } - const event = await prisma.medicationIntakeEvent.create({ - data: { + // The external ingest path runs without a User record in scope; + // resolve the timezone once — band attribution and the compliance + // recompute below both anchor on the user's local day. + const ingestUser = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + select: { timezone: true }, + }); + const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE; + + const effectiveTakenAt = takenAt ?? new Date(); + + // v1.16.9 — attribute the take to its scheduled slot by window-band + // membership, the SAME engine the per-medication intake route and the + // read ledger consume. The bare create this replaces anchored every + // ingest at `scheduledFor = takenAt`, leaving the slot's pending + // REMINDER row open: the reminder kept firing, the ledger showed an + // ad-hoc take PLUS a missed slot, and the today feed still said "due". + const attribution = await resolveSlotForWriteByBand({ + userId: apiToken.userId, + medicationId: medication.id, + userTz, + takenAt: effectiveTakenAt, + }); + + let event; + if (attribution.slotInstant) { + // Scheduled dose — converge onto the one canonical slot row (the + // pending REMINDER row when one exists) through the shared upsert: + // H1 deterministic selection, C2 no-downgrade, P2002-safe create. + const applied = await applyCanonicalSlotWrite({ + client: prisma, userId: apiToken.userId, medicationId: medication.id, - scheduledFor: takenAt ?? new Date(), - takenAt: takenAt ?? new Date(), + canonicalSlot: attribution.slotInstant, + takenAt: effectiveTakenAt, skipped: false, - source: "API", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, - }); + createSource: "API", + attributionSource: "AUTO", + }); + event = applied.row; + } else { + // Ad-hoc / PRN / off-window — standalone row under the documented + // contract (`scheduledFor = takenAt`). + event = await prisma.medicationIntakeEvent.create({ + data: { + userId: apiToken.userId, + medicationId: medication.id, + scheduledFor: effectiveTakenAt, + takenAt: effectiveTakenAt, + skipped: false, + source: "API", + idempotencyKey, + }, + }); + } await auditLog("medication.ingest.external", { userId: apiToken.userId, @@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - annotate({ meta: { medication_id: medication.id, event_id: event.id } }); + annotate({ + meta: { + medication_id: medication.id, + event_id: event.id, + slot_resolved: attribution.slotInstant !== null, + }, + }); + + // v1.16.9 — the ingest is an interactive dose record; the next read + // (today feed, card pill, compliance heatmap) must reflect it rather + // than wait out the TTL. + invalidateUserMedications(apiToken.userId, { evict: true }); // v1.4.39 W-MED — refresh the compliance rollup for the ingested - // event's user-day. The external ingest path runs without a User - // record in scope; resolve the tz once for the recompute. - const ingestUser = await prisma.user.findUnique({ - where: { id: apiToken.userId }, - select: { timezone: true }, - }); + // event's user-day. await recomputeMedicationComplianceForEvent({ userId: apiToken.userId, medicationId: medication.id, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 47b93df37..997db221a 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -264,6 +264,11 @@ export const PUT = apiHandler( idempotencyKey: null, createSource: "WEB", attributionSource, + // v1.16.9 — a slot move is a re-binding, not a new dose: the + // original row's recorded injection site and dose override ride + // along onto the converged row instead of being tombstoned away. + injectionSite: event.injectionSite, + doseTaken: event.doseTaken, }); updated = applied.row; } diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index faa5beb26..0fe4beafb 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // Skips carry no binding decision (undefined → column untouched/default). let attributionSource: "AUTO" | "USER_PIN" | undefined; if (skipped) { - canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - instantIsExplicit: scheduledFor !== undefined, - isTakenWrite: false, - }); + if (scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band membership + // on the skip moment, like a take does. The defaulted-now snap only + // reached the tight reminder-grace window, so a skip tapped past it + // landed as an orphan ad-hoc row while the slot's pending REMINDER + // row stayed open to auto-miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; + } else { + canonicalSlot = await resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: true, + isTakenWrite: false, + }); + } } else if (forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 919c01e26..b7c729399 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, @@ -137,7 +138,9 @@ export const GET = apiHandler( { autoMissed: true }, ], }, - select: { scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { scheduledFor: true, takenAt: true }, }); // v1.16.4 — current-era floor: the open-overdue search mints from // the LIVE schedule rows, so it must not reach past the newest @@ -161,7 +164,7 @@ export const GET = apiHandler( now, userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, - resolvedSlots: resolvedRows.map((r) => r.scheduledFor), + resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, }); diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index 10ead9ff6..bc9011a47 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; @@ -249,6 +250,10 @@ export const PATCH = apiHandler( // The corrected era re-segments history; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ @@ -321,6 +326,10 @@ export const DELETE = apiHandler( // History re-segments without the era; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 9eda687b5..c78500192 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,6 +40,7 @@ import { type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; @@ -280,6 +281,10 @@ export const POST = apiHandler( // The new era re-segments history the compliance rollups already // pre-aggregated; refresh them asynchronously. Best-effort like // every other rollup write-hook. + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 69c198e46..c00b3f3b0 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), }, + medicationIntakeEvent: { + groupBy: vi.fn().mockResolvedValue([]), + findMany: vi.fn().mockResolvedValue([]), + }, + medicationScheduleRevision: { + groupBy: vi.fn().mockResolvedValue([]), + }, medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]), }, @@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({ })), })); -import { POST } from "../route"; +import { GET, POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => { expect(res.status).toBe(422); }); }); + +describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => { + it("filters the today-count groupBy to taken-or-skipped rows", async () => { + // The dashboard projector mints a pending row per slot of the day, so + // an unfiltered count covered every passed dose after any dashboard + // visit and the cards' overdue pill went dark. The count read must + // exclude pending rows. + // (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so + // re-wire the pass-through + empty result sets here.) + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-06-10T00:00:00Z"), + end: new Date("2026-06-10T23:59:59Z"), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + // The handler body takes no parameters; `apiHandler` still receives + // the request through the wrapper's rest args. + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + + const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock + .calls; + const countCall = groupByCalls.find( + (c) => (c[0] as { _count?: unknown })._count !== undefined, + ); + expect(countCall).toBeDefined(); + const where = ( + countCall![0] as { + where: { OR?: unknown }; + } + ).where; + expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 8bc18e95b..5a1440f62 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -59,6 +59,7 @@ import { } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { + boundedTakenAtSchema, injectionSiteEnum, type InjectionSiteValue, } from "@/lib/validations/medication"; @@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({ .datetime({ offset: true }) .transform((s) => new Date(s)) .optional(), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — shared plausibility bounds (no future beyond clock skew, + // 5-year floor), matching the single-intake create + edit paths. + takenAt: boundedTakenAtSchema.optional(), skipped: z.boolean().optional().default(false), idempotencyKey: z.string().min(1).max(128).optional(), // v1.8.5 — optional per-entry injection site. Validated against the @@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + } else if (isExplicitSkip && entry.scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band + // membership on the skip moment, exactly like a take does. The + // defaulted-now snap below only reaches the tight reminder-grace + // window, so a skip tapped 20 minutes past the dose time inserted + // an orphan ad-hoc skip row while the slot's pending REMINDER row + // stayed open and later auto-missed — the user's deliberate skip + // recorded as a miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: entry.medicationId, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; } else { // v1.8.2 — source-agnostic anchor snap for pending echoes + skips. // iOS posts the reminder actions here (source API); without this diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 50891c00e..cbc3461a5 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => { isExplicitSkip: false, idempotencyKey: null, createSource: "WEB", - injectionSite: resolvedInjectionSite, + // v1.16.9 — carry the recorded site / dose override across the + // tombstone + re-insert: a slot move is a re-binding, not a new + // dose, so the original row's documentation must survive when the + // toggle write itself carries none. + injectionSite: + resolvedInjectionSite ?? + (existing.injectionSite as InjectionSiteValue | null), + doseTaken: existing.doseTaken, }); updated = applied.row; await prisma.medication.update({ @@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => { scheduledFor: existing.scheduledFor, tz: userTzForHook, }); + // v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant, + // possibly a different local day. Recompute the target day too, or the + // source day self-corrects while the destination keeps its stale tuple. + const movedTo = updated?.scheduledFor; + if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { + await recomputeMedicationComplianceForEvent({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: movedTo, + tz: userTzForHook, + }); + } return apiSuccess(updated); }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 34f8c1418..2da559eaa 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -73,10 +75,17 @@ async function buildMedicationsList( prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], // v1.7.0 sync — exclude tombstoned rows from the today-count map. + // v1.16.9 — count only ACTIONED rows (taken or skipped). The + // dashboard projector mints pending rows for every slot of the + // day, so counting all rows made `todayEventCount` cover every + // passed dose after any dashboard visit — and the cards' + // overdue-pill suppression (`todayEventCount < passedDoseCount`) + // went dark nondeterministically for genuinely overdue doses. where: { userId, deletedAt: null, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + OR: [{ takenAt: { not: null } }, { skipped: true }], }, _count: { id: true }, }), @@ -91,7 +100,11 @@ async function buildMedicationsList( { autoMissed: true }, ], }, - select: { medicationId: true, scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so the ad-hoc shape + // (`scheduledFor === takenAt`) is detectable: such a row must not + // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's + // genuinely-due 20:00 dose). + select: { medicationId: true, scheduledFor: true, takenAt: true }, }), // v1.16.4 — current-era floor per medication: the newest revision's // `validUntil` is where the LIVE schedule rows became valid. The @@ -106,11 +119,12 @@ async function buildMedicationsList( }), ]); - const resolvedSlotsByMedId = new Map(); + const resolvedSlotsByMedId = new Map(); for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(e.scheduledFor); - else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); } const eraStartByMedId = new Map(); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 71235937e..b537b765e 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "settings.data.clear" }, meta: result }); + // v1.16.9 — every cached payload was built on the rows just deleted; + // hard-evict the user's buckets so no surface serves pre-wipe data. + invalidateUserData(userId); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index e33c3622c..036acbb34 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { create: vi.fn(), findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, telegramReminderMessage: { deleteMany: vi.fn(), + findFirst: vi.fn(), }, telegramScheduledDeletion: { createMany: vi.fn(), @@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({ })), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); + +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { answerTelegramCallbackQuery, deleteMessage, @@ -67,10 +79,40 @@ const TG_USER = { id: "user-1", telegramBotToken: "ENC(token-blob)", locale: "en", + timezone: "Europe/Berlin", }; const MEDICATION = { id: "med-1", name: "Ramipril" }; +/** + * The slot-attribution load (`loadAttributeMedication`) selects the full + * schedule projection (distinguishable by `select.schedules`); every other + * `medication.findFirst` in the webhook wants the bare id/name shape. The + * default projection carries no schedules, so the generic dispatch tests + * exercise the ad-hoc (standalone create) path unchanged. + */ +function wireMedicationFindFirst( + schedules: unknown[] = [], + scheduleRevisions: unknown[] = [], +) { + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + select?: Record; + }) => { + if (args?.select && "schedules" in args.select) { + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules, + scheduleRevisions, + }); + } + return Promise.resolve(MEDICATION); + }) as never); +} + beforeEach(() => { vi.resetAllMocks(); process.env.TELEGRAM_WEBHOOK_SECRET = "sekret"; @@ -80,14 +122,18 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); - vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never); + wireMedicationFindFirst(); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({ count: 0, } as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({ count: 0, } as never); @@ -185,15 +231,7 @@ describe("Telegram webhook — callback dispatch", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); - // $transaction is invoked with [create-intake, update-medication]. Inspect - // its argument list for both writes. - expect(prisma.$transaction).toHaveBeenCalledTimes(1); - const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown; - expect(Array.isArray(txOps)).toBe(true); - - // The route calls prisma.medicationIntakeEvent.create + medication.update - // synchronously while building the transaction array, so these mocks - // record the arguments the way it built them. + // No schedules wired → the take is ad-hoc and inserts standalone. expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock .calls[0][0]; @@ -226,7 +264,7 @@ describe("Telegram webhook — callback dispatch", () => { null, ); await POST(tgRequest(callbackUpdate("taken:med-1"))); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // Second call: existing-row lookup hits → no second write. vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ @@ -234,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => { } as never); const res2 = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res2.status).toBe(200); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged }); it("'snooze::60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => { @@ -599,6 +637,159 @@ describe("Telegram webhook — text-message dispatch", () => { }); }); +describe("Telegram webhook — slot convergence (v1.16.9)", () => { + // Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row + // at the canonical slot instant. A Telegram take/skip must converge onto + // that row instead of inserting a second row anchored at `now` — the old + // shape left the pending row open to auto-miss, punishing compliance for + // a dose the user explicitly confirmed. + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_0903_BERLIN); + wireMedicationFindFirst([scheduleRow]); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + + // The pending row was updated in place — never a duplicate insert. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + expect(update.data.autoMissed).toBe(false); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("a take converges via band attribution when no reminder row matches", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + // 09:03 sits inside the 09:00 band → same convergence, no second row. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + expect( + ( + vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as { + where: { id: string }; + } + ).where.id, + ).toBe("evt-pending-0900"); + }); + + it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date | null; skipped: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt).toBeNull(); + expect(update.data.skipped).toBe(true); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("an exotic-zone reminder slot reconstructs on the correct local day", async () => { + // Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is + // 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off. + const slotKiritimati = new Date("2026-06-09T19:00:00.000Z"); + const nowKiritimati = new Date("2026-06-09T19:05:00.000Z"); + vi.setSystemTime(nowKiritimati); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...TG_USER, + timezone: "Pacific/Kiritimati", + } as never); + const pendingRow = { + ...pendingReminderRow, + scheduledFor: slotKiritimati, + }; + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingRow, + ] as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string } }; + expect(update.where.id).toBe("evt-pending-0900"); + }); +}); + describe("Telegram webhook — GET verification", () => { it("returns 200 with a valid header secret", async () => { const req = new NextRequest("http://localhost/api/telegram/webhook", { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index b492fa489..742c2c269 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface TelegramUpdate { update_id: number; @@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) { }); } +/** + * v1.16.9 — the slot a Telegram action should converge onto. + * + * The reminder context knows its own slot: the dispatcher records every + * reminder message as a `TelegramReminderMessage` row carrying the + * local `date` + `timeOfDay` of the dose it fired for. When the action + * callback carries the chat/message ids, that row names the EXACT slot + * — preferred over band attribution, which would orphan a confirmation + * tapped hours after the reminder (band closed → ad-hoc) even though + * the user is answering a specific dose prompt. + * + * Returns the canonical band anchor (validated against the schedule's + * real slots), or `null` when no reminder row matches / the instant is + * not a real slot — the caller then falls back to band attribution. + */ +async function resolveReminderSlot(input: { + userId: string; + medicationId: string; + userTz: string; + chatId: string; + messageId: number; +}): Promise { + const reminder = await prisma.telegramReminderMessage.findFirst({ + where: { + medicationId: input.medicationId, + chatId: input.chatId, + messageId: input.messageId, + }, + select: { date: true, timeOfDay: true }, + }); + if (!reminder) return null; + const instant = reminderLocalInstant( + reminder.date, + reminder.timeOfDay, + input.userTz, + ); + if (!instant) return null; + // Validate against the medication's real band anchors so a stale or + // malformed reminder row can never park a dose on a non-slot instant. + return resolveForcedSlotForWrite({ + userId: input.userId, + medicationId: input.medicationId, + userTz: input.userTz, + slotInstant: instant, + }); +} + +/** + * Mint the UTC instant of local `timeOfDay` on the local calendar day + * `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either + * side of UTC noon so the result is correct in every zone offset, then + * derives the DST-correct instant via `localHmAsUtc`. + */ +function reminderLocalInstant( + date: string, + timeOfDay: string, + tz: string, +): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) { + return null; + } + const [hour, minute] = timeOfDay.split(":").map(Number); + const base = new Date(`${date}T12:00:00.000Z`); + if (Number.isNaN(base.getTime())) return null; + for (const shiftDays of [0, -1, 1]) { + const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000); + const parts = wallClockInTz(ref, tz); + const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String( + parts.day, + ).padStart(2, "0")}`; + if (localDay === date) return localHmAsUtc(ref, tz, hour, minute); + } + return null; +} + async function markMedicationTaken( userId: string, medicationId: string, idempotencyKey: string, locale: Locale, userTz: string | null, + reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); const medication = await prisma.medication.findFirst({ @@ -146,29 +231,77 @@ async function markMedicationTaken( let scheduledFor: Date | null = null; if (!existing) { - scheduledFor = new Date(); - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ + const takenAt = new Date(); + const tz = userTz || DEFAULT_TIMEZONE; + + // v1.16.9 — converge onto the slot's canonical row instead of bare- + // creating a second row anchored at `now`. The old shape left the + // worker-minted pending REMINDER row open: the slot later auto-missed + // and compliance punished a dose the user explicitly confirmed. + // Prefer the reminder's own slot (the message names the dose it fired + // for); fall back to band attribution on the tap instant. + let canonicalSlot: Date | null = reminderRef + ? await resolveReminderSlot({ + userId, + medicationId: medication.id, + userTz: tz, + chatId: reminderRef.chatId, + messageId: reminderRef.messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId, + medicationId: medication.id, + userTz: tz, + takenAt, + }); + canonicalSlot = attribution.slotInstant; + } + + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId, + medicationId: medication.id, + canonicalSlot, + takenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey, + createSource: "REMINDER", + attributionSource: "AUTO", + }); + scheduledFor = applied.row.scheduledFor; + } else { + // Ad-hoc / PRN — standalone row under the documented contract + // (`scheduledFor = takenAt`). + const created = await prisma.medicationIntakeEvent.create({ data: { userId, medicationId: medication.id, - scheduledFor, - takenAt: scheduledFor, + scheduledFor: takenAt, + takenAt, skipped: false, source: "REMINDER", idempotencyKey, }, - }), - prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: null }, - }), - ]); + }); + scheduledFor = created.scheduledFor; + } + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: null }, + }); + // The confirmation must reflect on the next read (today feed, card + // pill, compliance) rather than wait out the cache TTL. + invalidateUserMedications(userId, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the // affected day so the next read after the cache miss reflects the - // new dose. Only fires when the create-path landed a new row. + // new dose. Only fires when the write landed a row. if (scheduledFor) { await recomputeMedicationComplianceForEvent({ userId, @@ -225,6 +358,7 @@ async function handleCallback(update: TelegramUpdate) { idempotencyKey, locale, user.timezone, + messageId !== undefined ? { chatId, messageId } : undefined, ); await answerTelegramCallbackQuery(botToken, callback.id, result.message); if (messageId) { @@ -313,25 +447,68 @@ async function handleCallback(update: TelegramUpdate) { if (!existing) { const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); - skippedScheduledFor = new Date(); + const skipMoment = new Date(); + const tz = user.timezone || DEFAULT_TIMEZONE; - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ + // v1.16.9 — a Telegram skip answers a specific dose prompt, so it + // must converge onto the slot's pending row like a take does. The + // old shape inserted a second row anchored at `now` and left the + // pending REMINDER row open to auto-miss — the user's deliberate + // skip recorded as a miss AND an orphan. Prefer the reminder's own + // slot; fall back to the band the skip moment falls in. + let canonicalSlot: Date | null = + messageId !== undefined + ? await resolveReminderSlot({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + chatId, + messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + takenAt: skipMoment, + }); + canonicalSlot = attribution.slotInstant; + } + + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId: user.id, + medicationId: medication.id, + canonicalSlot, + takenAt: null, + skipped: true, + isExplicitTaken: false, + isExplicitSkip: true, + idempotencyKey, + createSource: "REMINDER", + }); + skippedScheduledFor = applied.row.scheduledFor; + } else { + const created = await prisma.medicationIntakeEvent.create({ data: { userId: user.id, medicationId: medication.id, - scheduledFor: skippedScheduledFor, + scheduledFor: skipMoment, takenAt: null, skipped: true, source: "REMINDER", idempotencyKey, }, - }), - prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: endOfDay }, - }), - ]); + }); + skippedScheduledFor = created.scheduledFor; + } + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: endOfDay }, + }); + invalidateUserMedications(user.id, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the skipped diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index df9042c79..91d882fea 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -116,6 +116,65 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { expect(result).toBe("b"); }); + it("does not pre-select a medication whose server next-due is in the future (v1.16.9)", () => { + // The band alone reads "in window" at 10:00 Berlin, but the server + // says the next unresolved dose is tomorrow (a rolling cadence whose + // dose was taken) — the gate must keep the heuristic off it. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin (UTC+2) + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + nextDueAt: "2026-05-13T06:00:00.000Z", // tomorrow + nextDueOverdue: false, + schedules: [ + { + id: "s1", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + // Nothing genuinely due → alphabetical fallback ("Aaa"). + expect(result).toBe("a"); + }); + + it("does not pre-select a day-scale medication already taken early in its period (v1.16.9)", () => { + // Weekly Tuesday med (2026-05-12 is a Tuesday), taken two days ago. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + lastTakenAt: "2026-05-10T07:00:00.000Z", // Sunday — early take + nextDueAt: "2026-05-12T07:00:00.000Z", // today's slot (unresolved) + nextDueOverdue: true, + schedules: [ + { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "2", + timesOfDay: ["09:00"], + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + expect(result).toBe("a"); + }); + it("falls back to the alphabetical-first active medication when nothing is due", () => { // 03:00 Berlin time: no schedules overlap. The helper should // alphabetically sort and pick the leading id, "ramipril". diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 353156978..d617055af 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -80,6 +80,15 @@ export interface MedicationOption { schedules: Schedule[]; lastTakenAt: string | null; todayEventCount: number | null; + /** + * v1.16.9 — the server display-due verdict from `GET /api/medications` + * (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so + * the quick-add gates its "due now" heuristic exactly like the cards: + * a rolling cadence whose next dose is days away must not pre-select. + * Optional for older mocks; absent keeps the legacy band-only pick. + */ + nextDueAt?: string | null; + nextDueOverdue?: boolean; } interface MedicationIntakeQuickAddProps { @@ -127,6 +136,10 @@ export function pickDefaultMedicationId( const nowBerlin = toBerlinDate(now); const due = actives.find((m) => { + // v1.16.9 — the same server display-due gate the cards apply: a + // future (non-overdue) next-due suppresses the overdue tiers and a + // day-scale dose taken early in its period must not pre-select. + const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, nowBerlin, @@ -135,8 +148,14 @@ export function pickDefaultMedicationId( active: true, lastTakenAt: m.lastTakenAt, todayEventCount: m.todayEventCount ?? 0, + nextDue: + m.nextDueAt === undefined + ? undefined + : Number.isFinite(nextDueMs) + ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } + : null, }); - return status.status !== null; + return status.status !== null && !status.takenEarly; }); if (due) return due.id; diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index bc695e3b7..7161bd33d 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -71,6 +71,8 @@ export interface MedicationCardBodyProps { status: Exclude; windowStart: string; windowEnd: string; + /** v1.16.9 — day-scale early-take context (see the pill). */ + takenEarly?: boolean; } | null; /** @@ -178,7 +180,17 @@ export function MedicationCardBody({ overdue tier arrives with the compliance query, and a line popping in late used to shift every row below it (CLS). */}
- {overdueLabel ? ( + {windowStatus?.takenEarly ? ( + // v1.16.9 — taken-early context outranks the overdue line: a + // day-scale dose already taken earlier in its period must never + // escalate into a take prompt (double-dose risk). + + ) : overdueLabel ? (

+ + + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + +

+ ); + } + return (

x + 1, 0); @@ -288,7 +293,7 @@ export function Glp1MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(now); + const nowBerlin = toZonedDate(now, userTz); const lateMinutes = thresholds?.lateMinutes ?? 120; const missedMinutes = thresholds?.missedMinutes ?? 240; const currentWindowStatus = reduceCurrentWindowStatus({ @@ -299,6 +304,7 @@ export function Glp1MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next injection is days away can never paint an overdue // pill today. `undefined` (older payloads / fixtures) keeps legacy @@ -319,6 +325,7 @@ export function Glp1MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now, + timeZone: userTz, }); // v1.15.9 — the recent-injection list now feeds ONLY the post-dose @@ -439,6 +446,7 @@ export function Glp1MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarly: currentWindowStatus.takenEarly, } : null } diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index af72a7006..3edb02c54 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { reduceCurrentWindowStatus, - toBerlinDate, + toZonedDate, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -26,6 +26,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; +import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface Schedule { @@ -104,6 +105,11 @@ export function MedicationCard({ }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); + // v1.16.9 — the card reasons in the PROFILE timezone (the snapshot the + // session carries); Berlin stays the last-resort fallback so logged-out + // mounts and legacy fixtures behave unchanged. + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); // v1.8.5 — post-dose injection-site prompt state. Holds the intake @@ -199,7 +205,7 @@ export function MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(new Date()); + const nowBerlin = toZonedDate(new Date(), userTz); // v1.8.4 — the next-due instant comes from the server (`nextDueAt`, // computed by the canonical recurrence engine anchored on the last // intake). The day label below derives from it; the window-range / @@ -224,6 +230,7 @@ export function MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next dose is tomorrow can never paint an overdue pill // today. `undefined` (older payloads / fixtures) keeps legacy behaviour. @@ -244,6 +251,7 @@ export function MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now: new Date(), + timeZone: userTz, }); function formatLastTakenAt(value: string): string { @@ -251,7 +259,7 @@ export function MedicationCard({ // string-comparable for the today / yesterday / older bucketing below. // The actual user-facing display goes through formatTime / formatDateTime. const dayFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Berlin", + timeZone: userTz, year: "numeric", month: "2-digit", day: "2-digit", @@ -314,7 +322,7 @@ export function MedicationCard({ // Format day label relative to today let dayLabel = ""; if (nextAt) { - const nextDate = toBerlinDate(new Date(nextAt)); + const nextDate = toZonedDate(new Date(nextAt), userTz); const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; const tomorrow = new Date(nowBerlin); @@ -381,6 +389,7 @@ export function MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarly: currentWindowStatus.takenEarly, } : null } diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 063506be0..97ba155be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -67,6 +68,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -170,12 +172,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { score: 3 + (i % 3), })), ); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue( - Array.from({ length: 360 }, (_, i) => ({ - scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - skipped: false, - })), + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + // v1.16.9 — the compliance timeline derives from the band-engine + // ledger now; the snapshot loads medications (schedules + window + // events) instead of raw intake rows. + // Branch on the caller: the GLP-1 block filters on + // `treatmentClass: "GLP1"` (no GLP-1 therapy in this fixture); the + // compliance loader reads every medication. + const complianceMedRows = [ + { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000), + schedules: [ + { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + scheduleRevisions: [], + intakeEvents: Array.from({ length: 360 }, (_, i) => ({ + scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + })), + }, + ]; + prismaMock.medication.findMany.mockImplementation( + (args: { where?: { treatmentClass?: string } }) => + Promise.resolve( + args?.where?.treatmentClass === "GLP1" ? [] : complianceMedRows, + ), ); prismaMock.workout.findMany.mockResolvedValue( Array.from({ length: 80 }, (_, i) => ({ @@ -295,6 +336,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null, diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index f3d565677..df2c12a89 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -26,6 +27,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -60,6 +62,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => __resetCoachSnapshotCacheForTests(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default // null = legacy defaults so the existing test fixtures stay diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index a8bfa305a..f7725f414 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, })); @@ -36,6 +37,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; const featuresMock = extractFeatures as unknown as ReturnType; @@ -68,6 +70,7 @@ describe("buildCoachSnapshot", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply // per-user excludeMetrics. Default null = use legacy defaults. prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a144309cc..651488c0f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -35,6 +35,13 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { + buildComplianceLedgerRows, + buildComplianceMedicationContext, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, @@ -753,12 +760,46 @@ async function buildCoachSnapshotImpl( select: { moodLoggedAt: true, score: true }, }) : null; - const intakeRowsPromise = wantsCompliance - ? prisma.medicationIntakeEvent.findMany({ - // Tombstoned intake rows must never reach the Coach snapshot. - where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } }, - orderBy: { scheduledFor: "asc" }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + // v1.16.9 — the adherence timeline derives from the LEDGER tally (the + // same band engine the compliance % + dose history consume), not from a + // raw intake-row count. The raw count read a worker-minted pending row + // as "not taken" (today's later doses dragged the day's rate down all + // morning) and double-counted cross-source duplicate rows on one slot. + // Load each medication's schedules + eras + window events so the ledger + // can be reconstructed per medication. + const complianceMedsPromise = wantsCompliance + ? prisma.medication.findMany({ + where: { userId }, + select: { + id: true, + startsOn: true, + endsOn: true, + oneShot: true, + createdAt: true, + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, + intakeEvents: { + // Tombstoned intake rows must never reach the Coach snapshot. + where: { deletedAt: null, scheduledFor: { gte: cutoff } }, + orderBy: { scheduledFor: "asc" }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + attributionSource: true, + }, + }, + }, }) : null; const sleepRowsPromise = sources.has("sleep") @@ -826,7 +867,7 @@ async function buildCoachSnapshotImpl( const [ moodRows, - intakeRows, + complianceMeds, sleepRows, workoutRows, glp1Block, @@ -836,7 +877,7 @@ async function buildCoachSnapshotImpl( cycleBlock, ] = await Promise.all([ moodRowsPromise, - intakeRowsPromise, + complianceMedsPromise, sleepRowsPromise, workoutRowsPromise, glp1BlockPromise, @@ -939,31 +980,60 @@ async function buildCoachSnapshotImpl( registerBlock("mood", "mood"); } - if (wantsCompliance && intakeRows) { - // Medication compliance lives outside the structured features - // today — the legacy Coach surface labelled it as "general" - // provenance only. v1.4.20.1 ships a per-day adherence row built - // from the intake-event log so the Coach can answer "did I miss - // my dose on Tuesday?" without inventing the schedule. The read is - // issued in parallel above. - if (intakeRows.length > 0) { - // Per-day adherence rate within the recent window. Older days - // collapse into a single weekly bucket. - const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff); - const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff); + if (wantsCompliance && complianceMeds) { + // Medication compliance lives outside the structured features today — + // the legacy Coach surface labelled it as "general" provenance only. + // v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from + // the band-engine LEDGER (the same expansion the compliance % and the + // dose-history view consume): a slot counts against the rate only once + // it is genuinely missed, a pending/upcoming slot never reads as "not + // taken", deliberate skips and ad-hoc takes stay out of the + // denominator, and cross-source duplicate rows collapse onto one slot. + const ledgerRows: DoseHistoryRow[] = []; + for (const med of complianceMeds) { + if (med.schedules.length === 0) continue; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(med.intakeEvents), + userTz, + ); + ledgerRows.push( + ...buildComplianceLedgerRows( + med.intakeEvents, + med.schedules, + ctx, + cutoff, + now, + now, + ), + ); + } + // Countable rows: taken (on-time or late) or genuinely missed. The + // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. + const countable = ledgerRows.filter( + (r) => + r.status === "taken_on_time" || + r.status === "taken_late" || + r.status === "missed", + ); + if (countable.length > 0) { + const recent = countable.filter((r) => r.at >= recentCutoff); + const olderRows = countable.filter((r) => r.at < recentCutoff); + const isTaken = (r: DoseHistoryRow) => + r.status === "taken_on_time" || r.status === "taken_late"; const recentByDay = new Map< string, { date: Date; total: number; taken: number } >(); for (const r of recent) { - const key = tzDayKey(r.scheduledFor, userTz); + const key = tzDayKey(r.at, userTz); const e = recentByDay.get(key) ?? { - date: r.scheduledFor, + date: r.at, total: 0, taken: 0, }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; recentByDay.set(key, e); } const recentRows = Array.from(recentByDay.entries()) @@ -977,10 +1047,10 @@ async function buildCoachSnapshotImpl( .sort((a, b) => a.date.localeCompare(b.date)); const olderByWeek = new Map(); for (const r of olderRows) { - const key = isoWeekKey(r.scheduledFor, userTz); + const key = isoWeekKey(r.at, userTz); const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; olderByWeek.set(key, e); } const weeklyRows = Array.from(olderByWeek.entries()) @@ -995,7 +1065,7 @@ async function buildCoachSnapshotImpl( timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance"); - counts.compliance = intakeRows.length; + counts.compliance = countable.length; registerBlock("compliance", "compliance"); } else { // v1.7.0 — toggled-on cluster with no rows. Annotate so the diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 7b531e2ce..e38410022 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -8,7 +8,10 @@ import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; import { isValidTimezone } from "@/lib/tz/format"; import { encrypt } from "@/lib/crypto"; -import { invalidateUserProfile } from "@/lib/cache/invalidate"; +import { + invalidateUserMedications, + invalidateUserProfile, +} from "@/lib/cache/invalidate"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -168,6 +171,14 @@ export async function applyProfileUpdate( invalidateUserProfile(userId); } + // v1.16.9 — the medication payloads (list next-due, today counts, the + // compliance day buckets) are computed in the user's timezone, but the + // list cache key carries only the userId. A timezone change must evict + // the bucket or the next read serves the previous zone's day grid. + if (data.timezone !== undefined) { + invalidateUserMedications(userId, { evict: true }); + } + await auditLog("profile.update", { userId: updatedUser.id, ipAddress: ipAddress ?? null, diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts index 12664fbb2..6a0ea86b6 100644 --- a/src/lib/cache/__tests__/server-cache.test.ts +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => { }); }); +describe("ServerCache invalidation vs in-flight builders", () => { + it("a read right after an evict does NOT join the pre-write builder", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let releaseFirst: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + releaseFirst = r; + }); + } + return Promise.resolve("post-write"); + }); + + // Builder 1 starts (reads pre-write state) and is still in flight… + const first = cache.wrap("u1|list", builder); + // …when a write evicts the key. + cache.delete("u1|list"); + + // The next read must start a FRESH build, not join the stale one. + const second = await cache.wrap("u1|list", builder); + expect(second.outcome).toBe("miss"); + expect(second.value).toBe("post-write"); + expect(builder).toHaveBeenCalledTimes(2); + + // The detached pre-write builder resolves AFTER the fresh one — its + // commit must be discarded, never overwriting the post-write entry. + releaseFirst!("pre-write"); + const firstResult = await first; + expect(firstResult.value).toBe("pre-write"); + expect(cache.get("u1|list")).toBe("post-write"); + }); + + it("deleteByPrefix fences an in-flight builder the same way", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + const builder = vi.fn( + () => + new Promise((r) => { + release = r; + }), + ); + const inFlight = cache.wrap("u1|compliance", builder); + cache.deleteByPrefix("u1|"); + release!("pre-write"); + await inFlight; + // The pre-write result never entered the cache. + expect(cache.get("u1|compliance")).toBeNull(); + }); + + it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|list", builder); + vi.advanceTimersByTime(1500); + + // Stale read kicks off the detached background refresh (pre-write). + const stale = await cache.wrapSwr("u1|list", builder); + expect(stale.outcome).toBe("stale"); + + // A write evicts the bucket while the refresh is still in flight… + cache.deleteByPrefix("u1|"); + // …then the pre-write refresh completes. Without the fence it would + // re-insert the pre-write body as FRESH for the full TTL. + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + expect(cache.get("u1|list")).toBeNull(); + const next = await cache.wrapSwr("u1|list", builder); + expect(next.outcome).toBe("miss"); + expect(next.value).toBe("post-write"); + }); + + it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|k", builder); + vi.advanceTimersByTime(1500); + const stale = await cache.wrapSwr("u1|k", builder); + expect(stale.outcome).toBe("stale"); + + // Background write marks the bucket stale mid-refresh; the pre-write + // refresh result must not land as a fresh entry. + cache.markStaleByPrefix("u1|"); + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + // SWR semantics hold: the prior value still serves stale and a NEW + // (post-write) recompute is kicked off. + const next = await cache.wrapSwr("u1|k", builder); + expect(next.outcome).toBe("stale"); + expect(next.value).toBe("v1"); + await vi.runAllTimersAsync(); + expect(builder).toHaveBeenCalledTimes(3); + const warm = await cache.wrapSwr("u1|k", builder); + expect(warm.outcome).toBe("hit"); + expect(warm.value).toBe("post-write"); + }); + + it("invalidation between two stampeding readers gives the later reader fresh data", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + release = r; + }); + } + return Promise.resolve("fresh"); + }); + + const a = cache.wrap("k", builder); // pre-write build in flight + cache.delete("k"); // write + const b = cache.wrap("k", builder); // post-write read → fresh build + release!("stale"); + const [ra, rb] = await Promise.all([a, b]); + expect(ra.value).toBe("stale"); // joined its own (pre-write) build + expect(rb.value).toBe("fresh"); + expect(cache.get("k")).toBe("fresh"); + }); +}); + describe("global cache registry", () => { it("exposes the eight blueprint caches as module-scope singletons", () => { expect(caches.analytics).toBeDefined(); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index c099b2e09..140769403 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void { caches.insightsLayout.deleteByPrefix(userId); } +/** + * v1.16.9 — full per-user cache eviction for the wipe / restore paths. + * A data wipe or a backup restore replaces (or removes) every row the + * cached payloads were built on, so every per-user bucket hard-evicts — + * a marked-stale SWR cell would keep serving the pre-wipe body. + */ +export function invalidateUserData(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.medications.deleteByPrefix(userId); + caches.medicationCompliance.deleteByPrefix(`${userId}|`); + caches.medicationsIntake.deleteByPrefix(`${userId}|`); + caches.achievements.deleteByPrefix(userId); + caches.insightsTargets.deleteByPrefix(userId); + caches.insightsDerived.deleteByPrefix(`${userId}|`); + caches.moodAnalytics.deleteByPrefix(userId); + caches.moodInsights.deleteByPrefix(userId); + caches.workouts.deleteByPrefix(`${userId}|`); + caches.dashboardWidgets.deleteByPrefix(userId); + caches.insightsLayout.deleteByPrefix(userId); +} + +/** + * v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache + * empties; the next read per user is a cold build over the (now empty / + * restored) tables. Acceptable for an operation this rare. + */ +export function invalidateAllCaches(): void { + for (const cache of Object.values(caches)) { + cache.deleteByPrefix(""); + } +} + /** * Global eviction of the bug-report-status cache. * diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 3afdd06fa..c19601b2a 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -67,12 +67,30 @@ export interface ServerCacheStats { readonly stampedes: number; } +/** + * v1.16.9 — single-flight slot with invalidation fencing. A builder that + * was in flight when an invalidation (`delete` / `deleteByPrefix` / + * `markStaleByPrefix`) hit its key must NOT commit its result: the build + * read pre-write data, and committing it after the eviction would + * re-cache the pre-write body as fresh for the full TTL. The slot is the + * fence — invalidation flips `invalidated` and detaches the slot from + * `pending`, so (a) the builder's `.then` skips the `set()` and (b) the + * next read starts a fresh build instead of joining the stale one. + * Callers already awaiting the detached promise still receive its value + * (equivalent to having read a moment before the write); it just never + * enters the cache. + */ +interface PendingBuild { + promise: Promise; + invalidated: boolean; +} + export class ServerCache { private readonly map = new Map< string, { expiresAt: number; staleUntil: number; value: T } >(); - private readonly pending = new Map>(); + private readonly pending = new Map>(); private hits = 0; private misses = 0; private evictions = 0; @@ -80,6 +98,59 @@ export class ServerCache { constructor(private readonly opts: ServerCacheOptions) {} + /** + * Fence + detach the in-flight builder for one key (no-op when none). + * Part of every invalidation op — see `PendingBuild`. + */ + private detachPending(key: string): void { + const slot = this.pending.get(key); + if (slot) { + slot.invalidated = true; + this.pending.delete(key); + } + } + + /** Fence + detach every in-flight builder under `prefix`. */ + private detachPendingByPrefix(prefix: string): void { + for (const [key, slot] of this.pending) { + if (key.startsWith(prefix)) { + slot.invalidated = true; + this.pending.delete(key); + } + } + } + + /** + * Register a fenced single-flight build for `key`. The commit only + * lands when no invalidation hit the key while the builder ran; the + * `finally` only clears the slot it owns (an invalidation may already + * have detached it and a newer build may occupy the key). + */ + private startBuild( + key: string, + builder: () => Promise, + ttlMsOverride?: number, + ): PendingBuild { + const slot: PendingBuild = { + promise: undefined as unknown as Promise, + invalidated: false, + }; + slot.promise = builder() + .then((value) => { + if (!slot.invalidated) { + this.set(key, value, ttlMsOverride); + } + return value; + }) + .finally(() => { + if (this.pending.get(key) === slot) { + this.pending.delete(key); + } + }); + this.pending.set(key, slot); + return slot; + } + /** * Read a key. Returns null on miss, expired entry, or absent entry. * Side effect: an expired entry is removed; a live entry is @@ -178,6 +249,12 @@ export class ServerCache { * behaviour). */ markStaleByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders too: a refresh that started + // before the write would otherwise complete after this mark and + // re-cache the pre-write body as FRESH for the full TTL. Detaching + // keeps the SWR contract — the marked entry still serves stale, and + // the next read kicks off a fresh (post-write) recompute. + this.detachPendingByPrefix(prefix); let marked = 0; const now = Date.now(); for (const [key, entry] of this.map) { @@ -191,6 +268,9 @@ export class ServerCache { /** Single-key eviction. Returns true when the key was present. */ delete(key: string): boolean { + // v1.16.9 — fence the in-flight builder so a pre-write build can + // neither be joined by post-evict reads nor commit its result. + this.detachPending(key); return this.map.delete(key); } @@ -203,6 +283,8 @@ export class ServerCache { * possible cache-key variant the user might have warmed. */ deleteByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders under the prefix (see `delete`). + this.detachPendingByPrefix(prefix); let removed = 0; for (const key of this.map.keys()) { if (key.startsWith(prefix)) { @@ -238,21 +320,13 @@ export class ServerCache { const inFlight = this.pending.get(key); if (inFlight) { this.stampedes += 1; - const value = await inFlight; + const value = await inFlight.promise; return { value, outcome: "stampede" }; } this.misses += 1; - const promise = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, promise); - const value = await promise; + const slot = this.startBuild(key, builder, ttlMsOverride); + const value = await slot.promise; return { value, outcome: "miss" }; } @@ -288,20 +362,11 @@ export class ServerCache { // single-flight slot so concurrent stale reads share one rebuild. this.hits += 1; if (!this.pending.has(key)) { - const refresh = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .catch((err) => { - // The stale value already went out; don't crash the worker. - // Re-throw so the join path of any awaiting caller sees it. - throw err; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, refresh); + // v1.16.9 — the background refresh runs through the same fenced + // single-flight slot as `wrap`, so an invalidation that lands + // while it is in flight discards its (pre-write) result instead + // of re-caching it as fresh. + const refresh = this.startBuild(key, builder, ttlMsOverride).promise; // Detach: the foreground caller does not await the rebuild. // Handle the rejection on the detached handle so an // unhandled-rejection warning never fires — but never silently: @@ -344,6 +409,11 @@ export class ServerCache { /** Reset every counter + entry. Test-only escape hatch. */ __resetForTests(): void { this.map.clear(); + // Fence any in-flight builder so a build crossing a test boundary + // cannot commit into the next test's clean cache. + for (const slot of this.pending.values()) { + slot.invalidated = true; + } this.pending.clear(); this.hits = 0; this.misses = 0; diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index ec0266f46..8d738d11a 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -77,7 +77,11 @@ function medication( let callOrder: string[]; let medications: Array>; let todayRows: FakeIntakeRow[]; -let resolvedRows: Array<{ medicationId: string; scheduledFor: Date }>; +let resolvedRows: Array<{ + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; +}>; let latestIntakeGroups: Array<{ medicationId: string; _max: { takenAt: Date | null }; @@ -298,6 +302,8 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { { medicationId: "med-1", scheduledFor: new Date("2026-06-10T09:00:00.000Z"), + // Slot-anchored take (attributed): takenAt differs from the anchor. + takenAt: new Date("2026-06-10T09:05:00.000Z"), }, ]; diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 4f501cb3f..1d7745c3c 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -39,6 +39,8 @@ import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/pr import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; @@ -136,7 +138,9 @@ export async function buildMedsTodayBlock( { autoMissed: true }, ], }, - select: { medicationId: true, scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { medicationId: true, scheduledFor: true, takenAt: true }, }), // Current-era floor per medication: the newest revision's // `validUntil` is where the LIVE schedule rows became valid; the @@ -157,11 +161,12 @@ export async function buildMedsTodayBlock( const lastTakenAtByMedId = new Map( latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), ); - const resolvedSlotsByMedId = new Map(); + const resolvedSlotsByMedId = new Map(); for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(e.scheduledFor); - else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); } const eraStartByMedId = new Map(); for (const f of eraFloors) { diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index 8e2778214..f37ced16d 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -40,9 +40,12 @@ describe("prompt reads exclude tombstoned rows", () => { }); it("the Coach snapshot filters its intake read", () => { + // v1.16.9 — the compliance timeline reads intake events through the + // medication relation (`intakeEvents`) so the ledger can be rebuilt + // per medication; the tombstone filter must ride on that nested read. const src = read("../../ai/coach/snapshot.ts"); const intakeRead = src.match( - /medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/, + /intakeEvents:\s*\{[\s\S]{0,300}?where:\s*\{[^}]*\}/, ); expect(intakeRead).not.toBeNull(); expect(intakeRead![0]).toContain("deletedAt: null"); diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts new file mode 100644 index 000000000..ef78d293c --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -0,0 +1,226 @@ +/** + * v1.16.9 — ad-hoc→pending band convergence in the nightly dedup. + * + * The production damage this heals: the external-API and Telegram write + * paths historically bare-created `scheduledFor = takenAt`, leaving the + * worker-minted pending REMINDER row open. The legacy snap pass only + * reaches a ± half-window tolerance and the discovery query only matched + * 2-minute drift, so an 08:42 API take beside its pending 09:00 row + * never collapsed — the ledger showed an ad-hoc take PLUS a missed slot + * for one real dose. + * + * Covered here: + * 1. The 08:42 / 09:00 pair heals: the pending row tombstones, the + * take normalises onto the canonical anchor. + * 2. A take outside the legacy snap tolerance but inside the band's + * late tail still heals (the case the snap pass can never reach). + * 3. An ambiguous double-slot (two live rows on the anchor) is left + * alone by the convergence pass. + * 4. A WEB ad-hoc row (deliberate user shape) is never converged. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +// Daily 09:00 Berlin med. 09:00 CEST on 2026-06-10 = 07:00:00Z. +const SLOT_0900 = new Date("2026-06-10T07:00:00.000Z"); +const TAKEN_0842 = new Date("2026-06-10T06:42:00.000Z"); +// 12:30 Berlin — outside the ±2h legacy snap tolerance of the 09:00 +// anchor, but inside the band's late tail (on-time end 10:00 + 180 min). +const TAKEN_1230 = new Date("2026-06-10T10:30:00.000Z"); + +const SCHEDULE = { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], + scheduleRevisions: [], +}; + +function pendingRow(over: Record = {}) { + return { + id: "r-pending", + medicationId: "med-1", + scheduledFor: SLOT_0900, + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-10T05:00:00Z"), + attributionSource: "AUTO", + source: "REMINDER", + ...over, + }; +} + +function adhocRow(at: Date, over: Record = {}) { + return { + id: "r-adhoc", + medicationId: "med-1", + scheduledFor: at, + takenAt: at, + skipped: false, + syncVersion: 1, + createdAt: at, + attributionSource: "AUTO", + source: "API", + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); +}); + +describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => { + it("heals the 08:42 API take / pending 09:00 REMINDER pair", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_0842), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + // The pending row tombstones; the take is the dose of record. + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { + where: { id: string }; + data: { deletedAt: Date }; + }; + expect(tombstone.where.id).toBe("r-pending"); + expect(tombstone.data.deletedAt).toBeInstanceOf(Date); + + // The take normalises onto the canonical 09:00 anchor. + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { scheduledFor: Date }; + }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1); + }); + + it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { where: { id: string } }; + expect(tombstone.where.id).toBe("r-pending"); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + }); + + it("leaves an ambiguous double-slot (two live rows on the anchor) alone", async () => { + // Two live rows already sit on the 09:00 anchor — converging the take + // would have to pick one blindly. The convergence pass declines; the + // take stays where it is. (The legacy exact-instant collapse of the + // two anchor rows is a separate, pre-existing pass.) + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + pendingRow({ id: "r-pending-2", source: "API" }), + ] as never); + + await dedupeUserIntakeSlots("u1"); + + // The ad-hoc take was never normalised onto the anchor. + const normaliseCalls = vi + .mocked(prisma.medicationIntakeEvent.update) + .mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id); + expect(normaliseCalls).not.toContain("r-adhoc"); + }); + + it("never converges a WEB ad-hoc row (deliberate user shape)", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { source: "WEB" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(summary.rowsNormalised).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("never converges a USER_PIN released row", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { attributionSource: "USER_PIN" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 23dcb806f..4d2dd09d4 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -365,3 +365,95 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { expect(res.status).toBeNull(); }); }); + +/** + * v1.16.9 — day-scale early-take downgrade. A weekly injectable taken two + * days before its slot day is already on board; on the slot day the + * in-window pill must carry taken-early context instead of prompting a + * full take (a "take now" there is a double-dose prompt). + */ +describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { + // Weekly Wednesday 09:00 (2026-06-10 is a Wednesday). daysOfWeek "3" = + // Wednesday in the legacy CSV encoding. + const weeklyWedSchedule = { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "3", + timesOfDay: ["09:00"], + }; + // Monday 2026-06-08, 08:00 Berlin (CEST) — two days before the slot day. + const MONDAY_TAKE = "2026-06-08T06:00:00.000Z"; + + it("flags takenEarly on the slot day after a Monday take (Mon-take/Wed-slot)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(true); + }); + + it("does not flag takenEarly when the last take was a full period ago", () => { + // Previous Wednesday — the prior cycle's regular dose. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-03T07:05:00.000Z", + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(false); + }); + + it("does not flag a daily cadence (minute-scale periods stay untouched)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [ + { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + }, + ], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(false); + }); + + it("rides on the late tier too — an early take must not escalate to overdue", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(11, 30), // past the band + inside lateMinutes + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("late"); + expect(res.takenEarly).toBe(true); + }); + + it("evaluates the early-take day in the supplied timezone", () => { + // Pacific/Auckland (UTC+12): the same instant lands on different local + // days in Berlin vs Auckland near midnight. 2026-06-08T13:00:00Z is + // 2026-06-09 01:00 in Auckland (one day before the Wed slot day) — + // still inside the weekly period → takenEarly. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock + lastTakenAt: "2026-06-08T13:00:00.000Z", + todayEventCount: 0, + tz: "Pacific/Auckland", + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(true); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index b5e06713d..8f2004853 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -49,6 +49,11 @@ import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import { + buildMedicationDayBands, + type AttributeIntakeMedication, +} from "@/lib/medications/scheduling/attribute-intake"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { WorkerMedicationRow, WorkerScheduleRow, @@ -89,6 +94,8 @@ type DedupMedication = WorkerMedicationRow & { userId: string; timezone: string; schedules: WorkerScheduleRow[]; + /** v1.16.9 — archived eras so the band convergence honours old times. */ + scheduleRevisions: ScheduleRevisionLike[]; }; interface DedupIntakeRow { @@ -105,6 +112,13 @@ interface DedupIntakeRow { * ad-hoc). Such a row never joins a snap cluster. */ attributionSource: string; + /** + * v1.16.9 — write origin. The ad-hoc→pending band convergence only + * touches API / REMINDER rows (external ingest + the Telegram webhook, + * the two paths that historically bare-created `scheduledFor = + * takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape. + */ + source: string; } /** @@ -197,6 +211,21 @@ export async function dedupeUserIntakeSlots( scheduleType: true, cyclicOnWeeks: true, cyclicOffWeeks: true, + // v1.16.9 — the band convergence honours the persisted per-dose + // windows (the same lever the write path + read ledger consume). + doseWindows: true, + }, + }, + // v1.16.9 — archived eras: an old ad-hoc row converges against the + // schedule that was live at its takenAt, not today's times. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, }, }, }, @@ -235,12 +264,33 @@ export async function dedupeUserIntakeSlots( createdAt: true, // v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster. attributionSource: true, + // v1.16.9 — write origin for the ad-hoc→pending convergence gate. + source: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; if (rows.length < 2) continue; + // v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER + // rows onto the pending row of the slot whose minted band contains + // the takenAt. The legacy snap pass below only reaches a ±half-window + // tolerance, so an 18-minute-early API ingest beside its pending + // 09:00 REMINDER row never clustered — the production damage this + // backfill exists to repair. The pass mutates `rows` in place + // (tombstoned pending rows removed, the winner's `scheduledFor` + // normalised) so the snap pass below operates on the healed state. + await convergeAdhocRowsOntoPendingSlots({ + med, + rows, + userTz, + lastIntakeAt, + summary, + daysToRecompute, + }); + + if (rows.length < 2) continue; + // Group rows by the canonical slot instant they snap to. Rows that // resolve to `null` are PRN / off-slot — keyed individually so they // never collapse together. @@ -402,6 +452,161 @@ export async function dedupeUserIntakeSlots( return summary; } +/** + * v1.16.9 — converge ad-hoc actioned rows onto their slot's pending row. + * + * The healed shape: an external-API or Telegram take historically + * bare-created `scheduledFor = takenAt`, leaving the worker-minted + * pending REMINDER row open. The pending row later auto-missed, so the + * ledger showed an ad-hoc take PLUS a missed slot for one real dose. + * The write paths converge since v1.16.9; this pass repairs the rows + * already written. + * + * Deliberately conservative — a pair converges ONLY when: + * - the actioned row is ad-hoc shaped (`scheduledFor === takenAt`, + * taken, not skipped), source API or REMINDER, and not USER_PIN; + * - exactly ONE minted band contains the takenAt (the bands are + * disjoint by construction, but a zero- or ambiguous-match take is + * left alone); + * - the band's anchor carries exactly ONE live row and that row is + * unactioned (no takenAt, not skipped — a cron auto-miss flag does + * not count as an action: the take proves the miss wrong). + * + * The actioned row is the dose of record: its `scheduledFor` normalises + * to the band anchor and the pending row is tombstoned (soft-deleted, + * syncVersion bumped) per the canonical pattern. `rows` is mutated in + * place so the caller's snap pass sees the healed state. + */ +async function convergeAdhocRowsOntoPendingSlots(input: { + med: DedupMedication; + rows: DedupIntakeRow[]; + userTz: string; + lastIntakeAt: Date | null; + summary: IntakeSlotDedupSummary; + daysToRecompute: Set; +}): Promise { + const { med, rows, userTz, lastIntakeAt, summary, daysToRecompute } = input; + + const isRolling = med.schedules.some((s) => s.rollingIntervalDays !== null); + + const adhocCandidates = rows.filter( + (r) => + r.takenAt !== null && + !r.skipped && + r.scheduledFor.getTime() === r.takenAt.getTime() && + r.attributionSource !== "USER_PIN" && + (r.source === "API" || r.source === "REMINDER"), + ); + if (adhocCandidates.length === 0) return; + + const medication: AttributeIntakeMedication = { + id: med.id, + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + schedules: med.schedules, + scheduleRevisions: med.scheduleRevisions ?? [], + }; + + for (const adhoc of adhocCandidates) { + try { + const takenAt = adhoc.takenAt as Date; + // Rolling cadences anchor their retrospective grid AT each intake; + // build the instants from the rows already in memory, EXCLUDING the + // candidate itself (its own ad-hoc anchor must not mint the band it + // is judged against). + const intakeInstants = isRolling + ? rows + .filter( + (r) => + r.id !== adhoc.id && + r.takenAt !== null && + !r.skipped && + r.takenAt.getTime() <= takenAt.getTime(), + ) + .map((r) => r.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()) + : undefined; + + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication, + userTz, + around: takenAt, + lastIntakeAt, + intakeInstants, + now: takenAt, + }); + if (!hasExpectedSlots) continue; + + const t = takenAt.getTime(); + const matching = bands.filter( + (b) => t >= b.onTimeStart.getTime() && t <= b.overdueEnd.getTime(), + ); + if (matching.length !== 1) continue; // zero or ambiguous — leave alone. + const anchor = matching[0].at; + if (anchor.getTime() === adhoc.scheduledFor.getTime()) continue; // same instant — the snap pass owns it. + + const anchorRows = rows.filter( + (r) => + r.id !== adhoc.id && r.scheduledFor.getTime() === anchor.getTime(), + ); + if (anchorRows.length !== 1) continue; // empty or double-slot — leave alone. + const pending = anchorRows[0]; + if (pending.takenAt !== null || pending.skipped) continue; // already served. + + // Tombstone the pending row; the take is the dose of record. + await prisma.medicationIntakeEvent.updateMany({ + where: { id: pending.id, deletedAt: null }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + summary.rowsSoftDeleted += 1; + + const adhocDayInstant = adhoc.scheduledFor; + // Normalise the take onto the canonical anchor. Same P2002 guard as + // the snap pass: a tombstoned row may already occupy + // `(anchor, source)`; when it does the take keeps its own anchor — + // the pending row is already gone, so the slot no longer double-counts. + try { + await prisma.medicationIntakeEvent.update({ + where: { id: adhoc.id }, + data: { scheduledFor: anchor, syncVersion: { increment: 1 } }, + }); + summary.rowsNormalised += 1; + adhoc.scheduledFor = anchor; + } catch (normErr) { + if (!isP2002(normErr)) throw normErr; + annotate({ + meta: { + intake_slot_dedup_converge_collision: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_slot: anchor.toISOString(), + }, + }); + } + + summary.slotsCollapsed += 1; + + // Both local days can differ for day-scale bands — recompute each. + daysToRecompute.add(`${med.id}|${anchor.toISOString()}`); + daysToRecompute.add(`${med.id}|${adhocDayInstant.toISOString()}`); + + // Mutate the in-memory rows so the snap pass sees the healed state. + const pendingIdx = rows.findIndex((r) => r.id === pending.id); + if (pendingIdx >= 0) rows.splice(pendingIdx, 1); + } catch (pairErr) { + annotate({ + meta: { + intake_slot_dedup_converge_failed: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_error: + pairErr instanceof Error ? pairErr.message : String(pairErr), + }, + }); + } + } +} + /** * Discovery pass — runs at worker boot and on the daily cron tick (the * cron payload omits `userId`; the worker handler dispatches here). Finds @@ -444,6 +649,15 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ // `(user_id, medication_id, scheduled_for)`. The window absorbs the // sub-minute iOS-vs-server drift; the per-user pass re-checks // precisely so a wider-than-needed window only costs idle jobs. + // + // v1.16.9 — second arm: the ad-hoc→pending heal pattern. An actioned + // ad-hoc row (`scheduled_for = taken_at`) beside an unactioned row on + // the same medication can sit up to the widest band reach apart + // (weekly on-time 1d + overdue 4d ≈ 5 days; 475200 s adds half a day + // of slack) — far outside the 2-minute drift arm, which is why the + // 18-minute API/REMINDER pairs were never enqueued. The per-user pass + // re-checks by exact band membership, so the wide arm only costs idle + // jobs. const users = await prisma.$queryRaw>` SELECT DISTINCT a."user_id" AS id FROM "medication_intake_events" a @@ -453,7 +667,18 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ AND a."id" <> b."id" AND a."deleted_at" IS NULL AND b."deleted_at" IS NULL - AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + AND ( + abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + OR ( + a."taken_at" IS NOT NULL + AND a."scheduled_for" = a."taken_at" + AND a."skipped" = false + AND a."source" IN ('API', 'REMINDER') + AND b."taken_at" IS NULL + AND b."skipped" = false + AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 475200 + ) + ) `; if (users.length === 0) { diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts index 9d2b40a0f..227f30e3f 100644 --- a/src/lib/medications/scheduling/__tests__/attribution.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -96,8 +96,17 @@ describe("attributeIntakeToSlot — summer (CEST)", () => { expect(r?.status).toBe("on_time"); }); - it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { - expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull(); + it("a take at 17:30 credits the 19:00 slot through the bounded early grace (v1.16.9)", () => { + // 30 min before the 18:00 on-time start — inside the 60-min early + // grace, so a slightly-early take credits its slot instead of + // orphaning ad-hoc while the slot later reads missed. + const r = attributeIntakeToSlot(at(day, 17, 30), bands); + expect(r?.band.timeOfDay).toBe("19:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take at 16:30 stays ad-hoc (beyond the 60-min early grace)", () => { + expect(attributeIntakeToSlot(at(day, 16, 30), bands)).toBeNull(); }); }); @@ -166,3 +175,73 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => { expect(r?.status).toBe("on_time"); }); }); + +/** + * v1.16.9 — bounded early grace ahead of the on-time band. + * + * A window configured to start AT the dose time ("09:00–10:00") refused + * an 08:42 take: it orphaned ad-hoc and the slot read missed. Takes up to + * 60 min before `onTimeStart` now credit the slot; the reach is capped at + * the previous slot's `overdueEnd` so a take inside the prior dose's late + * tail is never re-claimed. + */ +describe("attributeIntakeToSlot — bounded early grace (v1.16.9)", () => { + const day = new Date("2026-06-08T12:00:00Z"); + + function nineToTenWindow(): SlotWindowInput { + return { + at: localHmAsUtc(day, TZ, 9, 0), + timeOfDay: "09:00", + onTimeStart: localHmAsUtc(day, TZ, 9, 0), + onTimeEnd: localHmAsUtc(day, TZ, 10, 0), + lateGraceMs: 180 * MIN, + }; + } + + it("an 08:42 take credits the 09:00 slot whose window starts AT 09:00", () => { + const bands = buildSlotBands([nineToTenWindow()]); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("09:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take more than 60 min before the window start stays ad-hoc", () => { + const bands = buildSlotBands([nineToTenWindow()]); + expect(attributeIntakeToSlot(at(day, 7, 55), bands)).toBeNull(); + }); + + it("the early grace never crosses into the previous slot's band", () => { + // 06:00 slot with a long late tail reaching to 09:00 (capped exactly + // at the 09:00 window start) + the 09:00–10:00 window. A take at + // 08:42 sits inside the 06:00 slot's tail — it must stay attributed + // LATE to the 06:00 slot, never early-claimed by the 09:00 slot. + const slots: SlotWindowInput[] = [ + { + at: localHmAsUtc(day, TZ, 6, 0), + timeOfDay: "06:00", + onTimeStart: localHmAsUtc(day, TZ, 5, 30), + onTimeEnd: localHmAsUtc(day, TZ, 6, 30), + lateGraceMs: 600 * MIN, // capped at the next window's start + }, + nineToTenWindow(), + ]; + const bands = buildSlotBands(slots); + // The cap binds: the 09:00 band's early reach starts where the 06:00 + // tail ends. + expect(bands[1].earlyStart.toISOString()).toBe( + bands[0].overdueEnd.toISOString(), + ); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("06:00"); + expect(r?.status).toBe("late"); + }); + + it("isLastIntakeInBand suppression mirrors the grace (no take-now after an 08:42 take)", () => { + // Covered at the card level: see window-status — this pins the band + // shape the suppression relies on (earlyStart <= onTimeStart always). + const bands = buildSlotBands([nineToTenWindow()]); + expect(bands[0].earlyStart.getTime()).toBeLessThanOrEqual( + bands[0].onTimeStart.getTime(), + ); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 495fecd35..3a9dec442 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -11,12 +11,22 @@ */ import { describe, expect, it } from "vitest"; -import { computeDisplayDue, computeNextDueAt } from "../next-due"; +import { + computeDisplayDue, + computeNextDueAt, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "../next-due"; import type { WorkerMedicationRow, WorkerScheduleRow, } from "../worker-helpers"; +/** A slot-anchored resolved mark (the write paths' canonical shape). */ +function mark(at: Date): ResolvedSlotMark { + return { at, slotAnchored: true }; +} + function d(iso: string): Date { return new Date(iso); } @@ -155,8 +165,8 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { userTz: BERLIN, lastIntakeAt: d("2026-06-10T15:30:00Z"), resolvedSlots: [ - d("2026-06-10T05:00:00Z"), // today 07:00 Berlin - d("2026-06-10T17:00:00Z"), // today 19:00 Berlin + mark(d("2026-06-10T05:00:00Z")), // today 07:00 Berlin + mark(d("2026-06-10T17:00:00Z")), // today 19:00 Berlin ], }); expect(next).not.toBeNull(); @@ -173,7 +183,7 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { now, userTz: BERLIN, lastIntakeAt: d("2026-06-10T07:13:00Z"), - resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00 + resolvedSlots: [mark(d("2026-06-10T05:00:00Z"))], // only today 07:00 }); expect(next).not.toBeNull(); expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 @@ -315,7 +325,7 @@ describe("computeDisplayDue — open overdue slot", () => { now, userTz: BERLIN, lastIntakeAt: null, - resolvedSlots: [d("2026-06-10T19:00:00Z")], + resolvedSlots: [mark(d("2026-06-10T19:00:00Z"))], }); expect(due).not.toBeNull(); expect(due!.overdue).toBe(false); @@ -336,3 +346,137 @@ describe("computeDisplayDue — open overdue slot", () => { expect(due!.overdue).toBe(false); }); }); + +/** + * v1.16.9 — an AD-HOC row (`scheduledFor === takenAt`) must never + * ±6h-resolve a DIFFERENT slot. The proven failure: twice-daily 08:00 / + * 20:00 Berlin, an ad-hoc take at 14:30 sat within 6h of tonight's 20:00 + * anchor and resolved it — the genuinely-due dose disappeared from the + * card while the ledger still counted the slot missed. + */ +describe("resolved-slot marks — ad-hoc rows only match their own anchor", () => { + const medication = makeMedication(); + // 08:00 / 20:00 Berlin (CEST): 06:00Z / 18:00Z. + function schedule(): WorkerScheduleRow { + return { + id: "sched-2x", + windowStart: "08:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + const ADHOC_1430 = d("2026-06-10T12:30:00Z"); // 14:30 Berlin + + it("a 14:30 ad-hoc take leaves tonight's 20:00 slot due", () => { + const now = d("2026-06-10T15:00:00Z"); // 17:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + // The morning slot was genuinely taken (slot-anchored row)… + mark(d("2026-06-10T06:00:00Z")), + // …and the 14:30 take recorded ad-hoc (`scheduledFor === takenAt`). + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(next).not.toBeNull(); + // Tonight's 20:00 Berlin (18:00Z) stays due — the ad-hoc row must not + // resolve it across the radius. + expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a 14:30 ad-hoc take does not suppress the 20:00 overdue pill either", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin — inside the late tail + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a slot-anchored attributed row still resolves its slot across drift", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:05:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Attributed take: scheduledFor on the anchor, takenAt 20:05. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:05:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + // Both today's slots resolved → tomorrow's 08:00 Berlin (06:00Z). + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("an ad-hoc row sitting exactly on the anchor still resolves that slot", () => { + const now = d("2026-06-10T19:00:00Z"); + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:00:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Take recorded at the exact anchor instant — ad-hoc shaped but ON + // the slot; the near-exact epsilon keeps it resolving. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:00:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("behaves identically in a non-European zone (Pacific/Auckland)", () => { + const AUCKLAND = "Pacific/Auckland"; // UTC+12 in June (NZST) + // 08:00 / 20:00 Auckland on 2026-06-10 = 2026-06-09T20:00Z / 2026-06-10T08:00Z. + const adhoc = d("2026-06-10T02:30:00Z"); // 14:30 Auckland + const now = d("2026-06-10T09:00:00Z"); // 21:00 Auckland + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: AUCKLAND, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-09T20:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts index 7d8b705bb..91e428d35 100644 --- a/src/lib/medications/scheduling/attribution.ts +++ b/src/lib/medications/scheduling/attribution.ts @@ -39,10 +39,27 @@ export interface SlotWindowInput { lateGraceMs: number; } +/** + * v1.16.9 — bounded early grace ahead of the on-time band. A user who + * configures a window to start AT the dose time ("09:00–10:00") would + * otherwise have an 08:42 take refused and orphaned ad-hoc while the + * slot read missed. A take up to this far before `onTimeStart` still + * credits the slot (as on-time); the reach is capped at the PREVIOUS + * slot's `overdueEnd` so it can never claim a take that belongs to the + * prior dose's late tail. + */ +export const EARLY_GRACE_MS = 60 * 60 * 1000; + /** A slot window with its (capped) late-tail cutoff resolved. */ export interface SlotBand extends SlotWindowInput { /** Cutoff past which a take no longer pairs to this slot (capped). */ overdueEnd: Date; + /** + * Earliest instant a take still credits this slot (the bounded early + * grace before `onTimeStart`, capped at the previous slot's + * `overdueEnd`). + */ + earlyStart: Date; } export type AttributionStatus = "on_time" | "late"; @@ -59,7 +76,7 @@ export interface AttributionResult { */ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime()); - return sorted.map((slot, i) => { + const withTails = sorted.map((slot, i) => { let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs; const next = sorted[i + 1]; if (next) { @@ -71,6 +88,19 @@ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime()); return { ...slot, overdueEnd: new Date(overdueEnd) }; }); + // Second pass — the early reach is bounded by the PREVIOUS slot's + // resolved tail, so it never claims a take inside the prior dose's + // band. The previous `overdueEnd` is already capped at this slot's + // `onTimeStart`, so `earlyStart <= onTimeStart` holds by construction. + return withTails.map((band, i) => { + let earlyStart = band.onTimeStart.getTime() - EARLY_GRACE_MS; + const prev = withTails[i - 1]; + if (prev) { + earlyStart = Math.max(earlyStart, prev.overdueEnd.getTime()); + } + earlyStart = Math.min(earlyStart, band.onTimeStart.getTime()); + return { ...band, earlyStart: new Date(earlyStart) }; + }); } /** @@ -90,7 +120,10 @@ export function attributeIntakeToSlot( null; for (const band of bands) { - const onStart = band.onTimeStart.getTime(); + // v1.16.9 — the bounded early grace extends the on-time capture below + // `onTimeStart` (never into the previous slot's band — `buildSlotBands` + // caps it). Hand-built bands without the field keep the strict start. + const onStart = (band.earlyStart ?? band.onTimeStart).getTime(); const onEnd = band.onTimeEnd.getTime(); const overdueEnd = band.overdueEnd.getTime(); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 4f646770a..21cb71e94 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -29,6 +29,63 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d */ const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; +/** + * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors + * `scheduledFor = takenAt` on its own instant, so it can only resolve a + * slot it actually sits on (sub-minute drift absorbed); letting it use + * the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved + * tonight's 20:00 dose while the ledger still counted that slot missed. + */ +const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; + +/** + * A row that resolves a slot (taken / deliberately skipped / + * cron-auto-missed), with its anchoring shape preserved. + * + * `slotAnchored: false` marks the ad-hoc shape (`scheduledFor === + * takenAt`): such a row resolves a slot only on a near-exact anchor + * match, never across the ±6h radius. Slot-anchored rows (the write + * paths snap their `scheduledFor` to the canonical slot instant) keep + * the defensive radius. + */ +export interface ResolvedSlotMark { + at: Date; + slotAnchored: boolean; +} + +/** + * Map a resolved intake row to its `ResolvedSlotMark`. The ad-hoc shape + * is detectable only for taken rows (`scheduledFor === takenAt` to the + * millisecond — the documented standalone-insert contract); skips and + * auto-misses anchor on their slot by construction. + */ +export function toResolvedSlotMark(row: { + scheduledFor: Date; + takenAt: Date | null; +}): ResolvedSlotMark { + return { + at: row.scheduledFor, + slotAnchored: + row.takenAt === null || + row.takenAt.getTime() !== row.scheduledFor.getTime(), + }; +} + +function buildIsResolved( + resolved: ResolvedSlotMark[], +): (slotAt: Date) => boolean { + return (slotAt: Date): boolean => { + const slot = slotAt.getTime(); + for (const r of resolved) { + const radius = r.slotAnchored + ? RESOLVE_RADIUS_MS + : ADHOC_RESOLVE_EPSILON_MS; + if (Math.abs(r.at.getTime() - slot) <= radius) return true; + } + return false; + }; +} + /** * v1.16.4 — how far back the open-overdue search mints bands. The widest * possible band reach is a weekly slot's on-time half-width plus its @@ -57,21 +114,15 @@ export function computeNextDueAt(input: { * these so a twice-daily med whose remaining slots today are all logged * advances to the next genuinely-open slot (tomorrow's first dose) instead * of re-surfacing a resolved present/past slot. Omit for the legacy - * purely-time-anchored next-due. + * purely-time-anchored next-due. v1.16.9 — each mark carries its + * anchoring shape; ad-hoc rows only resolve on a near-exact match. */ - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; }): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); let earliest: Date | null = null; @@ -113,7 +164,7 @@ export interface ComputeDisplayDueInput { now: Date; userTz: string; lastIntakeAt: Date | null; - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; /** * Floor of the CURRENT schedule era (the newest revision's `validUntil`), * when the medication has archived revisions. The overdue search mints @@ -148,14 +199,7 @@ function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 527026b53..2fba3b485 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -117,6 +117,18 @@ export interface RecurrenceContext { * Latest `MedicationIntakeEvent.takenAt` for this medication. * Used only by rolling schedules. Null when the user has never * logged an intake (rolling then anchors on `startsOn ?? createdAt`). + * + * Skip semantics (documented decision, v1.16.9): a deliberate SKIP + * does NOT advance the rolling anchor. A skipped row carries + * `takenAt: null`, so every `lastIntakeAt` feeder query + * (`takenAt: { not: null }`) excludes it by construction, and the + * next due stays `previous take + N` — the skipped dose keeps + * surfacing as due until a real take re-anchors the grid. Advancing + * the anchor from the skipped instant instead would silently shift + * the whole future grid off the user's established rhythm; if that + * trade-off is ever revisited, every `lastIntakeAt` feeder (the + * projector, the reminder worker, the list route, the write-path + * resolvers, the dedup pass) must change together. */ lastIntakeAt: Date | null; } diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index a0df2d2c8..f50e55ac0 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -54,8 +54,13 @@ export interface ApplyCanonicalSlotWriteInput { isExplicitTaken: boolean; isExplicitSkip: boolean; idempotencyKey: string | null; - /** Source to stamp on a freshly-created slot row. */ - createSource: "WEB" | "API"; + /** + * Source to stamp on a freshly-created slot row. `REMINDER` is the + * Telegram-webhook path — its take / skip confirmations converge onto + * the worker-minted pending row like every other write, and a fresh + * create keeps the historical reminder provenance. + */ + createSource: "WEB" | "API" | "REMINDER"; /** * v1.8.5 — resolved + server-validated injection site to persist on a * taken write. `null` = no site (the column stays / is set NULL). Only diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index d97e2f2f8..22a3f0230 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -87,6 +87,16 @@ export interface CurrentWindowStatus { schedule: Schedule | null; /** Non-null exactly when `status` is non-null. */ window: MatchedDoseWindow | null; + /** + * v1.16.9 — true when the matched schedule is a DAY-SCALE cadence + * (weekly / N-weekly injectable) and a recent actioned take exists + * inside the current cadence period but on an earlier day (an early + * shot the band attribution recorded ad-hoc). The dose is already on + * board: the card must render taken-early context instead of the + * "take now" / overdue prompt — a full take prompt on the slot day is + * a double-dose prompt. Always false when `status` is null. + */ + takenEarly: boolean; } /** @@ -303,11 +313,25 @@ function getScheduleStatus( return best; } +/** + * v1.16.9 — mirror of the attribution module's bounded early grace: a + * take slightly before the on-time band still credits the slot, so the + * pill suppression must accept it too (an 08:42 take for a 09:00-window + * slot must not leave "take now" burning at 09:05). + */ +const EARLY_GRACE_MINUTES = 60; + function isLastIntakeInBand( lastTakenAt: string | null, band: DoseWindowBounds, nowLocal: Date, tz: string, + /** + * Lower bound (minutes-of-day) the early grace may not cross — the end + * of the schedule's previous dose band, so a take belonging to the + * prior dose never suppresses this one. Defaults to 0 (no earlier band). + */ + earlyFloorMins = 0, ): boolean { if (!lastTakenAt) return false; @@ -330,7 +354,77 @@ function isLastIntakeInBand( ? intakeMins + 24 * 60 : intakeMins; - return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins; + const startWithGrace = Math.max( + band.startMins - EARLY_GRACE_MINUTES, + earlyFloorMins, + 0, + ); + return adjustedIntake >= startWithGrace && adjustedIntake <= band.endMins; +} + +/** + * v1.16.9 — day-scale cadence detection for the taken-early downgrade. + * + * A schedule whose dose days sit ≥ 2 days apart (a single weekday, a + * sparse Mo/Th pattern, an N-weekly interval) is day-scale: one dose + * covers a multi-day period, so a take earlier in the period must inform + * the slot-day pill. Daily / near-daily patterns (minute-scale) return + * `dayScale: false` — their period is a day and the same-day band checks + * already cover them. `periodDays` is the smallest gap between + * consecutive dose days (the span one dose covers). + */ +function dayScaleCadence(daysOfWeek: string | null): { + dayScale: boolean; + periodDays: number; +} { + const recurrence = parseScheduleRecurrence(daysOfWeek); + const days = [...new Set(recurrence.daysOfWeek)].sort((a, b) => a - b); + if (days.length === 0) return { dayScale: false, periodDays: 0 }; + const intervalWeeks = Math.max(1, recurrence.intervalWeeks ?? 1); + if (days.length === 1) { + return { dayScale: true, periodDays: 7 * intervalWeeks }; + } + let minGap = Infinity; + for (let i = 1; i < days.length; i++) { + minGap = Math.min(minGap, days[i] - days[i - 1]); + } + // Wrap from the week's last dose day to the next cycle's first. + const wrapGap = days[0] + 7 * intervalWeeks - days[days.length - 1]; + minGap = Math.min(minGap, wrapGap); + return { dayScale: minGap >= 2, periodDays: minGap }; +} + +/** + * True when the last actioned take lies on an EARLIER local day inside + * the current cadence period — the early-shot shape the taken-early + * downgrade fires on. A same-day take is the in-band suppression's job; + * a take a full period (or more) ago is the previous cycle's dose. + */ +function hasEarlyTakeInPeriod( + lastTakenAt: string | null, + nowLocal: Date, + tz: string, + periodDays: number, +): boolean { + if (!lastTakenAt || periodDays < 2) return false; + const intake = toZonedDate(new Date(lastTakenAt), tz); + const dayMs = 24 * 60 * 60 * 1000; + const intakeDay = Math.floor( + new Date( + intake.getFullYear(), + intake.getMonth(), + intake.getDate(), + ).getTime() / dayMs, + ); + const nowDay = Math.floor( + new Date( + nowLocal.getFullYear(), + nowLocal.getMonth(), + nowLocal.getDate(), + ).getTime() / dayMs, + ); + const diff = nowDay - intakeDay; + return diff >= 1 && diff < periodDays; } /** @@ -396,6 +490,13 @@ export function reduceCurrentWindowStatus( missedMinutes: number; active: boolean; lastTakenAt: string | null; + /** + * v1.16.9 — number of ACTIONED intake events today (taken or + * explicitly skipped). The server list feeder counts only actioned + * rows: the dashboard projector mints a pending row for every slot of + * the day, so an all-rows count covered every passed dose after any + * dashboard visit and the overdue pill went dark nondeterministically. + */ todayEventCount: number; /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ tz?: string; @@ -430,6 +531,7 @@ export function reduceCurrentWindowStatus( status: null, schedule: null, window: null, + takenEarly: false, }; if (!active) return none; if (nextDue === null) return none; @@ -465,14 +567,33 @@ export function reduceCurrentWindowStatus( // intake events for the day. if (hit.status !== "in_window" && !hasUncoveredOverdue) return best; // Don't show in_window if last intake is already within this band - // today. + // today (including the bounded early grace, floored at the end of + // the schedule's previous dose band so a prior dose's take never + // suppresses this one). + const earlyFloorMins = resolveDoseWindows(s).reduce( + (floor, b) => + b.endMins <= hit.band.startMins && b.endMins > floor + ? b.endMins + : floor, + 0, + ); if ( hit.status === "in_window" && - isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz) + isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins) ) { return best; } if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) { + // v1.16.9 — day-scale early-take downgrade: a weekly injectable + // taken days before its slot day is already on board, so the pill + // must carry taken-early context instead of prompting a full take + // (a "Jetzt einnehmen" / overdue prompt here is a double-dose + // prompt). The server next-due stays canonical — only the pill's + // framing changes. + const cadence = dayScaleCadence(s.daysOfWeek); + const takenEarly = + cadence.dayScale && + hasEarlyTakeInPeriod(lastTakenAt, nowBerlin, tz, cadence.periodDays); return { status: hit.status, schedule: s, @@ -481,6 +602,7 @@ export function reduceCurrentWindowStatus( start: hit.band.startHm, end: hit.band.endHm, }, + takenEarly, }; } return best; diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index fbbde4171..2bec317a1 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -217,7 +217,7 @@ const medicationListEntry = medicationResource .int() .nonnegative() .describe( - "Number of intake events scheduled for today (user-local day window).", + "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", ), }) .meta({ @@ -619,7 +619,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "List medications for the calling user", description: - "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", + "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", responses: { "200": { description: "Medication list.", diff --git a/src/lib/validations/__tests__/medication-intake-event.test.ts b/src/lib/validations/__tests__/medication-intake-event.test.ts index 402fe7110..25930c729 100644 --- a/src/lib/validations/__tests__/medication-intake-event.test.ts +++ b/src/lib/validations/__tests__/medication-intake-event.test.ts @@ -11,7 +11,11 @@ */ import { describe, expect, it } from "vitest"; -import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { + externalIntakeSchema, + intakeSchema, + updateIntakeEventSchema, +} from "@/lib/validations/medication"; const HOUR_MS = 60 * 60 * 1000; const YEAR_MS = 365 * 24 * HOUR_MS; @@ -74,3 +78,70 @@ describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => { expect(result.success).toBe(true); }); }); + +/** + * v1.16.9 — the same bounds on every CREATE path. The retro-add dialog + * posts through `intakeSchema`, the iOS sync through the bulk entry + * shape, external integrations through `externalIntakeSchema`; none of + * them may insert a row the edit path would refuse. + */ +describe("intakeSchema — takenAt bounds on create (v1.16.9)", () => { + it("rejects a future takenAt beyond the skew allowance", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("future"); + } + }); + + it("rejects a takenAt more than 5 years in the past", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("5 years"); + } + }); + + it("accepts a recent takenAt and a body that omits it", () => { + expect( + intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(), + }).success, + ).toBe(true); + expect(intakeSchema.safeParse({ medicationId: "med-1" }).success).toBe( + true, + ); + }); +}); + +describe("externalIntakeSchema — takenAt bounds on ingest (v1.16.9)", () => { + it("rejects future and implausibly-old instants, accepts recent ones", () => { + const future = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(future.success).toBe(false); + + const ancient = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-2", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(ancient.success).toBe(false); + + const ok = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-3", + takenAt: new Date(Date.now() - HOUR_MS).toISOString(), + }); + expect(ok.success).toBe(true); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 03bda0bc9..1c9213675 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -163,6 +163,30 @@ function hhmmToMinutes(hhmm: string): number { return Number(h) * 60 + Number(m); } +/** + * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path + * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the + * per-medication intake POST, the bulk endpoint, the external ingest) so + * the retro-add dialog and a hand-rolled API call can't insert a row a + * decade in the past or hours in the future. Future allowance covers + * client clock skew; the 5-year floor matches the GLP-1 dose-change + * validator's window. Slot-distance / medication-start checks stay in + * the routes — the schema cannot see the medication. + */ +export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; +export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */ +export const boundedTakenAtSchema = z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { + message: "takenAt must not be in the future", + }) + .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { + message: "takenAt must be within the last 5 years", + }); + export const scheduleSchema = z .object({ windowStart: z @@ -598,12 +622,10 @@ export const intakeSchema = z .describe( "Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.", ), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) + takenAt: boundedTakenAtSchema .optional() .describe( - "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.", + "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.", ), skipped: z .boolean() @@ -673,10 +695,8 @@ export const intakeSchema = z export const externalIntakeSchema = z.object({ medicationName: z.string().min(1).max(200), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — same plausibility bounds as the interactive create paths. + takenAt: boundedTakenAtSchema.optional(), idempotencyKey: z.string().max(128), }); @@ -718,22 +738,9 @@ export const listIntakeEventsSchema = z.object({ * checks stay out of the schema — it cannot see the medication — and live * in the route (start-date guard) + the edit dialog (non-blocking hint). */ -const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; -const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; - export const updateIntakeEventSchema = z .object({ - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { - message: "takenAt must not be in the future", - }) - .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { - message: "takenAt must be within the last 5 years", - }) - .nullable() - .optional(), + takenAt: boundedTakenAtSchema.nullable().optional(), skipped: z.boolean().optional(), scheduledFor: z.iso .datetime({ offset: true }) From b4d2d51d4663ed681f3f9730cb3739f7a4a96322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 11:22:21 +0200 Subject: [PATCH 5/7] fix(dashboard,medications): review findings before the cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh crisis-level blood-pressure reading outranks an overdue dose in the daily verdict, and once it ages past a day the dose leads again. The greeting returns to the page header whenever the daily band is hidden, and the settings toggle says it rides along. The nightly convergence pass honours the early grace, so a take recorded shortly before a window that starts at the dose time heals like the write path credits it. The early-take context line states a fact — last dose yesterday, last dose n days ago — instead of claiming an early intake after a late one, the quick-add default pick runs in the profile timezone, a dose line without a medication name reads cleanly, the early-grace constant has one home, and the Telegram confirm writes intake and snooze atomically again. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 9 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- .../__tests__/dashboard-hero-wiring.test.ts | 24 +++- .../telegram/webhook/__tests__/route.test.ts | 12 +- src/app/api/telegram/webhook/route.ts | 128 ++++++++++------- src/app/page.tsx | 10 +- .../__tests__/dashboard-header.test.tsx | 57 ++++++++ .../medication-intake-quick-add.test.tsx | 37 +++++ src/components/dashboard/dashboard-header.tsx | 75 +++++++++- .../hero/__tests__/dashboard-hero.test.tsx | 38 +++++ .../dashboard/hero/dashboard-hero.tsx | 16 ++- .../dashboard/medication-intake-quick-add.tsx | 25 +++- .../card-parts/medication-card-body.tsx | 10 +- .../card-parts/medication-status-pill.tsx | 20 +-- .../medications/glp1-medication-card.tsx | 2 +- .../medications/medication-card.tsx | 11 +- .../dashboard-layout-section.test.tsx | 2 +- src/lib/dashboard/__tests__/verdict.test.ts | 54 +++++-- src/lib/dashboard/verdict.ts | 35 ++--- .../intake-slot-dedup-converge.test.ts | 41 +++++- .../__tests__/window-status.test.ts | 10 +- src/lib/medications/intake-slot-dedup.ts | 9 +- src/lib/medications/scheduling/attribution.ts | 8 +- .../scheduling/dose-window-defaults.ts | 9 ++ src/lib/medications/window-status.ts | 134 ++++++++++-------- 29 files changed, 601 insertions(+), 210 deletions(-) create mode 100644 src/components/dashboard/__tests__/dashboard-header.test.tsx diff --git a/messages/de.json b/messages/de.json index bea30b511..008dd5f5f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -285,8 +285,10 @@ }, "verdict": { "doseOverdue": "Eine Dosis {name} ist überfällig.", + "doseOverdueNoName": "Eine Dosis ist überfällig.", "bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.", "doseUpcoming": "Um {time} steht {name} an.", + "doseUpcomingNoName": "Um {time} steht eine Dosis an.", "weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", "shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.", "silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.", @@ -352,7 +354,7 @@ "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", "heroToggleLabel": "Tagesüberblick", - "heroToggleDescription": "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "heroToggleDescription": "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", @@ -760,7 +762,8 @@ "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", - "takenEarly": "Bereits vorzeitig eingenommen", + "lastDoseYesterday": "Letzte Dosis gestern", + "lastDoseDaysAgo": "Letzte Dosis vor {count} Tagen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", "compliance7d": "Therapietreue (7 Tage)", diff --git a/messages/en.json b/messages/en.json index 2a398b384..891a06406 100644 --- a/messages/en.json +++ b/messages/en.json @@ -285,8 +285,10 @@ }, "verdict": { "doseOverdue": "A dose of {name} is overdue.", + "doseOverdueNoName": "A dose is overdue.", "bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.", "doseUpcoming": "{name} is due at {time}.", + "doseUpcomingNoName": "A dose is due at {time}.", "weightDrift": "Your weight is moving away from the target range this week.", "shortNights": "Your nights have been short lately — {hours} h on average.", "silence": "No measurement for {days} days. A quick entry keeps your picture current.", @@ -352,7 +354,7 @@ "mood": "Mood", "customizeTitle": "Dashboard layout", "heroToggleLabel": "Daily overview", - "heroToggleDescription": "Shows your score, daily focus and dose status at the top of the dashboard.", + "heroToggleDescription": "Shows your greeting, score, daily focus and dose status at the top of the dashboard.", "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", @@ -760,7 +762,8 @@ "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", - "takenEarly": "Already taken early", + "lastDoseYesterday": "Last dose yesterday", + "lastDoseDaysAgo": "Last dose {count} days ago", "overdue": "Overdue", "veryOverdue": "Very overdue", "compliance7d": "Adherence (7 days)", diff --git a/messages/es.json b/messages/es.json index 3d0217e3a..06537b0b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -284,9 +284,11 @@ "none": "Hoy no hay dosis programadas" }, "verdict": { - "doseOverdue": "Una dosis de {name} está pendiente desde hace rato.", + "doseOverdue": "Una dosis de {name} está atrasada.", + "doseOverdueNoName": "Una dosis está atrasada.", "bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.", "doseUpcoming": "A las {time} toca {name}.", + "doseUpcomingNoName": "A las {time} toca una dosis.", "weightDrift": "Tu peso se está alejando del rango objetivo esta semana.", "shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.", "silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.", @@ -352,7 +354,7 @@ "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", "heroToggleLabel": "Resumen del día", - "heroToggleDescription": "Muestra la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", + "heroToggleDescription": "Muestra el saludo, la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", @@ -760,7 +762,8 @@ "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", - "takenEarly": "Ya tomado con antelación", + "lastDoseYesterday": "Última dosis ayer", + "lastDoseDaysAgo": "Última dosis hace {count} días", "overdue": "Vencida", "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", diff --git a/messages/fr.json b/messages/fr.json index a4bdb7f90..445d9e1f3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -285,8 +285,10 @@ }, "verdict": { "doseOverdue": "Une dose de {name} est en retard.", + "doseOverdueNoName": "Une dose est en retard.", "bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.", "doseUpcoming": "{name} est prévu à {time}.", + "doseUpcomingNoName": "Une dose est prévue à {time}.", "weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.", "shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.", "silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.", @@ -352,7 +354,7 @@ "mood": "Humeur", "customizeTitle": "Mise en page du tableau", "heroToggleLabel": "Aperçu du jour", - "heroToggleDescription": "Affiche le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", + "heroToggleDescription": "Affiche le message d’accueil, le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", @@ -760,7 +762,8 @@ "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", - "takenEarly": "Déjà pris en avance", + "lastDoseYesterday": "Dernière dose hier", + "lastDoseDaysAgo": "Dernière dose il y a {count} jours", "overdue": "En retard", "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", diff --git a/messages/it.json b/messages/it.json index 5358627c9..a71112349 100644 --- a/messages/it.json +++ b/messages/it.json @@ -285,8 +285,10 @@ }, "verdict": { "doseOverdue": "Una dose di {name} è in ritardo.", + "doseOverdueNoName": "Una dose è in ritardo.", "bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.", "doseUpcoming": "Alle {time} è prevista {name}.", + "doseUpcomingNoName": "Alle {time} è prevista una dose.", "weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.", "shortNights": "Le tue notti sono state corte di recente — in media {hours} h.", "silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.", @@ -352,7 +354,7 @@ "mood": "Umore", "customizeTitle": "Layout della dashboard", "heroToggleLabel": "Panoramica del giorno", - "heroToggleDescription": "Mostra punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", + "heroToggleDescription": "Mostra saluto, punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", @@ -760,7 +762,8 @@ "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", - "takenEarly": "Già assunto in anticipo", + "lastDoseYesterday": "Ultima dose ieri", + "lastDoseDaysAgo": "Ultima dose {count} giorni fa", "overdue": "In ritardo", "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", diff --git a/messages/pl.json b/messages/pl.json index 72127650e..18ac910b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -285,8 +285,10 @@ }, "verdict": { "doseOverdue": "Dawka {name} jest zaległa.", + "doseOverdueNoName": "Dawka jest zaległa.", "bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.", "doseUpcoming": "O {time} pora na {name}.", + "doseUpcomingNoName": "O {time} pora na dawkę.", "weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.", "shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.", "silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.", @@ -352,7 +354,7 @@ "mood": "Nastrój", "customizeTitle": "Układ panelu", "heroToggleLabel": "Przegląd dnia", - "heroToggleDescription": "Pokazuje wynik, fokus dnia i status dawek na górze pulpitu.", + "heroToggleDescription": "Pokazuje powitanie, wynik, fokus dnia i status dawek na górze pulpitu.", "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", @@ -760,7 +762,8 @@ "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", - "takenEarly": "Już przyjęto wcześniej", + "lastDoseYesterday": "Ostatnia dawka wczoraj", + "lastDoseDaysAgo": "Ostatnia dawka {count} dni temu", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 9b2cf45d5..9e26a1939 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -14,10 +14,10 @@ import { join } from "node:path"; * silhouettes use) a footprint-identical skeleton holds the band, * so the hero and the tiles swap to content in the SAME render * pass; - * - the greeting paragraph left the header for the hero. The header - * renders no name-bearing text at all any more, so the SSR pass - * stays name-free by construction (the hero itself only mounts - * once the snapshot resolved — post-hydration). + * - the greeting paragraph lives in the hero when that renders; when + * the hero does NOT render (snapshot flag off, or hidden via the + * layout toggle) the header takes it back behind `showGreeting`, + * fed from the page's hero gate — the greeting never disappears. */ const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); const HEADER_PATH = join( @@ -69,9 +69,19 @@ describe("dashboard hero — page wiring", () => { describe("dashboard hero — header handoff", () => { const header = readFileSync(HEADER_PATH, "utf8"); - it("the header dropped the greeting paragraph and its reservation", () => { - expect(header).not.toContain("welcomeText"); - expect(header).not.toContain("min-h-5"); + it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => { + // The greeting renders here ONLY when the hero band does not — the + // prop gates the paragraph, and the `min-h-5` line-box reservation + // keeps the header stable through the post-hydration name swap. + expect(header).toContain("showGreeting"); + expect(header).toMatch(/\{showGreeting\s*\?/); + expect(header).toContain("min-h-5"); + expect(header).toContain('data-slot="dashboard-header-greeting"'); + }); + + it("the page feeds showGreeting from the inverted hero gate", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + expect(src).toMatch(/showGreeting=\{!heroVisible\}/); }); it("the header keeps the title + customize + add actions", () => { diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 036acbb34..ce8c8f72d 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -137,7 +137,13 @@ beforeEach(() => { vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({ count: 0, } as never); - vi.mocked(prisma.$transaction).mockResolvedValue([] as never); + // Interactive transactions run their callback against the same mock + // client (the route converges the intake write + snooze update inside + // one transaction); batch arrays resolve like Promise.all. + vi.mocked(prisma.$transaction).mockImplementation(((arg: unknown) => + typeof arg === "function" + ? (arg as (tx: unknown) => unknown)(prisma) + : Promise.all(arg as Promise[])) as never); vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never); vi.mocked(deleteMessage).mockResolvedValue(undefined as never); vi.mocked(sendTelegramMessage).mockResolvedValue({ @@ -249,6 +255,8 @@ describe("Telegram webhook — callback dispatch", () => { where: { id: "med-1" }, data: { snoozedUntil: null }, }); + // Intake write + snooze reset commit atomically. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); expect(deleteMessage).toHaveBeenCalledWith( @@ -319,6 +327,8 @@ describe("Telegram webhook — callback dispatch", () => { .snoozedUntil; expect(snoozedUntil.getHours()).toBe(23); expect(snoozedUntil.getMinutes()).toBe(59); + // Skip row + rest-of-day snooze commit atomically. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); it("'ack:' answers the callback without creating an intake event", async () => { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 742c2c269..6a95195a6 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -259,40 +259,53 @@ async function markMedicationTaken( canonicalSlot = attribution.slotInstant; } - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId, - medicationId: medication.id, - canonicalSlot, - takenAt, - skipped: false, - isExplicitTaken: true, - isExplicitSkip: false, - idempotencyKey, - createSource: "REMINDER", - attributionSource: "AUTO", - }); - scheduledFor = applied.row.scheduledFor; - } else { - // Ad-hoc / PRN — standalone row under the documented contract - // (`scheduledFor = takenAt`). - const created = await prisma.medicationIntakeEvent.create({ - data: { + // The intake write and the snooze reset commit atomically — a + // recorded dose must never leave the medication snoozed-out of its + // own reminders, and a snooze reset must never land without the dose + // (the pre-band shape used a batch transaction here; the slot + // convergence dropped it). The canonical slot write accepts the + // transaction client; its P2002 race-recovery cannot continue inside + // an aborted Postgres transaction, so the rare create race rolls the + // whole write back instead of converging in place — Telegram + // redelivers the non-200 update and the retry finds the raced row. + scheduledFor = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, userId, medicationId: medication.id, - scheduledFor: takenAt, + canonicalSlot, takenAt, skipped: false, - source: "REMINDER", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, + createSource: "REMINDER", + attributionSource: "AUTO", + }); + slotScheduledFor = applied.row.scheduledFor; + } else { + // Ad-hoc / PRN — standalone row under the documented contract + // (`scheduledFor = takenAt`). + const created = await tx.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + } + await tx.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: null }, }); - scheduledFor = created.scheduledFor; - } - await prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: null }, + return slotScheduledFor; }); // The confirmation must reflect on the next read (today feed, card // pill, compliance) rather than wait out the cache TTL. @@ -476,37 +489,46 @@ async function handleCallback(update: TelegramUpdate) { canonicalSlot = attribution.slotInstant; } - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: medication.id, - canonicalSlot, - takenAt: null, - skipped: true, - isExplicitTaken: false, - isExplicitSkip: true, - idempotencyKey, - createSource: "REMINDER", - }); - skippedScheduledFor = applied.row.scheduledFor; - } else { - const created = await prisma.medicationIntakeEvent.create({ - data: { + // Atomic like the take path: the skip row and the rest-of-day + // snooze commit together, so a failure can never leave the user + // re-reminded for a recorded skip or silenced without one. Same + // P2002 trade-off as the take path — the rare create race rolls + // back and Telegram's redelivery converges on the raced row. + skippedScheduledFor = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, userId: user.id, medicationId: medication.id, - scheduledFor: skipMoment, + canonicalSlot, takenAt: null, skipped: true, - source: "REMINDER", + isExplicitTaken: false, + isExplicitSkip: true, idempotencyKey, - }, + createSource: "REMINDER", + }); + slotScheduledFor = applied.row.scheduledFor; + } else { + const created = await tx.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: skipMoment, + takenAt: null, + skipped: true, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + } + await tx.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: endOfDay }, }); - skippedScheduledFor = created.scheduledFor; - } - await prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: endOfDay }, + return slotScheduledFor; }); invalidateUserMedications(user.id, { evict: true }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 16c68c38b..48e4dce3d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -711,13 +711,19 @@ export default function DashboardPage() { // with it). The hero gates on the layout's `heroVisible` flag // (default on; anything but the literal `false` renders) and holds // a footprint-identical skeleton while the snapshot is in flight, - // so the band swaps in place — no growth, no greeting flash. + // so the band swaps in place — no growth, no greeting flash. When + // the hero does NOT render (toggle off, or snapshot flag off) the + // header takes the greeting back — it must never disappear with the + // band. const heroVisible = snapshotEnabled && layout.heroVisible !== false; const heroSnapshot = snapshotQuery.data; return (

- + {heroVisible && (primaryLoading || !heroSnapshot ? ( diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx new file mode 100644 index 000000000..da873f652 --- /dev/null +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -0,0 +1,57 @@ +/** + * `` greeting contract. + * + * The greeting line lives in the hero band when that renders; the hero + * is optional (snapshot flag off, or hidden via the dashboard-layout + * toggle), so the page feeds `showGreeting` from its hero gate and the + * header restores the pre-hero greeting paragraph for exactly those + * mounts. Pinned here: + * + * 1. `showGreeting` renders the greeting line (with the `min-h-5` + * line-box reservation that keeps the header height stable through + * the post-hydration name personalisation); + * 2. without the prop (the hero owns the greeting) the line is absent + * — no duplicate greeting above the hero band; + * 3. the SSR pass renders the name-less fallback (hydration-safe). + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { username: "tester", timezone: "Europe/Berlin" }, + }), +})); + +import { DashboardHeader } from "../dashboard-header"; + +function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — greeting", () => { + it("renders the greeting line when the hero is hidden (showGreeting)", () => { + const html = renderSSR( + undefined} showGreeting />, + ); + expect(html).toContain('data-slot="dashboard-header-greeting"'); + // SSR pass: name-less fallback (personalises post-hydration only). + expect(html).toContain("willkommen zurück."); + // Reserved line box keeps the header height stable through the + // post-hydration personalisation swap. + expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/); + expect(html).toContain("min-h-5"); + }); + + it("renders NO greeting line when the hero band owns it (default)", () => { + const html = renderSSR( undefined} />); + expect(html).not.toContain('data-slot="dashboard-header-greeting"'); + expect(html).not.toContain("willkommen zurück."); + // The title itself stays. + expect(html).toContain("Dashboard"); + }); +}); diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 91d882fea..54593d1fa 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -41,6 +41,13 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); +// v1.16.9 — the component reads the profile timezone for the auto-pick. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { username: "tester", timezone: "Europe/Berlin" }, + }), +})); + import { MedicationIntakeQuickAdd, pickDefaultMedicationId, @@ -175,6 +182,36 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { expect(result).toBe("a"); }); + it("reasons in the supplied profile timezone, not Berlin (v1.16.9)", () => { + // 08:00 UTC = 10:00 Berlin (inside the 08:00–11:00 window) but only + // 04:00 in New York (hours before it). The same instant must flip + // the pick with the timezone — pinning that the tz threads through + // both the wall-clock conversion and the window reduction. + const now = new Date("2026-05-12T08:00:00Z"); + const meds = [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + schedules: [ + { + id: "s1", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ]; + expect(pickDefaultMedicationId(meds, now, undefined, "Europe/Berlin")).toBe( + "b", + ); + expect( + pickDefaultMedicationId(meds, now, undefined, "America/New_York"), + ).toBe("a"); + }); + it("falls back to the alphabetical-first active medication when nothing is due", () => { // 03:00 Berlin time: no schedules overlap. The helper should // alphabetically sort and pick the leading id, "ramipril". diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 79b0a9589..51c48967f 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -3,12 +3,16 @@ /** * Dashboard page header: title, the customize shortcut, and the * quick-add dropdown that opens the quick-entry sheets. The greeting - * line moved into the hero band (``), which derives it - * from the snapshot's server-computed `greetingHour`. + * line lives in the hero band (``) when that renders — + * but the hero is optional (snapshot flag off, or hidden via the + * dashboard-layout toggle), and the greeting must never disappear with + * it. `showGreeting` (fed from the page's hero gate) restores the + * pre-hero greeting paragraph under the title for exactly those mounts. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. */ +import { useMemo } from "react"; import Link from "next/link"; import { Activity, Pill, Plus, Settings2, Waves } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -19,19 +23,78 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { getHourForTimeZone } from "@/components/dashboard/range-display"; +import { cn } from "@/lib/utils"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ onQuickEntry, + showGreeting = false, }: { onQuickEntry: (dialog: NonNullable) => void; + /** Render the greeting line here because the hero band does not. */ + showGreeting?: boolean; }) { const { t } = useTranslations(); + const { user } = useAuth(); + const mounted = useMounted(); + + // The pre-hero greeting derivation, kept hydration-safe: `user` comes + // from the auth query, which can resolve before this boundary + // hydrates — the text must match the name-less SSR output during + // hydration (React #418) and may only personalise from the first + // client re-render. + const userTimezone = mounted ? user?.timezone : undefined; + const hour = useMemo( + () => (userTimezone ? getHourForTimeZone(userTimezone) : null), + [userTimezone], + ); + const timeGreeting = + hour == null + ? t("dashboard.greeting.day") + : hour >= 5 && hour < 12 + ? t("dashboard.greeting.morning") + : hour >= 12 && hour < 18 + ? t("dashboard.greeting.day") + : t("dashboard.greeting.evening"); + const welcomeText = + mounted && user?.username && user.username.trim().length > 0 + ? t("dashboard.welcomeBackWithName", { + greeting: timeGreeting, + name: user.username, + }) + : t("dashboard.welcomeBack", { greeting: timeGreeting }); + return ( -
-

- {t("dashboard.title")} -

+ /* With the greeting the title block is two lines on narrow + viewports: centre the buttons against it on mobile, top-align on + sm+ (the pre-hero posture). Without the greeting the single-line + centre alignment stands. */ +
+
+

+ {t("dashboard.title")} +

+ {showGreeting ? ( + /* `min-h-5` reserves the greeting's line box from the SSR + pass on: the text personalises (name appended) on the first + client re-render after hydration, and the reserved line + keeps the header from collapsing/growing around that swap. */ +

+ {welcomeText} +

+ ) : null} +
{/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 16b9ac94b..0c386bbf8 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -147,6 +147,44 @@ describe(" — verdict variants", () => { expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); }); + it("doseOverdue: a null medication name renders the name-less sentence (no hole)", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursFromNow(-1), + nextDueOverdue: true, + nextDueMedicationName: null, + }), + }), + ); + expect(html).toContain('data-verdict-variant="doseOverdue"'); + expect(html).toContain("Eine Dosis ist überfällig."); + // The named template with an empty interpolation leaves a double + // space — it must never render. + expect(html).not.toContain("Eine Dosis ist überfällig."); + }); + + it("doseUpcoming: a null medication name renders the name-less sentence (no hole)", () => { + const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt, + nextDueMedicationName: null, + }), + }), + ); + const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( + nextDueAt, + ); + expect(html).toContain('data-verdict-variant="doseUpcoming"'); + expect(html).toContain(`Um ${expectedTime} steht eine Dosis an.`); + expect(html).not.toContain(`steht an`); + }); + it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => { const html = render( baseSnapshot({ diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index a746951d2..3d917740c 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -20,7 +20,7 @@ * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the * past with `nextDueOverdue: false` means the slot's anchor passed * after the snapshot was built. The verdict resolver already falls - * through (rung 1 keys on the flag, rung 3 requires a future anchor), + * through (rung 2 keys on the flag, rung 3 requires a future anchor), * and the dose row below only prints "next at" for a FUTURE anchor on * the user's local today — the stale state renders as the plain day * summary, never as overdue. @@ -134,11 +134,23 @@ export function DashboardHero({ : t("dashboard.welcomeBack", { greeting: timeGreeting }); // ── Verdict sentence + CTA ────────────────────────────────────────── + // The dose variants interpolate a medication name; a snapshot can carry + // none (the resolver emits `""`), and the named sentence would render + // with a hole ("Eine Dosis ist überfällig."). Swap to the name-less + // sibling key instead of patching a generic word into the gap. + const verdictName = + typeof verdict.values.name === "string" ? verdict.values.name.trim() : ""; + const messageKey = (variant: Exclude) => + variant === "doseOverdue" && verdictName.length === 0 + ? "dashboard.hero.verdict.doseOverdueNoName" + : variant === "doseUpcoming" && verdictName.length === 0 + ? "dashboard.hero.verdict.doseUpcomingNoName" + : VERDICT_MESSAGE_KEY[variant]; const sentence = verdict.variant === "briefing" ? // Model headline VERBATIM as a plain text child — no key. String(verdict.values.headline ?? "") - : t(VERDICT_MESSAGE_KEY[verdict.variant], { + : t(messageKey(verdict.variant), { ...verdict.values, // The resolver emits a fixed 24 h wall-clock string; re-format // the underlying instant through the user's hour-cycle diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index d617055af..b85d45fa2 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -25,9 +25,10 @@ import { } from "@/lib/query-keys"; import { reduceCurrentWindowStatus, - toBerlinDate, + toZonedDate, type ScheduleWindowInput, } from "@/lib/medications/window-status"; +import { useAuth } from "@/hooks/use-auth"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; /** @@ -129,12 +130,18 @@ export function pickDefaultMedicationId( lateMinutes: 120, missedMinutes: 240, }, + /** + * v1.16.9 — the profile timezone the "due now" heuristic reasons in + * (the same source the cards use). Berlin stays the last-resort + * fallback for logged-out mounts and legacy fixtures. + */ + tz: string = "Europe/Berlin", ): string | null { const actives = options.filter((m) => m.active); if (actives.length === 0) return null; if (actives.length === 1) return actives[0].id; - const nowBerlin = toBerlinDate(now); + const nowLocal = toZonedDate(now, tz); const due = actives.find((m) => { // v1.16.9 — the same server display-due gate the cards apply: a // future (non-overdue) next-due suppresses the overdue tiers and a @@ -142,12 +149,13 @@ export function pickDefaultMedicationId( const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, - nowBerlin, + nowBerlin: nowLocal, lateMinutes: thresholds.lateMinutes, missedMinutes: thresholds.missedMinutes, active: true, lastTakenAt: m.lastTakenAt, todayEventCount: m.todayEventCount ?? 0, + tz, nextDue: m.nextDueAt === undefined ? undefined @@ -155,7 +163,7 @@ export function pickDefaultMedicationId( ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } : null, }); - return status.status !== null && !status.takenEarly; + return status.status !== null && status.takenEarlyDaysAgo === null; }); if (due) return due.id; @@ -173,6 +181,11 @@ export function MedicationIntakeQuickAdd({ }: MedicationIntakeQuickAddProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same + // source the cards use); Berlin stays the last-resort fallback so + // logged-out mounts behave unchanged. + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({ queryKey: queryKeys.medications(), @@ -229,8 +242,8 @@ export function MedicationIntakeQuickAdd({ const formId = useId(); const defaultMedicationId = useMemo( - () => pickDefaultMedicationId(medications), - [medications], + () => pickDefaultMedicationId(medications, new Date(), undefined, userTz), + [medications, userTz], ); const medicationId = medicationOverride ?? defaultMedicationId ?? ""; const selectedMedication = medications.find((m) => m.id === medicationId); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 7161bd33d..5a27eeadb 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -71,8 +71,8 @@ export interface MedicationCardBodyProps { status: Exclude; windowStart: string; windowEnd: string; - /** v1.16.9 — day-scale early-take context (see the pill). */ - takenEarly?: boolean; + /** v1.16.9 — day-scale last-dose context in whole days (see the pill). */ + takenEarlyDaysAgo?: number | null; } | null; /** @@ -180,15 +180,15 @@ export function MedicationCardBody({ overdue tier arrives with the compliance query, and a line popping in late used to shift every row below it (CLS). */}
- {windowStatus?.takenEarly ? ( - // v1.16.9 — taken-early context outranks the overdue line: a + {windowStatus?.takenEarlyDaysAgo != null ? ( + // v1.16.9 — last-dose context outranks the overdue line: a // day-scale dose already taken earlier in its period must never // escalate into a take prompt (double-dose risk). ) : overdueLabel ? (

diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 221cc3930..7f9e41be9 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -10,13 +10,15 @@ interface MedicationStatusPillProps { windowStart: string; windowEnd: string; /** - * v1.16.9 — day-scale early-take context. When true the dose was + * v1.16.9 — day-scale last-dose context. Non-null when a dose was * already taken earlier in the cadence period (a weekly shot days - * before its slot day), so the pill renders a calm "taken early" note - * instead of the take-now / overdue prompt — prompting a full take on - * the slot day would be a double-dose prompt. + * before its slot day, or the previous slot served late); the pill + * then renders a calm factual "last dose {n} days ago" note instead + * of the take-now / overdue prompt — prompting a full take on the + * slot day would be a double-dose prompt. The value is the whole + * local days since that take. */ - takenEarly?: boolean; + takenEarlyDaysAgo?: number | null; } /** @@ -31,16 +33,18 @@ export function MedicationStatusPill({ status, windowStart, windowEnd, - takenEarly = false, + takenEarlyDaysAgo = null, }: MedicationStatusPillProps) { const { t, locale } = useTranslations(); - if (takenEarly) { + if (takenEarlyDaysAgo != null) { return (

{" "} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index c87ec1fad..a54846022 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -446,7 +446,7 @@ export function Glp1MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, - takenEarly: currentWindowStatus.takenEarly, + takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo, } : null } diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3edb02c54..30a7a8ec2 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -238,7 +238,10 @@ export function MedicationCard({ medication.nextDueAt === undefined ? undefined : nextAt !== undefined - ? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true } + ? { + at: new Date(nextAt), + overdue: medication.nextDueOverdue === true, + } : null, }); @@ -389,14 +392,16 @@ export function MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, - takenEarly: currentWindowStatus.takenEarly, + takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo, } : null } doseStatus={doseStatus} nextLine={nextLine} lastLine={ - medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null + medication.lastTakenAt + ? formatLastTakenAt(medication.lastTakenAt) + : null } compliance={ compliance ? { rate7, rate30, streak, shortDays, longDays } : null diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 71121b0b2..73ab9f58f 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -293,7 +293,7 @@ describe(" — hero visibility switch", () => { const de = render(, "de"); expect(de).toContain("Tagesüberblick"); expect(de).toContain( - "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", ); const en = render(); expect(en).toContain("Daily overview"); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 61b32560f..c5f704c19 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -5,7 +5,8 @@ * 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; + * thresholds), only on a fresh reading, and outranks an overdue + * dose (a crisis-level reading is rung 1); * - 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; @@ -111,7 +112,7 @@ const WATCH_FINDING = { sourceMetric: "bp" as const, }; -describe("rung 1 — doseOverdue", () => { +describe("rung 2 — doseOverdue", () => { it("fires on nextDueOverdue with the medication name and a quick-entry CTA", () => { const verdict = resolveDashboardVerdict( baseSnapshot({ @@ -132,7 +133,9 @@ describe("rung 1 — doseOverdue", () => { }); }); - it("wins over a simultaneously critical BP reading (precedence)", () => { + it("yields to a simultaneously critical fresh BP reading (precedence)", () => { + // A crisis-level reading (≥ 180 / ≥ 110, ≤ 1 day old) is rung 1 — + // it must not hide behind a routine medication prompt. const verdict = resolveDashboardVerdict( baseSnapshot({ medsToday: medsToday({ @@ -152,6 +155,29 @@ describe("rung 1 — doseOverdue", () => { }), NOW, ); + expect(verdict.variant).toBe("bpCritical"); + }); + + it("wins over the BP rung once the critical reading has aged past a day", () => { + 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(3 * 24), daysAgo: 3 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + NOW, + ); expect(verdict.variant).toBe("doseOverdue"); }); @@ -173,7 +199,7 @@ describe("rung 1 — doseOverdue", () => { }); }); -describe("rung 2 — bpCritical", () => { +describe("rung 1 — bpCritical", () => { function bpSnapshot( sys: number | null, dia: number | null, @@ -392,14 +418,12 @@ describe("rung 4 — weightDrift", () => { }); it("skips when avg7 or avg30 is null", () => { + expect(resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant).toBe( + "allQuiet", + ); expect( - resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant, - ).toBe("allQuiet"); - expect( - resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 5, null), - NOW, - ).variant, + resolveDashboardVerdict(weightSnapshot(GREEN_MAX_180 + 5, null), NOW) + .variant, ).toBe("allQuiet"); }); @@ -414,7 +438,10 @@ describe("rung 4 — weightDrift", () => { }); describe("rung 5 — shortNights", () => { - function sleepSnapshot(avg7: number | null, count: number): DashboardSnapshot { + function sleepSnapshot( + avg7: number | null, + count: number, + ): DashboardSnapshot { return baseSnapshot({ tiles: { summaries: { @@ -540,8 +567,7 @@ describe("rung 7 — scoreDrop", () => { "allQuiet", ); expect( - resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW) - .variant, + resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW).variant, ).toBe("allQuiet"); }); }); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index ae6c56cef..59c62e330 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -7,7 +7,8 @@ * 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, + * the fixed-floor BP emergency first (a fresh crisis-level reading + * outranks an overdue dose), then the actionable medication states, * then trend nudges, then the briefing teaser, then the all-quiet * fallback. Threshold constants are imported from the client-safe * coach-nudge-thresholds leaf where one exists so the hero and the @@ -21,7 +22,7 @@ * * 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 + * after the snapshot was built. Rung 2 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. */ @@ -128,22 +129,12 @@ export function resolveDashboardVerdict( 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 ───────────────────────────────────────────────── + // ── 1 · 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`). + // injected `now`). Sits ABOVE the overdue dose: a fresh crisis-level + // reading is the one item that must not hide behind a routine + // medication prompt. const sysLatest = summaries.BLOOD_PRESSURE_SYS?.latest ?? null; const diaLatest = summaries.BLOOD_PRESSURE_DIA?.latest ?? null; const sysSeen = lastSeen.BLOOD_PRESSURE_SYS; @@ -162,6 +153,18 @@ export function resolveDashboardVerdict( }; } + // ── 2 · 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" }, + }; + } + // ── 3 · doseUpcoming ─────────────────────────────────────────────── // Unresolved doses remain today AND the next anchor sits within the // next two hours on the user's local calendar day. diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts index ef78d293c..e9b0ba44b 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -119,9 +119,7 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 1, } as never); - vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( - {} as never, - ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({} as never); }); describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => { @@ -159,6 +157,43 @@ describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1); }); + it("heals an early take ahead of an explicit window that starts AT the dose time", async () => { + // Explicit 09:00–10:00 dose window: the on-time band starts AT the + // dose time, so an 08:42 take sits only inside the bounded early + // grace below `onTimeStart`. The convergence must accept it exactly + // like the write-path attribution does — otherwise the actioned + // ad-hoc row stays beside the unactioned 09:00 REMINDER row and the + // slot later auto-misses a dose the user took. + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + ...MED, + schedules: [ + { + ...SCHEDULE, + windowStart: "09:00", + windowEnd: "10:00", + doseWindows: [{ timeOfDay: "09:00", start: "09:00", end: "10:00" }], + }, + ], + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_0842), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { where: { id: string } }; + expect(tombstone.where.id).toBe("r-pending"); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + expect(summary.slotsCollapsed).toBe(1); + }); + it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ adhocRow(TAKEN_1230), diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 4d2dd09d4..ce6328f62 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -393,7 +393,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(2); }); it("does not flag takenEarly when the last take was a full period ago", () => { @@ -406,7 +406,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(false); + expect(res.takenEarlyDaysAgo).toBeNull(); }); it("does not flag a daily cadence (minute-scale periods stay untouched)", () => { @@ -425,7 +425,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(false); + expect(res.takenEarlyDaysAgo).toBeNull(); }); it("rides on the late tier too — an early take must not escalate to overdue", () => { @@ -437,7 +437,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("late"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(2); }); it("evaluates the early-take day in the supplied timezone", () => { @@ -454,6 +454,6 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { tz: "Pacific/Auckland", }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(1); }); }); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 8f2004853..1e475ad9f 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -540,8 +540,15 @@ async function convergeAdhocRowsOntoPendingSlots(input: { if (!hasExpectedSlots) continue; const t = takenAt.getTime(); + // The lower bound honours the bounded early grace (`earlyStart`): + // an explicit window that starts AT the dose time ("09:00–10:00") + // must still converge an 08:42 take — the same reach the write-path + // attribution credits. Bands without the field keep the strict + // on-time start. const matching = bands.filter( - (b) => t >= b.onTimeStart.getTime() && t <= b.overdueEnd.getTime(), + (b) => + t >= (b.earlyStart ?? b.onTimeStart).getTime() && + t <= b.overdueEnd.getTime(), ); if (matching.length !== 1) continue; // zero or ambiguous — leave alone. const anchor = matching[0].at; diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts index 91e428d35..3c69488c9 100644 --- a/src/lib/medications/scheduling/attribution.ts +++ b/src/lib/medications/scheduling/attribution.ts @@ -25,6 +25,8 @@ * this module timezone-agnostic and trivially testable. */ +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; + /** Per-slot window the caller supplies (bounds already minted as instants). */ export interface SlotWindowInput { /** Canonical anchor instant for the slot (labelling / next-due / pairing). */ @@ -46,9 +48,11 @@ export interface SlotWindowInput { * slot read missed. A take up to this far before `onTimeStart` still * credits the slot (as on-time); the reach is capped at the PREVIOUS * slot's `overdueEnd` so it can never claim a take that belongs to the - * prior dose's late tail. + * prior dose's late tail. Sourced from the shared dose-window-defaults + * leaf (client-safe) so the card pill suppression reads the same width. */ -export const EARLY_GRACE_MS = 60 * 60 * 1000; +export const EARLY_GRACE_MS = + DOSE_WINDOW_DEFAULTS.earlyGraceMinutes * 60 * 1000; /** A slot window with its (capped) late-tail cutoff resolved. */ export interface SlotBand extends SlotWindowInput { diff --git a/src/lib/medications/scheduling/dose-window-defaults.ts b/src/lib/medications/scheduling/dose-window-defaults.ts index 3831bf840..668501f68 100644 --- a/src/lib/medications/scheduling/dose-window-defaults.ts +++ b/src/lib/medications/scheduling/dose-window-defaults.ts @@ -20,6 +20,15 @@ export const DOSE_WINDOW_DEFAULTS = { /** Daily / intraday on-time half-width around the target instant (min). */ dailyOnTimeMinutes: 60, + /** + * Bounded early grace ahead of a dose's on-time window (min). A take up + * to this far before the window start still credits the slot (the + * attribution caps the reach at the previous slot's overdue end). + * Single source for the write-path attribution AND the card pill's + * last-intake suppression, so the two can never disagree on how early + * a take may land. + */ + earlyGraceMinutes: 60, /** * Daily / intraday overdue tail past the on-time window (min). A dose * taken inside `[onTime, onTime + overdue]` still counts; beyond it the diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index 22a3f0230..0c8ad5f18 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -88,15 +88,17 @@ export interface CurrentWindowStatus { /** Non-null exactly when `status` is non-null. */ window: MatchedDoseWindow | null; /** - * v1.16.9 — true when the matched schedule is a DAY-SCALE cadence + * v1.16.9 — non-null when the matched schedule is a DAY-SCALE cadence * (weekly / N-weekly injectable) and a recent actioned take exists - * inside the current cadence period but on an earlier day (an early - * shot the band attribution recorded ad-hoc). The dose is already on - * board: the card must render taken-early context instead of the - * "take now" / overdue prompt — a full take prompt on the slot day is - * a double-dose prompt. Always false when `status` is null. + * inside the current cadence period but on an earlier day. The dose is + * already on board: the card must render last-dose context instead of + * the "take now" / overdue prompt — a full take prompt on the slot day + * is a double-dose prompt. The value is the whole local days since + * that take, so the pill copy stays factual (the take may have been an + * early shot OR the previous slot served late — the day distance does + * not distinguish). Always null when `status` is null. */ - takenEarly: boolean; + takenEarlyDaysAgo: number | null; } /** @@ -314,12 +316,15 @@ function getScheduleStatus( } /** - * v1.16.9 — mirror of the attribution module's bounded early grace: a - * take slightly before the on-time band still credits the slot, so the - * pill suppression must accept it too (an 08:42 take for a 09:00-window - * slot must not leave "take now" burning at 09:05). + * v1.16.9 — the attribution module's bounded early grace: a take + * slightly before the on-time band still credits the slot, so the pill + * suppression must accept it too (an 08:42 take for a 09:00-window slot + * must not leave "take now" burning at 09:05). Read from the shared + * dose-window-defaults leaf — the same constant the write-path + * attribution derives `EARLY_GRACE_MS` from — so the two surfaces can + * never disagree. */ -const EARLY_GRACE_MINUTES = 60; +const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes; function isLastIntakeInBand( lastTakenAt: string | null, @@ -395,18 +400,22 @@ function dayScaleCadence(daysOfWeek: string | null): { } /** - * True when the last actioned take lies on an EARLIER local day inside - * the current cadence period — the early-shot shape the taken-early - * downgrade fires on. A same-day take is the in-band suppression's job; - * a take a full period (or more) ago is the previous cycle's dose. + * Whole local days since the last actioned take when it lies on an + * EARLIER local day inside the current cadence period — the shape the + * taken-early downgrade fires on — else `null`. A same-day take is the + * in-band suppression's job; a take a full period (or more) ago is the + * previous cycle's dose. The day count rides into the pill copy, which + * stays neutral ("last dose {n} days ago"): the period may have opened + * with an early shot OR the previous slot served late, and the distance + * alone cannot tell the two apart. */ -function hasEarlyTakeInPeriod( +function earlyTakeDaysAgo( lastTakenAt: string | null, nowLocal: Date, tz: string, periodDays: number, -): boolean { - if (!lastTakenAt || periodDays < 2) return false; +): number | null { + if (!lastTakenAt || periodDays < 2) return null; const intake = toZonedDate(new Date(lastTakenAt), tz); const dayMs = 24 * 60 * 60 * 1000; const intakeDay = Math.floor( @@ -424,7 +433,7 @@ function hasEarlyTakeInPeriod( ).getTime() / dayMs, ); const diff = nowDay - intakeDay; - return diff >= 1 && diff < periodDays; + return diff >= 1 && diff < periodDays ? diff : null; } /** @@ -482,39 +491,39 @@ function countPassedDoses( * the default are the legacy Berlin contract — callers that serve other * timezones pass their own. */ -export function reduceCurrentWindowStatus( - options: { - schedules: Schedule[]; - nowBerlin: Date; - lateMinutes: number; - missedMinutes: number; - active: boolean; - lastTakenAt: string | null; - /** - * v1.16.9 — number of ACTIONED intake events today (taken or - * explicitly skipped). The server list feeder counts only actioned - * rows: the dashboard projector mints a pending row for every slot of - * the day, so an all-rows count covered every passed dose after any - * dashboard visit and the overdue pill went dark nondeterministically. - */ - todayEventCount: number; - /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ - tz?: string; - /** - * v1.16.6 — the server display-due gate. `undefined` keeps the legacy - * purely band-derived behaviour (callers without the list payload); - * `null` means the server found NO upcoming slot (paused / ended / - * one-shot past) so no pill renders at all. With a gate present: - * - `overdue: false` (next unresolved slot in the future) suppresses - * late / very_late outright and allows in_window only when the due - * instant falls on the current local day — a rolling cadence whose - * next dose is tomorrow stays calm today; - * - `overdue: true` keeps the band-derived escalation (the slot is - * genuinely in its catch-up tail). - */ - nextDue?: NextDueGate | null; - }, -): CurrentWindowStatus { +export function reduceCurrentWindowStatus< + Schedule extends ScheduleWindowInput, +>(options: { + schedules: Schedule[]; + nowBerlin: Date; + lateMinutes: number; + missedMinutes: number; + active: boolean; + lastTakenAt: string | null; + /** + * v1.16.9 — number of ACTIONED intake events today (taken or + * explicitly skipped). The server list feeder counts only actioned + * rows: the dashboard projector mints a pending row for every slot of + * the day, so an all-rows count covered every passed dose after any + * dashboard visit and the overdue pill went dark nondeterministically. + */ + todayEventCount: number; + /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ + tz?: string; + /** + * v1.16.6 — the server display-due gate. `undefined` keeps the legacy + * purely band-derived behaviour (callers without the list payload); + * `null` means the server found NO upcoming slot (paused / ended / + * one-shot past) so no pill renders at all. With a gate present: + * - `overdue: false` (next unresolved slot in the future) suppresses + * late / very_late outright and allows in_window only when the due + * instant falls on the current local day — a rolling cadence whose + * next dose is tomorrow stays calm today; + * - `overdue: true` keeps the band-derived escalation (the slot is + * genuinely in its catch-up tail). + */ + nextDue?: NextDueGate | null; +}): CurrentWindowStatus { const { schedules, nowBerlin, @@ -531,7 +540,7 @@ export function reduceCurrentWindowStatus( status: null, schedule: null, window: null, - takenEarly: false, + takenEarlyDaysAgo: null, }; if (!active) return none; if (nextDue === null) return none; @@ -583,17 +592,20 @@ export function reduceCurrentWindowStatus( ) { return best; } - if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) { + if ( + !best.status || + STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status] + ) { // v1.16.9 — day-scale early-take downgrade: a weekly injectable // taken days before its slot day is already on board, so the pill - // must carry taken-early context instead of prompting a full take + // must carry last-dose context instead of prompting a full take // (a "Jetzt einnehmen" / overdue prompt here is a double-dose // prompt). The server next-due stays canonical — only the pill's - // framing changes. + // framing changes; the day count rides along for the copy. const cadence = dayScaleCadence(s.daysOfWeek); - const takenEarly = - cadence.dayScale && - hasEarlyTakeInPeriod(lastTakenAt, nowBerlin, tz, cadence.periodDays); + const takenEarlyDaysAgo = cadence.dayScale + ? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays) + : null; return { status: hit.status, schedule: s, @@ -602,7 +614,7 @@ export function reduceCurrentWindowStatus( start: hit.band.startHm, end: hit.band.endHm, }, - takenEarly, + takenEarlyDaysAgo, }; } return best; From 12fce6fc14901468f9759557b7a2ea59e07ac0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 11:23:00 +0200 Subject: [PATCH 6/7] =?UTF-8?q?chore(release):=20v1.16.9=20=E2=80=94=20the?= =?UTF-8?q?=20day=20opens=20with=20a=20verdict,=20and=20every=20dose=20pat?= =?UTF-8?q?h=20tells=20the=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 +++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ee59e7e..08947ecb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth + +### Added + +- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header. + +### Fixed + +- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed. +- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh. +- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions. +- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed. +- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one. +- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL. + ## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b781a8621..541c54915 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.8 + version: 1.16.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 0e81d3c37..cbbc776ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.8", + "version": "1.16.9", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 7e498760f54a3c30179dbbfe9ecb26af4d6b5427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 12:05:24 +0200 Subject: [PATCH 7/7] test(medications): anchor bulk-intake fixtures to relative past instants The intake plausibility bound rejects a future takenAt by design, so the fixed calendar dates and the today-evening slot in the bulk suite rotted into rejections depending on the run date and hour. --- .../integration/medications-intake-bulk.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/integration/medications-intake-bulk.test.ts b/tests/integration/medications-intake-bulk.test.ts index df18caf3f..2e42a6324 100644 --- a/tests/integration/medications-intake-bulk.test.ts +++ b/tests/integration/medications-intake-bulk.test.ts @@ -377,8 +377,11 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => { }); const { POST } = await import("@/app/api/medications/intake/bulk/route"); - const t1 = new Date("2026-06-15T09:00:00.000Z"); - const t2 = new Date("2026-06-15T15:00:00.000Z"); + // Relative past instants: takenAt now carries a no-future plausibility + // bound, so fixed calendar dates would rot into rejections. + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + const t1 = new Date(yesterday.setUTCHours(9, 0, 0, 0)); + const t2 = new Date(yesterday.setUTCHours(15, 0, 0, 0)); await POST( makeRequest({ entries: [ @@ -408,7 +411,14 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => { it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => { const prisma = getPrismaClient(); const medId = await makeScheduledMed(["19:00"]); - const slot = localHmAsUtc(new Date(), TZ, 19, 0); + // Yesterday's slot: today's 19:00 is in the future for any run before + // the evening, and a future takenAt is now rejected by design. + const slot = localHmAsUtc( + new Date(Date.now() - 24 * 60 * 60 * 1000), + TZ, + 19, + 0, + ); const { POST } = await import("@/app/api/medications/intake/bulk/route"); const res = await POST(