From b00be2863d7710d58089cd1ac35800fbdcd1b513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 00:20:27 +0200 Subject: [PATCH] perf(charts): bound health-chart fetches to the active range window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/measurements/route.ts | 49 ++++++- .../__tests__/health-chart-range.test.tsx | 107 +++++++++++++++ src/components/charts/health-chart.tsx | 123 ++++++++++------- .../__tests__/range-aggregation.test.ts | 102 ++++++++++++++ src/lib/measurements/range-aggregation.ts | 127 ++++++++++++++++++ src/lib/validations/measurement.ts | 10 +- 6 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-range.test.tsx create mode 100644 src/lib/measurements/__tests__/range-aggregation.test.ts create mode 100644 src/lib/measurements/range-aggregation.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6e756e561..eb5081679 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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, diff --git a/src/components/charts/__tests__/health-chart-range.test.tsx b/src/components/charts/__tests__/health-chart-range.test.tsx new file mode 100644 index 000000000..bf01f2394 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-range.test.tsx @@ -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; + 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(" — 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( + + + , + ); + + // 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( + + + , + ); + + 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); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 02ebc6515..0dcfbc535 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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) => { diff --git a/src/lib/measurements/__tests__/range-aggregation.test.ts b/src/lib/measurements/__tests__/range-aggregation.test.ts new file mode 100644 index 000000000..f98ee037d --- /dev/null +++ b/src/lib/measurements/__tests__/range-aggregation.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + aggregateRows, + pickAggregateGrain, + rangeLengthDays, + DAILY_AGGREGATE_THRESHOLD_DAYS, + WEEKLY_AGGREGATE_THRESHOLD_DAYS, +} from "../range-aggregation"; + +describe("range-aggregation", () => { + describe("pickAggregateGrain", () => { + it("returns raw for ranges at or under the daily threshold", () => { + expect(pickAggregateGrain(7)).toBe("raw"); + expect(pickAggregateGrain(30)).toBe("raw"); + expect(pickAggregateGrain(DAILY_AGGREGATE_THRESHOLD_DAYS)).toBe("raw"); + }); + + it("returns daily for ranges between 90 and 365 days", () => { + expect(pickAggregateGrain(DAILY_AGGREGATE_THRESHOLD_DAYS + 1)).toBe( + "daily", + ); + expect(pickAggregateGrain(180)).toBe("daily"); + expect(pickAggregateGrain(WEEKLY_AGGREGATE_THRESHOLD_DAYS)).toBe("daily"); + }); + + it("returns weekly for ranges over one year", () => { + expect(pickAggregateGrain(WEEKLY_AGGREGATE_THRESHOLD_DAYS + 1)).toBe( + "weekly", + ); + expect(pickAggregateGrain(1000)).toBe("weekly"); + }); + + it("honours an explicit grain override", () => { + expect(pickAggregateGrain(7, "daily")).toBe("daily"); + expect(pickAggregateGrain(7, "weekly")).toBe("weekly"); + // "raw" override falls through to the threshold ladder. + expect(pickAggregateGrain(500, "raw")).toBe("weekly"); + }); + }); + + describe("rangeLengthDays", () => { + it("counts inclusive days for the same calendar window", () => { + const from = new Date("2026-05-01T00:00:00.000Z"); + const to = new Date("2026-05-08T00:00:00.000Z"); + expect(rangeLengthDays(from, to)).toBe(7); + }); + + it("returns at least 1 for sub-day windows", () => { + const t = new Date("2026-05-01T00:00:00.000Z"); + expect(rangeLengthDays(t, t)).toBe(1); + }); + }); + + describe("aggregateRows", () => { + it("collapses to one row per day per type with mean values", () => { + const t0 = new Date("2026-05-01T08:00:00.000Z"); + const t1 = new Date("2026-05-01T18:00:00.000Z"); + const t2 = new Date("2026-05-02T08:00:00.000Z"); + const rows = [ + { type: "PULSE", value: 60, measuredAt: t0 }, + { type: "PULSE", value: 80, measuredAt: t1 }, + { type: "PULSE", value: 70, measuredAt: t2 }, + ]; + + const out = aggregateRows(rows, "daily"); + expect(out).toHaveLength(2); + expect(out[0].avg).toBe(70); + expect(out[0].count).toBe(2); + expect(out[1].avg).toBe(70); + expect(out[1].count).toBe(1); + }); + + it("collapses to one row per ISO week (Monday-anchored) with weekly", () => { + // 2026-05-04 is a Monday; surrounding days share the same bucket. + const monday = new Date("2026-05-04T10:00:00.000Z"); + const tuesday = new Date("2026-05-05T10:00:00.000Z"); + const nextMonday = new Date("2026-05-11T10:00:00.000Z"); + const rows = [ + { type: "WEIGHT", value: 75.0, measuredAt: monday }, + { type: "WEIGHT", value: 75.5, measuredAt: tuesday }, + { type: "WEIGHT", value: 76.0, measuredAt: nextMonday }, + ]; + const out = aggregateRows(rows, "weekly"); + expect(out).toHaveLength(2); + expect(out[0].count).toBe(2); + expect(out[0].avg).toBeCloseTo(75.25, 5); + expect(out[1].count).toBe(1); + }); + + it("keeps per-type buckets separate on the same day", () => { + const t = new Date("2026-05-01T12:00:00.000Z"); + const rows = [ + { type: "PULSE", value: 70, measuredAt: t }, + { type: "WEIGHT", value: 75, measuredAt: t }, + ]; + const out = aggregateRows(rows, "daily"); + expect(out).toHaveLength(2); + const types = new Set(out.map((r) => r.type)); + expect(types).toEqual(new Set(["PULSE", "WEIGHT"])); + }); + }); +}); diff --git a/src/lib/measurements/range-aggregation.ts b/src/lib/measurements/range-aggregation.ts new file mode 100644 index 000000000..65e5557ee --- /dev/null +++ b/src/lib/measurements/range-aggregation.ts @@ -0,0 +1,127 @@ +/** + * v1.4.28 R3a FB-D2 (R1.2 H0) — server-side range aggregation for the + * health-chart pipeline. + * + * Before: every chart navigation issued an unbounded `while (true)` + * paginated walk of `/api/measurements?type=…` from the client and + * bucketed the rows daily in the browser. For Apple-Health-rich + * accounts (pulse every minute, months of history) this was tens of + * thousands of rows per type per page visit. + * + * After: the chart sends `from` / `to` derived from the active range + * selector and an optional `aggregate` hint. For ranges that exceed + * `DAILY_AGGREGATE_THRESHOLD_DAYS` (90 d) the API hands back one row + * per day per type; for ranges that exceed `WEEKLY_AGGREGATE_THRESHOLD_DAYS` + * (365 d) the row grain is weekly. The downsampling keeps the chart's + * visual fidelity (one point per visible bucket) while bounding the + * payload regardless of underlying density. + * + * The helper is exported so the GET /api/measurements handler can pick + * the bucket grain and so unit tests can lock the boundary conditions. + */ + +export const DAILY_AGGREGATE_THRESHOLD_DAYS = 90; +export const WEEKLY_AGGREGATE_THRESHOLD_DAYS = 365; + +export type AggregateGrain = "raw" | "daily" | "weekly"; + +/** + * Pick the bucket grain for a date window. `raw` returns the + * measurements unchanged (legacy behaviour); `daily` collapses to one + * row per UTC day per type with avg / count; `weekly` collapses to one + * row per ISO week per type. + * + * The thresholds favour the user's perceived smoothness over absolute + * resolution. Charts that draw 7 / 30 / 90 days of pulse stay raw — + * the in-browser daily bucket already does the right thing. The "all + * time" range pulls daily aggregates; multi-year scopes (rare today, + * common with Apple Health backfill) pull weekly. + */ +export function pickAggregateGrain( + rangeDays: number, + explicit?: AggregateGrain, +): AggregateGrain { + if (explicit && explicit !== "raw") return explicit; + if (rangeDays > WEEKLY_AGGREGATE_THRESHOLD_DAYS) return "weekly"; + if (rangeDays > DAILY_AGGREGATE_THRESHOLD_DAYS) return "daily"; + return "raw"; +} + +/** + * Day count between two dates (inclusive of both ends). + */ +export function rangeLengthDays(from: Date, to: Date): number { + const ms = to.getTime() - from.getTime(); + return Math.max(1, Math.ceil(ms / 86_400_000)); +} + +export interface AggregatedRow { + bucketStart: Date; + type: string; + avg: number; + count: number; +} + +interface RawRow { + type: string; + value: number; + measuredAt: Date; +} + +/** + * In-memory daily / weekly aggregation. The route uses this when the + * client requests an aggregated grain — Postgres pre-aggregates would + * be faster, but the row volume is already bounded by the from/to + * window and the per-type ceiling, so a single linear pass is enough + * for v1.4.28 and keeps the helper portable to the test harness. + */ +export function aggregateRows( + rows: RawRow[], + grain: "daily" | "weekly", +): AggregatedRow[] { + const buckets = new Map< + string, + { bucketStart: Date; type: string; sum: number; count: number } + >(); + + for (const row of rows) { + const bucketStart = bucketStartFor(row.measuredAt, grain); + const key = `${row.type}@${bucketStart.getTime()}`; + const slot = buckets.get(key); + if (slot) { + slot.sum += row.value; + slot.count += 1; + } else { + buckets.set(key, { + bucketStart, + type: row.type, + sum: row.value, + count: 1, + }); + } + } + + return Array.from(buckets.values()) + .map((s) => ({ + bucketStart: s.bucketStart, + type: s.type, + avg: s.sum / s.count, + count: s.count, + })) + .sort((a, b) => a.bucketStart.getTime() - b.bucketStart.getTime()); +} + +function bucketStartFor(d: Date, grain: "daily" | "weekly"): Date { + if (grain === "daily") { + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), + ); + } + // Weekly — anchor to the start of the ISO week (Monday) in UTC. + const day = d.getUTCDay() || 7; // Sunday → 7 + const monday = new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), + ); + monday.setUTCDate(monday.getUTCDate() - (day - 1)); + return monday; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 45d37dc0c..7e75c0b0d 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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({