mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user