From 68c4bb73b2f55d8edd9ff825a9e67ae9c8f51d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 20:58:25 +0200 Subject: [PATCH] perf(api): wire 3 hot read routes through the server cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/analytics — wraps both the slim (?slice=summaries) and default slice paths through caches.analytics, keyed on ${userId}|${slice}. TTL 60s. The default builder lifts into buildAnalyticsResponse() so the cache miss / hit boundary stays at the route entry. /api/gamification/achievements — wraps the locale-agnostic web result through caches.achievements keyed on userId. TTL 60s. The iOS-format locale transform runs after the cache read so the cache stays format- neutral and the dashboard's duplicate mount (HAR-captured) coalesces through single-flight. /api/medications/intake?scope=compliance — wraps the per-day compliance bucket build through caches.medicationsIntake keyed on ${userId}|compliance|${days}|${userTz}. TTL 15min because daily buckets are slow-moving. The POST handler invalidates the per-user medications/compliance/achievement caches after committing the dose status change. Each cached() call hands annotate() to the wrapper so cache.. outcome (hit|miss|stampede) + cache..key_hash land on every request's wide event. --- src/app/api/analytics/route.ts | 64 ++++++++++++--- .../api/gamification/achievements/route.ts | 80 +++++++++++++------ src/app/api/medications/intake/route.ts | 63 ++++++++++++--- 3 files changed, 156 insertions(+), 51 deletions(-) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index a934a8191..63b4acf47 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -64,7 +65,16 @@ export const GET = apiHandler(async (request?: Request) => { // tile's `` boundary. Consumers that need only the headline // tile values land on this branch and skip the heavy chain. if (readSliceParam(request) === "summaries") { - const slim = await computeSummariesSlice(user.id); + // v1.4.34 IW-G — read-through the analytics cache keyed on + // (userId, slice). The slim slice is the dashboard tile strip's hot + // path; multiple dashboard mounts inside a 60-second TTL all hit a + // warm cache. + const slim = await cached( + caches.analytics as ServerCache>>, + `${user.id}|summaries`, + () => computeSummariesSlice(user.id), + annotate, + ); // v1.4.34 IW-B — bfcache-friendly directive on the slim slice too // so a back-forward navigation that landed on the dashboard tile // strip can restore from memory instead of paying a full reload. @@ -73,6 +83,43 @@ export const GET = apiHandler(async (request?: Request) => { return slimRes; } + // v1.4.34 IW-G — wrap the heavy default-slice body in the analytics + // cache keyed on (userId, "default"). The dashboard, the checklist + // mount, and the Coach drawer all hit this endpoint within seconds + // of each other; the 60s TTL converts the 7.99s combined dashboard + // wait to a Map lookup on every subsequent caller. + const body = await cached( + caches.analytics as ServerCache>>, + `${user.id}|default`, + () => buildAnalyticsResponse(user, request), + annotate, + ); + + const response = apiSuccess(body); + // v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation + // restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`: + // `private` keeps shared caches out of personal data, `max-age=0` forces + // revalidation on every navigation so session swaps detect on the wire, + // and `must-revalidate` holds the staleness contract. Replaces the + // framework's stock `no-store` which Chromium treats as a hard + // bfcache breaker. + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; +}); + +type AuthedUser = Awaited>["user"]; + +/** + * v1.4.34 IW-G — the heavy default-slice body, lifted out of the route + * handler so `cached()` can wrap it. Returns the raw JSON payload; the + * route handler attaches `Cache-Control` headers afterward. The split + * matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` → + * `buildCoachSnapshotImpl` shape. + */ +async function buildAnalyticsResponse( + user: AuthedUser, + _request: Request | undefined, +) { // v1.4.25 W7b — every day-bucket call inside this route now honours // the user's display timezone. The legacy `berlinDayKey()` import // remains for sleep-stage and correlation paths that share their @@ -346,7 +393,7 @@ export const GET = apiHandler(async (request?: Request) => { }); } - const response = apiSuccess({ + return { summaries: results, bmi, bpInTargetPct, @@ -363,17 +410,8 @@ export const GET = apiHandler(async (request?: Request) => { // staleness caption on each ``. Additive: clients that // don't read the field stay unchanged. lastSeenByType, - }); - // v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation - // restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`: - // `private` keeps shared caches out of personal data, `max-age=0` forces - // revalidation on every navigation so session swaps detect on the wire, - // and `must-revalidate` holds the staleness contract. Replaces the - // framework's stock `no-store` which Chromium treats as a hard - // bfcache breaker. - response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); - return response; -}); + }; +} /** * Per-stage sleep-minutes breakdown over the trailing 30 days. diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index b04f4a72e..b81be4833 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { ACHIEVEMENT_DEFINITIONS, GAMIFICATION_ROLLOUT_AT, @@ -463,6 +464,58 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { format: isIosFormat ? "ios" : "default" }, }); + // v1.4.34 IW-G — cache the web-shape result keyed on userId. The + // iOS-format branch runs the locale-aware transform after the cache + // read so the cache stays format-agnostic and the achievement-progress + // dashboard duplicate (seen twice per dashboard mount in the v1.4.33 + // HAR) coalesces into one builder call. Newly-unlocked achievements + // get persisted inside the builder so the second concurrent mount + // observes the up-to-date `persisted` state. + const result = await cached( + caches.achievements as ServerCache, + user.id, + () => buildAchievementsResult(user), + annotate, + ); + + if (isIosFormat) { + const locale = await resolveServerLocale({ + request, + userLocale: user.locale, + }); + const t = getServerTranslator(locale); + const ios: IosAchievement[] = result.achievements.map((a) => ({ + id: a.id, + key: a.id, + title: t.t(a.titleKey), + description: t.t(a.descriptionKey), + iconName: a.icon, + unlocked: a.unlocked, + unlockedAt: a.completedAt, + progress: Math.max(0, Math.min(1, a.progressPercent / 100)), + })); + return apiSuccess(ios); + } + + return apiSuccess(result); +}); + +type AuthedUser = Awaited>["user"]; + +type AchievementsResult = Awaited>; + +/** + * v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap + * the heavy aggregation. Returns the locale-agnostic web payload; the + * iOS-format transform runs in the handler after the cache read. + * + * `prisma.userAchievement.createMany` inside the builder is kept here + * (rather than moved to the cache hit path) because new unlocks are + * a side effect of the same aggregate computation. A duplicate concurrent + * read coalesces through the cache's single-flight `pending` map so the + * createMany only fires once. + */ +async function buildAchievementsResult(user: AuthedUser) { const now = new Date(); const userId = user.id; const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT); @@ -868,31 +921,8 @@ export const GET = apiHandler(async (request: NextRequest) => { metrics: redactedMetrics, }; - if (isIosFormat) { - const locale = await resolveServerLocale({ - request, - userLocale: user.locale, - }); - const t = getServerTranslator(locale); - // Same redaction principle: iOS clients receive opaque - // placeholders for hidden+locked entries. The native client - // mirrors the web-card opaque-placeholder UI when it sees the - // sentinel id `achievements.hiddenCard.title` come back. - const ios: IosAchievement[] = redactedAchievements.map((a) => ({ - id: a.id, - key: a.id, - title: t.t(a.titleKey), - description: t.t(a.descriptionKey), - iconName: a.icon, - unlocked: a.unlocked, - unlockedAt: a.completedAt, - progress: Math.max(0, Math.min(1, a.progressPercent / 100)), - })); - return apiSuccess(ios); - } - - return apiSuccess(result); -}); + return result; +} /** * Hidden Easter-eggs ship to the client *only* as opaque cards while diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index fe00c01b4..e21d00228 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -24,6 +24,8 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -109,16 +111,54 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - // compliance: per-day scheduled vs taken for the last N days - const start = new Date(Date.now() - days * 86_400_000); + // v1.4.34 IW-G — compliance: per-day scheduled vs taken for the last + // N days. Cached at a 15-minute TTL because daily compliance buckets + // are slow-moving (intake events trickle in, yesterday's row doesn't + // move). Cache key carries the userTz so a user who changes timezone + // doesn't read another tz's bucketing. + const result = await cached( + caches.medicationsIntake as ServerCache, + `${user.id}|compliance|${days}|${userTz}`, + () => buildComplianceBuckets(user.id, days, userTz), + annotate, + ); + + annotate({ + action: { name: "medications.intake.compliance" }, + meta: { days, count: result.length }, + }); + + return apiSuccess(result); +}); + +interface ComplianceBucket { + date: string; + scheduled: number; + taken: number; +} + +/** + * v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap + * the per-user compliance aggregation. Pure function over the user's + * intake events + the requested window; deterministic given a fixed + * "now" wall-clock (we anchor on `Date.now()` once at call time so a + * 15-minute cached row stays internally consistent). + */ +async function buildComplianceBuckets( + userId: string, + days: number, + userTz: string, +): Promise { + const nowMs = Date.now(); + const start = new Date(nowMs - days * 86_400_000); const events = await prisma.medicationIntakeEvent.findMany({ - where: { userId: user.id, scheduledFor: { gte: start } }, + where: { userId, scheduledFor: { gte: start } }, select: { scheduledFor: true, takenAt: true, skipped: true }, }); const buckets = new Map(); for (let i = 0; i < days; i++) { - const d = new Date(Date.now() - i * 86_400_000); + const d = new Date(nowMs - i * 86_400_000); buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 }); } for (const e of events) { @@ -129,17 +169,10 @@ export const GET = apiHandler(async (request: NextRequest) => { if (e.takenAt && !e.skipped) bucket.taken += 1; } - const result = [...buckets.entries()] + return [...buckets.entries()] .map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken })) .sort((a, b) => a.date.localeCompare(b.date)); - - annotate({ - action: { name: "medications.intake.compliance" }, - meta: { days, count: result.length }, - }); - - return apiSuccess(result); -}); +} export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -201,5 +234,9 @@ export const POST = apiHandler(async (request: NextRequest) => { meta: { intakeId, status }, }); + // 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); + return apiSuccess(updated); });