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

+9 -1
View File
@@ -242,13 +242,21 @@ export const listMeasurementsSchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
// v1.4.28 FB-D2 — when the chart sends an explicit from/to window the
// payload is already bounded; lift the per-request ceiling to 5000
// (still cheaper than the legacy unbounded `while (true)` walk).
// Callers that omit from/to keep the historical 500-row cap.
limit: z.coerce.number().int().min(1).max(5000).optional().default(100),
offset: z.coerce.number().int().min(0).optional().default(0),
sortBy: z
.enum(["type", "value", "measuredAt", "source"])
.optional()
.default("measuredAt"),
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
// v1.4.28 FB-D2 — bucket hint for range-aware queries. When set the
// GET handler downsamples server-side via `aggregateRows` and returns
// one row per bucket per type rather than the raw measurement rows.
aggregate: z.enum(["raw", "daily", "weekly"]).optional(),
});
export const createBatchMeasurementSchema = z.object({