mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
821c2eb884
commit
0e991c3640
10 files changed
+474
-32
No files matched your search
@@ -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(
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user