From 0e991c3640d2b0da6121f69d1e89de028c306ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 21:07:29 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 9 +- .../comprehensive/__tests__/route.test.ts | 12 +- src/app/api/insights/comprehensive/route.ts | 35 +-- .../series/__tests__/route.test.ts | 69 ++++- src/app/api/measurements/series/route.ts | 44 +++- src/lib/analytics/resting-pulse-read.ts | 66 +++++ .../__tests__/leaf-gating-sweep.test.ts | 1 + src/lib/doctor-report/collect.ts | 26 +- src/lib/openapi/routes/measurements.ts | 6 +- .../dense-pulse-raw-window-reads.test.ts | 238 ++++++++++++++++++ 10 files changed, 474 insertions(+), 32 deletions(-) create mode 100644 src/lib/analytics/resting-pulse-read.ts create mode 100644 tests/integration/dense-pulse-raw-window-reads.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 26f29e710..bde3f1cbb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40909,7 +40909,9 @@ components: properties: id: type: string - description: Measurement row id, or `sleep:` for a sleep night. + description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / + `glucose` beyond 90 days); `hour:` 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: diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index d6673001e..3ffce0401 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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).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).mockResolvedValue( [], ); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index cc55cd882..cfe291dd4 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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(); for (const s of restingPulseSeries) { const key = localDayKey(s.measuredAt); diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index fbeaa46a8..de6f8dced 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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>; + 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([ diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 925d778a4..80d5ed035 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -51,6 +51,21 @@ const SLEEP_SERIES_MAX_DAYS = 365; const DENSE_SERIES_RAW_WINDOW_DAYS = 90; const DENSE_SERIES_KINDS: ReadonlySet = 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(), diff --git a/src/lib/analytics/resting-pulse-read.ts b/src/lib/analytics/resting-pulse-read.ts new file mode 100644 index 000000000..58996b42e --- /dev/null +++ b/src/lib/analytics/resting-pulse-read.ts @@ -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; +}): Promise { + 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)), + })); +} diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 7517335c4..c279ed081 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 05fa74ec1..fe2154b0a 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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]; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 13158a3e4..e36506223 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -695,7 +695,9 @@ const seriesQuerySchema = z.object({ const seriesPointSchema = z.object({ id: z .string() - .describe("Measurement row id, or `sleep:` for a sleep night."), + .describe( + "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:` 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) ──────── diff --git a/tests/integration/dense-pulse-raw-window-reads.test.ts b/tests/integration/dense-pulse-raw-window-reads.test.ts new file mode 100644 index 000000000..1ff3d0398 --- /dev/null +++ b/tests/integration/dense-pulse-raw-window-reads.test.ts @@ -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 { + 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); + }); +});