fix(measurements): hour-bucket a CGM glucose series inside the 90-day window

A sensor reading every five minutes puts about 26 000 glucose readings in
a 90-day window, and the per-kind series sent every one of them raw. The
hourly bucketing that already applies to a dense heart-rate stream now
applies to glucose too, with the same 10 000-reading cap: the hour's mean
converted once to the user's unit, statistics over every raw reading.
A fingerstick meter stays far under the cap and keeps its raw points.

The doctor report keeps glucose raw inside 90 days. Its clinical panel is
computed from the raw readings of every source, while the day path works
from canonical-source day buckets, so the two do not give the same figures
when a meter and a sensor overlap.
This commit is contained in:
Marc-André Bombeck committed 2026-09-30 23:01:57 +02:00
1 parent ca10f2e5df
commit 731e073284
5 files changed
+340 -18

No files matched your search

@@ -631,6 +631,85 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
expect(bucketSql.slice(1)).toContain("hour");
});
it("hour-buckets a CGM glucose window too dense to send raw, in the user's unit", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
// A sensor reading every five minutes for 90 days.
vi.mocked(prisma.measurement.count).mockResolvedValue(25_920 as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
bucket_start: new Date("2026-06-01T08:00:00Z"),
mean: 100,
min_value: 90,
max_value: 112,
},
] as never)
.mockResolvedValueOnce([
{ n: 25_920, mean: 118.004, min: 48, max: 260, sd: 36.004 },
] as never);
const res = await GET(req("kind=glucose&days=90"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
unit: string;
points: Array<Record<string, unknown>>;
stats: {
count: number;
mean: number;
min: number;
max: number;
stdDev: number;
};
};
};
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(vi.mocked(prisma.measurement.count).mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
where: expect.objectContaining({ type: "BLOOD_GLUCOSE" }),
}),
);
expect(body.data.unit).toBe("mmol/L");
// Converted once at serialization; no band on a glucose point.
expect(body.data.points).toEqual([
{
id: "hour:2026-06-01T08:00:00.000Z",
at: "2026-06-01T08:00:00.000Z",
value: 5.5,
secondary: null,
},
]);
// Stats describe every reading (mg/dL aggregate, converted once).
expect(body.data.stats).toEqual({
count: 25_920,
mean: 6.5,
min: 2.7,
max: 14.4,
stdDev: 2,
});
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(bucketSql.slice(1)).toContain("hour");
});
it("keeps fingerstick glucose at or under the row cap raw", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.count).mockResolvedValue(360 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 104, measuredAt: new Date("2026-06-01T07:12:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=90"));
const body = (await res.json()) as {
data: { points: Array<Record<string, unknown>> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(body.data.points).toEqual([
{ id: "g1", at: "2026-06-01T07:12:00.000Z", value: 104, secondary: null },
]);
});
it("keeps a pulse window at or under the row cap raw", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.count).mockResolvedValue(10_000 as never);
+14 -11
View File
@@ -52,17 +52,20 @@ const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
/**
* #1023 — inside the raw window, a pulse series with more rows than this is
* bucketed per local hour in SQL instead of sent raw.
* #1023 — inside the raw window, a pulse or glucose series with more rows
* than this is bucketed per local hour in SQL instead of sent raw.
*
* A watch that records heart rate once a minute puts 43 000 rows in the
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
* could not draw at that resolution anyway, built from as many objects on the
* server. Hourly buckets carry the hour's mean with its low/high band, the
* shape an hourly-bucket import already has, and cap the answer at 24 points
* a day. The cap sits far above what sparse sources produce (a cuff, hourly
* buckets over 90 days, a watch sampling every five minutes over 30 days), so
* those series stay raw and unchanged.
* server. A CGM reading every five minutes puts 26 000 glucose rows in 90
* days, the same problem at a smaller scale. Hourly buckets carry the hour's
* mean (pulse also its low/high band, the shape an hourly-bucket import
* already has) and cap the answer at 24 points a day; the statistics stay
* computed over every raw reading. The cap sits far above what sparse sources
* produce (a cuff, fingerstick glucose, hourly buckets over 90 days, a watch
* sampling every five minutes over 30 days), so those series stay raw and
* unchanged.
*/
const DENSE_SERIES_RAW_ROW_CAP = 10_000;
@@ -368,18 +371,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
} else if (
(DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) ||
(kind === "pulse" &&
(DENSE_SERIES_KINDS.has(kind) &&
(await prisma.measurement.count({
where: {
userId: user.id,
type: "PULSE",
type: KIND_TO_TYPE[kind],
measuredAt: { gte: since },
deletedAt: null,
},
})) > DENSE_SERIES_RAW_ROW_CAP)
) {
// Day buckets past the raw window; hour buckets for a pulse stream too
// dense to send raw inside it.
// Day buckets past the raw window; hour buckets for a pulse or CGM
// stream too dense to send raw inside it.
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
+2 -2
View File
@@ -696,7 +696,7 @@ const seriesPointSchema = z.object({
id: z
.string()
.describe(
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` within 90 days when the window holds more than 10 000 readings).",
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days when the window holds more than 10 000 readings).",
),
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
value: z.number().describe("Primary value in the top-level `unit`."),
@@ -751,7 +751,7 @@ const seriesResponse = z
.meta({
id: "MeasurementsSeriesResponse",
description:
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` within 90 days per local hour when the window holds more than 10 000 readings (a bucket carries the mean as `value` and its range as `valueMin` / `valueMax`). `stats` is always computed over the underlying readings, never over the buckets.",
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────