diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index be5d6ee14..4cc34596c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2366,6 +2366,27 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/compliance: + get: + tags: + - Medications + summary: Batched adherence read for every medication of the caller + description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt + DESC` — the single round trip the medication cards consume instead of fanning out one + `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the + cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no + writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each + other. + responses: + "200": + description: One adherence row per medication. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationComplianceResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/medications/{id}/compliance: get: tags: @@ -8897,11 +8918,13 @@ components: additionalProperties: false description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour. - GetMedicationComplianceResponse: + ListMedicationComplianceResponse: type: object properties: data: - $ref: "#/components/schemas/MedicationComplianceResponse" + type: array + items: + $ref: "#/components/schemas/MedicationComplianceSummaryEntry" error: type: "null" meta: @@ -8914,33 +8937,26 @@ components: - data - error additionalProperties: false - MedicationComplianceResponse: + MedicationComplianceSummaryEntry: type: object properties: + medicationId: + type: string compliance7: $ref: "#/components/schemas/ComplianceResult" compliance30: $ref: "#/components/schemas/ComplianceResult" - dailyCompliance: - type: object - propertyNames: - type: string - additionalProperties: - $ref: "#/components/schemas/DailyComplianceEntry" - description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped - to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — - this is the raw daily grid. complianceDisplay: $ref: "#/components/schemas/ComplianceDisplay" required: + - medicationId - compliance7 - compliance30 - - dailyCompliance - complianceDisplay additionalProperties: false - description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected - summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year - series used elsewhere for AI prompts does NOT apply here — this response is never downsampled. + description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the + cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication + `/compliance` endpoint for the history glyph track." ComplianceResult: type: object properties: @@ -8984,62 +9000,6 @@ components: description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map. - DailyComplianceEntry: - type: object - properties: - expected: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers. - expectedCount: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from - `expected`). - due: - type: boolean - description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / - pre-creation / PRN days are not misses." - taken: - type: integer - minimum: 0 - maximum: 9007199254740991 - skipped: - type: integer - minimum: 0 - maximum: 9007199254740991 - onTime: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant). - late: - type: integer - minimum: 0 - maximum: 9007199254740991 - veryLate: - type: integer - minimum: 0 - maximum: 9007199254740991 - early: - description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for - consumers that differentiate. - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - expected - - expectedCount - - due - - taken - - skipped - - onTime - - late - - veryLate - additionalProperties: false - description: Per-day compliance cell with the timing breakdown that drives the history glyph track. ComplianceDisplay: type: object properties: @@ -9206,6 +9166,106 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + GetMedicationComplianceResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationComplianceResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceResponse: + type: object + properties: + compliance7: + $ref: "#/components/schemas/ComplianceResult" + compliance30: + $ref: "#/components/schemas/ComplianceResult" + dailyCompliance: + type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/DailyComplianceEntry" + description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped + to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — + this is the raw daily grid. + complianceDisplay: + $ref: "#/components/schemas/ComplianceDisplay" + required: + - compliance7 + - compliance30 + - dailyCompliance + - complianceDisplay + additionalProperties: false + description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected + summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year + series used elsewhere for AI prompts does NOT apply here — this response is never downsampled. + DailyComplianceEntry: + type: object + properties: + expected: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers. + expectedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from + `expected`). + due: + type: boolean + description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / + pre-creation / PRN days are not misses." + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + onTime: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant). + late: + type: integer + minimum: 0 + maximum: 9007199254740991 + veryLate: + type: integer + minimum: 0 + maximum: 9007199254740991 + early: + description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for + consumers that differentiate. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - expected + - expectedCount + - due + - taken + - skipped + - onTime + - late + - veryLate + additionalProperties: false + description: Per-day compliance cell with the timing breakdown that drives the history glyph track. ListScheduleRevisionsResponse: type: object properties: diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 7a644fb26..e89faabaa 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/medications/route-guards", () => ({ - assertMedicationOwnership: vi.fn().mockResolvedValue(null), -})); - // v1.15.20 — the route reads through the per-user compliance cache and a // per-user rate limit. Pass-through mocks keep each test's fixture // isolated (the real module-scope cache would leak state across tests); -// the rate-limit tests below flip `allowed` explicitly. +// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the +// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the +// builder, so the pass-through exercises the guard on every call. vi.mock("@/lib/cache/server-cache", () => ({ - cached: vi.fn( + cachedSwr: vi.fn( async (_cache: unknown, _key: string, builder: () => Promise) => builder(), ), @@ -71,7 +69,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { cached } from "@/lib/cache/server-cache"; +import { cachedSwr } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; @@ -130,7 +128,7 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // `resetAllMocks` clears the factory implementations — re-prime the // pass-through cache and the allowing rate limit for every test. - vi.mocked(cached).mockImplementation( + vi.mocked(cachedSwr).mockImplementation( async (_cache: unknown, _key: string, builder: () => Promise) => builder(), ); @@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); expect(res.status).toBe(200); - expect(vi.mocked(cached)).toHaveBeenCalledTimes(1); - const [, key] = vi.mocked(cached).mock.calls[0]; + expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1); + const [, key] = vi.mocked(cachedSwr).mock.calls[0]; // `${userId}|...` prefix is what `invalidateUserMedications` sweeps. // The trailing tz segment (v1.16.1) makes a timezone change miss the // old entry instead of serving day buckets computed for the prior zone. @@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { }); }); }); + +// v1.16.8 — the ownership guard + medication read moved INSIDE the cache +// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by +// construction a payload this caller built for a medication they own, and +// the pre-cache session→rate-limit path no longer pays two row reads per +// card. The guard itself still runs on every cold build. +describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => { + it("returns the sealed 404 when the medication belongs to another user", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + ...medication([]), + userId: "user-2", + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(404); + const body = await res.json(); + // Same envelope as the absent case — the existence channel stays sealed. + expect(body.error).toBe("Medication not found"); + }); + + it("returns the sealed 404 when the medication does not exist", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Medication not found"); + }); + + it("never touches the medication tables on a warm cache hit", async () => { + // Simulate a warm cell: the SWR read resolves without invoking the + // builder, so neither the medication nor the intake table is read. + vi.mocked(cachedSwr).mockResolvedValue({ + compliance7: { rate: 100 }, + compliance30: { rate: 100 }, + dailyCompliance: {}, + complianceDisplay: { shortDays: 7, longDays: 30 }, + } as never); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + expect(prisma.medication.findUnique).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 2265171a5..ba7f2ef57 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { - buildComplianceMedicationContext, - buildMedicationComplianceBundle, - lastNonSkippedTakenAt, - type ComplianceDisplay, - type ComplianceResult, - type DailyComplianceEntry, -} from "@/lib/analytics/compliance"; -import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; -import { assertMedicationOwnership } from "@/lib/medications/route-guards"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; + buildCompliancePayload, + complianceCacheKey, + type CompliancePayload, +} from "@/lib/medications/compliance-payload"; +import { + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; -import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DAY_MS = 24 * 60 * 60 * 1000; - /** - * Lower bound for the intake-event read. The widest window any served - * block needs is the 365-day display rung; one extra day absorbs the - * inclusive-boundary edge so the ledger's own 365-day clamp never sits - * on a half-fetched day. + * Builder-internal sentinel for "no such medication for this caller". + * Thrown from inside the cache builder so a rejected build is never + * persisted; the handler maps it onto the canonical 404 envelope (the + * same "Medication not found" shape `assertMedicationOwnership` mints, + * so the existence channel stays sealed). */ -const EVENT_FETCH_WINDOW_DAYS = 366; - -/** The cached response body (the exact public wire shape). */ -interface CompliancePayload { - compliance7: ComplianceResult; - compliance30: ComplianceResult; - dailyCompliance: Record; - complianceDisplay: ComplianceDisplay; -} - -/** - * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. - * - * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot - * count): a taken slot adds to `taken` and its timing bucket; a missed slot - * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming - * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule - * take — it counts as `taken` AND adds its own `expected` slot (so the - * heatmap's `missed = expected − taken − skipped` math stays non-negative) and - * reads on-time (a logged dose colours green). - */ -function bucketLedgerRow( - entry: DailyComplianceEntry, - row: DoseHistoryRow, -): void { - switch (row.status) { - case "taken_on_time": - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.onTime++; - break; - case "taken_late": - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.late++; - break; - case "missed": - entry.expected++; - entry.expectedCount++; - break; - case "skipped": - entry.expected++; - entry.expectedCount++; - entry.skipped++; - break; - case "upcoming": - // A future / still-takeable slot is due but not yet acted on. It counts - // toward the day's expected/due grid but not toward taken or missed. - entry.expected++; - entry.expectedCount++; - break; - case "ad_hoc": - // An off-schedule take: a real taken dose with no scheduled slot. Count - // it as taken + its own expected slot so the heatmap missed math holds. - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.onTime++; - break; +class MedicationNotFoundError extends Error { + constructor() { + super("Medication not found"); } } -/** - * The full compliance computation for one medication. Lifted out of the - * handler so the server cache can wrap it: ONE bounded intake-event read - * (only the four columns the math consumes, floored at the 366-day fetch - * window) feeds ONE shared band-expansion pass - * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks, - * the cadence-scaled display rows AND the 90-day heatmap — replacing the - * historical five-plus per-request expansions over an unbounded, - * all-columns event read. - */ -async function buildCompliancePayload( - medication: { - id: string; - createdAt: Date; - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - schedules: Parameters[1]; - }, - userId: string, - userTz: string, -): Promise { - // v1.15.9 — pin a single `now` and thread it into every cadence - // computation so no block can straddle a day boundary on a slow request. - const now = new Date(); - const createdAt = medication.createdAt; - - const fetchFrom = new Date( - Math.max( - createdAt.getTime(), - now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, - ), - ); - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance read. - // Bounded to the 366-day fetch window: every served block is a suffix - // of the 365-day ledger window, so older rows can never change the - // response. - where: { - medicationId: medication.id, - userId, - deletedAt: null, - scheduledFor: { gte: fetchFrom }, - }, - orderBy: { scheduledFor: "desc" }, - select: { - takenAt: true, - skipped: true, - scheduledFor: true, - // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a - // miss, not a neutral skip. - autoMissed: true, - // v1.15.20 — pinned takes bind by anchor in the unified ledger so the - // % agrees with the history view on a "zugeordnet" dose (taken-late). - attributionSource: true, - }, - }); - - // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator - // routes through the canonical engine (RRULE / rolling / one-shot / PRN / - // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the - // fetch window (rolling cadences re-anchor on it). - const lastIntakeAt = lastNonSkippedTakenAt(events); - const medicationContext = buildComplianceMedicationContext( - medication, - lastIntakeAt, - userTz, - ); - - // ONE shared expansion pass for every served block (the v1.15.20 - // performance fix): bands + ledger + timeline are minted once over the - // widest window and each sub-window is a tally over the same rows. - const bundle = buildMedicationComplianceBundle( - events, - medication.schedules, - medicationContext, - now, - ); - - // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE - // unified dose-history ledger (the same bands the % + the history view - // read), so the per-day timing split (on-time / late) and the per-day - // missed marks can never disagree with the headline rate. The 90-day - // window is carved out of the shared ledger by `row.at`; the window - // floor clamps to the medication's creation so pre-existence days never - // mint phantom slots. - const heatmapFrom = new Date( - Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), - ); - - const dailyCompliance: Record = {}; - const byDay = new Map(); - const ensureDay = (key: string): DailyComplianceEntry => { - let entry = byDay.get(key); - if (!entry) { - entry = { - expected: 0, - expectedCount: 0, - due: false, - taken: 0, - skipped: 0, - onTime: 0, - late: 0, - veryLate: 0, - early: 0, - }; - byDay.set(key, entry); - } - return entry; - }; - - // Bucket each ledger row into its user-tz day. A slot row buckets on its - // anchor; an ad-hoc row on its real take time. - for (const row of bundle.ledgerRows) { - if (row.at.getTime() < heatmapFrom.getTime()) continue; - const key = userDayKey(row.at, userTz); - const entry = ensureDay(key); - bucketLedgerRow(entry, row); - } - - for (const [key, entry] of byDay) { - entry.due = entry.expectedCount > 0; - dailyCompliance[key] = entry; - } - - return { - compliance7: bundle.compliance7, - compliance30: bundle.compliance30, - dailyCompliance, - complianceDisplay: bundle.complianceDisplay, - }; -} - export const GET = apiHandler( async (_request: Request, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; - // The medications list fans this endpoint out once per card, so the - // per-user budget is generous — it only caps a runaway client loop. + // The per-user budget is generous — it only caps a runaway client + // loop (the cards read the batched `/api/medications/compliance` + // sibling; this endpoint serves the detail page). const rl = await checkRateLimit( `medication-compliance:${user.id}`, 60, @@ -240,39 +47,50 @@ export const GET = apiHandler( return apiError("Too many compliance requests. Please retry later.", 429); } - // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - const medication = await prisma.medication.findUnique({ - where: { id }, - include: { - schedules: true, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - }); - - if (!medication) { - return apiError("Medication not found", 404); - } - const userTz = user.timezone || "Europe/Berlin"; - // Read-through the per-user compliance cache (15 min TTL). Every - // intake / medication write flushes the `${userId}|` prefix via - // `invalidateUserMedications`, so a warm entry can only be stale - // relative to wall-clock drift, never to a user action. The key - // carries the timezone the payload was bucketed in: day keys, the - // heatmap, and the overdue cut all derive from `userTz`, so a - // timezone change must miss the old entry instead of serving day - // buckets computed for the previous zone until the TTL lapses. - const payload = await cached( - caches.medicationCompliance as ServerCache, - `${user.id}|${id}|compliance|${userTz}`, - () => buildCompliancePayload(medication, user.id, userTz), - annotate, - ); + // Read-through the per-user compliance cache (15 min fresh TTL + + // SWR window). The ownership guard and the medication read live + // INSIDE the builder: the cache key is `${userId}|${medicationId}|…`, + // so a warm or stale hit is by construction a payload this caller + // already built for a medication they own — re-running the guard on + // every hit only re-paid two row reads per card. A cross-user id + // misses this user's prefix, runs the builder, and 404s there. + // + // Interactive intake / medication writes EVICT the `${userId}|` + // prefix via `invalidateUserMedications({ evict: true })`, so a warm + // entry can only be stale relative to wall-clock drift, never to the + // user's own action; background sync paths mark the bucket stale and + // the SWR read serves the prior payload while one coalesced rebuild + // warms a fresh one. + let payload: CompliancePayload; + try { + payload = await cachedSwr( + caches.medicationCompliance as ServerCache, + complianceCacheKey(user.id, id, userTz), + async () => { + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { + schedules: true, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + }); + // Privacy gate — same sealed 404 for "absent" and "not yours". + if (!medication || medication.userId !== user.id) { + throw new MedicationNotFoundError(); + } + return buildCompliancePayload(medication, user.id, userTz); + }, + annotate, + ); + } catch (err) { + if (err instanceof MedicationNotFoundError) { + return apiError("Medication not found", 404); + } + throw err; + } annotate({ action: { diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index b8896c325..47b93df37 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -285,7 +285,7 @@ export const PUT = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the edited event. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the rollup row for both the old + new // scheduledFor day-keys when the dose moved across a day boundary @@ -364,7 +364,7 @@ export const DELETE = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the deletion. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the rollup row for the day the deleted // event sat on. When that day now holds zero events the helper diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 46732457d..29eb45e76 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -180,7 +180,7 @@ export const POST = apiHandler( meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size }, }); - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ deleted: count }); }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 9765c07ac..96e618490 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -163,7 +163,7 @@ export const POST = apiHandler( // (list, intake caches, compliance payload). This write path was the // one intake surface missing the flush. if (imported > 0) { - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); } await auditLog("medication.intake.import", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 7328792f0..351f3c821 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => { it("invalidates the user medication caches on success (F-1 C-3)", async () => { await DELETE(deleteReq(), ROUTE_PARAMS); - expect(invalidateUserMedications).toHaveBeenCalledWith("user-1"); + // v1.16.8 — the purge is an interactive write, so it hard-evicts the + // SWR buckets instead of marking them stale. + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); }); it("returns the 404 from the shared ownership helper without invalidating", async () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 61be38f6d..1988e879c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -64,7 +64,7 @@ export const DELETE = apiHandler( // post-purge counts rather than reading stale values for the // TTL of each cache. Matches the sibling routes (POST intake, // PUT medication, bulk-delete) that all fire this bundle. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ purged: true, count }); }, diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 1283727bc..faa5beb26 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the dose event. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the persistent compliance rollup for the // affected day. The hook is best-effort; failures annotate but never @@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // actually receive its dose. const reconcileAction = await reconcileOneShotState(prisma, id, user.id); if (reconcileAction !== "noop") { - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); } return apiSuccess(event, 201); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index cbca72a9d..919c01e26 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -618,7 +618,7 @@ export const PUT = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the schedule change. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ ...medication, @@ -675,7 +675,7 @@ export const DELETE = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the deletion. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index cf47f9795..69c198e46 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({ cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => f(), ), + cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => + f(), + ), caches: { medicationsList: {} }, __resetAllCachesForTests: vi.fn(), })); diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts new file mode 100644 index 000000000..b947205d6 --- /dev/null +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -0,0 +1,196 @@ +/** + * v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`). + * + * One round trip returns a compact adherence row per medication the + * caller owns, replacing the per-card fan-out over the per-id endpoint. + * The rows build through the SAME per-medication cache cells + * (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route + * reads, so the two endpoints warm each other and invalidate together. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/cache/server-cache", () => ({ + cachedSwr: vi.fn( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ), + caches: { medicationCompliance: {} }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const TZ = "UTC"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + timezone: TZ, + }, +}; + +function dailySchedule(id: string) { + return { + id, + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +function medication(id: string) { + return { + id, + userId: "user-1", + createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [dailySchedule(`${id}-sched`)], + scheduleRevisions: [], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(cachedSwr).mockImplementation( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/medications/compliance", () => { + it("returns one compact row per medication, without the heatmap grid", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const items = body.data as Array>; + + expect(items).toHaveLength(2); + expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]); + for (const item of items) { + expect(item.compliance7).toBeDefined(); + expect(item.compliance30).toBeDefined(); + expect(item.complianceDisplay).toBeDefined(); + // The heavy per-day grid stays on the per-id endpoint. + expect(item).not.toHaveProperty("dailyCompliance"); + } + }); + + it("reads each medication through the shared per-medication cache cell", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + + // Same key shape as the per-id route, so the two endpoints warm each + // other and `invalidateUserMedications` sweeps both with one prefix. + const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key); + expect(keys).toEqual([ + "user-1|med-1|compliance|UTC", + "user-1|med-2|compliance|UTC", + ]); + }); + + it("scopes the medication read to the calling user", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + + const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as { + where: { userId: string }; + }; + expect(call.where.userId).toBe("user-1"); + }); + + it("returns 429 when the per-user rate limit is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(429); + expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith( + "medication-compliance-summary:user-1", + 30, + 60_000, + ); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts new file mode 100644 index 000000000..2bf720e01 --- /dev/null +++ b/src/app/api/medications/compliance/route.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { + buildCompliancePayload, + complianceCacheKey, + type CompliancePayload, +} from "@/lib/medications/compliance-payload"; +import { + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** + * Batched card-compliance read: one round trip for every medication the + * caller owns, replacing the per-card fan-out over + * `GET /api/medications/{id}/compliance` (one request per card, ~1 s + * cold each). The per-medication payload still builds and caches through + * the SAME `medicationCompliance` cells the per-id route reads, so this + * read warms the detail page (and vice versa) and both invalidate + * together on a write. + * + * The heavy `dailyCompliance` heatmap map is intentionally NOT on this + * wire shape — the cards render rates / streak / display block only; the + * detail page keeps the per-id route for the grid. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `medication-compliance-summary:${user.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many compliance requests. Please retry later.", 429); + } + + const userTz = user.timezone || "Europe/Berlin"; + + // Same ordering as the medications list so the page's card order and + // this payload walk the same sequence. + const medications = await prisma.medication.findMany({ + where: { userId: user.id }, + include: { + schedules: true, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Sequential on purpose: each cold cell costs one bounded intake read + + // one band-expansion pass, and a sequential walk keeps a many-meds + // account from stampeding the pool. Warm / stale cells return without + // touching the database at all. + const items = []; + for (const medication of medications) { + const payload = await cachedSwr( + caches.medicationCompliance as ServerCache, + complianceCacheKey(user.id, medication.id, userTz), + () => buildCompliancePayload(medication, user.id, userTz), + annotate, + ); + items.push({ + medicationId: medication.id, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + complianceDisplay: payload.complianceDisplay, + }); + } + + annotate({ + action: { name: "medication.compliance_summary.read" }, + meta: { count: items.length }, + }); + + return apiSuccess(items); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index d806fdc60..6b7b42759 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -569,7 +569,11 @@ async function postBulk(request: NextRequest): Promise { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches when at least one row landed so the next read reflects the - // ingested batch. + // ingested batch. v1.16.8 — this is the background sync path (iOS bulk + // ingest), so the SWR buckets are marked stale rather than hard-evicted: + // the readers keep serving the prior list / compliance payloads while + // one coalesced recompute warms each cell, instead of a high-frequency + // sync busting every card into an inline cold rebuild. if (inserted > 0 || updated > 0) { invalidateUserMedications(user.id); } diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index ff9ab8407..50891c00e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.34 IW-G — bust the medications + compliance + achievement // caches for this user so the next read reflects the dose change. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the persistent compliance rollup row for // the affected day so the next read after the cache miss returns diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index ee3f6b32d..34f8c1418 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -21,7 +21,7 @@ import { } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NextRequest } from "next/server"; type MedicationsListResult = Array>; @@ -187,11 +187,16 @@ export const GET = apiHandler(async () => { const userTz = user.timezone || "Europe/Berlin"; - // Cache the list shape on userId. The 60s TTL bounds the cross-midnight - // staleness window for `todayEventCount` (the only time-of-day-derived - // field in the response); writes via POST/PUT/DELETE flush through - // `invalidateUserMedications` before the next read lands. - const result = await cached( + // Cache the list shape on userId. The 60 s fresh TTL bounds the + // cross-midnight staleness window for `todayEventCount` and the + // next-due fields; v1.16.8 reads through `cachedSwr`, so a visit + // minutes after the last one serves the prior list immediately while + // one background recompute warms a fresh entry (the 10-minute stale + // window bounds how old that served list can be). Interactive writes + // via POST/PUT/DELETE hard-evict through + // `invalidateUserMedications({ evict: true })` before the next read + // lands; background sync paths mark the bucket stale instead. + const result = await cachedSwr( caches.medications as ServerCache, user.id, () => buildMedicationsList(user.id, userTz), @@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the new schedule. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess( { diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index e0a9902a3..0c8c712d0 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -327,11 +327,20 @@ describe("streak-token parity — generic vs GLP-1 card", () => { ], }; + // v1.16.8 — the cards read ONE batched summary key and `select` their + // own row, so seeding merges into the shared array. function seedStreak(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } it("both cards render the flame with the canonical token, never the drift", () => { diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index fb5517007..4ddaa4688 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -140,13 +140,22 @@ function seedCompliance( streak?: number; } = {}, ) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { - rate: payload.rate7 ?? 85, - streak: payload.streak ?? 0, + // v1.16.8 — the cards read ONE batched summary key and `select` their + // own row, so seeding merges into the shared array. + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { + rate: payload.rate7 ?? 85, + streak: payload.streak ?? 0, + }, + compliance30: { rate: payload.rate30 ?? 82 }, }, - compliance30: { rate: payload.rate30 ?? 82 }, - }); + ]); } function render( diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index 0f14c672c..3c01dcf44 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -34,11 +34,20 @@ function render(node: React.ReactNode, client: QueryClient) { ); } +// v1.16.8 — the cards read ONE batched summary key and `select` their +// own row, so seeding merges into the shared array. function seedCompliance(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } // A schedule whose window has already passed for today (01:00–02:00) so diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index 12c6af0b4..5059cda57 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -59,11 +59,21 @@ function render( ); } +// v1.16.8 — the cards read ONE batched summary key and `select` their +// own row, so seeding merges into the shared array instead of writing a +// per-medication key. function seedCompliance(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } function seedGlp1Details(client: QueryClient, medId: string) { diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 72466325a..0c4791549 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -379,9 +379,11 @@ describe("runUndoIntake — shared soft-delete", () => { await runUndoIntake({ medication, eventId: "evt-1", t, queryClient }); + // Method + path are the contract; the shared fetch wrapper may attach + // transport details (e.g. an abort signal) the undo path doesn't own. expect(fetchMock).toHaveBeenCalledWith( "/api/medications/med-1/intake/evt-1", - { method: "DELETE" }, + expect.objectContaining({ method: "DELETE" }), ); expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone"); expect(queryClient.invalidateQueries).toHaveBeenCalled(); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index a0850c332..c2cd0c74d 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -160,19 +160,24 @@ export function MedicationCardBody({ {/* Top status line. The in-window take-now pill (success) and the overdue escalation are mutually exclusive — a dose is either still in its take-window or past it. The overdue line wins when present - so a heavily-overdue dose reads loud at the top of the card. */} - {overdueLabel ? ( -

-

- ) : windowStatus ? ( - - ) : null} + so a heavily-overdue dose reads loud at the top of the card. + The slot keeps a constant one-line height even when empty: the + overdue tier arrives with the compliance query, and a line + popping in late used to shift every row below it (CLS). */} +
+ {overdueLabel ? ( +

+

+ ) : windowStatus ? ( + + ) : null} +
{/* Next / last intake — the two decisive lines, each shown once. */} @@ -192,9 +197,14 @@ export function MedicationCardBody({ ))} - {/* Open-cycle status line — calm, rate-decoupled. */} - {active && currentCycle && ( - + {/* Open-cycle status line — calm, rate-decoupled. The slot reserves + one xs text line on every active card: the descriptor rides the + compliance query, and a line appearing after the fetch resolved + used to grow the card and shift the grid row (CLS). */} + {active && ( +
+ {currentCycle && } +
)} {/* Quick actions — bottom-pinned so the action rows align across a diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index cc807610b..df99aaa17 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -26,7 +26,7 @@ import { toBerlinDate, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; -import type { ComplianceDisplay } from "@/lib/analytics/compliance"; +import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; /** * v1.4.25 W4d — GLP-1 medication card variant. @@ -110,19 +110,6 @@ export interface Glp1Medication { schedules: ScheduleLite[]; } -interface ComplianceData { - compliance7: { rate: number; streak: number }; - compliance30: { rate: number }; - /** - * v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1 - * injections are commonly weekly, so this card's windows step up to the - * 30-/90-day rung where the bars stay meaningful. Additive — older mocks - * omit it, in which case the card falls back to the static 7-/30-day - * fields. - */ - complianceDisplay?: ComplianceDisplay; -} - interface DetailsResponse { doseChanges: DoseChangeLite[]; recentIntakes: IntakeLite[]; @@ -202,22 +189,10 @@ export function Glp1MedicationCard({ }, }); - const { data: compliance } = useQuery({ - queryKey: queryKeys.medicationCompliance(medication.id), - queryFn: async () => { - try { - return await apiGet( - `/api/medications/${medication.id}/compliance`, - ); - } catch { - return null; - } - }, - // v1.15.20 — same 5-minute window as the generic card: dose actions - // invalidate the key explicitly, so a short staleTime only re-fired - // a request per card on every list visit. - staleTime: 5 * 60 * 1000, - }); + // v1.16.8 — same batched compliance source as the generic card: one + // `GET /api/medications/compliance` round trip shared by every card on + // the page (the per-id endpoint stays for the detail page's heatmap). + const { data: compliance } = useMedicationComplianceSummary(medication.id); // v1.4.37 W4b — same reminder-thresholds source as the generic // medication card so the take-now / overdue / very-overdue pill diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 724960d9b..a9cac69e2 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -23,10 +23,10 @@ import { queryKeys, } from "@/lib/query-keys"; 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 type { InjectionSiteKey } from "@/lib/medications/injection-sites"; -import type { ComplianceDisplay } from "@/lib/analytics/compliance"; interface Schedule { id: string; @@ -87,25 +87,6 @@ interface Medication { schedules: Schedule[]; } -interface ComplianceData { - compliance7: { - rate: number; - streak: number; - }; - compliance30: { - rate: number; - }; - /** - * v1.8.6 — the two compliance windows scaled to the dosing cadence. The - * card always shows two percentage rows; `shortDays` / `longDays` name the - * windows and `short` / `long` carry their rates. v1.15.9 also carries the - * open dose's `currentDose.status` for the green / overdue presentation. - * Additive — older mocks omit it, in which case the card falls back to the - * static 7-/30-day `compliance7` / `compliance30` fields and a calm state. - */ - complianceDisplay?: ComplianceDisplay; -} - interface MedicationCardProps { medication: Medication; onEdit: (med: Medication) => void; @@ -147,24 +128,10 @@ export function MedicationCard({ }, }); - const { data: compliance } = useQuery({ - queryKey: queryKeys.medicationCompliance(medication.id), - queryFn: async () => { - try { - return await apiGet( - `/api/medications/${medication.id}/compliance`, - ); - } catch { - return null; - } - }, - // v1.15.20 — the card renders two percentage rows that move on a - // dose action (which invalidates this key through - // `medicationDependentKeys`) or over hours of wall-clock drift; a - // 30 s window only re-fired one request per card on every list - // visit. Five minutes matches the reminder-thresholds query below. - staleTime: 5 * 60 * 1000, - }); + // v1.16.8 — the per-card compliance read rides the ONE batched + // `GET /api/medications/compliance` round trip every card on the page + // shares (the per-id endpoint stays for the detail page's heatmap). + const { data: compliance } = useMedicationComplianceSummary(medication.id); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index f0cb8941e..15bf74a86 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -213,6 +213,57 @@ describe("invalidateUserMedications", () => { caches.medicationCompliance.get(`${USER_B}|med-1|compliance`), ).not.toBeNull(); }); + + it("marks the SWR buckets stale by default — background sync posture", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A); + + // v1.16.8 — the default (bulk sync / cron) path marks stale: the + // entries are gone for plain `get()` (asserted above) but stay + // serveable for the `cachedSwr` readers (medications list, both + // compliance routes, the dashboard snapshot), so a high-frequency + // iOS sync never busts every card into an inline cold rebuild. + expect(caches.medications.getAllowStale(USER_A)).toEqual({ + value: { m: 1 }, + stale: true, + }); + expect( + caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), + ).toEqual({ value: { mc: 1 }, stale: true }); + expect( + caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), + ).toEqual({ value: { snap: 1 }, stale: true }); + + // User B stays fresh. + expect(caches.medications.getAllowStale(USER_B)).toEqual({ + value: { m: 2 }, + stale: false, + }); + }); + + it("hard-evicts the SWR buckets with { evict: true } — interactive write posture", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A, { evict: true }); + + // v1.16.8 — an interactive take / skip / CRUD write must NOT leave a + // stale-serveable body behind: the SWR readers would hand the user + // back the pre-write card rates. The evict drops the entries. + expect(caches.medications.getAllowStale(USER_A)).toBeNull(); + expect( + caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), + ).toBeNull(); + expect( + caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), + ).toBeNull(); + + // User B stays warm; the non-SWR buckets evict as before. + expect(caches.medications.getAllowStale(USER_B)).not.toBeNull(); + expect( + caches.medicationCompliance.getAllowStale(`${USER_B}|med-1|compliance`), + ).not.toBeNull(); + expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull(); + expect(caches.achievements.get(USER_A)).toBeNull(); + }); }); describe("invalidateUserDashboardWidgets", () => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index e315a8dc6..f1672e151 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -117,24 +117,51 @@ export function invalidateUserMood(userId: string): void { * Invalidate every cache that may reflect a user's medication state. * * Covers the medications cache, the medication-intake compliance cache - * (cached daily), and achievement progress. + * (cached daily), the per-medication compliance payload, and achievement + * progress. + * + * v1.16.8 — `evict` splits the SWR buckets (medications, the + * per-medication compliance payload, analytics) by write origin, the + * `invalidateUserMeasurements` pattern. Interactive writes (a card take / + * skip, a CRUD edit, an intake edit / delete / purge / import) pass + * `{ evict: true }`: the user must see their own action on the very next + * read, and a stale-serveable entry would hand back the pre-write body. + * Background paths (the iOS bulk-intake sync, the auto-miss cron, slot + * dedup) keep the default mark-stale so a high-frequency sync never busts + * the buckets into inline cold-rebuild storms — the `cachedSwr` readers + * serve the prior payload while one coalesced recompute warms each cell. */ -export function invalidateUserMedications(userId: string): void { - caches.medications.deleteByPrefix(userId); +export function invalidateUserMedications( + userId: string, + opts?: { evict?: boolean }, +): void { + if (opts?.evict) { + caches.medications.deleteByPrefix(userId); + // v1.15.20 — the per-medication compliance payload is cached 15 min; + // an interactive intake / medication / schedule write must flush it + // so the card rates and the heatmap reflect the action on the next + // read. + caches.medicationCompliance.deleteByPrefix(`${userId}|`); + // v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics + // cache and reads `medicationIntakeEvent` for both today's compliance + // tally and the 365-day streak feed. A taken / skipped event must + // evict the user-bucket so the next iOS poll reflects the change. + caches.analytics.deleteByPrefix(`${userId}|`); + } else { + // Mark-stale collapses the fresh TTL to "now": the SWR readers (the + // medications list, both compliance routes, the dashboard snapshot) + // serve the prior value while a single background recompute warms a + // fresh one; the plain `cached` readers in the analytics bucket see + // a clean miss — identical to the old evict for those keys. + caches.medications.markStaleByPrefix(userId); + caches.medicationCompliance.markStaleByPrefix(`${userId}|`); + caches.analytics.markStaleByPrefix(`${userId}|`); + } caches.medicationsIntake.deleteByPrefix(`${userId}|`); - // v1.15.20 — the per-medication compliance payload is cached 15 min; - // every intake / medication / schedule write must flush it so the card - // rates and the heatmap reflect the action on the next read. - caches.medicationCompliance.deleteByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); // v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE // target rollup (compliance7 / compliance30 / consistency strip). caches.insightsTargets.deleteByPrefix(userId); - // v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics - // cache and reads `medicationIntakeEvent` for both today's compliance - // tally and the 365-day streak feed. A taken / skipped event must - // evict the user-bucket so the next iOS poll reflects the change. - caches.analytics.deleteByPrefix(`${userId}|`); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 9ecfa729c..44dcffd22 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -363,10 +363,22 @@ export const caches = { ttlMs: 60_000, staleTtlMs: 600_000, }), + /** + * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh + * TTL meant every visit minutes apart paid the full cold list build + * (five parallel reads + the next-due engine per medication). The list + * GET reads via `cachedSwr` now: inside the 10-minute stale window the + * prior list serves immediately while one background recompute warms a + * fresh one. Interactive writes hard-evict the bucket through + * `invalidateUserMedications({ evict: true })`, so the window only + * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never + * user-action staleness. + */ medications: new ServerCache({ name: "medications", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), achievements: new ServerCache({ name: "achievements", @@ -405,11 +417,21 @@ export const caches = { * `invalidateUserMedications`, so the 15-minute TTL only bounds * wall-clock drift (a dose flipping overdue), never user-action * staleness. Sized for a few hundred users × a handful of meds. + * + * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id + * detail read and the batched card read) consume via `cachedSwr`: + * inside the 10-minute stale window an expired cell serves the prior + * payload immediately while one coalesced rebuild warms it. Interactive + * intake / medication writes hard-evict (the user must see their own + * dose on the next read); background sync paths (iOS bulk intake, the + * auto-miss cron, slot dedup) mark stale so a high-frequency sync never + * busts every card into an inline cold rebuild. */ medicationCompliance: new ServerCache({ name: "medicationCompliance", maxEntries: 2000, ttlMs: 900_000, + staleTtlMs: 600_000, }), moodAnalytics: new ServerCache({ name: "moodAnalytics", diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts new file mode 100644 index 000000000..927eccdba --- /dev/null +++ b/src/lib/medications/compliance-payload.ts @@ -0,0 +1,240 @@ +/** + * The full compliance computation for one medication, shared by + * `GET /api/medications/[id]/compliance` (detail page, full payload incl. + * the 90-day heatmap) and `GET /api/medications/compliance` (the batched + * card read). Lifted out of the per-id route so the server cache can wrap + * one canonical builder from both entry points: ONE bounded intake-event + * read (only the columns the math consumes, floored at the 366-day fetch + * window) feeds ONE shared band-expansion pass + * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks, + * the cadence-scaled display rows AND the 90-day heatmap. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + lastNonSkippedTakenAt, + type ComplianceDisplay, + type ComplianceResult, + type DailyComplianceEntry, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { userDayKey } from "@/lib/tz/format"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Lower bound for the intake-event read. The widest window any served + * block needs is the 365-day display rung; one extra day absorbs the + * inclusive-boundary edge so the ledger's own 365-day clamp never sits + * on a half-fetched day. + */ +const EVENT_FETCH_WINDOW_DAYS = 366; + +/** The cached response body (the exact public wire shape). */ +export interface CompliancePayload { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + dailyCompliance: Record; + complianceDisplay: ComplianceDisplay; +} + +/** The medication slice the payload builder consumes. */ +export interface ComplianceMedicationInput { + id: string; + createdAt: Date; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + schedules: Parameters[1]; + /** v1.16.3 — archived schedule eras for era-aware compliance. */ + scheduleRevisions?: Parameters< + typeof buildComplianceMedicationContext + >[0]["scheduleRevisions"]; +} + +/** + * One canonical cache key per `(user, medication, timezone)` cell. The + * key carries the timezone the payload was bucketed in: day keys, the + * heatmap, and the overdue cut all derive from `userTz`, so a timezone + * change must miss the old entry instead of serving day buckets computed + * for the previous zone until the TTL lapses. The `${userId}|` prefix is + * what `invalidateUserMedications` sweeps. + */ +export function complianceCacheKey( + userId: string, + medicationId: string, + userTz: string, +): string { + return `${userId}|${medicationId}|compliance|${userTz}`; +} + +/** + * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. + * + * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot + * count): a taken slot adds to `taken` and its timing bucket; a missed slot + * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming + * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule + * take — it counts as `taken` AND adds its own `expected` slot (so the + * heatmap's `missed = expected − taken − skipped` math stays non-negative) and + * reads on-time (a logged dose colours green). + */ +function bucketLedgerRow( + entry: DailyComplianceEntry, + row: DoseHistoryRow, +): void { + switch (row.status) { + case "taken_on_time": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + case "taken_late": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.late++; + break; + case "missed": + entry.expected++; + entry.expectedCount++; + break; + case "skipped": + entry.expected++; + entry.expectedCount++; + entry.skipped++; + break; + case "upcoming": + // A future / still-takeable slot is due but not yet acted on. It counts + // toward the day's expected/due grid but not toward taken or missed. + entry.expected++; + entry.expectedCount++; + break; + case "ad_hoc": + // An off-schedule take: a real taken dose with no scheduled slot. Count + // it as taken + its own expected slot so the heatmap missed math holds. + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + } +} + +export async function buildCompliancePayload( + medication: ComplianceMedicationInput, + userId: string, + userTz: string, +): Promise { + // v1.15.9 — pin a single `now` and thread it into every cadence + // computation so no block can straddle a day boundary on a slow request. + const now = new Date(); + const createdAt = medication.createdAt; + + const fetchFrom = new Date( + Math.max( + createdAt.getTime(), + now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, + ), + ); + const events = await prisma.medicationIntakeEvent.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the compliance read. + // Bounded to the 366-day fetch window: every served block is a suffix + // of the 365-day ledger window, so older rows can never change the + // response. + where: { + medicationId: medication.id, + userId, + deletedAt: null, + scheduledFor: { gte: fetchFrom }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + takenAt: true, + skipped: true, + scheduledFor: true, + // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a + // miss, not a neutral skip. + autoMissed: true, + // v1.15.20 — pinned takes bind by anchor in the unified ledger so the + // % agrees with the history view on a "zugeordnet" dose (taken-late). + attributionSource: true, + }, + }); + + // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator + // routes through the canonical engine (RRULE / rolling / one-shot / PRN / + // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the + // fetch window (rolling cadences re-anchor on it). + const lastIntakeAt = lastNonSkippedTakenAt(events); + const medicationContext = buildComplianceMedicationContext( + medication, + lastIntakeAt, + userTz, + ); + + // ONE shared expansion pass for every served block (the v1.15.20 + // performance fix): bands + ledger + timeline are minted once over the + // widest window and each sub-window is a tally over the same rows. + const bundle = buildMedicationComplianceBundle( + events, + medication.schedules, + medicationContext, + now, + ); + + // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE + // unified dose-history ledger (the same bands the % + the history view + // read), so the per-day timing split (on-time / late) and the per-day + // missed marks can never disagree with the headline rate. The 90-day + // window is carved out of the shared ledger by `row.at`; the window + // floor clamps to the medication's creation so pre-existence days never + // mint phantom slots. + const heatmapFrom = new Date( + Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), + ); + + const dailyCompliance: Record = {}; + const byDay = new Map(); + const ensureDay = (key: string): DailyComplianceEntry => { + let entry = byDay.get(key); + if (!entry) { + entry = { + expected: 0, + expectedCount: 0, + due: false, + taken: 0, + skipped: 0, + onTime: 0, + late: 0, + veryLate: 0, + early: 0, + }; + byDay.set(key, entry); + } + return entry; + }; + + // Bucket each ledger row into its user-tz day. A slot row buckets on its + // anchor; an ad-hoc row on its real take time. + for (const row of bundle.ledgerRows) { + if (row.at.getTime() < heatmapFrom.getTime()) continue; + const key = userDayKey(row.at, userTz); + const entry = ensureDay(key); + bucketLedgerRow(entry, row); + } + + for (const [key, entry] of byDay) { + entry.due = entry.expectedCount > 0; + dailyCompliance[key] = entry; + } + + return { + compliance7: bundle.compliance7, + compliance30: bundle.compliance30, + dailyCompliance, + complianceDisplay: bundle.complianceDisplay, + }; +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 0890be136..fbbde4171 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -507,6 +507,19 @@ const medicationComplianceResponse = z "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", }); +const medicationComplianceSummaryEntry = z + .object({ + medicationId: z.string(), + compliance7: complianceResult, + compliance30: complianceResult, + complianceDisplay, + }) + .meta({ + id: "MedicationComplianceSummaryEntry", + description: + "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.", + }); + // v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). // Archived eras come from two provenances: the wholesale-replace write // path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow @@ -856,6 +869,28 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/compliance": { + get: { + tags: ["Medications"], + summary: "Batched adherence read for every medication of the caller", + description: + "Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.", + responses: { + "200": { + description: "One adherence row per medication.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(medicationComplianceSummaryEntry), + "ListMedicationComplianceResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/{id}/compliance": { get: { tags: ["Medications"], diff --git a/src/lib/queries/prefetch-medications.ts b/src/lib/queries/prefetch-medications.ts index e6f1bd6b6..4ac9bd05a 100644 --- a/src/lib/queries/prefetch-medications.ts +++ b/src/lib/queries/prefetch-medications.ts @@ -12,11 +12,20 @@ import { apiGet } from "@/lib/api/api-fetch"; * puts `/api/medications` (the response carries the per-medication * `nextDueAt` due times) in flight while the navigation commits. * + * v1.16.8 — the batched card-compliance read + * (`/api/medications/compliance`) prefetches alongside the list, so the + * compliance bars + the status line resolve in the same paint as the + * cards instead of swapping in late. Two independent requests on + * purpose: the list response must stay fast (the due line never waits + * on a compliance ledger build), and the compliance prefetch rides the + * same server-side cache cells the page query would warm anyway. + * * The 15 s prefetch window only bounds how old a cache entry may be * before an intent re-fires the request — the mounted page query rides * the provider-default `staleTime` (5 min) and consumes whatever this - * prefetch parked. The server caches the list 60 s per user, so even a - * missed window stays cheap. + * prefetch parked. The server caches both reads per user (60 s list / + * 15 min compliance, each with an SWR window), so even a missed window + * stays cheap. */ export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000; @@ -26,6 +35,11 @@ export function prefetchMedicationsList(queryClient: QueryClient): void { queryFn: () => apiGet("/api/medications"), staleTime: MEDICATIONS_LIST_STALE_TIME_MS, }); + void queryClient.prefetchQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: () => apiGet("/api/medications/compliance"), + staleTime: MEDICATIONS_LIST_STALE_TIME_MS, + }); } /** diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts new file mode 100644 index 000000000..0bba1d1bd --- /dev/null +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { ComplianceDisplay } from "@/lib/analytics/compliance"; + +/** + * One batched-summary row — the compact slice of the per-medication + * compliance payload the cards render (rates, streak, the cadence-scaled + * display block with `currentDose` / `currentCycle`). The heavy + * `dailyCompliance` heatmap stays on the per-id endpoint the detail page + * reads. + */ +export interface MedicationComplianceSummaryEntry { + medicationId: string; + compliance7: { rate: number; streak: number }; + compliance30: { rate: number }; + complianceDisplay?: ComplianceDisplay; +} + +/** + * v1.16.8 — the per-card compliance read, batched. + * + * Every medication card used to fire its own + * `GET /api/medications/{id}/compliance` (N+1 over the grid, ~1 s cold + * each). All cards now share ONE `GET /api/medications/compliance` + * round trip under a single query key; each card `select`s its own row, + * so a grid of mounted cards collapses into one request and one cache + * entry. The key rides the `["medications"]` prefix, so every intake / + * CRUD mutation that invalidates `medicationDependentKeys` refreshes it. + * + * Lives in one shared hook (not per-card `useQuery` calls) so the two + * card variants can never register the same key with diverging fetch + * shapes. + */ +export function useMedicationComplianceSummary(medicationId: string): { + data: MedicationComplianceSummaryEntry | null | undefined; +} { + const { data } = useQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: async () => { + try { + return await apiGet( + "/api/medications/compliance", + ); + } catch { + return null; + } + }, + // Dose actions invalidate the key explicitly through + // `medicationDependentKeys`; a shorter window would only re-fire the + // batch on every list visit. Matches the reminder-thresholds query + // the cards mount alongside. + staleTime: 5 * 60 * 1000, + select: (rows: MedicationComplianceSummaryEntry[] | null) => + rows?.find((row) => row.medicationId === medicationId) ?? null, + }); + return { data }; +} diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 278ef062e..99ecee37f 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -16,6 +16,18 @@ export const medicationKeys = { */ medicationCompliance: (medicationId: string) => ["medications", medicationId, "compliance"] as const, + /** + * v1.16.8 — the batched card-compliance read + * (`GET /api/medications/compliance`, one round trip for every card on + * the medications page). Replaces the per-card fan-out over the per-id + * endpoint; `medicationCompliance(id)` above stays for the detail page, + * which needs the per-id payload's heatmap grid. Rides under the + * `["medications"]` prefix so every intake / CRUD mutation reaches it + * through `medicationDependentKeys`. The static second segment cannot + * collide with `medicationDetail(id)` — ids are cuids. + */ + medicationComplianceSummary: () => + ["medications", "compliance-summary"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/tz/__tests__/intl-cache.test.ts b/src/lib/tz/__tests__/intl-cache.test.ts new file mode 100644 index 000000000..feb8768b2 --- /dev/null +++ b/src/lib/tz/__tests__/intl-cache.test.ts @@ -0,0 +1,185 @@ +/** + * v1.16.8 — memoised `Intl.DateTimeFormat` construction. + * + * Constructing a formatter dominates the CPU cost of the wall-clock + * helpers (band expansion calls them in a tight loop), so the memo must + * (a) return byte-identical output to a freshly constructed formatter + * for every timezone / option shape the tz helpers use, and (b) actually + * share one instance per signature. + */ + +import { describe, it, expect, beforeEach } from "vitest"; + +import { + getDateTimeFormat, + __resetIntlCacheForTests, + __intlCacheSizeForTests, +} from "../intl-cache"; +import { wallClockInTz } from "../wall-clock"; +import { formatInUserTz, userDayKey, isValidTimezone } from "../format"; +import { getUserTodayBounds, localHmAsUtc } from "../local-day"; + +const ZONES = [ + "UTC", + "Europe/Berlin", + "America/New_York", + "Asia/Tokyo", + "Pacific/Kiritimati", // +14, no DST + "America/St_Johns", // -03:30 fractional offset +]; + +// Instants chosen to straddle DST transitions and a midnight boundary. +const INSTANTS = [ + new Date("2026-01-15T03:30:00Z"), + new Date("2026-03-29T01:30:00Z"), // EU spring-forward window + new Date("2026-06-10T23:45:00Z"), + new Date("2026-10-25T01:30:00Z"), // EU fall-back window +]; + +beforeEach(() => { + __resetIntlCacheForTests(); +}); + +describe("getDateTimeFormat", () => { + it("matches a freshly constructed formatter across zones and option shapes", () => { + const optionShapes: Omit[] = [ + { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", + }, + { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }, + { hour: "2-digit", minute: "2-digit", hour12: false }, + ]; + for (const tz of ZONES) { + for (const options of optionShapes) { + const memoised = getDateTimeFormat("en-CA", tz, options); + const fresh = new Intl.DateTimeFormat("en-CA", { + ...options, + timeZone: tz, + }); + for (const instant of INSTANTS) { + expect(memoised.format(instant)).toBe(fresh.format(instant)); + expect(memoised.formatToParts(instant)).toEqual( + fresh.formatToParts(instant), + ); + } + } + } + }); + + it("returns the same instance for the same (locale, timeZone, options) signature", () => { + const options = { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const; + const a = getDateTimeFormat("en-GB", "Europe/Berlin", options); + const b = getDateTimeFormat("en-GB", "Europe/Berlin", options); + expect(a).toBe(b); + // A structurally equal but distinct options object still hits the + // same cache cell (signature-keyed, not identity-keyed). + const c = getDateTimeFormat("en-GB", "Europe/Berlin", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const); + expect(c).toBe(a); + }); + + it("splits cells on locale, timezone, and options", () => { + const options = { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const; + const base = getDateTimeFormat("en-GB", "Europe/Berlin", options); + expect(getDateTimeFormat("en-US", "Europe/Berlin", options)).not.toBe(base); + expect(getDateTimeFormat("en-GB", "Asia/Tokyo", options)).not.toBe(base); + expect( + getDateTimeFormat("en-GB", "Europe/Berlin", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + } as const), + ).not.toBe(base); + expect(__intlCacheSizeForTests()).toBe(4); + }); + + it("throws on an invalid timezone without caching the failure", () => { + expect(() => getDateTimeFormat("en-US", "Not/AZone", {})).toThrow(); + expect(__intlCacheSizeForTests()).toBe(0); + }); +}); + +describe("memoised consumers stay output-identical to the pre-memo helpers", () => { + it("wallClockInTz decomposes DST-transition instants correctly", () => { + // 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning). + const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin"); + expect(parts).toEqual({ + year: 2026, + month: 3, + day: 29, + hour: 3, + minute: 30, + second: 0, + weekday: 0, + }); + // Same instant read from Tokyo. + const tokyo = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Asia/Tokyo"); + expect(tokyo).toEqual({ + year: 2026, + month: 3, + day: 29, + hour: 10, + minute: 30, + second: 0, + weekday: 0, + }); + }); + + it("formatInUserTz keeps every shape stable across repeat (memo-warm) calls", () => { + const instant = new Date("2026-06-10T23:45:00Z"); + for (let i = 0; i < 2; i++) { + expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe( + "2026-06-11T01:45:00+02:00", + ); + expect(formatInUserTz(instant, "Europe/Berlin", "wall-clock")).toBe( + "01:45", + ); + expect(formatInUserTz(instant, "Europe/Berlin", "date")).toBe( + "2026-06-11", + ); + expect(userDayKey(instant, "America/New_York")).toBe("2026-06-10"); + } + }); + + it("getUserTodayBounds + localHmAsUtc agree with the wall-clock read", () => { + const now = new Date("2026-06-10T23:45:00Z"); // 01:45 on the 11th in Berlin + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + expect(start.toISOString()).toBe("2026-06-10T22:00:00.000Z"); + expect(end.toISOString()).toBe("2026-06-11T21:59:59.999Z"); + const eight = localHmAsUtc(now, "Europe/Berlin", 8, 0); + expect(eight.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("isValidTimezone accepts real zones and rejects junk through the memo", () => { + expect(isValidTimezone("Europe/Berlin")).toBe(true); + expect(isValidTimezone("Pacific/Kiritimati")).toBe(true); + expect(isValidTimezone("Not/AZone")).toBe(false); + expect(isValidTimezone("")).toBe(false); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 112c65dcc..f54d91e0a 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -12,8 +12,35 @@ * lookup. */ +import { getDateTimeFormat } from "./intl-cache"; + export const DEFAULT_TIMEZONE = "Europe/Berlin"; +/** + * Hoisted option constants so the formatter memo's WeakMap signature + * lookup hits the same object identity on every call — see + * `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by + * `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME + * memoised formatter per timezone. + */ +const VALIDATION_OPTIONS: Omit = {}; + +const HOUR_MINUTE_OPTIONS: Omit = { + hour: "2-digit", + minute: "2-digit", + hour12: false, +}; + +const WALL_CLOCK_PARTS_OPTIONS: Omit = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}; + /** * Validate a timezone string by asking `Intl.DateTimeFormat` to use it. * Returns `true` if the runtime accepts the zone, `false` otherwise. @@ -24,7 +51,10 @@ export function isValidTimezone(tz: string): boolean { return false; } try { - new Intl.DateTimeFormat("en-US", { timeZone: tz }); + // The memo only caches successful constructions, so probing an + // invalid zone through it cannot poison the formatter map — and a + // valid zone leaves its formatter warm for the real callers. + getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS); return true; } catch { return false; @@ -133,12 +163,9 @@ export function formatInUserTz( case "iso-with-offset": return formatIsoWithOffset(date, safeTz); case "wall-clock": - return new Intl.DateTimeFormat("en-GB", { - timeZone: safeTz, - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).format(date); + return getDateTimeFormat("en-GB", safeTz, HOUR_MINUTE_OPTIONS).format( + date, + ); case "datetime": { const parts = wallClockParts(date, safeTz); return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`; @@ -171,16 +198,7 @@ interface WallClockParts { } function wallClockParts(date: Date, tz: string): WallClockParts { - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? "00"; @@ -223,16 +241,7 @@ function formatIsoWithOffset(date: Date, tz: string): string { * difference between the wall-clock parts and the UTC parts. */ function tzOffsetMinutes(date: Date, tz: string): number { - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find((p) => p.type === type)?.value ?? "0"); diff --git a/src/lib/tz/intl-cache.ts b/src/lib/tz/intl-cache.ts new file mode 100644 index 000000000..d029a126b --- /dev/null +++ b/src/lib/tz/intl-cache.ts @@ -0,0 +1,77 @@ +/** + * Module-level memo for `Intl.DateTimeFormat` construction. + * + * Constructing a formatter resolves locale + timezone data on every call + * and dominates the CPU cost of the wall-clock helpers (~10x the + * `format` / `formatToParts` call it feeds). The instances are stateless + * for `format` / `formatToParts`, so one shared formatter per + * `(locale, timeZone, options)` signature is safe to hand to every + * caller — compliance band expansion, rollups, the dose-history ledger + * and the export formatters all hit the same small set of signatures. + * + * `timeZone` is a first-class key segment (not folded into `options`) so + * call sites can hoist their options object to a module constant; a + * WeakMap memoises that constant's serialised signature, leaving a warm + * lookup as two string concats + one Map get. + */ + +const formatterCache = new Map(); + +/** + * Hard cap. The realistic population is bounded (valid IANA zones × a + * handful of option shapes), but the timezone segment is user-supplied + * upstream of validation in `isValidTimezone`, so the memo carries a + * safety valve: on overflow the whole map resets and re-warms. + */ +const MAX_FORMATTERS = 1000; + +const signatureByOptions = new WeakMap< + Omit, + string +>(); + +function optionsSignature( + options: Omit, +): string { + const memoised = signatureByOptions.get(options); + if (memoised !== undefined) return memoised; + const record = options as Record; + const signature = Object.keys(record) + .sort() + .map((key) => `${key}=${String(record[key])}`) + .join(","); + signatureByOptions.set(options, signature); + return signature; +} + +/** + * Shared, memoised `Intl.DateTimeFormat`. Throws (like the constructor) + * on an invalid `timeZone` — failures are never cached, so + * `isValidTimezone` can probe through this without poisoning the map. + */ +export function getDateTimeFormat( + locale: string, + timeZone: string | undefined, + options: Omit = {}, +): Intl.DateTimeFormat { + const key = `${locale}|${timeZone ?? ""}|${optionsSignature(options)}`; + const hit = formatterCache.get(key); + if (hit) return hit; + const formatter = new Intl.DateTimeFormat( + locale, + timeZone === undefined ? options : { ...options, timeZone }, + ); + if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear(); + formatterCache.set(key, formatter); + return formatter; +} + +/** Test-only escape hatch — clear the memo between assertions. */ +export function __resetIntlCacheForTests(): void { + formatterCache.clear(); +} + +/** Test-only introspection — current memo population. */ +export function __intlCacheSizeForTests(): number { + return formatterCache.size; +} diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 4ed3626b2..11de1fd20 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -18,6 +18,8 @@ * staying in lock-step; keeping them as separate definitions made * silent drift a real failure mode. */ +import { getDateTimeFormat } from "./intl-cache"; + export interface WallClockParts { year: number; /** 1–12 (January = 1, December = 12). */ @@ -42,6 +44,23 @@ const WEEKDAY_MAP: Record = { Sat: 6, }; +/** + * Hoisted so the formatter memo's WeakMap signature lookup hits the same + * object identity on every call — a warm `wallClockInTz` no longer pays + * `Intl.DateTimeFormat` construction (the dominant cost of the band + * expansion + rollup paths that call this in a tight loop). + */ +const WALL_CLOCK_OPTIONS: Omit = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", +}; + export function wallClockInTz( date: Date, tz: string | undefined, @@ -57,17 +76,9 @@ export function wallClockInTz( weekday: date.getDay(), }; } - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - weekday: "short", - }).formatToParts(date); + const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts( + date, + ); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? "0"; let hour = Number(get("hour"));