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

+48 -1
View File
@@ -14,6 +14,11 @@ import {
listMeasurementsSchema,
getUnitForType,
} from "@/lib/validations/measurement";
import {
aggregateRows,
pickAggregateGrain,
rangeLengthDays,
} from "@/lib/measurements/range-aggregation";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
import type {
@@ -32,7 +37,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { type, from, to, limit, offset, sortBy, sortDir } = parsed.data;
const { type, from, to, limit, offset, sortBy, sortDir, aggregate } =
parsed.data;
const where = {
userId: user.id,
@@ -47,6 +53,47 @@ export const GET = apiHandler(async (request: NextRequest) => {
: {}),
};
// v1.4.28 FB-D2 — when the caller supplies a from/to window AND asks
// for an aggregated grain (or the window is wide enough that the
// default grain should downsample), collapse to one row per bucket
// per type before returning. Raw mode keeps the historical wire shape
// so existing callers and the iOS client (which does not pass
// `aggregate`) see no change.
if (from && to) {
const grain = pickAggregateGrain(rangeLengthDays(from, to), aggregate);
if (grain !== "raw") {
const rows = await prisma.measurement.findMany({
where,
orderBy: { measuredAt: "asc" },
take: limit,
skip: offset,
select: { type: true, value: true, measuredAt: true },
});
const buckets = aggregateRows(
rows.map((r) => ({
type: r.type as string,
value: r.value,
measuredAt: r.measuredAt,
})),
grain,
);
const measurements = buckets.map((b) => ({
type: b.type,
value: b.avg,
measuredAt: b.bucketStart.toISOString(),
count: b.count,
}));
annotate({
action: { name: "measurement.list" },
meta: { total: measurements.length, type, aggregate: grain },
});
return apiSuccess({
measurements,
meta: { total: measurements.length, limit, offset, aggregate: grain },
});
}
}
const [measurements, total] = await Promise.all([
prisma.measurement.findMany({
where,