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,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"]));
});
});
});
+127
View File
@@ -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;
}
+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({