perf(medications): one compliance round trip and a memoised formatter hot path

Intl.DateTimeFormat instances are memoised per locale, timezone and
options signature — construction dominated the 365-day ledger build and
the hot path drops about fivefold. A batched GET /api/medications/compliance
serves every card from one request through the same per-medication cache
cells the detail page reads, replacing the per-card fan-out; the list
response stays untouched so the due line never waits on a ledger build.
The medications and compliance buckets serve stale-while-revalidate,
interactive writes evict while background bulk sync marks stale, the
per-id route defers ownership and schedule reads until a cache miss,
and the card body reserves constant slots for the status and cycle
lines so late data stops shifting the layout.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:17:17 +02:00
1 parent 23f7c74c04
commit b346e78cc7
36 files changed
+1453 -500

No files matched your search

+51
View File
@@ -213,6 +213,57 @@ describe("invalidateUserMedications", () => {
caches.medicationCompliance.get(`${USER_B}|med-1|compliance`),
).not.toBeNull();
});
it("marks the SWR buckets stale by default — background sync posture", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A);
// v1.16.8 — the default (bulk sync / cron) path marks stale: the
// entries are gone for plain `get()` (asserted above) but stay
// serveable for the `cachedSwr` readers (medications list, both
// compliance routes, the dashboard snapshot), so a high-frequency
// iOS sync never busts every card into an inline cold rebuild.
expect(caches.medications.getAllowStale(USER_A)).toEqual({
value: { m: 1 },
stale: true,
});
expect(
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
).toEqual({ value: { mc: 1 }, stale: true });
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toEqual({ value: { snap: 1 }, stale: true });
// User B stays fresh.
expect(caches.medications.getAllowStale(USER_B)).toEqual({
value: { m: 2 },
stale: false,
});
});
it("hard-evicts the SWR buckets with { evict: true } — interactive write posture", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A, { evict: true });
// v1.16.8 — an interactive take / skip / CRUD write must NOT leave a
// stale-serveable body behind: the SWR readers would hand the user
// back the pre-write card rates. The evict drops the entries.
expect(caches.medications.getAllowStale(USER_A)).toBeNull();
expect(
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
).toBeNull();
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toBeNull();
// User B stays warm; the non-SWR buckets evict as before.
expect(caches.medications.getAllowStale(USER_B)).not.toBeNull();
expect(
caches.medicationCompliance.getAllowStale(`${USER_B}|med-1|compliance`),
).not.toBeNull();
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
});
});
describe("invalidateUserDashboardWidgets", () => {
+39 -12
View File
@@ -117,24 +117,51 @@ export function invalidateUserMood(userId: string): void {
* Invalidate every cache that may reflect a user's medication state.
*
* Covers the medications cache, the medication-intake compliance cache
* (cached daily), and achievement progress.
* (cached daily), the per-medication compliance payload, and achievement
* progress.
*
* v1.16.8 — `evict` splits the SWR buckets (medications, the
* per-medication compliance payload, analytics) by write origin, the
* `invalidateUserMeasurements` pattern. Interactive writes (a card take /
* skip, a CRUD edit, an intake edit / delete / purge / import) pass
* `{ evict: true }`: the user must see their own action on the very next
* read, and a stale-serveable entry would hand back the pre-write body.
* Background paths (the iOS bulk-intake sync, the auto-miss cron, slot
* dedup) keep the default mark-stale so a high-frequency sync never busts
* the buckets into inline cold-rebuild storms — the `cachedSwr` readers
* serve the prior payload while one coalesced recompute warms each cell.
*/
export function invalidateUserMedications(userId: string): void {
caches.medications.deleteByPrefix(userId);
export function invalidateUserMedications(
userId: string,
opts?: { evict?: boolean },
): void {
if (opts?.evict) {
caches.medications.deleteByPrefix(userId);
// v1.15.20 — the per-medication compliance payload is cached 15 min;
// an interactive intake / medication / schedule write must flush it
// so the card rates and the heatmap reflect the action on the next
// read.
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
// cache and reads `medicationIntakeEvent` for both today's compliance
// tally and the 365-day streak feed. A taken / skipped event must
// evict the user-bucket so the next iOS poll reflects the change.
caches.analytics.deleteByPrefix(`${userId}|`);
} else {
// Mark-stale collapses the fresh TTL to "now": the SWR readers (the
// medications list, both compliance routes, the dashboard snapshot)
// serve the prior value while a single background recompute warms a
// fresh one; the plain `cached` readers in the analytics bucket see
// a clean miss — identical to the old evict for those keys.
caches.medications.markStaleByPrefix(userId);
caches.medicationCompliance.markStaleByPrefix(`${userId}|`);
caches.analytics.markStaleByPrefix(`${userId}|`);
}
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
// v1.15.20 — the per-medication compliance payload is cached 15 min;
// every intake / medication / schedule write must flush it so the card
// rates and the heatmap reflect the action on the next read.
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
// target rollup (compliance7 / compliance30 / consistency strip).
caches.insightsTargets.deleteByPrefix(userId);
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
// cache and reads `medicationIntakeEvent` for both today's compliance
// tally and the 365-day streak feed. A taken / skipped event must
// evict the user-bucket so the next iOS poll reflects the change.
caches.analytics.deleteByPrefix(`${userId}|`);
}
/**
+22
View File
@@ -363,10 +363,22 @@ export const caches = {
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh
* TTL meant every visit minutes apart paid the full cold list build
* (five parallel reads + the next-due engine per medication). The list
* GET reads via `cachedSwr` now: inside the 10-minute stale window the
* prior list serves immediately while one background recompute warms a
* fresh one. Interactive writes hard-evict the bucket through
* `invalidateUserMedications({ evict: true })`, so the window only
* bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never
* user-action staleness.
*/
medications: new ServerCache<unknown>({
name: "medications",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
achievements: new ServerCache<unknown>({
name: "achievements",
@@ -405,11 +417,21 @@ export const caches = {
* `invalidateUserMedications`, so the 15-minute TTL only bounds
* wall-clock drift (a dose flipping overdue), never user-action
* staleness. Sized for a few hundred users × a handful of meds.
*
* v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id
* detail read and the batched card read) consume via `cachedSwr`:
* inside the 10-minute stale window an expired cell serves the prior
* payload immediately while one coalesced rebuild warms it. Interactive
* intake / medication writes hard-evict (the user must see their own
* dose on the next read); background sync paths (iOS bulk intake, the
* auto-miss cron, slot dedup) mark stale so a high-frequency sync never
* busts every card into an inline cold rebuild.
*/
medicationCompliance: new ServerCache<unknown>({
name: "medicationCompliance",
maxEntries: 2000,
ttlMs: 900_000,
staleTtlMs: 600_000,
}),
moodAnalytics: new ServerCache<unknown>({
name: "moodAnalytics",