From e203f07cbda729bacaa79bfc021831d226593dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 12:36:27 +0200 Subject: [PATCH] perf(insights): cache /api/insights/targets behind the analytics LRU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The targets endpoint runs >1 s cold — pulls 30 days of measurements for every type plus medications, intakes, mood and glucose to build the consistency strips, streaks and target-met-this-week rollup. It's read on every Insights mount and on every sub-page click, so the cumulative cost dominates a power user's session. Wrap the body in the existing analytics-style server cache with a 60 s TTL and a per-user key. The invalidation helpers for measurement, mood and medication writes evict the user bucket so the next read paints fresh data without the cache lagging the underlying state. The bfcache-friendly Cache-Control header lets a back-forward navigation restore from the BFCache instead of repaying the cost. --- src/app/api/insights/targets/route.ts | 31 ++++++++++++++++++++++++--- src/lib/cache/invalidate.ts | 11 ++++++++++ src/lib/cache/server-cache.ts | 13 +++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 33f8dd864..0489c09df 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -25,6 +25,8 @@ import type { } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { getEffectiveRange, type ThresholdOverridesJson, @@ -105,9 +107,32 @@ interface TargetPageSummary { streakHighlight: { metric: string; days: number } | null; } +/** + * v1.4.36 W1 — `/api/insights/targets` body, lifted out of the route + * handler so `cached()` can wrap it. The handler attaches the + * bfcache-friendly Cache-Control header afterward. 60 s TTL keyed on + * the user id matches the analytics cache so multiple Insights mounts + * inside a minute all hit a warm cache. Invalidated alongside the + * analytics bucket on measurement / mood / medication writes. + */ +type AuthedUser = Awaited>["user"]; + export const GET = apiHandler(async () => { const { user } = await requireAuth(); + const body = await cached( + caches.insightsTargets as ServerCache< + Awaited> + >, + user.id, + () => buildTargetsResponse(user), + annotate, + ); + const response = apiSuccess(body); + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; +}); +async function buildTargetsResponse(user: AuthedUser) { const userId = user.id; // v1.4.25 W7b — every per-day bucket key in this route resolves @@ -1179,7 +1204,7 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess({ + return { targets, pageSummary, // Extra diastolic data for BP display @@ -1194,5 +1219,5 @@ export const GET = apiHandler(async () => { gender, glucoseUnit, }, - }); -}); + }; +} diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 4e8f57997..93dcf1f24 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -31,6 +31,11 @@ export function invalidateUserMeasurements(userId: string): void { caches.analytics.deleteByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); caches.workouts.deleteByPrefix(`${userId}|`); + // v1.4.36 W1 — measurement writes change the per-target consistency + // strips, the in-range rates and the streak counters that + // `/api/insights/targets` computes. Evict the user's bucket so the + // next mount paints fresh data. + caches.insightsTargets.deleteByPrefix(userId); } /** @@ -44,6 +49,9 @@ export function invalidateUserMood(userId: string): void { caches.moodAnalytics.deleteByPrefix(userId); caches.achievements.deleteByPrefix(userId); caches.analytics.deleteByPrefix(`${userId}|`); + // v1.4.36 W1 — mood writes change the mood target rows on the + // insights/targets response (MOOD_SCORE, MOOD_STABILITY). + caches.insightsTargets.deleteByPrefix(userId); } /** @@ -56,6 +64,9 @@ export function invalidateUserMedications(userId: string): void { caches.medications.deleteByPrefix(userId); caches.medicationsIntake.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); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 737ef9ba3..7ed377326 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -247,6 +247,19 @@ export const caches = { maxEntries: 1000, ttlMs: 60_000, }), + /** + * v1.4.36 W1 — `/api/insights/targets` is read on every Insights + * mount and runs >1 s cold (it pulls 30-day measurements for every + * type + medications + intakes + mood + glucose). 60 s TTL matches + * the analytics cache so multiple sub-page mounts inside a minute + * all hit a warm cache. Invalidated alongside the analytics bucket + * on measurement / mood / medication writes. + */ + insightsTargets: new ServerCache({ + name: "insightsTargets", + maxEntries: 1000, + ttlMs: 60_000, + }), } as const; /**