perf(gamification,dashboard,insights): cut achievements cold rebuilds and lazy-load three charts

Achievements: the bucket was 60 s hard-TTL while the app-wide unlock
notifier polls every 2 minutes, so every poll landed on an expired
entry and fired a cold ~400 ms rebuild in the foreground. Add a
10-minute stale-while-revalidate window and read the route through
cachedSwr; the route already persists pendingUnlocks outside the cached
factory, so a stale serve never skips an unlock write.

Achievements builder: the vitals array ran through several full-array
passes, each re-deriving a Berlin day-key (and hour) per row via
Intl.DateTimeFormat. Compute each row's day-key once and thread the
parallel array into the green-day, weekly-consistency, engagement, and
hidden passes; the hidden pass reuses a single precomputed hour array.
Results are byte-identical to the per-row derivation (parity test added).

dashboard/summary: read through cachedSwr so the iOS-polled endpoint
serves its existing 1 h stale window instead of re-paying the cold
rebuild every 60 s on the plain cached path.

Charts: route the three recharts consumers that bypassed the shared
next/dynamic boundary (lab biomarker chart, sleep stage stacked bar,
recommendation rationale charts) through dynamic imports with matching
loading skeletons, taking recharts off the first-load JS for
/labs/[id], /insights/sleep, and the insights recommendations surface.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 19:04:31 +02:00
1 parent 4aebaaf399
commit f3af5d361a
10 files changed
+357 -35

No files matched your search

+14 -3
View File
@@ -55,7 +55,7 @@ import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
summarizeSleepNights,
@@ -328,12 +328,23 @@ export const GET = apiHandler(async () => {
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU.
// v1.4.38 W-F — wrap the whole response in the analytics LRU.
// Subsequent iOS polls inside the TTL hit memory; measurement /
// mood / medication writes invalidate the user-bucket via the
// existing `invalidateUserMeasurements` + the v1.4.38-extended
// `invalidateUserMedications` hooks.
const body = await cached(
//
// v1.18.11 (W5 perf) — read via `cachedSwr`. The `analytics` bucket
// already provisions a 1 h `staleTtlMs` window, but this endpoint read
// through plain `cached` and so ignored it: the iOS client polls every
// 60 s, and a plain read past the 60 s fresh TTL re-paid the full cold
// rebuild on every poll. `cachedSwr` serves the prior body instantly
// inside the stale window and warms one background recompute instead.
// Writes still mark/evict the bucket, so a user's own action is
// reflected on the very next read — the window only bounds wall-clock
// drift (the same self-correcting `nextDueOverdue`/tally drift the
// snapshot read already tolerates).
const body = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildDashboardSummary>>
>,
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
buildAchievementsResult,
type AchievementsResult,
@@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
// a toggle change is reflected on the next read.
const moduleMap = await resolveModuleMap(user.id);
const result = await cached(
// v1.18.11 (W5 perf) — read via `cachedSwr`. The bucket carries a
// 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls
// every 2 minutes, so a hard-TTL read always missed and re-paid the cold
// build. SWR serves the prior payload instantly and warms one background
// recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see
// below), so a stale-served body never skips an unlock write.
const result = await cachedSwr(
caches.achievements as ServerCache<AchievementsResult>,
user.id,
() => buildAchievementsResult(user, moduleMap),