Files
HealthLog/src/lib/analytics/resting-pulse-read.ts
T
Marc-André Bombeck 0e991c3640 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.
2026-09-27 21:07:29 +02:00

67 lines
2.6 KiB
TypeScript

/**
* 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)),
}));
}