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

+132 -72
View File
@@ -2366,6 +2366,27 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/medications/compliance:
get:
tags:
- Medications
summary: Batched adherence read for every medication of the caller
description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt
DESC` — the single round trip the medication cards consume instead of fanning out one
`/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the
cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no
writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each
other.
responses:
"200":
description: One adherence row per medication.
content:
application/json:
schema:
$ref: "#/components/schemas/ListMedicationComplianceResponse"
"401": *a1
"422": *a3
"429": *a4
/api/medications/{id}/compliance:
get:
tags:
@@ -8897,11 +8918,13 @@ components:
additionalProperties: false
description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped |
missed | pending | future` and drives the chip colour.
GetMedicationComplianceResponse:
ListMedicationComplianceResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MedicationComplianceResponse"
type: array
items:
$ref: "#/components/schemas/MedicationComplianceSummaryEntry"
error:
type: "null"
meta:
@@ -8914,33 +8937,26 @@ components:
- data
- error
additionalProperties: false
MedicationComplianceResponse:
MedicationComplianceSummaryEntry:
type: object
properties:
medicationId:
type: string
compliance7:
$ref: "#/components/schemas/ComplianceResult"
compliance30:
$ref: "#/components/schemas/ComplianceResult"
dailyCompliance:
type: object
propertyNames:
type: string
additionalProperties:
$ref: "#/components/schemas/DailyComplianceEntry"
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
this is the raw daily grid.
complianceDisplay:
$ref: "#/components/schemas/ComplianceDisplay"
required:
- medicationId
- compliance7
- compliance30
- dailyCompliance
- complianceDisplay
additionalProperties: false
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the
cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication
`/compliance` endpoint for the history glyph track."
ComplianceResult:
type: object
properties:
@@ -8984,62 +9000,6 @@ components:
description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected'
read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from
the daily map.
DailyComplianceEntry:
type: object
properties:
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
expectedCount:
type: integer
minimum: 0
maximum: 9007199254740991
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
`expected`).
due:
type: boolean
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
pre-creation / PRN days are not misses."
taken:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: integer
minimum: 0
maximum: 9007199254740991
onTime:
type: integer
minimum: 0
maximum: 9007199254740991
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
late:
type: integer
minimum: 0
maximum: 9007199254740991
veryLate:
type: integer
minimum: 0
maximum: 9007199254740991
early:
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
consumers that differentiate.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- expected
- expectedCount
- due
- taken
- skipped
- onTime
- late
- veryLate
additionalProperties: false
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
ComplianceDisplay:
type: object
properties:
@@ -9206,6 +9166,106 @@ components:
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
GetMedicationComplianceResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MedicationComplianceResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationComplianceResponse:
type: object
properties:
compliance7:
$ref: "#/components/schemas/ComplianceResult"
compliance30:
$ref: "#/components/schemas/ComplianceResult"
dailyCompliance:
type: object
propertyNames:
type: string
additionalProperties:
$ref: "#/components/schemas/DailyComplianceEntry"
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
this is the raw daily grid.
complianceDisplay:
$ref: "#/components/schemas/ComplianceDisplay"
required:
- compliance7
- compliance30
- dailyCompliance
- complianceDisplay
additionalProperties: false
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
DailyComplianceEntry:
type: object
properties:
expected:
type: integer
minimum: 0
maximum: 9007199254740991
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
expectedCount:
type: integer
minimum: 0
maximum: 9007199254740991
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
`expected`).
due:
type: boolean
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
pre-creation / PRN days are not misses."
taken:
type: integer
minimum: 0
maximum: 9007199254740991
skipped:
type: integer
minimum: 0
maximum: 9007199254740991
onTime:
type: integer
minimum: 0
maximum: 9007199254740991
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
late:
type: integer
minimum: 0
maximum: 9007199254740991
veryLate:
type: integer
minimum: 0
maximum: 9007199254740991
early:
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
consumers that differentiate.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- expected
- expectedCount
- due
- taken
- skipped
- onTime
- late
- veryLate
additionalProperties: false
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
ListScheduleRevisionsResponse:
type: object
properties:
@@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
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.
// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the
// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the
// builder, so the pass-through exercises the guard on every call.
vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(
cachedSwr: vi.fn(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
),
@@ -71,7 +69,7 @@ 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 { cachedSwr } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
@@ -130,7 +128,7 @@ beforeEach(() => {
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(
vi.mocked(cachedSwr).mockImplementation(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
);
@@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
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];
expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1);
const [, key] = vi.mocked(cachedSwr).mock.calls[0];
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
// The trailing tz segment (v1.16.1) makes a timezone change miss the
// old entry instead of serving day buckets computed for the prior zone.
@@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
});
});
});
// v1.16.8 — the ownership guard + medication read moved INSIDE the cache
// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by
// construction a payload this caller built for a medication they own, and
// the pre-cache session→rate-limit path no longer pays two row reads per
// card. The guard itself still runs on every cold build.
describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => {
it("returns the sealed 404 when the medication belongs to another user", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
...medication([]),
userId: "user-2",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(404);
const body = await res.json();
// Same envelope as the absent case — the existence channel stays sealed.
expect(body.error).toBe("Medication not found");
});
it("returns the sealed 404 when the medication does not exist", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Medication not found");
});
it("never touches the medication tables on a warm cache hit", async () => {
// Simulate a warm cell: the SWR read resolves without invoking the
// builder, so neither the medication nor the intake table is read.
vi.mocked(cachedSwr).mockResolvedValue({
compliance7: { rate: 100 },
compliance30: { rate: 100 },
dailyCompliance: {},
complianceDisplay: { shortDays: 7, longDays: 30 },
} as never);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
});
+62 -244
View File
@@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
lastNonSkippedTakenAt,
type ComplianceDisplay,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
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";
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import {
cachedSwr,
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 }> };
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* 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.
* Builder-internal sentinel for "no such medication for this caller".
* Thrown from inside the cache builder so a rejected build is never
* persisted; the handler maps it onto the canonical 404 envelope (the
* same "Medication not found" shape `assertMedicationOwnership` mints,
* so the existence channel stays sealed).
*/
const EVENT_FETCH_WINDOW_DAYS = 366;
/** The cached response body (the exact public wire shape). */
interface CompliancePayload {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay;
}
/**
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
class MedicationNotFoundError extends Error {
constructor() {
super("Medication not found");
}
}
/**
* 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<typeof buildMedicationComplianceBundle>[1];
},
userId: string,
userTz: string,
): Promise<CompliancePayload> {
// 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.15.20 — pinned takes bind by anchor in the unified ledger so the
// % agrees with the history view on a "zugeordnet" dose (taken-late).
attributionSource: 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<string, DailyComplianceEntry> = {};
const byDay = new Map<string, DailyComplianceEntry>();
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.
// The per-user budget is generous — it only caps a runaway client
// loop (the cards read the batched `/api/medications/compliance`
// sibling; this endpoint serves the detail page).
const rl = await checkRateLimit(
`medication-compliance:${user.id}`,
60,
@@ -240,39 +47,50 @@ export const GET = apiHandler(
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;
const medication = await prisma.medication.findUnique({
where: { id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
if (!medication) {
return apiError("Medication not found", 404);
}
const userTz = user.timezone || "Europe/Berlin";
// 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. The key
// carries the timezone the payload was bucketed in: day keys, the
// heatmap, and the overdue cut all derive from `userTz`, so a
// timezone change must miss the old entry instead of serving day
// buckets computed for the previous zone until the TTL lapses.
const payload = await cached(
caches.medicationCompliance as ServerCache<CompliancePayload>,
`${user.id}|${id}|compliance|${userTz}`,
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
// Read-through the per-user compliance cache (15 min fresh TTL +
// SWR window). The ownership guard and the medication read live
// INSIDE the builder: the cache key is `${userId}|${medicationId}|…`,
// so a warm or stale hit is by construction a payload this caller
// already built for a medication they own — re-running the guard on
// every hit only re-paid two row reads per card. A cross-user id
// misses this user's prefix, runs the builder, and 404s there.
//
// Interactive intake / medication writes EVICT the `${userId}|`
// prefix via `invalidateUserMedications({ evict: true })`, so a warm
// entry can only be stale relative to wall-clock drift, never to the
// user's own action; background sync paths mark the bucket stale and
// the SWR read serves the prior payload while one coalesced rebuild
// warms a fresh one.
let payload: CompliancePayload;
try {
payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, id, userTz),
async () => {
const medication = await prisma.medication.findUnique({
where: { id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
// Privacy gate — same sealed 404 for "absent" and "not yours".
if (!medication || medication.userId !== user.id) {
throw new MedicationNotFoundError();
}
return buildCompliancePayload(medication, user.id, userTz);
},
annotate,
);
} catch (err) {
if (err instanceof MedicationNotFoundError) {
return apiError("Medication not found", 404);
}
throw err;
}
annotate({
action: {
@@ -285,7 +285,7 @@ export const PUT = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the edited event.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the rollup row for both the old + new
// scheduledFor day-keys when the dose moved across a day boundary
@@ -364,7 +364,7 @@ export const DELETE = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the rollup row for the day the deleted
// event sat on. When that day now holds zero events the helper
@@ -180,7 +180,7 @@ export const POST = apiHandler(
meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size },
});
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ deleted: count });
},
@@ -163,7 +163,7 @@ export const POST = apiHandler(
// (list, intake caches, compliance payload). This write path was the
// one intake surface missing the flush.
if (imported > 0) {
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
}
await auditLog("medication.intake.import", {
@@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => {
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1");
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
// SWR buckets instead of marking them stale.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns the 404 from the shared ownership helper without invalidating", async () => {
@@ -64,7 +64,7 @@ export const DELETE = apiHandler(
// post-purge counts rather than reading stale values for the
// TTL of each cache. Matches the sibling routes (POST intake,
// PUT medication, bulk-delete) that all fire this bundle.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ purged: true, count });
},
+2 -2
View File
@@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the persistent compliance rollup for the
// affected day. The hook is best-effort; failures annotate but never
@@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// actually receive its dose.
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
if (reconcileAction !== "noop") {
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
}
return apiSuccess(event, 201);
+2 -2
View File
@@ -618,7 +618,7 @@ export const PUT = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the schedule change.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({
...medication,
@@ -675,7 +675,7 @@ export const DELETE = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ deleted: true });
},
@@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
caches: { medicationsList: {} },
__resetAllCachesForTests: vi.fn(),
}));
@@ -0,0 +1,196 @@
/**
* v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`).
*
* One round trip returns a compact adherence row per medication the
* caller owns, replacing the per-card fan-out over the per-id endpoint.
* The rows build through the SAME per-medication cache cells
* (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route
* reads, so the two endpoints warm each other and invalidate together.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/cache/server-cache", () => ({
cachedSwr: vi.fn(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
),
caches: { medicationCompliance: {} },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
timezone: TZ,
},
};
function dailySchedule(id: string) {
return {
id,
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
function medication(id: string) {
return {
id,
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [dailySchedule(`${id}-sched`)],
scheduleRevisions: [],
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(cachedSwr).mockImplementation(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/medications/compliance", () => {
it("returns one compact row per medication, without the heatmap grid", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
const items = body.data as Array<Record<string, unknown>>;
expect(items).toHaveLength(2);
expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]);
for (const item of items) {
expect(item.compliance7).toBeDefined();
expect(item.compliance30).toBeDefined();
expect(item.complianceDisplay).toBeDefined();
// The heavy per-day grid stays on the per-id endpoint.
expect(item).not.toHaveProperty("dailyCompliance");
}
});
it("reads each medication through the shared per-medication cache cell", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
// Same key shape as the per-id route, so the two endpoints warm each
// other and `invalidateUserMedications` sweeps both with one prefix.
const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key);
expect(keys).toEqual([
"user-1|med-1|compliance|UTC",
"user-1|med-2|compliance|UTC",
]);
});
it("scopes the medication read to the calling user", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual([]);
const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as {
where: { userId: string };
};
expect(call.where.userId).toBe("user-1");
});
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 as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(429);
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
"medication-compliance-summary:user-1",
30,
60_000,
);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import {
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* Batched card-compliance read: one round trip for every medication the
* caller owns, replacing the per-card fan-out over
* `GET /api/medications/{id}/compliance` (one request per card, ~1 s
* cold each). The per-medication payload still builds and caches through
* the SAME `medicationCompliance` cells the per-id route reads, so this
* read warms the detail page (and vice versa) and both invalidate
* together on a write.
*
* The heavy `dailyCompliance` heatmap map is intentionally NOT on this
* wire shape — the cards render rates / streak / display block only; the
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`medication-compliance-summary:${user.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
// Same ordering as the medications list so the page's card order and
// this payload walk the same sequence.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
orderBy: { createdAt: "desc" },
});
// Sequential on purpose: each cold cell costs one bounded intake read +
// one band-expansion pass, and a sequential walk keeps a many-meds
// account from stampeding the pool. Warm / stale cells return without
// touching the database at all.
const items = [];
for (const medication of medications) {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
items.push({
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
});
}
annotate({
action: { name: "medication.compliance_summary.read" },
meta: { count: items.length },
});
return apiSuccess(items);
});
+5 -1
View File
@@ -569,7 +569,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
// ingested batch. v1.16.8 — this is the background sync path (iOS bulk
// ingest), so the SWR buckets are marked stale rather than hard-evicted:
// the readers keep serving the prior list / compliance payloads while
// one coalesced recompute warms each cell, instead of a high-frequency
// sync busting every card into an inline cold rebuild.
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id);
}
+1 -1
View File
@@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 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);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the persistent compliance rollup row for
// the affected day so the next read after the cache miss returns
+12 -7
View File
@@ -21,7 +21,7 @@ import {
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NextRequest } from "next/server";
type MedicationsListResult = Array<Record<string, unknown>>;
@@ -187,11 +187,16 @@ export const GET = apiHandler(async () => {
const userTz = user.timezone || "Europe/Berlin";
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
// staleness window for `todayEventCount` (the only time-of-day-derived
// field in the response); writes via POST/PUT/DELETE flush through
// `invalidateUserMedications` before the next read lands.
const result = await cached(
// Cache the list shape on userId. The 60 s fresh TTL bounds the
// cross-midnight staleness window for `todayEventCount` and the
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
// minutes after the last one serves the prior list immediately while
// one background recompute warms a fresh entry (the 10-minute stale
// window bounds how old that served list can be). Interactive writes
// via POST/PUT/DELETE hard-evict through
// `invalidateUserMedications({ evict: true })` before the next read
// lands; background sync paths mark the bucket stale instead.
const result = await cachedSwr(
caches.medications as ServerCache<MedicationsListResult>,
user.id,
() => buildMedicationsList(user.id, userTz),
@@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the new schedule.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
{
@@ -327,11 +327,20 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
],
};
// v1.16.8 — the cards read ONE batched summary key and `select` their
// own row, so seeding merges into the shared array.
function seedStreak(client: QueryClient, medId: string) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
});
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
},
]);
}
it("both cards render the flame with the canonical token, never the drift", () => {
@@ -140,13 +140,22 @@ function seedCompliance(
streak?: number;
} = {},
) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: {
rate: payload.rate7 ?? 85,
streak: payload.streak ?? 0,
// v1.16.8 — the cards read ONE batched summary key and `select` their
// own row, so seeding merges into the shared array.
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
compliance7: {
rate: payload.rate7 ?? 85,
streak: payload.streak ?? 0,
},
compliance30: { rate: payload.rate30 ?? 82 },
},
compliance30: { rate: payload.rate30 ?? 82 },
});
]);
}
function render(
@@ -34,11 +34,20 @@ function render(node: React.ReactNode, client: QueryClient) {
);
}
// v1.16.8 — the cards read ONE batched summary key and `select` their
// own row, so seeding merges into the shared array.
function seedCompliance(client: QueryClient, medId: string) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
});
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
},
]);
}
// A schedule whose window has already passed for today (01:00–02:00) so
@@ -59,11 +59,21 @@ function render(
);
}
// v1.16.8 — the cards read ONE batched summary key and `select` their
// own row, so seeding merges into the shared array instead of writing a
// per-medication key.
function seedCompliance(client: QueryClient, medId: string) {
client.setQueryData(["medications", medId, "compliance"], {
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
});
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
compliance30: { rate: 88 },
},
]);
}
function seedGlp1Details(client: QueryClient, medId: string) {
@@ -379,9 +379,11 @@ describe("runUndoIntake — shared soft-delete", () => {
await runUndoIntake({ medication, eventId: "evt-1", t, queryClient });
// Method + path are the contract; the shared fetch wrapper may attach
// transport details (e.g. an abort signal) the undo path doesn't own.
expect(fetchMock).toHaveBeenCalledWith(
"/api/medications/med-1/intake/evt-1",
{ method: "DELETE" },
expect.objectContaining({ method: "DELETE" }),
);
expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone");
expect(queryClient.invalidateQueries).toHaveBeenCalled();
@@ -160,19 +160,24 @@ export function MedicationCardBody({
{/* Top status line. The in-window take-now pill (success) and the
overdue escalation are mutually exclusive — a dose is either still
in its take-window or past it. The overdue line wins when present
so a heavily-overdue dose reads loud at the top of the card. */}
{overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</p>
) : windowStatus ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
/>
) : null}
so a heavily-overdue dose reads loud at the top of the card.
The slot keeps a constant one-line height even when empty: the
overdue tier arrives with the compliance query, and a line
popping in late used to shift every row below it (CLS). */}
<div className="min-h-5">
{overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</p>
) : windowStatus ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
/>
) : null}
</div>
{/* Next / last intake — the two decisive lines, each shown once. */}
<MedicationNextLastSlot next={nextLine} last={lastLine} />
@@ -192,9 +197,14 @@ export function MedicationCardBody({
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — calm, rate-decoupled. */}
{active && currentCycle && (
<MedicationCycleStatus cycle={currentCycle} />
{/* Open-cycle status line — calm, rate-decoupled. The slot reserves
one xs text line on every active card: the descriptor rides the
compliance query, and a line appearing after the fetch resolved
used to grow the card and shift the grid row (CLS). */}
{active && (
<div className="min-h-4">
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
</div>
)}
{/* Quick actions — bottom-pinned so the action rows align across a
@@ -26,7 +26,7 @@ import {
toBerlinDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
@@ -110,19 +110,6 @@ export interface Glp1Medication {
schedules: ScheduleLite[];
}
interface ComplianceData {
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
/**
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
* injections are commonly weekly, so this card's windows step up to the
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
* omit it, in which case the card falls back to the static 7-/30-day
* fields.
*/
complianceDisplay?: ComplianceDisplay;
}
interface DetailsResponse {
doseChanges: DoseChangeLite[];
recentIntakes: IntakeLite[];
@@ -202,22 +189,10 @@ export function Glp1MedicationCard({
},
});
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
queryFn: async () => {
try {
return await apiGet<ComplianceData>(
`/api/medications/${medication.id}/compliance`,
);
} catch {
return null;
}
},
// 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.16.8 — same batched compliance source as the generic card: one
// `GET /api/medications/compliance` round trip shared by every card on
// the page (the per-id endpoint stays for the detail page's heatmap).
const { data: compliance } = useMedicationComplianceSummary(medication.id);
// v1.4.37 W4b — same reminder-thresholds source as the generic
// medication card so the take-now / overdue / very-overdue pill
+5 -38
View File
@@ -23,10 +23,10 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
interface Schedule {
id: string;
@@ -87,25 +87,6 @@ interface Medication {
schedules: Schedule[];
}
interface ComplianceData {
compliance7: {
rate: number;
streak: number;
};
compliance30: {
rate: number;
};
/**
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
* card always shows two percentage rows; `shortDays` / `longDays` name the
* windows and `short` / `long` carry their rates. v1.15.9 also carries the
* open dose's `currentDose.status` for the green / overdue presentation.
* Additive — older mocks omit it, in which case the card falls back to the
* static 7-/30-day `compliance7` / `compliance30` fields and a calm state.
*/
complianceDisplay?: ComplianceDisplay;
}
interface MedicationCardProps {
medication: Medication;
onEdit: (med: Medication) => void;
@@ -147,24 +128,10 @@ export function MedicationCard({
},
});
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
queryFn: async () => {
try {
return await apiGet<ComplianceData>(
`/api/medications/${medication.id}/compliance`,
);
} catch {
return null;
}
},
// 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,
});
// v1.16.8 — the per-card compliance read rides the ONE batched
// `GET /api/medications/compliance` round trip every card on the page
// shares (the per-id endpoint stays for the detail page's heatmap).
const { data: compliance } = useMedicationComplianceSummary(medication.id);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
+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",
+240
View File
@@ -0,0 +1,240 @@
/**
* The full compliance computation for one medication, shared by
* `GET /api/medications/[id]/compliance` (detail page, full payload incl.
* the 90-day heatmap) and `GET /api/medications/compliance` (the batched
* card read). Lifted out of the per-id route so the server cache can wrap
* one canonical builder from both entry points: ONE bounded intake-event
* read (only the 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.
*/
import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
lastNonSkippedTakenAt,
type ComplianceDisplay,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { userDayKey } from "@/lib/tz/format";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* 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.
*/
const EVENT_FETCH_WINDOW_DAYS = 366;
/** The cached response body (the exact public wire shape). */
export interface CompliancePayload {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay;
}
/** The medication slice the payload builder consumes. */
export interface ComplianceMedicationInput {
id: string;
createdAt: Date;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
/** v1.16.3 — archived schedule eras for era-aware compliance. */
scheduleRevisions?: Parameters<
typeof buildComplianceMedicationContext
>[0]["scheduleRevisions"];
}
/**
* One canonical cache key per `(user, medication, timezone)` cell. The
* key carries the timezone the payload was bucketed in: day keys, the
* heatmap, and the overdue cut all derive from `userTz`, so a timezone
* change must miss the old entry instead of serving day buckets computed
* for the previous zone until the TTL lapses. The `${userId}|` prefix is
* what `invalidateUserMedications` sweeps.
*/
export function complianceCacheKey(
userId: string,
medicationId: string,
userTz: string,
): string {
return `${userId}|${medicationId}|compliance|${userTz}`;
}
/**
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
}
}
export async function buildCompliancePayload(
medication: ComplianceMedicationInput,
userId: string,
userTz: string,
): Promise<CompliancePayload> {
// 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.15.20 — pinned takes bind by anchor in the unified ledger so the
// % agrees with the history view on a "zugeordnet" dose (taken-late).
attributionSource: 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<string, DailyComplianceEntry> = {};
const byDay = new Map<string, DailyComplianceEntry>();
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,
};
}
+35
View File
@@ -507,6 +507,19 @@ const medicationComplianceResponse = z
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
});
const medicationComplianceSummaryEntry = z
.object({
medicationId: z.string(),
compliance7: complianceResult,
compliance30: complianceResult,
complianceDisplay,
})
.meta({
id: "MedicationComplianceSummaryEntry",
description:
"Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.",
});
// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline).
// Archived eras come from two provenances: the wholesale-replace write
// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow
@@ -856,6 +869,28 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/compliance": {
get: {
tags: ["Medications"],
summary: "Batched adherence read for every medication of the caller",
description:
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
responses: {
"200": {
description: "One adherence row per medication.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(medicationComplianceSummaryEntry),
"ListMedicationComplianceResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/medications/{id}/compliance": {
get: {
tags: ["Medications"],
+16 -2
View File
@@ -12,11 +12,20 @@ import { apiGet } from "@/lib/api/api-fetch";
* puts `/api/medications` (the response carries the per-medication
* `nextDueAt` due times) in flight while the navigation commits.
*
* v1.16.8 — the batched card-compliance read
* (`/api/medications/compliance`) prefetches alongside the list, so the
* compliance bars + the status line resolve in the same paint as the
* cards instead of swapping in late. Two independent requests on
* purpose: the list response must stay fast (the due line never waits
* on a compliance ledger build), and the compliance prefetch rides the
* same server-side cache cells the page query would warm anyway.
*
* The 15 s prefetch window only bounds how old a cache entry may be
* before an intent re-fires the request — the mounted page query rides
* the provider-default `staleTime` (5 min) and consumes whatever this
* prefetch parked. The server caches the list 60 s per user, so even a
* missed window stays cheap.
* prefetch parked. The server caches both reads per user (60 s list /
* 15 min compliance, each with an SWR window), so even a missed window
* stays cheap.
*/
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
@@ -26,6 +35,11 @@ export function prefetchMedicationsList(queryClient: QueryClient): void {
queryFn: () => apiGet("/api/medications"),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
void queryClient.prefetchQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: () => apiGet("/api/medications/compliance"),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
}
/**
@@ -0,0 +1,61 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
/**
* One batched-summary row — the compact slice of the per-medication
* compliance payload the cards render (rates, streak, the cadence-scaled
* display block with `currentDose` / `currentCycle`). The heavy
* `dailyCompliance` heatmap stays on the per-id endpoint the detail page
* reads.
*/
export interface MedicationComplianceSummaryEntry {
medicationId: string;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay?: ComplianceDisplay;
}
/**
* v1.16.8 — the per-card compliance read, batched.
*
* Every medication card used to fire its own
* `GET /api/medications/{id}/compliance` (N+1 over the grid, ~1 s cold
* each). All cards now share ONE `GET /api/medications/compliance`
* round trip under a single query key; each card `select`s its own row,
* so a grid of mounted cards collapses into one request and one cache
* entry. The key rides the `["medications"]` prefix, so every intake /
* CRUD mutation that invalidates `medicationDependentKeys` refreshes it.
*
* Lives in one shared hook (not per-card `useQuery` calls) so the two
* card variants can never register the same key with diverging fetch
* shapes.
*/
export function useMedicationComplianceSummary(medicationId: string): {
data: MedicationComplianceSummaryEntry | null | undefined;
} {
const { data } = useQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: async () => {
try {
return await apiGet<MedicationComplianceSummaryEntry[]>(
"/api/medications/compliance",
);
} catch {
return null;
}
},
// Dose actions invalidate the key explicitly through
// `medicationDependentKeys`; a shorter window would only re-fire the
// batch on every list visit. Matches the reminder-thresholds query
// the cards mount alongside.
staleTime: 5 * 60 * 1000,
select: (rows: MedicationComplianceSummaryEntry[] | null) =>
rows?.find((row) => row.medicationId === medicationId) ?? null,
});
return { data };
}
+12
View File
@@ -16,6 +16,18 @@ export const medicationKeys = {
*/
medicationCompliance: (medicationId: string) =>
["medications", medicationId, "compliance"] as const,
/**
* v1.16.8 — the batched card-compliance read
* (`GET /api/medications/compliance`, one round trip for every card on
* the medications page). Replaces the per-card fan-out over the per-id
* endpoint; `medicationCompliance(id)` above stays for the detail page,
* which needs the per-id payload's heatmap grid. Rides under the
* `["medications"]` prefix so every intake / CRUD mutation reaches it
* through `medicationDependentKeys`. The static second segment cannot
* collide with `medicationDetail(id)` — ids are cuids.
*/
medicationComplianceSummary: () =>
["medications", "compliance-summary"] as const,
medicationCadence: (medicationId: string) =>
["medications", medicationId, "cadence"] as const,
medicationGlp1Details: (medicationId: string) =>
+185
View File
@@ -0,0 +1,185 @@
/**
* v1.16.8 — memoised `Intl.DateTimeFormat` construction.
*
* Constructing a formatter dominates the CPU cost of the wall-clock
* helpers (band expansion calls them in a tight loop), so the memo must
* (a) return byte-identical output to a freshly constructed formatter
* for every timezone / option shape the tz helpers use, and (b) actually
* share one instance per signature.
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
getDateTimeFormat,
__resetIntlCacheForTests,
__intlCacheSizeForTests,
} from "../intl-cache";
import { wallClockInTz } from "../wall-clock";
import { formatInUserTz, userDayKey, isValidTimezone } from "../format";
import { getUserTodayBounds, localHmAsUtc } from "../local-day";
const ZONES = [
"UTC",
"Europe/Berlin",
"America/New_York",
"Asia/Tokyo",
"Pacific/Kiritimati", // +14, no DST
"America/St_Johns", // -03:30 fractional offset
];
// Instants chosen to straddle DST transitions and a midnight boundary.
const INSTANTS = [
new Date("2026-01-15T03:30:00Z"),
new Date("2026-03-29T01:30:00Z"), // EU spring-forward window
new Date("2026-06-10T23:45:00Z"),
new Date("2026-10-25T01:30:00Z"), // EU fall-back window
];
beforeEach(() => {
__resetIntlCacheForTests();
});
describe("getDateTimeFormat", () => {
it("matches a freshly constructed formatter across zones and option shapes", () => {
const optionShapes: Omit<Intl.DateTimeFormatOptions, "timeZone">[] = [
{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
},
{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
},
{ hour: "2-digit", minute: "2-digit", hour12: false },
];
for (const tz of ZONES) {
for (const options of optionShapes) {
const memoised = getDateTimeFormat("en-CA", tz, options);
const fresh = new Intl.DateTimeFormat("en-CA", {
...options,
timeZone: tz,
});
for (const instant of INSTANTS) {
expect(memoised.format(instant)).toBe(fresh.format(instant));
expect(memoised.formatToParts(instant)).toEqual(
fresh.formatToParts(instant),
);
}
}
}
});
it("returns the same instance for the same (locale, timeZone, options) signature", () => {
const options = {
hour: "2-digit",
minute: "2-digit",
hour12: false,
} as const;
const a = getDateTimeFormat("en-GB", "Europe/Berlin", options);
const b = getDateTimeFormat("en-GB", "Europe/Berlin", options);
expect(a).toBe(b);
// A structurally equal but distinct options object still hits the
// same cache cell (signature-keyed, not identity-keyed).
const c = getDateTimeFormat("en-GB", "Europe/Berlin", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
} as const);
expect(c).toBe(a);
});
it("splits cells on locale, timezone, and options", () => {
const options = {
hour: "2-digit",
minute: "2-digit",
hour12: false,
} as const;
const base = getDateTimeFormat("en-GB", "Europe/Berlin", options);
expect(getDateTimeFormat("en-US", "Europe/Berlin", options)).not.toBe(base);
expect(getDateTimeFormat("en-GB", "Asia/Tokyo", options)).not.toBe(base);
expect(
getDateTimeFormat("en-GB", "Europe/Berlin", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
} as const),
).not.toBe(base);
expect(__intlCacheSizeForTests()).toBe(4);
});
it("throws on an invalid timezone without caching the failure", () => {
expect(() => getDateTimeFormat("en-US", "Not/AZone", {})).toThrow();
expect(__intlCacheSizeForTests()).toBe(0);
});
});
describe("memoised consumers stay output-identical to the pre-memo helpers", () => {
it("wallClockInTz decomposes DST-transition instants correctly", () => {
// 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning).
const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin");
expect(parts).toEqual({
year: 2026,
month: 3,
day: 29,
hour: 3,
minute: 30,
second: 0,
weekday: 0,
});
// Same instant read from Tokyo.
const tokyo = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Asia/Tokyo");
expect(tokyo).toEqual({
year: 2026,
month: 3,
day: 29,
hour: 10,
minute: 30,
second: 0,
weekday: 0,
});
});
it("formatInUserTz keeps every shape stable across repeat (memo-warm) calls", () => {
const instant = new Date("2026-06-10T23:45:00Z");
for (let i = 0; i < 2; i++) {
expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe(
"2026-06-11T01:45:00+02:00",
);
expect(formatInUserTz(instant, "Europe/Berlin", "wall-clock")).toBe(
"01:45",
);
expect(formatInUserTz(instant, "Europe/Berlin", "date")).toBe(
"2026-06-11",
);
expect(userDayKey(instant, "America/New_York")).toBe("2026-06-10");
}
});
it("getUserTodayBounds + localHmAsUtc agree with the wall-clock read", () => {
const now = new Date("2026-06-10T23:45:00Z"); // 01:45 on the 11th in Berlin
const { start, end } = getUserTodayBounds(now, "Europe/Berlin");
expect(start.toISOString()).toBe("2026-06-10T22:00:00.000Z");
expect(end.toISOString()).toBe("2026-06-11T21:59:59.999Z");
const eight = localHmAsUtc(now, "Europe/Berlin", 8, 0);
expect(eight.toISOString()).toBe("2026-06-11T06:00:00.000Z");
});
it("isValidTimezone accepts real zones and rejects junk through the memo", () => {
expect(isValidTimezone("Europe/Berlin")).toBe(true);
expect(isValidTimezone("Pacific/Kiritimati")).toBe(true);
expect(isValidTimezone("Not/AZone")).toBe(false);
expect(isValidTimezone("")).toBe(false);
});
});
+36 -27
View File
@@ -12,8 +12,35 @@
* lookup.
*/
import { getDateTimeFormat } from "./intl-cache";
export const DEFAULT_TIMEZONE = "Europe/Berlin";
/**
* Hoisted option constants so the formatter memo's WeakMap signature
* lookup hits the same object identity on every call — see
* `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by
* `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME
* memoised formatter per timezone.
*/
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {};
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
hour: "2-digit",
minute: "2-digit",
hour12: false,
};
const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
/**
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
* Returns `true` if the runtime accepts the zone, `false` otherwise.
@@ -24,7 +51,10 @@ export function isValidTimezone(tz: string): boolean {
return false;
}
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
// The memo only caches successful constructions, so probing an
// invalid zone through it cannot poison the formatter map — and a
// valid zone leaves its formatter warm for the real callers.
getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS);
return true;
} catch {
return false;
@@ -133,12 +163,9 @@ export function formatInUserTz(
case "iso-with-offset":
return formatIsoWithOffset(date, safeTz);
case "wall-clock":
return new Intl.DateTimeFormat("en-GB", {
timeZone: safeTz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
return getDateTimeFormat("en-GB", safeTz, HOUR_MINUTE_OPTIONS).format(
date,
);
case "datetime": {
const parts = wallClockParts(date, safeTz);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
@@ -171,16 +198,7 @@ interface WallClockParts {
}
function wallClockParts(date: Date, tz: string): WallClockParts {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
const parts = fmt.formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "00";
@@ -223,16 +241,7 @@ function formatIsoWithOffset(date: Date, tz: string): string {
* difference between the wall-clock parts and the UTC parts.
*/
function tzOffsetMinutes(date: Date, tz: string): number {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
const parts = fmt.formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((p) => p.type === type)?.value ?? "0");
+77
View File
@@ -0,0 +1,77 @@
/**
* Module-level memo for `Intl.DateTimeFormat` construction.
*
* Constructing a formatter resolves locale + timezone data on every call
* and dominates the CPU cost of the wall-clock helpers (~10x the
* `format` / `formatToParts` call it feeds). The instances are stateless
* for `format` / `formatToParts`, so one shared formatter per
* `(locale, timeZone, options)` signature is safe to hand to every
* caller — compliance band expansion, rollups, the dose-history ledger
* and the export formatters all hit the same small set of signatures.
*
* `timeZone` is a first-class key segment (not folded into `options`) so
* call sites can hoist their options object to a module constant; a
* WeakMap memoises that constant's serialised signature, leaving a warm
* lookup as two string concats + one Map get.
*/
const formatterCache = new Map<string, Intl.DateTimeFormat>();
/**
* Hard cap. The realistic population is bounded (valid IANA zones × a
* handful of option shapes), but the timezone segment is user-supplied
* upstream of validation in `isValidTimezone`, so the memo carries a
* safety valve: on overflow the whole map resets and re-warms.
*/
const MAX_FORMATTERS = 1000;
const signatureByOptions = new WeakMap<
Omit<Intl.DateTimeFormatOptions, "timeZone">,
string
>();
function optionsSignature(
options: Omit<Intl.DateTimeFormatOptions, "timeZone">,
): string {
const memoised = signatureByOptions.get(options);
if (memoised !== undefined) return memoised;
const record = options as Record<string, unknown>;
const signature = Object.keys(record)
.sort()
.map((key) => `${key}=${String(record[key])}`)
.join(",");
signatureByOptions.set(options, signature);
return signature;
}
/**
* Shared, memoised `Intl.DateTimeFormat`. Throws (like the constructor)
* on an invalid `timeZone` — failures are never cached, so
* `isValidTimezone` can probe through this without poisoning the map.
*/
export function getDateTimeFormat(
locale: string,
timeZone: string | undefined,
options: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {},
): Intl.DateTimeFormat {
const key = `${locale}|${timeZone ?? ""}|${optionsSignature(options)}`;
const hit = formatterCache.get(key);
if (hit) return hit;
const formatter = new Intl.DateTimeFormat(
locale,
timeZone === undefined ? options : { ...options, timeZone },
);
if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear();
formatterCache.set(key, formatter);
return formatter;
}
/** Test-only escape hatch — clear the memo between assertions. */
export function __resetIntlCacheForTests(): void {
formatterCache.clear();
}
/** Test-only introspection — current memo population. */
export function __intlCacheSizeForTests(): number {
return formatterCache.size;
}
+22 -11
View File
@@ -18,6 +18,8 @@
* staying in lock-step; keeping them as separate definitions made
* silent drift a real failure mode.
*/
import { getDateTimeFormat } from "./intl-cache";
export interface WallClockParts {
year: number;
/** 1–12 (January = 1, December = 12). */
@@ -42,6 +44,23 @@ const WEEKDAY_MAP: Record<string, number> = {
Sat: 6,
};
/**
* Hoisted so the formatter memo's WeakMap signature lookup hits the same
* object identity on every call — a warm `wallClockInTz` no longer pays
* `Intl.DateTimeFormat` construction (the dominant cost of the band
* expansion + rollup paths that call this in a tight loop).
*/
const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
};
export function wallClockInTz(
date: Date,
tz: string | undefined,
@@ -57,17 +76,9 @@ export function wallClockInTz(
weekday: date.getDay(),
};
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(date);
const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts(
date,
);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));