perf: collapse the medication compliance hot path and the dashboard fan-out

The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 12:55:56 +02:00
1 parent 41ba4c6c17
commit 298e4c34b4
24 files changed
+1483 -319

No files matched your search

+53 -14
View File
@@ -18,7 +18,13 @@ import {
ReferenceDot,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo, useEffect, type ComponentType } from "react";
import {
useState,
useMemo,
useEffect,
useCallback,
type ComponentType,
} from "react";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { TileHeader } from "@/components/insights/tile-header";
@@ -607,10 +613,17 @@ export function HealthChart({
// and a fresh measurement evicts every per-chart daily cache in one
// pass (audit C2). The factory-packed tuple is byte-identical with
// the pre-v1.4.40 inline literal so the cache layout stays stable.
// v1.15.20 — BMI is a pure transformation of the WEIGHT series
// (value / height²), so the BMI view shares the raw series' cache
// entry instead of re-fetching the same window under its own key.
// The key pins the literal "raw" / "no-bmi" discriminators (the
// factory tuple shape is unchanged) and the BMI division happens in
// the `select` callback below — the factory rule stays intact and
// the cached data is mode-agnostic.
queryKey: queryKeys.chartData(
types.join(","),
valueMode,
bmiDivisor ?? "no-bmi",
"raw",
"no-bmi",
// v1.4.25 W7b — bucket keys + tick labels depend on the active
// user timezone, so re-key the cache when it changes. Without
// this, a tz change inside a session would render stale buckets.
@@ -745,15 +758,12 @@ export function HealthChart({
// personal baseline, the trend, and the tooltip all operate
// on the scaled series uniformly. `valueScale` defaults to 1
// (identity), so non-unit-fixed charts read byte-identical.
const rawValue = measurement.value * valueScale;
const value =
valueMode === "bmi"
? bmiDivisor
? rawValue / bmiDivisor
: null
: rawValue;
// v1.15.20 — the BMI division moved into the query's `select`
// callback so the cached series stays raw and the WEIGHT and
// BMI views share one cache entry.
const value = measurement.value * valueScale;
if (value == null || !Number.isFinite(value)) {
if (!Number.isFinite(value)) {
continue;
}
@@ -773,11 +783,13 @@ export function HealthChart({
// v1.8.5 — carry the rollup bucket's min / max through when the
// API supplies them (daily-aggregate path, non-cumulative).
// `valueScale` already folded into `value` above; apply the
// same scale to the spread so the band tracks the line.
// same scale to the spread so the band tracks the line. The
// BMI view drops the range tuple in its `select` callback (it
// has never rendered the band), so the raw cache always
// carries the spread.
if (
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number" &&
valueMode !== "bmi"
typeof measurement.maxValue === "number"
) {
const scaledMin = measurement.minValue * valueScale;
const scaledMax = measurement.maxValue * valueScale;
@@ -826,6 +838,33 @@ export function HealthChart({
return allData;
},
// v1.15.20 — BMI view: transform the cached raw WEIGHT series into
// BMI at read time. The transformation lives in `select` (not the
// queryFn) so the cache entry stays raw and is shared with the
// weight chart; tanstack re-runs the projection without a refetch.
// No height on the profile → empty series (matches the previous
// behaviour where every point was skipped). The `${type}__range`
// tuples are dropped — the BMI view has never rendered the band.
select: useCallback(
(points: ChartDataPoint[]): ChartDataPoint[] => {
if (valueMode !== "bmi") return points;
if (!bmiDivisor) return [];
return points.map((p) => {
const out: ChartDataPoint = {
date: p.date,
timestamp: p.timestamp,
};
for (const type of types) {
const v = p[type];
if (typeof v === "number") {
out[type] = v / bmiDivisor;
}
}
return out;
});
},
[valueMode, bmiDivisor, types],
),
enabled: isAuthenticated,
});
@@ -203,7 +203,10 @@ export function Glp1MedicationCard({
const json = await res.json();
return json.data as ComplianceData;
},
staleTime: 30 * 1000,
// 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.4.37 W4b — same reminder-thresholds source as the generic
@@ -146,7 +146,12 @@ export function MedicationCard({
const json = await res.json();
return json.data as ComplianceData;
},
staleTime: 30 * 1000,
// 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,
});
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
@@ -14,14 +14,12 @@ import {
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
// `ChecklistAnalyticsData`; aliased back to the local name.
import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { queryKeys } from "@/lib/query-keys";
import {
buildChecklist,
checklistProgress,
@@ -181,40 +179,54 @@ export function GettingStartedChecklist() {
}
}, [expanded]);
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together. v1.4.33 IW2 routes the checklist
// onto IW1's slim `?slice=summaries` branch — the only field
// consumed is `summaries[METRIC].count`, which the slim slice fills
// directly. The cache slot is distinct from the dashboard's thick
// slice but each consumer's payload is byte-correct for its own
// reads, and the shared hook keeps the legacy `?.data` unwrap so the
// canonical consumers still see `{summaries, …}` not `{data: …}`.
const analyticsQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !!user,
});
const analyticsData = analyticsQuery.data as AnalyticsData | undefined;
// v1.15.20 — the measurement count is derived from the dashboard
// snapshot the page already fetches (`tiles.summaries` is the same
// `computeSummariesSlice` payload the legacy `?slice=summaries`
// analytics query carried), so the checklist no longer fires its own
// analytics request on every dashboard load. Subscribing to the shared
// `queryKeys.dashboardSnapshot()` cell costs zero extra network.
const snapshotQuery = useDashboardSnapshot(!!user);
const snapshotData = snapshotQuery.data;
const measurementCount = useMemo(() => {
const summaries = snapshotData?.tiles?.summaries ?? {};
let total = 0;
for (const key of Object.keys(summaries)) {
total += summaries[key]?.count ?? 0;
}
return total;
}, [snapshotData]);
// v1.15.20 — only fetch the checklist's supporting data while the card
// can actually render: the user is still in setup (`shouldShowChecklist`'s
// `onboardingCompletedAt == null || measurementCount < 5` rule) and has
// not dismissed it. Established users used to pay this medications fetch
// (and, pre-v1.5, two more) on every dashboard load for a card that
// never mounts. Gated on the snapshot having resolved so the count is
// real, not the loading-default 0.
const checklistRelevant =
!!user &&
!dismissedAll &&
snapshotData !== undefined &&
(user.onboardingCompletedAt == null || measurementCount < 5);
const { data: medsData } = useQuery<Array<{ id: string }>>({
queryKey: ["medications"],
queryKey: queryKeys.medications(),
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data;
},
enabled: !!user,
enabled: checklistRelevant,
});
// v1.5 perf audit: skip these two fetches once the user is past
// onboarding. The checklist hides itself anyway via shouldShowChecklist
// when onboardingCompletedAt != null AND measurementCount >= 5; the
// analytics + medications queries above piggy-back on the dashboard's
// shared cache, but withings/status and notifications/preferences are
// unique to this component and were burning ~950 ms of network on every
// dashboard load for established users. See docs/audit/v15-performance.md.
// when onboardingCompletedAt != null AND measurementCount >= 5;
// withings/status and notifications/preferences are unique to this
// component and were burning ~950 ms of network on every dashboard load
// for established users. See docs/audit/v15-performance.md.
const onboardingPending = !!user && user.onboardingCompletedAt == null;
const { data: withingsData } = useQuery<WithingsStatus>({
@@ -239,15 +251,6 @@ export function GettingStartedChecklist() {
enabled: onboardingPending,
});
const measurementCount = useMemo(() => {
const summaries = analyticsData?.summaries ?? {};
let total = 0;
for (const key of Object.keys(summaries)) {
total += summaries[key]?.count ?? 0;
}
return total;
}, [analyticsData]);
const medicationCount = medsData?.length ?? 0;
const withingsConnected = withingsData?.connected === true;
const notificationsConfigured = (notificationsData?.channels ?? []).some(
@@ -291,19 +294,19 @@ export function GettingStartedChecklist() {
});
// v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user
// AND the analytics query have resolved we render NOTHING. The
// AND the snapshot query have resolved we render NOTHING. The
// previous version rendered a default-true `show` branch while
// `measurementCount` was still 0 (because analytics hadn't returned),
// `measurementCount` was still 0 (because the data hadn't returned),
// so a user whose actual `measurementCount >= 5` saw the card flash
// for ~500 ms before `shouldShowChecklist` flipped to false. We rely
// on `analyticsQuery.data === undefined` as the loading sentinel —
// on `snapshotData === undefined` as the loading sentinel —
// tanstack-query writes `data` exactly once per fetch, so this is
// race-free across hot-reloads and stale-cache invalidation. (The
// dashboard's `/api/analytics` query shares the cache key, so the
// checklist almost always sees the cached value synchronously on
// mount and never blocks UI.)
// dashboard's snapshot query shares the cache key, so the checklist
// almost always sees the cached value synchronously on mount and
// never blocks UI.)
if (!user) return null;
if (analyticsQuery.data === undefined) return null;
if (snapshotData === undefined) return null;
if (!show) return null;
return (