diff --git a/public/sw.js b/public/sw.js index bbd0ae200..a726a174c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -50,16 +50,24 @@ self.addEventListener("install", (event) => { // ── Activate: clean up old caches ──────────────────────────────────────────── self.addEventListener("activate", (event) => { event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) - .map((k) => caches.delete(k)), + Promise.all([ + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) + .map((k) => caches.delete(k)), + ), ), - ) - .then(() => self.clients.claim()), + // Navigation preload lets the browser start the navigation request + // in parallel with service-worker startup; `networkFirst` consumes + // it via `event.preloadResponse`, shaving SW boot latency off every + // page navigation. Guarded — older engines lack the API. + self.registration.navigationPreload + ? self.registration.navigationPreload.enable() + : Promise.resolve(), + ]).then(() => self.clients.claim()), ); }); @@ -86,9 +94,10 @@ self.addEventListener("fetch", (event) => { return; } - // HTML pages — network-first with cache fallback + // HTML pages — network-first with cache fallback. The whole event is + // passed so `networkFirst` can consume the navigation-preload response. if (request.headers.get("accept")?.includes("text/html")) { - event.respondWith(networkFirst(request, PAGE_CACHE)); + event.respondWith(networkFirst(event, PAGE_CACHE)); return; } }); @@ -110,11 +119,21 @@ async function cacheFirst(request, cacheName) { } /** - * Network-first: try network, fall back to cache. + * Network-first: try network (preferring the navigation-preload response + * when the browser already started it), fall back to cache. + * + * The offline fallback only serves a shell cached under the CURRENT + * `CACHE_VERSION` cache names. The previous global `caches.match()` could + * resolve from a stale pre-update cache in the activation gap and serve a + * shell whose `/_next/static/*` chunk graph no longer exists + * (`ChunkLoadError` on the first lazy navigation). */ -async function networkFirst(request, cacheName) { +async function networkFirst(event, cacheName) { + const { request } = event; try { - const response = await fetch(request); + const response = + (event.preloadResponse ? await event.preloadResponse : null) || + (await fetch(request)); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); @@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) { } return response; } catch { - const cached = await caches.match(request); - if (cached) return cached; + // Version-scoped fallback: the page cache first, then the precached + // app shell — both names embed CACHE_VERSION, so a stale shell from a + // previous release can never be served here. + const pageCache = await caches.open(cacheName); + const cachedPage = await pageCache.match(request); + if (cachedPage) return cachedPage; + const staticCache = await caches.open(STATIC_CACHE); + const precached = await staticCache.match(request); + if (precached) return precached; // Offline fallback. F-7 (mobile security audit, 2026-05-16): the // previous fallback rendered hard-coded German body copy, which is @@ -143,14 +169,16 @@ async function networkFirst(request, cacheName) { } /** - * Trim cache to maxEntries by removing oldest entries. + * Trim cache to maxEntries by removing oldest entries. `keys()` returns + * insertion order, so one read + a single pass over the excess prefix + * replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads). */ async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); - let keys = await cache.keys(); - while (keys.length > maxEntries) { - await cache.delete(keys[0]); - keys = await cache.keys(); + const keys = await cache.keys(); + const excess = keys.length - maxEntries; + for (let i = 0; i < excess; i++) { + await cache.delete(keys[i]); } } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts new file mode 100644 index 000000000..66ba97460 --- /dev/null +++ b/src/__tests__/service-worker.test.ts @@ -0,0 +1,264 @@ +/** + * v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox. + * + * `public/sw.js` is plain script (no module system), so the test boots it + * inside a vm context with a fake CacheStorage and dispatches the captured + * event listeners. Three behaviours are pinned: + * + * 1. the offline HTML fallback only serves a shell cached under the + * CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would + * reference a chunk graph that no longer exists); + * 2. `activate` enables navigation preload and `networkFirst` consumes + * `event.preloadResponse` when the browser supplies it; + * 3. `trimCache` reads the key list once and deletes the excess prefix + * in a single pass (the previous loop re-fetched all keys per delete). + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import vm from "node:vm"; + +const SW_SOURCE = readFileSync( + resolve(__dirname, "../../public/sw.js"), + "utf8", +); + +const ORIGIN = "https://app.example"; +// `importScripts` throws in the sandbox, so the literal fallback version +// is active and the cache names are deterministic. +const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43"; +const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43"; + +class FakeCache { + map = new Map(); + keysCalls = 0; + + private keyOf(request: RequestInfo | URL): string { + if (typeof request === "string") return new URL(request, ORIGIN).href; + if (request instanceof URL) return request.href; + return request.url; + } + + async match(request: RequestInfo | URL): Promise { + return this.map.get(this.keyOf(request)); + } + + async put(request: RequestInfo | URL, response: Response): Promise { + this.map.set(this.keyOf(request), response); + } + + async addAll(urls: string[]): Promise { + for (const url of urls) { + this.map.set(this.keyOf(url), new Response(`precached:${url}`)); + } + } + + async keys(): Promise { + this.keysCalls += 1; + return [...this.map.keys()].map((url) => new Request(url)); + } + + async delete(request: RequestInfo | URL): Promise { + return this.map.delete(this.keyOf(request)); + } +} + +class FakeCacheStorage { + stores = new Map(); + + async open(name: string): Promise { + let store = this.stores.get(name); + if (!store) { + store = new FakeCache(); + this.stores.set(name, store); + } + return store; + } + + async keys(): Promise { + return [...this.stores.keys()]; + } + + async delete(name: string): Promise { + return this.stores.delete(name); + } + + async match(request: RequestInfo | URL): Promise { + for (const store of this.stores.values()) { + const hit = await store.match(request); + if (hit) return hit; + } + return undefined; + } +} + +interface SwHarness { + listeners: Map void>; + cacheStorage: FakeCacheStorage; + navPreload: { enabled: boolean; enable: () => Promise }; + context: Record; +} + +function bootServiceWorker(): SwHarness { + const listeners = new Map void>(); + const cacheStorage = new FakeCacheStorage(); + const navPreload = { + enabled: false, + enable: async () => { + navPreload.enabled = true; + }, + }; + const selfObj = { + addEventListener: (type: string, fn: (event: unknown) => void) => { + listeners.set(type, fn); + }, + skipWaiting: async () => {}, + clients: { claim: async () => {} }, + registration: { navigationPreload: navPreload }, + location: { origin: ORIGIN }, + }; + const context: Record = { + self: selfObj, + caches: cacheStorage, + importScripts: () => { + throw new Error("no generated version file in the sandbox"); + }, + // Default: network down. Individual tests override `context.fetch`. + fetch: async () => { + throw new TypeError("network down"); + }, + Response, + Request, + URL, + Promise, + console, + }; + vm.createContext(context); + vm.runInContext(SW_SOURCE, context); + return { listeners, cacheStorage, navPreload, context }; +} + +async function dispatchActivate(harness: SwHarness): Promise { + let settled: Promise = Promise.resolve(); + harness.listeners.get("activate")!({ + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; +} + +function dispatchNavigationFetch( + harness: SwHarness, + path: string, + preloadResponse?: Promise, +): Promise { + let captured: Promise | null = null; + harness.listeners.get("fetch")!({ + request: new Request(`${ORIGIN}${path}`, { + headers: { accept: "text/html" }, + }), + respondWith: (p: Promise) => { + captured = p; + }, + preloadResponse, + }); + if (!captured) throw new Error("fetch handler did not respond"); + return captured; +} + +describe("sw.js — activate", () => { + it("drops stale-version caches and enables navigation preload", async () => { + const harness = bootServiceWorker(); + const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1"); + await stale.put(`${ORIGIN}/`, new Response("stale shell")); + await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + + await dispatchActivate(harness); + + expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe( + false, + ); + expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true); + expect(harness.navPreload.enabled).toBe(true); + }); +}); + +describe("sw.js — networkFirst offline fallback", () => { + it("never serves a shell cached under a previous CACHE_VERSION", async () => { + const harness = bootServiceWorker(); + // A stale pre-update cache that survived into the activation gap. + const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1"); + await stale.put(`${ORIGIN}/`, new Response("stale shell")); + + const res = await dispatchNavigationFetch(harness, "/"); + + // The stale shell is ignored; the language-neutral offline page wins. + expect(res.status).toBe(503); + expect(await res.text()).toContain("Offline"); + }); + + it("serves the current-version page cache when offline", async () => { + const harness = bootServiceWorker(); + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + await pages.put(`${ORIGIN}/`, new Response("current shell")); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.text()).toBe("current shell"); + }); + + it("falls back to the current-version precached shell when the page cache misses", async () => { + const harness = bootServiceWorker(); + const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); + await statics.addAll(["/"]); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.text()).toBe("precached:/"); + }); + + it("consumes the navigation-preload response instead of re-fetching", async () => { + const harness = bootServiceWorker(); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return new Response("network shell"); + }; + + const res = await dispatchNavigationFetch( + harness, + "/", + Promise.resolve(new Response("preloaded shell")), + ); + + expect(await res.clone().text()).toBe("preloaded shell"); + expect(fetchCalls).toBe(0); + // The preloaded response was cached under the current page cache. + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/`)).toBeDefined(); + }); +}); + +describe("sw.js — trimCache", () => { + it("reads the key list once and deletes only the oldest excess entries", async () => { + const harness = bootServiceWorker(); + const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + for (let i = 0; i < 8; i++) { + await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`)); + } + store.keysCalls = 0; + + const trimCache = ( + harness.context as { trimCache?: (name: string, max: number) => Promise } + ).trimCache; + expect(typeof trimCache).toBe("function"); + await trimCache!(CURRENT_PAGE_CACHE, 5); + + // Single key read (the previous implementation re-read per deletion). + expect(store.keysCalls).toBe(1); + // Oldest three gone, newest five kept. + expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false); + expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false); + expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true); + expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true); + }); +}); diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts index bebb0a292..4788c0107 100644 --- a/src/app/api/analytics/range/route.ts +++ b/src/app/api/analytics/range/route.ts @@ -20,8 +20,9 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { ANALYTICS_RANGES, @@ -40,6 +41,12 @@ const rangeQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + const parsed = rangeQuerySchema.safeParse({ type: request.nextUrl.searchParams.get("type"), range: request.nextUrl.searchParams.get("range"), diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 637a40e5a..c9f7d5033 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -50,6 +50,12 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -84,6 +90,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -104,6 +111,12 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); __resetAllCachesForTests(); + // v1.15.20 — default to an allowing analytics-read budget. + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); // Default to assistant-on so the gate doesn't 403 every test. ( prisma.appSettings.findUnique as ReturnType diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 9a9042b28..9b36a8f23 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { resolveProvider } from "@/lib/ai/provider"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; import { summarize } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -23,6 +23,7 @@ import { getMedicationCategories } from "@/lib/medication-category"; import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { @@ -34,6 +35,13 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + // v1.4.31 — comprehensive feeds the hero strip narration and the // recommendations grid that share the Coach gate. await requireAssistantSurface("coach"); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index ae31fdea0..9933d4ec1 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -17,6 +17,12 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -35,6 +41,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -43,6 +50,12 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // v1.15.20 — default to an allowing analytics-read budget. + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); // v1.4.31 — default to an all-on flag set so existing assertions // ride through unchanged. Tests that need the gate-off path set // their own findUnique resolution. @@ -118,4 +131,20 @@ describe("GET /api/insights/correlations", () => { const res = await callGet(makeReq()); expect(res.status).toBe(403); }); + + it("returns 429 when the shared analytics-read budget is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(429); + expect(vi.mocked(checkAnalyticsReadRateLimit)).toHaveBeenCalledWith( + "user-1", + ); + // The limited request never reaches the series reads. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index d628e588f..eae72346b 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -16,8 +16,9 @@ * fetches + day-keys + responds. No LLM, no narrative, no cache table. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { getLocalDateParts } from "@/lib/timezone"; @@ -65,6 +66,13 @@ function toDailyMeans( export const GET = apiHandler(async () => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index 7711d0fcf..fca913f30 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -17,6 +17,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +// v1.15.20 — the route checks the shared analytics-read budget before any +// DB work; the real helper would hit the unmocked `$queryRaw`. This file +// uses `clearAllMocks` (implementations survive), so the factory default +// is enough. +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: 0, + }), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 0cc883acf..8c0f9d0e8 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -20,9 +20,10 @@ */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { @@ -50,6 +51,13 @@ const derivedQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + await requireAssistantSurface("insightStatus"); const parsed = derivedQuerySchema.safeParse({ 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 4541af588..142293b92 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -33,6 +33,26 @@ 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. +vi.mock("@/lib/cache/server-cache", () => ({ + cached: vi.fn( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ), + caches: { medicationCompliance: {} }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -51,6 +71,8 @@ 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 { checkRateLimit } from "@/lib/rate-limit"; import { getUserTodayBounds } from "@/lib/timezone"; import { userDayKey } from "@/lib/tz/format"; @@ -106,6 +128,17 @@ function medication(schedules: unknown[]) { beforeEach(() => { vi.resetAllMocks(); 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( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); }); async function callRoute(): Promise> { @@ -353,3 +386,84 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { expect(display.longDays).toBeGreaterThan(30); }); }); + +// v1.15.20 — server cache + bounded event read + per-user rate limit. +describe("GET /api/medications/[id]/compliance — caching and limits", () => { + function mockDailyMed() { + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + medication([ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ]) as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + } + + 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(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(429); + expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith( + "medication-compliance:user-1", + 60, + 60_000, + ); + // The limited request never touches the medication tables. + expect(prisma.medication.findUnique).not.toHaveBeenCalled(); + }); + + it("reads through the per-user compliance cache key", async () => { + mockDailyMed(); + + 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]; + // `${userId}|...` prefix is what `invalidateUserMedications` sweeps. + expect(key).toBe("user-1|med-1|compliance"); + }); + + it("bounds the intake-event read to a trailing window and the consumed columns", async () => { + mockDailyMed(); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + + const call = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0] as { + where: { scheduledFor?: { gte?: Date } }; + select?: Record; + }; + // Lower-bounded: the widest served window is 365 days (+1 day margin). + const gte = call.where.scheduledFor?.gte; + expect(gte).toBeInstanceOf(Date); + const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000; + expect((gte as Date).getTime()).toBeGreaterThan(minExpected); + // Column-bounded: only the four fields the math consumes. + expect(call.select).toEqual({ + takenAt: true, + skipped: true, + scheduledFor: true, + autoMissed: true, + }); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 7f75efd1a..76c1face0 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -3,81 +3,37 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { - buildComplianceDisplay, buildComplianceMedicationContext, - calculateCompliance, + buildMedicationComplianceBundle, lastNonSkippedTakenAt, + type ComplianceDisplay, + type ComplianceResult, + type DailyComplianceEntry, } from "@/lib/analytics/compliance"; -import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; -import type { SlotBand } from "@/lib/medications/scheduling/attribution"; -import { - buildBandsForSchedules, - type BandMinterMedication, -} from "@/lib/medications/scheduling/band-minter"; -import { - reconstructDoseHistory, - type DoseHistoryRow, - type HistoryIntake, -} from "@/lib/medications/scheduling/dose-history"; -import { - type CanonicalSchedule, - type RecurrenceContext, -} from "@/lib/medications/scheduling/recurrence"; -import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +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"; +import { checkRateLimit } from "@/lib/rate-limit"; import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -type MedicationScheduleRow = { - id: string; - rrule: string | null; - rollingIntervalDays: number | null; - timesOfDay: string[]; - daysOfWeek: string | null; - windowStart: string; - windowEnd: string; - reminderGraceMinutes: number | null; - scheduleType: "SCHEDULED" | "PRN" | "CYCLIC"; - cyclicOnWeeks: number | null; - cyclicOffWeeks: number | null; - /** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */ - doseWindows: unknown; -}; +const DAY_MS = 24 * 60 * 60 * 1000; /** - * v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the - * band minter consumes. A legacy daily row carrying only `windowStart` - * surfaces it as the single `timeOfDay` so the minter mints its daily band. + * 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. */ -function toCanonicalForBands( - s: MedicationScheduleRow, - oneShot: boolean, -): CanonicalSchedule { - const base: CanonicalSchedule = { - id: s.id, - rrule: s.rrule, - rollingIntervalDays: s.rollingIntervalDays, - timesOfDay: s.timesOfDay, - daysOfWeek: s.daysOfWeek, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - reminderGraceMinutes: s.reminderGraceMinutes, - scheduleType: s.scheduleType, - cyclicOnWeeks: s.cyclicOnWeeks, - cyclicOffWeeks: s.cyclicOffWeeks, - doseWindows: normaliseDoseWindows(s.doseWindows), - }; - if ( - base.timesOfDay.length === 0 && - base.rrule === null && - base.rollingIntervalDays === null && - base.scheduleType !== "PRN" && - !oneShot - ) { - return { ...base, timesOfDay: [base.windowStart] }; - } - return base; +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; } /** @@ -134,11 +90,153 @@ function bucketLedgerRow( } } +/** + * 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.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. + const rl = await checkRateLimit( + `medication-compliance:${user.id}`, + 60, + 60_000, + ); + if (!rl.allowed) { + 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; @@ -152,167 +250,19 @@ export const GET = apiHandler( return apiError("Medication not found", 404); } - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance read. - where: { medicationId: id, userId: user.id, deletedAt: null }, - orderBy: { scheduledFor: "desc" }, - }); - - const mapped = events.map((e) => ({ - takenAt: e.takenAt, - skipped: e.skipped, - scheduledFor: e.scheduledFor, - // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a - // miss, not a neutral skip. - autoMissed: e.autoMissed, - })); - - const createdAt = medication.createdAt; - - // v1.7.0 SB-SCHED-2 — thread the medication context so the - // denominator routes through the canonical engine (RRULE / rolling / - // one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker. - // `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences - // re-anchor on it); the events list is already ordered scheduledFor - // desc, so scan for the max takenAt. - const lastIntakeAt = lastNonSkippedTakenAt(mapped); const userTz = user.timezone || "Europe/Berlin"; - const medicationContext = buildComplianceMedicationContext( - medication, - lastIntakeAt, - userTz, + + // 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. + const payload = await cached( + caches.medicationCompliance as ServerCache, + `${user.id}|${id}|compliance`, + () => buildCompliancePayload(medication, user.id, userTz), + annotate, ); - // v1.15.9 — pin a single `now` and thread it into every cadence - // computation. The two `calculateCompliance` calls + the display windows - // can otherwise straddle a day boundary on a slow request, so a dose - // would count in one window and not the next within the same response. - const now = new Date(); - - const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, { - now, - medicationContext, - }); - const compliance30 = calculateCompliance( - mapped, - medication.schedules, - 30, - createdAt, - { now, medicationContext }, - ); - - // v1.8.6 — the two-row compliance display. The card always shows two - // percentage rows; the server scales the two windows to the dosing - // cadence (dense meds keep 7 / 30 days, sparse meds step both windows - // up) and computes each row's rate over the chosen span. `compliance7` - // / `compliance30` above are untouched — iOS + the Health Score read - // them verbatim. - const complianceDisplay = buildComplianceDisplay( - mapped, - medication.schedules, - medicationContext, - { now }, - ); - - // v1.15.18 — the heatmap / line-chart daily map is now 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 legacy - // per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount - // ForDay` walk are retired here in favour of band membership: a take inside - // a slot's on-time band reads on-time, inside its late tail reads late, - // outside every band reads ad-hoc; an unfilled slot past its miss cutoff - // reads missed; a future slot is upcoming (not yet due). - const dailyCompliance: Record = {}; - - // Build the bands across the whole 90-day heatmap window once. The window - // floor clamps to the medication's creation so pre-existence days never - // mint phantom slots. - const DAY_MS = 24 * 60 * 60 * 1000; - const windowFrom = new Date( - Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), - ); - const bandMedication: BandMinterMedication = { - id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, - oneShot: medication.oneShot, - createdAt: medication.createdAt, - }; - const bandCtx: RecurrenceContext = { - medication: { - id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, - oneShot: medication.oneShot, - createdAt: medication.createdAt, - }, - timeZone: userTz, - lastIntakeAt, - }; - const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map( - (s) => toCanonicalForBands(s, medication.oneShot), - ); - const intakeInstants = mapped - .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now) - .map((e) => e.takenAt as Date) - .sort((a, b) => a.getTime() - b.getTime()); - const groups = buildBandsForSchedules({ - medication: bandMedication, - schedules: canonicalSchedules, - ctx: bandCtx, - userTz, - range: { from: windowFrom, to: now }, - now, - intakeInstants, - }); - const bands: SlotBand[] = []; - for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); - } - const historyIntakes: HistoryIntake[] = mapped - .filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed, - })); - const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now); - - // 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. - 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; - }; - - for (const row of ledgerRows) { - 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; - } - annotate({ action: { name: "medication.compliance", @@ -320,18 +270,13 @@ export const GET = apiHandler( entity_id: id, }, meta: { - compliance7: compliance7.rate, - compliance30: compliance30.rate, - complianceShortDays: complianceDisplay.shortDays, - complianceLongDays: complianceDisplay.longDays, + compliance7: payload.compliance7.rate, + compliance30: payload.compliance30.rate, + complianceShortDays: payload.complianceDisplay.shortDays, + complianceLongDays: payload.complianceDisplay.longDays, }, }); - return apiSuccess({ - compliance7, - compliance30, - dailyCompliance, - complianceDisplay, - }); + return apiSuccess(payload); }, ); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 0cff38b3f..9765c07ac 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForDay, dayKeyForScheduledFor, @@ -158,6 +159,13 @@ export const POST = apiHandler( } } + // v1.15.20 — imported intakes change every cached medication read + // (list, intake caches, compliance payload). This write path was the + // one intake surface missing the flush. + if (imported > 0) { + invalidateUserMedications(user.id); + } + await auditLog("medication.intake.import", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 330c672bd..df600ff4c 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -56,7 +56,12 @@ export default function MedicationDetailPage({ if (!res.ok) throw new Error("medication_detail_failed"); return (await res.json()).data as MedicationDetailSnapshot; }, - enabled: isAuthenticated, + // v1.15.20 — deliberately NOT gated on `isAuthenticated`: the session + // cookie rides the fetch either way, so waiting for `/api/auth/me` + // serialised two round-trips into a waterfall on every detail + // navigation. An unauthenticated visit fails fast with a 401 here and + // the redirect effect above still routes to the login page (the + // render guard below keeps the spinner up until it does). // A deleted medication 404s here. Don't burn a retry/backoff cycle on // a resource that no longer exists — the delete handler evicts this // key on success, but `retry: false` hardens any caller that @@ -64,7 +69,7 @@ export default function MedicationDetailPage({ retry: false, }); - if (authLoading || isLoading) { + if (authLoading || isLoading || !isAuthenticated) { return (
{ + if (valueMode !== "bmi") return points; + if (!bmiDivisor) return []; + return points.map((p) => { + const out: ChartDataPoint = { + date: p.date, + timestamp: p.timestamp, + }; + for (const type of types) { + const v = p[type]; + if (typeof v === "number") { + out[type] = v / bmiDivisor; + } + } + return out; + }); + }, + [valueMode, bmiDivisor, types], + ), enabled: isAuthenticated, }); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 4fe02593f..a9fdc9433 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -203,7 +203,10 @@ export function Glp1MedicationCard({ const json = await res.json(); return json.data as ComplianceData; }, - staleTime: 30 * 1000, + // 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.4.37 W4b — same reminder-thresholds source as the generic diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7342cb11d..9ee34e52b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -146,7 +146,12 @@ export function MedicationCard({ const json = await res.json(); return json.data as ComplianceData; }, - staleTime: 30 * 1000, + // 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, }); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index d911b1d0b..cdbdf9da1 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -14,14 +14,12 @@ import { X, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as -// `ChecklistAnalyticsData`; aliased back to the local name. -import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { queryKeys } from "@/lib/query-keys"; import { buildChecklist, checklistProgress, @@ -181,40 +179,54 @@ export function GettingStartedChecklist() { } }, [expanded]); - // Light-weight queries: each fetch is small and cached by tanstack. - // We rely on the React Query cache the dashboard already uses for - // analytics, so this won't fire a second request when the dashboard - // and the checklist mount together. v1.4.33 IW2 routes the checklist - // onto IW1's slim `?slice=summaries` branch — the only field - // consumed is `summaries[METRIC].count`, which the slim slice fills - // directly. The cache slot is distinct from the dashboard's thick - // slice but each consumer's payload is byte-correct for its own - // reads, and the shared hook keeps the legacy `?.data` unwrap so the - // canonical consumers still see `{summaries, …}` not `{data: …}`. - const analyticsQuery = useAnalyticsQuery({ - slice: "summaries", - enabled: !!user, - }); - const analyticsData = analyticsQuery.data as AnalyticsData | undefined; + // v1.15.20 — the measurement count is derived from the dashboard + // snapshot the page already fetches (`tiles.summaries` is the same + // `computeSummariesSlice` payload the legacy `?slice=summaries` + // analytics query carried), so the checklist no longer fires its own + // analytics request on every dashboard load. Subscribing to the shared + // `queryKeys.dashboardSnapshot()` cell costs zero extra network. + const snapshotQuery = useDashboardSnapshot(!!user); + const snapshotData = snapshotQuery.data; + + const measurementCount = useMemo(() => { + const summaries = snapshotData?.tiles?.summaries ?? {}; + let total = 0; + for (const key of Object.keys(summaries)) { + total += summaries[key]?.count ?? 0; + } + return total; + }, [snapshotData]); + + // v1.15.20 — only fetch the checklist's supporting data while the card + // can actually render: the user is still in setup (`shouldShowChecklist`'s + // `onboardingCompletedAt == null || measurementCount < 5` rule) and has + // not dismissed it. Established users used to pay this medications fetch + // (and, pre-v1.5, two more) on every dashboard load for a card that + // never mounts. Gated on the snapshot having resolved so the count is + // real, not the loading-default 0. + const checklistRelevant = + !!user && + !dismissedAll && + snapshotData !== undefined && + (user.onboardingCompletedAt == null || measurementCount < 5); const { data: medsData } = useQuery>({ - queryKey: ["medications"], + queryKey: queryKeys.medications(), queryFn: async () => { const res = await fetch("/api/medications"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data; }, - enabled: !!user, + enabled: checklistRelevant, }); // v1.5 perf audit: skip these two fetches once the user is past // onboarding. The checklist hides itself anyway via shouldShowChecklist - // when onboardingCompletedAt != null AND measurementCount >= 5; the - // analytics + medications queries above piggy-back on the dashboard's - // shared cache, but withings/status and notifications/preferences are - // unique to this component and were burning ~950 ms of network on every - // dashboard load for established users. See docs/audit/v15-performance.md. + // when onboardingCompletedAt != null AND measurementCount >= 5; + // withings/status and notifications/preferences are unique to this + // component and were burning ~950 ms of network on every dashboard load + // for established users. See docs/audit/v15-performance.md. const onboardingPending = !!user && user.onboardingCompletedAt == null; const { data: withingsData } = useQuery({ @@ -239,15 +251,6 @@ export function GettingStartedChecklist() { enabled: onboardingPending, }); - const measurementCount = useMemo(() => { - const summaries = analyticsData?.summaries ?? {}; - let total = 0; - for (const key of Object.keys(summaries)) { - total += summaries[key]?.count ?? 0; - } - return total; - }, [analyticsData]); - const medicationCount = medsData?.length ?? 0; const withingsConnected = withingsData?.connected === true; const notificationsConfigured = (notificationsData?.channels ?? []).some( @@ -291,19 +294,19 @@ export function GettingStartedChecklist() { }); // v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user - // AND the analytics query have resolved we render NOTHING. The + // AND the snapshot query have resolved we render NOTHING. The // previous version rendered a default-true `show` branch while - // `measurementCount` was still 0 (because analytics hadn't returned), + // `measurementCount` was still 0 (because the data hadn't returned), // so a user whose actual `measurementCount >= 5` saw the card flash // for ~500 ms before `shouldShowChecklist` flipped to false. We rely - // on `analyticsQuery.data === undefined` as the loading sentinel — + // on `snapshotData === undefined` as the loading sentinel — // tanstack-query writes `data` exactly once per fetch, so this is // race-free across hot-reloads and stale-cache invalidation. (The - // dashboard's `/api/analytics` query shares the cache key, so the - // checklist almost always sees the cached value synchronously on - // mount and never blocks UI.) + // dashboard's snapshot query shares the cache key, so the checklist + // almost always sees the cached value synchronously on mount and + // never blocks UI.) if (!user) return null; - if (analyticsQuery.data === undefined) return null; + if (snapshotData === undefined) return null; if (!show) return null; return ( diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts new file mode 100644 index 000000000..519975a8f --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -0,0 +1,258 @@ +/** + * v1.15.20 — single-pass compliance bundle parity. + * + * `buildMedicationComplianceBundle` replaces the per-medication compliance + * route's five-plus expansion passes (calculateCompliance × 4, the window + * ladder probes, the heatmap mint) with ONE band expansion + ONE cadence + * timeline. These tests pin the bundle's blocks against the historical + * per-window composition so the refactor cannot drift the public payload: + * every block must be deep-equal to what `calculateCompliance` / + * `buildComplianceDisplay` produce for the same fixture. + */ +import { describe, it, expect } from "vitest"; + +import { + buildComplianceMedicationContext, + buildComplianceLedgerRows, + buildMedicationComplianceBundle, + buildComplianceDisplay, + calculateCompliance, + lastNonSkippedTakenAt, + tallyComplianceFromLedger, + tallyLedgerRows, + type ComplianceSchedule, +} from "../compliance"; + +const TZ = "UTC"; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Deterministic anchor — keeps every window fully in the fixture's hands. */ +const NOW = new Date("2026-06-01T12:00:00Z"); + +function dailySchedule(): ComplianceSchedule { + return { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +function rollingSchedule(intervalDays: number): ComplianceSchedule { + return { + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: intervalDays, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +/** A slot instant `daysAgo` before NOW at the given "HH:mm" (UTC fixture). */ +function slotAt(daysAgo: number, hhmm: string): Date { + const day = new Date(NOW.getTime() - daysAgo * DAY_MS); + const [h, m] = hhmm.split(":").map(Number); + const d = new Date(day); + d.setUTCHours(h, m, 0, 0); + return d; +} + +interface FixtureEvent { + takenAt: Date | null; + skipped: boolean; + scheduledFor: Date; + autoMissed?: boolean; +} + +function dailyFixtureEvents(): FixtureEvent[] { + return [ + // Taken on time across several days. + { takenAt: slotAt(1, "08:05"), skipped: false, scheduledFor: slotAt(1, "08:00") }, + { takenAt: slotAt(2, "08:10"), skipped: false, scheduledFor: slotAt(2, "08:00") }, + { takenAt: slotAt(4, "07:55"), skipped: false, scheduledFor: slotAt(4, "08:00") }, + // A deliberate skip (excluded from the denominator). + { takenAt: null, skipped: true, scheduledFor: slotAt(3, "08:00") }, + // A taken-late dose (inside the late tail). + { takenAt: slotAt(5, "10:30"), skipped: false, scheduledFor: slotAt(5, "08:00") }, + // Day 6 has no event → the unfilled slot reads missed. + // An auto-missed forgotten dose. + { + takenAt: null, + skipped: false, + scheduledFor: slotAt(8, "08:00"), + autoMissed: true, + }, + ]; +} + +describe("buildMedicationComplianceBundle — parity with the per-window composition", () => { + it("daily medication: every block equals the legacy multi-pass output", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance(events, schedules, 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.compliance30).toEqual( + calculateCompliance(events, schedules, 30, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay(events, schedules, ctx, { now: NOW }), + ); + + // The ledger window clamps to the medication's creation. + expect(bundle.ledgerFrom).toEqual(createdAt); + // One band per daily slot over the 40-day life → 40 or 41 slot rows + // (the boundary day depends on the creation instant vs slot time). + const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot"); + expect(slotRows.length).toBeGreaterThanOrEqual(39); + expect(slotRows.length).toBeLessThanOrEqual(41); + }); + + it("rolling 35-day medication: display windows + rates match the legacy path", () => { + const createdAt = new Date(NOW.getTime() - 400 * DAY_MS); + const schedules = [rollingSchedule(35)]; + const events: FixtureEvent[] = [ + { + takenAt: new Date(NOW.getTime() - 25 * DAY_MS), + skipped: false, + scheduledFor: new Date(NOW.getTime() - 25 * DAY_MS), + }, + { + takenAt: new Date(NOW.getTime() - 60 * DAY_MS), + skipped: false, + scheduledFor: new Date(NOW.getTime() - 60 * DAY_MS), + }, + ]; + const ctx = buildComplianceMedicationContext( + { + startsOn: new Date(NOW.getTime() - 380 * DAY_MS), + endsOn: null, + oneShot: false, + createdAt, + }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance(events, schedules, 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.compliance30).toEqual( + calculateCompliance(events, schedules, 30, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay(events, schedules, ctx, { now: NOW }), + ); + // A 35-day cadence cannot clear the stability floor on [7, 30]. + expect(bundle.complianceDisplay.shortDays).toBeGreaterThan(7); + expect(bundle.complianceDisplay.longDays).toBeGreaterThan(30); + }); + + it("empty schedule list: zeros short-circuit matches calculateCompliance", () => { + const createdAt = new Date(NOW.getTime() - 10 * DAY_MS); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + const bundle = buildMedicationComplianceBundle([], [], ctx, NOW); + + expect(bundle.compliance7).toEqual( + calculateCompliance([], [], 7, createdAt, { + now: NOW, + medicationContext: ctx, + }), + ); + expect(bundle.complianceDisplay).toEqual( + buildComplianceDisplay([], [], ctx, { now: NOW }), + ); + expect(bundle.ledgerRows).toEqual([]); + }); +}); + +describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { + it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + const from = new Date(NOW.getTime() - 30 * DAY_MS); + + const rows = buildComplianceLedgerRows(events, schedules, ctx, from, NOW, NOW); + expect(tallyLedgerRows(rows)).toEqual( + tallyComplianceFromLedger(events, schedules, ctx, from, NOW, NOW), + ); + }); + + it("a sub-window tally counts only rows inside the window", () => { + const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); + const schedules = [dailySchedule()]; + const events = dailyFixtureEvents(); + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + lastNonSkippedTakenAt(events), + TZ, + ); + + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + createdAt, + NOW, + NOW, + ); + const wide = tallyLedgerRows(rows); + // 3.5 days reaches back past the day −3 08:00 slot (NOW is 12:00Z, so a + // plain 3-day bound would cut that morning slot off). + const narrow = tallyLedgerRows(rows, { + from: new Date(NOW.getTime() - 3.5 * DAY_MS), + to: NOW, + }); + + // Days −1 and −2 are taken on time; day −3 is a skip. Everything + // older falls outside the window. + expect(narrow.takenOnTime).toBe(2); + expect(narrow.skipped).toBe(1); + expect(narrow.denominator).toBeLessThan(wide.denominator); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index e1acb9718..bb144c361 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -36,6 +36,7 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { reconstructDoseHistory, + type DoseHistoryRow, type HistoryIntake, } from "@/lib/medications/scheduling/dose-history"; import { @@ -1155,6 +1156,39 @@ export function tallyComplianceFromLedger( now: Date, windowConfig?: DoseWindowConfig, ): LedgerComplianceTally { + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + from, + to, + now, + windowConfig, + ); + return tallyLedgerRows(rows); +} + +/** + * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE + * unified dose-history ledger for them. This is the expansion pass behind + * {@link tallyComplianceFromLedger}, extracted so a caller that needs + * several trailing sub-windows (7-day / 30-day / display / heatmap, all + * ending at `now`) can mint the bands ONCE over the widest window and tally + * each sub-window from the same rows via {@link tallyLedgerRows} instead of + * re-expanding per window. + * + * Pure / synchronous: bands come from pre-fetched schedules + intake + * instants; no DB access. + */ +export function buildComplianceLedgerRows( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): DoseHistoryRow[] { const medication: BandMinterMedication = { id: "compliance-tally", startsOn: ctx.startsOn, @@ -1222,14 +1256,31 @@ export function tallyComplianceFromLedger( autoMissed: e.autoMissed ?? false, })); - const rows = reconstructDoseHistory(bands, intakes, now); + return reconstructDoseHistory(bands, intakes, now); +} +/** + * Tally pre-built ledger rows into the {@link LedgerComplianceTally} + * counters. When `window` is supplied only rows whose instant (`row.at`) + * falls inside `[window.from, window.to]` (inclusive) are counted — that is + * how a sub-window tally is carved out of a wider single-pass ledger. The + * window-less call tallies every row, byte-identical to the historical + * {@link tallyComplianceFromLedger} behaviour. + */ +export function tallyLedgerRows( + rows: DoseHistoryRow[], + window?: { from: Date; to: Date }, +): LedgerComplianceTally { let takenOnTime = 0; let takenLate = 0; let missed = 0; let skipped = 0; let adHoc = 0; for (const row of rows) { + if (window) { + const t = row.at.getTime(); + if (t < window.from.getTime() || t > window.to.getTime()) continue; + } switch (row.status) { case "taken_on_time": takenOnTime++; @@ -1517,3 +1568,277 @@ export function calculateCompliance( return { totalExpected, taken, skipped, missed, rate, streak }; } + +/** + * Widest trailing window the single-pass compliance ledger has to cover: + * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every + * sub-window the per-medication compliance endpoint serves (7 / 30 / + * cadence-scaled display rows / 90-day heatmap) is a suffix of it. + */ +export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; + +/** + * The per-medication compliance payload computed from ONE shared expansion + * pass. `ledgerRows` is the unified dose-history ledger over + * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by + * filtering on `row.at`. + */ +export interface MedicationComplianceBundle { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + complianceDisplay: ComplianceDisplay; + /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ + ledgerRows: DoseHistoryRow[]; + /** Lower bound of the mint window (clamped to the medication's creation). */ + ledgerFrom: Date; +} + +/** + * Build every block of the per-medication compliance response from a single + * band-expansion pass. + * + * The historical composition called {@link calculateCompliance} four times + * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more + * occurrence expansions for the rung probes) and a separate 90-day heatmap + * mint — five-plus full band expansions per request. This builder instead: + * + * 1. mints the bands + reconstructs the ledger ONCE over + * `[max(createdAt, now − 365 d), now]` (every served window is a + * suffix of that range and ends at `now`, so a sub-window tally is a + * filter over `row.at`, not a re-expansion); + * 2. builds ONE cadence timeline over the same range for the per-window + * streaks ({@link streaksFromTimeline} bounds its day-walk by the + * requested window, so the wider timeline serves every window); + * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row + * counts (one band per engine occurrence, so the counts match the + * historical `expectedSlotsBetween` probes). + * + * The returned blocks carry the exact public shapes the route has always + * served — only the number of expansion passes changed. + */ +export function buildMedicationComplianceBundle( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, +): MedicationComplianceBundle { + const ledgerPeriodStart = new Date( + now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, + ); + const ledgerFrom = + ctx.createdAt.getTime() > ledgerPeriodStart.getTime() + ? ctx.createdAt + : ledgerPeriodStart; + + const hasSchedules = schedules.length > 0; + const ledgerRows = hasSchedules + ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) + : []; + + // ONE cadence timeline over the full ledger window. Each per-window + // streak below walks only its own trailing `effectiveDays`, so sharing + // the wide timeline reproduces the per-window builds. + const fullDays = Math.max( + 1, + Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), + ); + const timeline = hasSchedules + ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) + : []; + + const resultForWindow = (days: number): ComplianceResult => { + if (!hasSchedules) { + // Mirrors `calculateCompliance`'s empty-schedule short-circuit. + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); + const effectiveStart = + ctx.createdAt.getTime() > periodStart.getTime() + ? ctx.createdAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), + ); + const tally = tallyLedgerRows(ledgerRows, { from: effectiveStart, to: now }); + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + ctx.timeZone, + ); + return { + totalExpected: tally.taken + tally.skipped + tally.missed, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + streak, + }; + }; + + // Window-ladder selection from the ledger's slot rows. A slot row is one + // minted band, and the minter emits one band per engine occurrence, so + // counting slot rows over a trailing window equals the historical + // `expectedSlotsBetween(...).length` probe for that window. + const expectedCache = new Map(); + const expectedOver = (days: number): number => { + const hit = expectedCache.get(days); + if (hit !== undefined) return hit; + const from = Math.max( + ctx.createdAt.getTime(), + now.getTime() - days * ONE_DAY_MS, + ); + let count = 0; + for (const row of ledgerRows) { + if (row.kind !== "slot") continue; + const t = row.at.getTime(); + if (t >= from && t <= now.getTime()) count++; + } + expectedCache.set(days, count); + return count; + }; + + let selection: ComplianceWindowSelection | null = null; + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expectedOver(shortDays); + const expectedLong = expectedOver(longDays); + if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { + selection = { shortDays, longDays, expectedShort, expectedLong }; + break; + } + } + if (!selection) { + // No rung cleared the floor — fall back to the widest rung, exactly + // like `selectComplianceWindows`. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + selection = { + shortDays, + longDays, + expectedShort: expectedOver(shortDays), + expectedLong: expectedOver(longDays), + }; + } + + const compliance7 = resultForWindow(7); + const compliance30 = resultForWindow(30); + const short = resultForWindow(selection.shortDays); + const long = resultForWindow(selection.longDays); + + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + selection.expectedShort, + events, + ); + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + const complianceDisplay: ComplianceDisplay = { + shortDays: selection.shortDays, + longDays: selection.longDays, + expectedShort: selection.expectedShort, + expectedLong: selection.expectedLong, + minStableDoses: MIN_STABLE_DOSES, + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; + + return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom }; +} + +/** + * The cadence-timeline construction from {@link calculateCompliance}, + * extracted so {@link buildMedicationComplianceBundle} can build it once + * over the full ledger window instead of once per served sub-window. Same + * normalisation, same engine context, same rolling-retrospective routing. + */ +function buildTimelineForWindow( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + effectiveStart: Date, + effectiveDays: number, +): ReturnType { + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${i}`, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + daysOfWeek: s.daysOfWeek ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? null, + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + })); + + const engineCtx: CadenceEngineContext = { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + }; + + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + return buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + ctx.createdAt, + ctx.timeZone, + engineCtx, + retro, + ); +} diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index d927feac0..e5b406017 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -50,6 +50,18 @@ async function primeAllCaches(): Promise { c: 2, })); + // v1.15.20 — per-medication compliance payload cache. + await cached( + caches.medicationCompliance, + `${USER_A}|med-1|compliance`, + async () => ({ mc: 1 }), + ); + await cached( + caches.medicationCompliance, + `${USER_B}|med-1|compliance`, + async () => ({ mc: 2 }), + ); + await cached(caches.achievements, USER_A, async () => ({ ach: 1 })); await cached(caches.achievements, USER_B, async () => ({ ach: 2 })); @@ -149,6 +161,20 @@ describe("invalidateUserMedications", () => { expect(caches.medications.get(USER_B)).not.toBeNull(); expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull(); }); + + it("evicts the per-medication compliance payload for the target user only", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A); + + // v1.15.20 — the compliance route caches 15 min; an intake write must + // flush the `${userId}|` prefix so the next card read is fresh. + expect( + caches.medicationCompliance.get(`${USER_A}|med-1|compliance`), + ).toBeNull(); + expect( + caches.medicationCompliance.get(`${USER_B}|med-1|compliance`), + ).not.toBeNull(); + }); }); describe("invalidateUserDashboardWidgets", () => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index ada0837bf..6a14bc8ec 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -105,6 +105,10 @@ export function invalidateUserMood(userId: string): void { export function invalidateUserMedications(userId: string): void { caches.medications.deleteByPrefix(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). diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 2ede5fe63..13818664b 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -393,6 +393,22 @@ export const caches = { maxEntries: 1000, ttlMs: 900_000, }), + /** + * v1.15.20 — per-medication compliance payload + * (`GET /api/medications/[id]/compliance`). The medications list fans + * the endpoint out once per card, and each cold build runs the full + * band-expansion pass — so the warm path has to live server-side. + * Keyed `${userId}|${medicationId}|compliance`; every intake / + * medication write flushes the `${userId}|` prefix via + * `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. + */ + medicationCompliance: new ServerCache({ + name: "medicationCompliance", + maxEntries: 2000, + ttlMs: 900_000, + }), moodAnalytics: new ServerCache({ name: "moodAnalytics", maxEntries: 1000, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 4ee45cac4..1429b7aed 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -64,6 +64,29 @@ export function rateLimitHeaders( }; } +/** + * v1.15.20 — shared per-user budget for the heavy read-only analytics + * surfaces (`/api/insights/comprehensive`, `/api/insights/correlations`, + * `/api/insights/derived`, `/api/analytics/range`). The insights page + * legitimately fans several of these out per mount, so the bucket is + * generous — 120/min only caps a runaway client loop or a scripted + * drain, mirroring the `sync:changes` budget. One shared bucket (not + * per-surface) keeps the cap meaningful when a loop rotates across + * endpoints. + */ +const ANALYTICS_READ_LIMIT = 120; +const ANALYTICS_READ_WINDOW_MS = 60 * 1000; + +export async function checkAnalyticsReadRateLimit( + userId: string, +): Promise { + return checkRateLimit( + `analytics-read:${userId}`, + ANALYTICS_READ_LIMIT, + ANALYTICS_READ_WINDOW_MS, + ); +} + /** * v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every * caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the