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

@@ -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);