mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
538b44f74d
commit
b00be2863d
6 files changed
+471
-47
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user