fix(measurements): fold a dense pulse stream inside the 90-day raw window

Past 90 days every pulse reader already folded in SQL. Inside it, three
still read every raw heart-rate row, which on a watch that records a
reading a minute is 130 000 rows per read (#1023):

- The insights page's resting-pulse proxy, used when an account has no
  resting heart rate, walked the whole window to take one percentile per
  day and held the event loop for over a second per view. It now folds in
  Postgres (`readRestingPulseProxy`) with the same percentile, rounding,
  minimum-sample rule and day anchor.
- The per-kind series sent every reading: 14.7 MB for 90 days. A pulse
  window with more than 10 000 readings is now bucketed per local hour,
  mean plus low/high band, with stats still over the readings.
- The doctor report drew every reading into one sparkline. A pulse window
  over the same cap now takes the per-day path longer reports already use.

Sparse pulse (a cuff, hourly imports, a watch sampling every few minutes)
stays under the cap and keeps its raw shape.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 21:07:29 +02:00
1 parent 821c2eb884
commit 0e991c3640
10 files changed
+474 -32

No files matched your search

+7 -2
View File
@@ -40909,7 +40909,9 @@ components:
properties:
id:
type: string
description: Measurement row id, or `sleep:<wake-day>` for a sleep night.
description: 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).
at:
type: string
format: date-time
@@ -40982,7 +40984,10 @@ components:
- stats
additionalProperties: false
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."
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."
ListMeasurementsResponse:
type: object
properties:
@@ -48,6 +48,12 @@ vi.mock("@/lib/rollups/mood-rollups", async () => {
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// The raw-PULSE resting proxy folds in SQL; against the mocked client it is an
// account with no pulse.
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
readRestingPulseProxy: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -107,6 +113,7 @@ import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
@@ -155,8 +162,9 @@ beforeEach(() => {
dateOfBirth: new Date("1985-01-01"),
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
// The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows (and the
// raw-PULSE proxy only when none exist) straight from the measurement table.
vi.mocked(readRestingPulseProxy).mockResolvedValue([]);
// The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows straight
// from the measurement table (the raw-PULSE proxy is mocked above).
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
+18 -17
View File
@@ -24,6 +24,7 @@ import {
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
import {
apiHandler,
requireRecordAuth,
@@ -341,24 +342,24 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
},
select: { measuredAt: true, value: true },
});
const rawPulseRowsForResting =
restingPulseRows.length === 0
? await prisma.measurement.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: ninetyDaysAgo },
},
select: { measuredAt: true, value: true },
})
: [];
//
// #1023 — the proxy folds in Postgres. It needs one percentile per day, and
// reading the raw samples to get it walked 130 000 rows per page view on a
// watch that records once a minute, blocking the event loop for over a
// second. `readRestingPulseProxy` returns the same series one row per day.
const localDayKey = (d: Date) => userDayKey(d, userTz);
const { series: restingPulseSeries } = resolveRestingPulseSeries({
restingSamples: restingPulseRows,
pulseSamples: rawPulseRowsForResting,
dayKeyOf: localDayKey,
});
const restingPulseSeries =
restingPulseRows.length > 0
? resolveRestingPulseSeries({
restingSamples: restingPulseRows,
pulseSamples: [],
dayKeyOf: localDayKey,
}).series
: await readRestingPulseProxy({
userId,
since: ninetyDaysAgo,
timeZone: userTz,
});
const restingByDay = new Map<string, { sum: number; count: number }>();
for (const s of restingPulseSeries) {
const key = localDayKey(s.measuredAt);
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
measurement: { findMany: vi.fn(), count: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
@@ -68,6 +68,7 @@ function req(query: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -586,6 +587,72 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
expect(body.data.points[0].id).toBe("g1");
});
it("hour-buckets a pulse window too dense to send raw (#1023)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// One reading a minute for 30 days.
vi.mocked(prisma.measurement.count).mockResolvedValue(43_200 as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
bucket_start: new Date("2026-06-01T08:00:00Z"),
mean: 71.666,
min_value: 58,
max_value: 96,
},
] as never)
.mockResolvedValueOnce([
{ n: 43_200, mean: 72.1, min: 50, max: 150, sd: 11.5 },
] as never);
const res = await GET(req("kind=pulse&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
points: Array<Record<string, unknown>>;
stats: { count: number; mean: number };
};
};
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(body.data.points).toEqual([
{
id: "hour:2026-06-01T08:00:00.000Z",
at: "2026-06-01T08:00:00.000Z",
value: 71.67,
secondary: null,
valueMin: 58,
valueMax: 96,
},
]);
// Stats describe the readings, not the buckets.
expect(body.data.stats.count).toBe(43_200);
expect(body.data.stats.mean).toBe(72.1);
// The bucket read folds per hour, not per day.
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(bucketSql.slice(1)).toContain("hour");
});
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);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "p1",
value: 64,
measuredAt: new Date("2026-06-01T08:03:00Z"),
valueMin: null,
valueMax: null,
},
] as never);
const res = await GET(req("kind=pulse&days=90"));
const body = (await res.json()) as {
data: { points: Array<{ id: string; at: string }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(body.data.points).toEqual([
expect.objectContaining({ id: "p1", at: "2026-06-01T08:03:00.000Z" }),
]);
});
it("keeps sparse kinds raw even on the ten-year window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+37 -7
View File
@@ -51,6 +51,21 @@ const SLEEP_SERIES_MAX_DAYS = 365;
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.
*
* 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.
*/
const DENSE_SERIES_RAW_ROW_CAP = 10_000;
const kindEnum = z.enum([
"weight",
"bloodPressure",
@@ -352,9 +367,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
};
});
} else if (
DENSE_SERIES_KINDS.has(kind) &&
days > DENSE_SERIES_RAW_WINDOW_DAYS
(DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) ||
(kind === "pulse" &&
(await prisma.measurement.count({
where: {
userId: user.id,
type: "PULSE",
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.
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
// every raw row into JS: one aggregate pass in Postgres returns at
@@ -377,7 +403,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
m."value_min",
m."value_max",
date_trunc(
'day',
${grain},
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${userTz}
) AS local_day
FROM measurements m
@@ -422,7 +448,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
AND m."deleted_at" IS NULL
`;
const round2 = (v: number) => Math.round(v * 100) / 100;
const dayId = (d: Date) => `day:${userDayKey(d, userTz)}`;
const dayId = (d: Date) =>
grain === "day"
? `day:${userDayKey(d, userTz)}`
: `hour:${d.toISOString()}`;
if (kind === "glucose") {
// Same unit engine as the raw glucose branch: canonical mg/dL in
// the DB, converted ONCE at serialization to the user's preference.
@@ -457,9 +486,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
};
}
} else {
// pulse — `value` is the day's average; valueMin/valueMax carry the
// day's low/high band (folding each hourly bucket's own spread via
// the COALESCE above), same band semantics as the per-hour shape.
// pulse — `value` is the bucket's average; valueMin/valueMax carry the
// bucket's low/high band (folding each hourly import bucket's own
// spread via the COALESCE above), same band semantics as the per-hour
// shape.
points = bucketRows.map((r) => ({
id: dayId(r.bucket_start),
at: r.bucket_start.toISOString(),
+66
View File
@@ -0,0 +1,66 @@
/**
* The low-percentile resting-pulse proxy, folded in Postgres.
*
* `deriveRestingProxyFromPulse` takes every raw `PULSE` sample of a window
* and reduces each local day to its 20th percentile. On a watch that records
* heart rate once a minute, a 90-day window is 130 000 samples: reading them
* as objects, keying each one to its local day and sorting each day held the
* event loop for over a second per request, and the route that did it runs on
* every insights page view. The reduction needs only the day's percentile,
* its sample count and its first instant, so it runs in SQL and one row per
* day crosses the wire.
*
* Output matches `deriveRestingProxyFromPulse` exactly: `percentile_cont`
* interpolates between the two neighbouring order statistics the same way
* `percentile` in `strain-score.ts` does, the value is rounded the same way
* in JavaScript, days under `RESTING_PROXY_MIN_DAILY_SAMPLES` are skipped,
* and each day is anchored on its earliest sample. Days are cut in the zone
* the caller passes, with the same expression `readDayAggregates` uses.
*/
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { isValidTimezone } from "@/lib/tz/format";
import {
RESTING_PROXY_DAILY_PERCENTILE,
RESTING_PROXY_MIN_DAILY_SAMPLES,
type PulseSample,
} from "./resting-pulse";
export async function readRestingPulseProxy(opts: {
userId: string;
/** Inclusive lower bound on `measuredAt`. */
since: Date;
/** IANA zone the day boundary is cut in. */
timeZone: string;
db?: Pick<PrismaClient, "$queryRaw">;
}): Promise<PulseSample[]> {
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
const fraction = RESTING_PROXY_DAILY_PERCENTILE / 100;
const rows = await (opts.db ?? prisma).$queryRaw<
Array<{ first_at: Date; p: number }>
>(Prisma.sql`
WITH src AS (
SELECT
to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day,
m."measured_at" AS measured_at,
m."value" AS value
FROM measurements m
WHERE m."user_id" = ${opts.userId}
AND m."type" = 'PULSE'::measurement_type
AND m."deleted_at" IS NULL
AND m."measured_at" >= ${opts.since}
)
SELECT
MIN(measured_at) AS first_at,
(percentile_cont(${fraction}::double precision) WITHIN GROUP (ORDER BY value))::double precision AS p
FROM src
GROUP BY day
HAVING COUNT(*) >= ${RESTING_PROXY_MIN_DAILY_SAMPLES}
ORDER BY first_at ASC
`);
return rows.map((r) => ({
measuredAt: r.first_at,
value: Math.round(Number(r.p)),
}));
}
@@ -48,6 +48,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
}
return Promise.resolve(rows[name] ?? []);
},
count: () => Promise.resolve(0),
findUnique: () => {
calls.push(name);
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
+25 -1
View File
@@ -76,6 +76,20 @@ import {
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/**
* #1023 — inside the raw window, pulse takes the per-day path anyway once the
* window holds more readings than this.
*
* A watch that records heart rate once a minute puts 130 000 readings in a
* 90-day report. Read raw, every one of them became an object, went through
* the canonical-source collapse, and landed in the report's series, where the
* trend line and the sparkline draw a few hundred points at most. The day
* path already serves every longer report: statistics over the raw readings,
* one point per local day for the series. A cuff, an hourly-bucket import or
* a watch sampling every few minutes stays under the cap and stays raw.
*/
const DENSE_REPORT_RAW_ROW_CAP = 10_000;
/** No medication row is read unless at least one medication leaf is admitted. */
const MEDICATION_LEAVES = [
"MEDICATION_LIST",
@@ -156,7 +170,17 @@ export async function collectDoctorReportData(
const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
const densePulse =
!excluded.includes("PULSE") &&
(aggregateDenseTypes ||
(await prisma.measurement.count({
where: {
userId,
type: "PULSE",
measuredAt: { gte: start, lte: end },
deletedAt: null,
},
})) > DENSE_REPORT_RAW_ROW_CAP);
const denseGlucose =
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
const rawExcluded = [...excluded];
+4 -2
View File
@@ -695,7 +695,9 @@ const seriesQuerySchema = z.object({
const seriesPointSchema = z.object({
id: z
.string()
.describe("Measurement row id, or `sleep:<wake-day>` for a sleep night."),
.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).",
),
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
value: z.number().describe("Primary value in the top-level `unit`."),
secondary: z
@@ -749,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.",
"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.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
@@ -0,0 +1,238 @@
/**
* #1023 — three reads inside the 90-day raw window, on a dense pulse stream.
*
* Past 90 days every pulse reader already folded in SQL. Inside it, three
* still read every raw heart-rate row: the insights page's resting-pulse
* proxy, the per-kind series the iOS chart draws, and the doctor report. On
* a watch that records a reading a minute that is 130 000 rows per read; on
* the reproduction fixture (1.3 M rows) the insights page blocked the event
* loop for 1.3 s, the 90-day series answered with 14.7 MB of JSON and the
* 90-day report held 6.9 MB of pulse points it would draw as one sparkline.
*
* Each case here runs on real Postgres against two accounts: a dense one (one
* reading a minute) and a sparse one (a cuff and a few readings a day), so
* the tests pin both that the dense stream folds and that sparse data keeps
* exactly its old shape.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { selectionFromLeaves } from "@/lib/report-selection/selection";
import { userDayKey } from "@/lib/tz/format";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const prisma = getPrismaClient();
const TZ = "America/New_York";
const DENSE = "dense-raw-window-owner";
const SPARSE = "sparse-raw-window-owner";
/** Ten days of one reading a minute: over every raw-window row cap. */
const DENSE_ROWS = 10 * 24 * 60;
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
/**
* A cuff and a watch that samples a few times a day: irregular times, values
* with fractional percentiles, one day with too few readings for the proxy,
* and days that straddle the New York midnight in UTC.
*/
function sparseReadings(): Array<{ at: Date; value: number }> {
const out: Array<{ at: Date; value: number }> = [];
for (let day = 1; day <= 20; day++) {
const perDay = day === 7 ? 2 : 3 + (day % 5);
for (let i = 0; i < perDay; i++) {
out.push({
at: new Date(
NOW.getTime() - day * 86_400_000 + (i * 197 + day * 13) * 60_000,
),
value: 52 + ((day * 7 + i * 11) % 41) + (i % 2 ? 0.5 : 0),
});
}
}
return out;
}
async function session(userId: string): Promise<void> {
const s = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
}
beforeAll(async () => {
await truncateAllTables(prisma);
for (const id of [DENSE, SPARSE]) {
await prisma.user.create({ data: { id, username: id, timezone: TZ } });
invalidateUserTimezone(id);
}
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (
id, user_id, type, value, unit, source, measured_at,
created_at, updated_at, sync_version)
SELECT 'dr' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type,
55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source,
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
now(), now(), 1
FROM generate_series(0, $3::int - 1) g`,
DENSE,
NOW.toISOString(),
DENSE_ROWS,
);
await prisma.measurement.createMany({
data: sparseReadings().map((r, i) => ({
id: `sp${String(i).padStart(4, "0")}`,
userId: SPARSE,
type: "PULSE" as const,
value: r.value,
unit: "bpm",
source: "MANUAL" as const,
measuredAt: r.at,
})),
});
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
}, 120_000);
describe("resting-pulse proxy folded in Postgres", () => {
it("returns the in-memory derivation's series exactly", async () => {
const since = new Date(NOW.getTime() - 90 * 86_400_000);
for (const userId of [SPARSE, DENSE]) {
const raw = await prisma.measurement.findMany({
where: { userId, type: "PULSE", measuredAt: { gte: since } },
select: { measuredAt: true, value: true },
});
const expected = deriveRestingProxyFromPulse(raw, (d) =>
userDayKey(d, TZ),
);
const actual = await readRestingPulseProxy({
userId,
since,
timeZone: TZ,
});
expect(actual.length).toBeGreaterThan(0);
expect(actual).toEqual(expected);
}
});
it("is what the insights page reads, not the raw stream", async () => {
await session(DENSE);
const spy = vi.spyOn(prisma.measurement, "findMany");
try {
const { GET } = await import("@/app/api/insights/comprehensive/route");
const res = await GET(
new NextRequest("http://localhost/api/insights/comprehensive"),
);
expect(res.status).toBe(200);
const pulseReads = spy.mock.calls.filter(
([args]) =>
(args as { where?: { type?: unknown } } | undefined)?.where?.type ===
"PULSE",
);
expect(pulseReads).toEqual([]);
} finally {
spy.mockRestore();
}
});
});
describe("per-kind pulse series inside the raw window", () => {
async function series(userId: string, days: number) {
await session(userId);
const { GET } = await import("@/app/api/measurements/series/route");
const res = await GET(
new NextRequest(
`http://localhost/api/measurements/series?kind=pulse&days=${days}`,
),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: {
points: Array<{
id: string;
at: string;
value: number;
valueMin?: number | null;
valueMax?: number | null;
}>;
stats: { count: number; min: number; max: number };
};
}
).data;
}
it("sends a dense stream as hour buckets with its full range", async () => {
const data = await series(DENSE, 30);
// Ten days of hours, give or take the partial hours at either end.
expect(data.points.length).toBeLessThanOrEqual(10 * 24 + 2);
expect(data.points.length).toBeGreaterThanOrEqual(10 * 24 - 2);
expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true);
expect(data.stats.count).toBe(DENSE_ROWS);
expect(Math.min(...data.points.map((p) => p.valueMin ?? Infinity))).toBe(
data.stats.min,
);
expect(Math.max(...data.points.map((p) => p.valueMax ?? -Infinity))).toBe(
data.stats.max,
);
});
it("keeps sparse readings raw, one point per reading", async () => {
const data = await series(SPARSE, 30);
expect(data.points).toHaveLength(sparseReadings().length);
expect(data.points.every((p) => p.id.startsWith("sp"))).toBe(true);
});
});
describe("doctor report inside the raw window", () => {
async function report(userId: string, days: number) {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
return collectDoctorReportData(
userId,
{ start, end, days },
selectionFromLeaves(["PULSE"]),
);
}
it("folds a dense pulse stream to one point per day, statistics exact", async () => {
const data = await report(DENSE, 30);
const points = data.measurements.PULSE ?? [];
expect(points.length).toBeLessThanOrEqual(12);
expect(points.length).toBeGreaterThanOrEqual(10);
expect(data.stats.PULSE?.count).toBe(DENSE_ROWS);
});
it("keeps sparse readings raw", async () => {
const data = await report(SPARSE, 30);
expect(data.measurements.PULSE).toHaveLength(sparseReadings().length);
expect(data.stats.PULSE?.count).toBe(sparseReadings().length);
});
});