perf(charts): bound health-chart fetches to the active range window

The chart-data query in health-chart.tsx walked the full measurement
history with a `while (true)` paginated loop on every visit. For
Apple-Health-rich accounts (pulse every minute, months of backfill)
this meant tens of thousands of rows per type per page navigation,
which is the root cause behind FB-D2's "pulse insights loads forever"
report.

Derive `from` / `to` from the active range tab (plus the
comparison-baseline shift when overlay is on), pass both to
/api/measurements, and drop the unbounded paginator. The query key
re-keys on the window so a range-tab change refetches the right
slice. staleTime 60s + gcTime 5min hold the bounded payload across
tab navigation between insights sub-pages.

New `aggregate` query param + range-aggregation helper let the GET
handler downsample server-side for ranges over 90 d (one row per
day) and over 365 d (one row per ISO week). The per-request `limit`
ceiling lifts from 500 to 5000 — still bounded, but enough headroom
for an explicit from/to window without re-introducing pagination.

iOS contract — GET /api/measurements: additive. The `from`, `to`,
`aggregate` params are all optional; callers that omit them (the
iOS client, the existing measurements page) see the unchanged
response shape and the historical 500-row default cap. The
aggregated branch is only entered when both from + to are present
AND the window is wide enough for the threshold ladder to engage.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 00:20:27 +02:00
1 parent 538b44f74d
commit b00be2863d
6 files changed
+471 -47

No files matched your search

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.28 R3a FB-D2 (R1.2 H0) — assert the HealthChart fetcher passes
* a bounded `from` / `to` window to `/api/measurements` instead of
* walking the full history with a `while (true)` loop.
*
* The test intercepts the underlying `useQuery` so the assertion can
* lock down the query key shape AND the fetch URL the queryFn invokes.
* If a future refactor re-introduces an unbounded walk the queryFn
* mock will fire with no `from=` / `to=` segment and the test fails.
*/
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
useQuery: ({
queryFn,
queryKey,
}: {
queryKey: unknown[];
queryFn: () => Promise<unknown>;
staleTime?: number;
gcTime?: number;
}) => {
// Surface the query key so the test can introspect it.
(globalThis as unknown as { __lastQueryKey?: unknown[] }).__lastQueryKey =
queryKey;
// Fire the queryFn synchronously (it will call our fetch stub).
void queryFn();
return { data: [], isLoading: false };
},
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
beforeEach(() => {
fetchCalls.length = 0;
global.fetch = vi.fn(async (url: RequestInfo | URL) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify({ data: { measurements: [], meta: { total: 0 } } }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
});
describe("<HealthChart> — bounded range fetch", () => {
it("sends from + to + a 5000-row cap to /api/measurements", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
// Wait a tick so the queryFn fetch resolves.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchCalls.length).toBeGreaterThan(0);
const measurementsUrl = fetchCalls.find((u) =>
u.includes("/api/measurements"),
);
expect(measurementsUrl).toBeDefined();
expect(measurementsUrl!).toMatch(/from=/);
expect(measurementsUrl!).toMatch(/to=/);
expect(measurementsUrl!).toMatch(/limit=5000/);
expect(measurementsUrl!).toMatch(/type=PULSE/);
// Guard against the legacy unbounded paginator returning.
expect(measurementsUrl!).not.toMatch(/offset=/);
});
it("re-keys the query when the range window changes", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
const key = (globalThis as unknown as { __lastQueryKey?: unknown[] })
.__lastQueryKey;
expect(key).toBeDefined();
// Last two slots are `from` / `to`. Both must be ISO date strings.
const isoRe = /\d{4}-\d{2}-\d{2}T/;
expect(String(key![key!.length - 2])).toMatch(isoRe);
expect(String(key![key!.length - 1])).toMatch(isoRe);
});
});
+78 -45
View File
@@ -504,6 +504,40 @@ export function HealthChart({
const bmiDivisor =
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
// v1.4.28 FB-D2 (R1.2 H0) — derive a bounded date window from the
// active range selector so the chart fetches only what it will
// actually render. The legacy `while (true)` paginated walk pulled
// tens of thousands of pulse rows on every visit; the bounded
// window + the server-side aggregation hint keeps every navigation
// payload-flat regardless of underlying density.
//
// Comparison overlays (lastMonth / lastYear) extend the `from`
// boundary backwards by the shift distance so the prior-period
// slice rides the same fetch and `shiftDailySeriesForward` finds
// its input. "All" range (rangePoints === 0) defaults to a
// 365-day window; the chart's existing all-time UX already
// re-renders against whatever window we hand it.
const fetchWindow = useMemo(() => {
const to = new Date();
const windowDays = rangePoints > 0 ? rangePoints : 365;
const compareShift =
effectiveCompareBaseline === "lastMonth"
? 30
: effectiveCompareBaseline === "lastYear"
? 365
: 0;
const totalDays = windowDays + compareShift;
const from = new Date(to.getTime() - totalDays * 86_400_000);
// Use ISO strings so the cache key is stable across the day; the
// server treats `to` as the upper bound and the chart truncates to
// `rangePoints` on the client.
return {
from: from.toISOString(),
to: to.toISOString(),
windowDays: totalDays,
};
}, [rangePoints, effectiveCompareBaseline]);
const { data, isLoading } = useQuery({
queryKey: [
"chart-data",
@@ -514,12 +548,19 @@ export function HealthChart({
// user timezone, so re-key the cache when it changes. Without
// this, a tz change inside a session would render stale buckets.
userTimezone,
// v1.4.28 FB-D2 — bound the cache by the active fetch window so
// a range-tab change re-fetches the right slice rather than
// re-running the unbounded walk.
fetchWindow.from,
fetchWindow.to,
],
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
// navigation between insights sub-pages does not re-fire every
// chart's fetch. `gcTime` keeps the inactive cache for five
// minutes so a quick back-and-forth doesn't repay the cost.
staleTime: 60_000,
gcTime: 5 * 60_000,
queryFn: async () => {
const params = new URLSearchParams();
params.set("sortBy", "measuredAt");
params.set("sortDir", "asc");
const dailyAggregates = new Map<
string,
{
@@ -528,56 +569,48 @@ export function HealthChart({
}
>();
async function fetchAllMeasurementsByType(type: string) {
const pageSize = 500;
let offset = 0;
async function fetchMeasurementsByType(type: string) {
const typeParams = new URLSearchParams();
typeParams.set("type", type);
typeParams.set("sortBy", "measuredAt");
typeParams.set("sortDir", "asc");
typeParams.set("from", fetchWindow.from);
typeParams.set("to", fetchWindow.to);
typeParams.set("limit", "5000");
while (true) {
const typeParams = new URLSearchParams(params);
typeParams.set("type", type);
typeParams.set("limit", String(pageSize));
typeParams.set("offset", String(offset));
const res = await fetch(`/api/measurements?${typeParams}`);
if (!res.ok) return;
const res = await fetch(`/api/measurements?${typeParams}`);
if (!res.ok) break;
const json = await res.json();
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
const json = await res.json();
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
const total = Number(json.data?.meta?.total ?? page.length);
for (const measurement of page) {
const rawValue = measurement.value;
const value =
valueMode === "bmi"
? bmiDivisor
? rawValue / bmiDivisor
: null
: rawValue;
for (const measurement of page) {
const rawValue = measurement.value;
const value =
valueMode === "bmi"
? bmiDivisor
? rawValue / bmiDivisor
: null
: rawValue;
if (value == null || !Number.isFinite(value)) {
continue;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
current.sum += value;
current.count += 1;
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
if (value == null || !Number.isFinite(value)) {
continue;
}
offset += page.length;
if (page.length === 0 || offset >= total || page.length < pageSize) {
break;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
current.sum += value;
current.count += 1;
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
}
await Promise.all(types.map(fetchAllMeasurementsByType));
await Promise.all(types.map(fetchMeasurementsByType));
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {