From da937718bc982e3d1352f9836253e03535258965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 14:13:52 +0200 Subject: [PATCH] refactor(measurements): move the series readers out of their route GET /api/measurements/series keeps authentication, the query and its refusal; readSeries (lib/measurements/series/read-series.ts) picks the reader in the old order (sleep nights, paired blood pressure, the bucketed read of a dense kind, the raw rows) and adds the direction, change, reference lines and stat strip. The kinds, units and point shape live in kinds.ts. The route falls from 752 to 95 lines. The parity walk recorded before the move is unchanged. The guards on the verdict resolver, the spread, the pulse day mean and the unit literals read the new files, the last two with their scope widened to the series folder. --- .../pulse-day-mean-reader-guard.test.ts | 6 +- .../resolved-field-consumer-guard.test.ts | 2 +- ...spread-single-implementation-guard.test.ts | 5 +- .../unit-preference-display-guard.test.ts | 4 +- src/app/api/measurements/series/route.ts | 665 +----------------- src/lib/measurements/series/kinds.ts | 167 +++++ .../series/read-blood-pressure.ts | 127 ++++ src/lib/measurements/series/read-bucketed.ts | 152 ++++ src/lib/measurements/series/read-raw.ts | 165 +++++ src/lib/measurements/series/read-series.ts | 148 ++++ src/lib/measurements/series/read-sleep.ts | 57 ++ 11 files changed, 829 insertions(+), 669 deletions(-) create mode 100644 src/lib/measurements/series/kinds.ts create mode 100644 src/lib/measurements/series/read-blood-pressure.ts create mode 100644 src/lib/measurements/series/read-bucketed.ts create mode 100644 src/lib/measurements/series/read-raw.ts create mode 100644 src/lib/measurements/series/read-series.ts create mode 100644 src/lib/measurements/series/read-sleep.ts diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 1250da987..d51ae0742 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -70,7 +70,9 @@ const SQL_MEAN = const TS_MEAN = /\.reduce\(\s*\(\s*\w+\s*,\s*\w+\s*\)\s*=>\s*\w+\s*\+\s*[\w.]+\s*,\s*0\s*\)\s*\/\s*[\w.]+\.length|\bavgInWindow\s*\(|\bsummarize\s*\(|\bbucketWeekly\s*\(|\bbuildDailyValueRows\s*\(|\bapplyPayloadBudget\s*\(/g; -const NAMES_PULSE = /\bPULSE\b/; +// The stored type, or the series route's `kind === "pulse"` (its readers name +// the wire kind, not the type). +const NAMES_PULSE = /\bPULSE\b|===\s*"pulse"/; /** * A day folded from its hours' means with every hour weighing one: SQL @@ -113,7 +115,7 @@ const SQL_READERS: Record = { /** Files that name pulse and hold an in-memory mean or a mean-making fold. */ const TS_READERS: Record = { - "app/api/measurements/series/route.ts": HELPER, + "lib/measurements/series/read-raw.ts": HELPER, "lib/doctor-report/collect.ts": HELPER, "lib/doctor-report-pdf/clinical-summary.ts": HELPER, "lib/insights/comprehensive-generate.ts": HELPER, diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index 8fda69874..1458ec216 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -407,7 +407,7 @@ const V143_FIELDS: ResolvedFieldEntry[] = [ sameResolver: { pattern: VERDICT_RESOLVER, files: [ - "src/app/api/measurements/series/route.ts", + "src/lib/measurements/series/read-series.ts", "src/lib/dashboard/snapshot.ts", ], }, diff --git a/src/__tests__/spread-single-implementation-guard.test.ts b/src/__tests__/spread-single-implementation-guard.test.ts index 434e49f7d..0ac981d2c 100644 --- a/src/__tests__/spread-single-implementation-guard.test.ts +++ b/src/__tests__/spread-single-implementation-guard.test.ts @@ -90,10 +90,7 @@ describe("one standard deviation", () => { it("the series API and the AI and MCP payload report the same spread", () => { expect( stripComments( - readSource( - "src/app/api/measurements/series/route.ts", - "READING_SPREAD", - ), + readSource("src/lib/measurements/series/kinds.ts", "READING_SPREAD"), ), ).toMatch(/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/); const features = stripComments( diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index df2d41130..b84376146 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -250,6 +250,8 @@ const WIDE_SWEEP_ROOTS = [ "lib/jobs/", "lib/targets/", "lib/daily/", + // The series route's readers, moved out of `app/` with their unit tokens. + "lib/measurements/series/", ]; const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [ @@ -351,7 +353,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< lines: 1, reason: "the legacy export endpoint's canonical storage-unit contract", }, - "app/api/measurements/series/route.ts": { + "lib/measurements/series/kinds.ts": { lines: 4, reason: "canonical wire units; glucose is overridden to the reader's unit per request", diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 5fccd4d6c..eca0eefdf 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -14,10 +14,8 @@ * window, judged by that direction) and `referenceLines` (the person's own * target lines, else the clinical ones, in the unit of the points). */ -import { readDenseSeries } from "@/lib/measurements/dense-series-read"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { @@ -27,94 +25,9 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { redactForExcerpt } from "@/lib/observability/redact-payload"; -import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; -import { readSleepNights } from "@/lib/measurements/list/sleep-nights"; -import { VALUE_RANGES } from "@/lib/validations/measurement"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; -import { readingsMean } from "@/lib/measurements/day-mean"; -import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; -import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; -import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { resolveTrendVerdict } from "@/lib/targets/trend-verdict"; -import { - computeSeriesChange, - seriesDayValues, - seriesReferenceValue, -} from "@/lib/measurements/series-change"; -import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { resolveJobLocale } from "@/lib/i18n/job-locale"; -import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread"; - -/** - * v1.28.25 — sample-dense kinds read raw rows only up to this window. - * A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and - * per-sample PULSE piles up similarly, so the 365 / 3650-day requests - * walked a six-figure row set into JS, mapped it twice, and serialized - * everything. Mirrors the sleep precedent above, but instead of silently - * clamping the window, requests beyond the cap keep their full range and - * are day-bucketed in SQL (one point per day, mean + min/max band) — the - * chart still shows the whole requested history, just at daily grain. - * The short windows users actually browse (7 / 30 / 90 d) stay raw and - * byte-identical. Sparse kinds (weight, BP, …) are untouched. - */ -const DENSE_SERIES_RAW_WINDOW_DAYS = 90; -/** - * The kinds a device can write at sampling rate. Heart-rate variability and - * blood oxygen joined pulse and glucose once watches and rings began writing - * them every few minutes overnight: their 3650-day "Alle" range read every - * raw row, which is the read this rule exists to stop. - */ -const DENSE_SERIES_KINDS: ReadonlySet = new Set([ - "glucose", - "pulse", - "heartRateVariability", - "oxygenSaturation", -]); - -/** - * #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV, - * blood oxygen) 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. 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; - -const kindEnum = z.enum([ - "weight", - "bloodPressure", - "pulse", - "bodyFat", - "glucose", - "sleep", - "steps", - "totalBodyWater", - "boneMass", - "oxygenSaturation", - // v1.5.5 — the iOS app surfaces these as series-capable, but - // the route used to reject the trend/detail view request with a - // 422. The underlying MeasurementType enum already carries each - // value; this list extends the camelCase wire shape so the route - // matches the data it can serve. - "restingHeartRate", - "heartRateVariability", - "vo2Max", -]); +import { kindEnum } from "@/lib/measurements/series/kinds"; +import { readSeries } from "@/lib/measurements/series/read-series"; const querySchema = z.object({ kind: kindEnum, @@ -127,80 +40,6 @@ const querySchema = z.object({ days: z.coerce.number().int().min(1).max(3650).optional().default(30), }); -const KIND_TO_TYPE: Record, MeasurementType> = { - weight: "WEIGHT", - bloodPressure: "BLOOD_PRESSURE_SYS", - pulse: "PULSE", - bodyFat: "BODY_FAT", - glucose: "BLOOD_GLUCOSE", - sleep: "SLEEP_DURATION", - steps: "ACTIVITY_STEPS", - totalBodyWater: "TOTAL_BODY_WATER", - boneMass: "BONE_MASS", - oxygenSaturation: "OXYGEN_SATURATION", - restingHeartRate: "RESTING_HEART_RATE", - heartRateVariability: "HEART_RATE_VARIABILITY", - vo2Max: "VO2_MAX", -}; - -/** - * v1.11.4 — explicit per-kind unit token returned at the top level so - * the client never has to infer the value's unit. `bloodPressure` reuses - * the systolic type's unit (mmHg, same for both lines). `sleep` is - * overridden at request time to `"h"` because the route returns per-night - * TIME-ASLEEP in hours rather than the canonical per-stage minutes. - * - * v1.16.16 — `glucose` is the canonical-stored unit (mg/dL) here; it is - * overridden at request time to the user's `glucoseUnit` preference and the - * point values are converted to match, so this wire DTO reads in the SAME - * unit as the CSV + FHIR exports and the blood-glucose detail page (one - * number, one engine). - */ -const SERIES_UNIT: Record, string> = { - weight: "kg", - bloodPressure: "mmHg", - pulse: "bpm", - bodyFat: "%", - glucose: "mg/dL", - sleep: "h", - steps: "steps", - totalBodyWater: "kg", - boneMass: "kg", - oxygenSaturation: "%", - restingHeartRate: "bpm", - heartRateVariability: "ms", - vo2Max: "mL/(kg·min)", -}; - -interface SeriesPoint { - id: string; - at: string; - value: number; - secondary: number | null; - /** - * v1.11.4 — per-stage minutes for a sleep night point. `null` for - * every non-sleep kind. Lets a sleep detail view render the night's - * stage breakdown without a second fetch; the headline `value` stays - * the night's TIME-ASLEEP total. - */ - sleepStages?: Partial> | null; - /** - * v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an - * aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm - * and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row - * (no bucket spread) and absent for every non-pulse kind, so a client can - * render a low/high band only where the data supports it. - */ - valueMin?: number | null; - valueMax?: number | null; -} - -function stdDev(values: number[]): number { - if (values.length < 2) return 0; - const sd = standardDeviation(values, READING_SPREAD) ?? 0; - return Math.round(sd * 100) / 100; -} - export const GET = apiHandler(async (request: NextRequest) => { const { user, actor } = await requireRecordAuth("read", "measurements"); @@ -250,503 +89,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { kind, days } = parsed.data; - const since = new Date(Date.now() - days * 86_400_000); - - // v1.11.4 — explicit unit token so the client never infers the unit - // from the kind. Sleep is special-cased below (per-night TIME-ASLEEP - // in HOURS); every other kind carries its canonical stored unit. - let unit = SERIES_UNIT[kind]; - - let points: SeriesPoint[] = []; - // v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the - // detail page + FHIR), the branch fills this so the response below skips the - // generic over-points path that would double-round mmol/L figures. - // v1.28.25 — the dense day-bucket branch also fills it (stats stay - // aggregated over the RAW rows in SQL, not over the day-bucket points). - let statsOverride: { - mean: number; - min: number; - max: number; - stdDev: number; - count: number; - } | null = null; - if (kind === "sleep") { - // SLEEP_DURATION is stored one row per STAGE per night (minutes). - // Collapse the stage rows into ONE point per night carrying the - // night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE), - // converted to HOURS. The single-stage rows the legacy path returned - // made the chart show one fragment per stage instead of a nightly - // trend. The per-night reconstruction clusters stages into sessions - // (so a midnight-spanning night stays one point) and collapses a - // dual-source night to one canonical source via the user's `sleep` - // priority ladder. - // - // Sleep is the one kind read row-per-stage rather than from the rollup - // tier, so the nights are named in Postgres and reconstructed a chunk at - // a time (`readSleepNights`): "All" reaches as far back as the history - // does, and the stage rows in the heap stay a chunk's worth. - const [tz, priorityJson] = await Promise.all([ - resolveUserTimezone(user.id), - loadUserSourcePriority(user.id), - ]); - const nights = await readSleepNights(user.id, tz, priorityJson, { - from: since, - // A window of `days` days holds at most one night more than that. - maxNights: days + 1, - }); - unit = "h"; - points = nights - .filter((n) => n.asleepMinutes > 0) - .map((n) => { - const stageHours = Object.fromEntries( - Object.entries(n.stages).map(([s, m]) => [ - s, - Math.round((m / 60) * 100) / 100, - ]), - ) as Partial>; - return { - id: `sleep:${n.night}`, - at: n.measuredAt.toISOString(), - value: Math.round((n.asleepMinutes / 60) * 100) / 100, - secondary: null, - sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null, - }; - }); - } else if (kind === "bloodPressure") { - // Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the - // series AND the implicit "latest" (the last point). BP is two rows paired - // client-side, so a corrupt row that slipped in before the input floor was - // enforced — a seed-era systolic-0, iOS #33 — must never surface as the - // latest reading. Floors mirror VALUE_RANGES (the input validator's min); - // this is a read-side selection guard, not a tightening of the write floor. - // - // The ladder picks ONE source per day for the reading, not one per type: - // the source is chosen from the day's systolic rows that pass the floor, - // and both halves are read from it. Two per-type picks diverge whenever a - // source holds only half of a reading on a day (a deleted diastolic, a - // sub-floor systolic, an orphaned sample), and the pairing below then - // joined one source's systolic to another source's diastolic. Picking on - // floor-passing systolic rows also keeps a corrupt row from claiming a day - // it then contributes nothing to. The day is the UTC day, as in every - // other ladder read (`canonicalMeasurementsCte`), and the pick looks at - // the whole edge day, not only the slice after `since`. - // - // `rank` is the closed-enum whitelist splice from `buildSourceRankCase`; - // every request value is a positional parameter. - const rank = buildSourceRankCase( - await loadUserSourcePriority(user.id), - '"type"', - '"source"', - ); - const bpRows = await prisma.$queryRawUnsafe< - Array<{ - id: string; - type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA"; - source: string; - value: number; - measured_at: Date; - }> - >( - ` - SELECT m."id", m."type"::text AS type, m."source"::text AS source, - m."value", m."measured_at" - FROM measurements m - JOIN ( - SELECT DISTINCT ON (date_trunc('day', "measured_at")) - date_trunc('day', "measured_at") AS d, - "source" AS canon - FROM measurements - WHERE "user_id" = $1 - AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" - AND "deleted_at" IS NULL - AND "value" >= $3 - AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') - ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text - ) c - ON c.d = date_trunc('day', m."measured_at") - AND c.canon = m."source" - WHERE m."user_id" = $1 - AND m."deleted_at" IS NULL - AND m."measured_at" >= $2 - AND ( - (m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3) - OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4) - ) - ORDER BY m."measured_at" ASC, m."id" ASC - `, - user.id, - since, - VALUE_RANGES.BLOOD_PRESSURE_SYS.min, - VALUE_RANGES.BLOOD_PRESSURE_DIA.min, - ); - const sys = bpRows.filter((r) => r.type === "BLOOD_PRESSURE_SYS"); - // Diastolic rows per source: a pair is only ever made inside one source, - // which also holds across midnight, where the two days may have picked - // different sources. - const diaBySource = new Map(); - for (const r of bpRows) { - if (r.type !== "BLOOD_PRESSURE_DIA") continue; - const list = diaBySource.get(r.source); - if (list) list.push(r); - else diaBySource.set(r.source, [r]); - } - - // Pair by closest timestamp within ±5 minutes. Both reads arrive - // sorted ascending, so the nearest-diastolic index is non-decreasing - // across the systolic walk — a two-pointer merge (one pointer per - // source) finds each pair in O(sys + dia) instead of the previous full - // diastolic rescan per systolic row (O(sys × dia) — quadratic on dense - // imports). Ties keep the earlier diastolic row, matching the old - // scan's strict-`<` keep. - const PAIR_WINDOW_MS = 5 * 60_000; - const diaIdx = new Map(); - points = sys.map((s) => { - const sysMs = s.measured_at.getTime(); - const dia = diaBySource.get(s.source) ?? []; - let idx = diaIdx.get(s.source) ?? 0; - while ( - idx + 1 < dia.length && - Math.abs(dia[idx + 1].measured_at.getTime() - sysMs) < - Math.abs(dia[idx].measured_at.getTime() - sysMs) - ) { - idx += 1; - } - diaIdx.set(s.source, idx); - const best = dia[idx]; - const paired = - best !== undefined && - Math.abs(best.measured_at.getTime() - sysMs) <= PAIR_WINDOW_MS; - return { - id: s.id, - at: s.measured_at.toISOString(), - value: s.value, - secondary: paired ? best.value : null, - }; - }); - } else if ( - (DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) || - (DENSE_SERIES_KINDS.has(kind) && - (await prisma.measurement.count({ - where: { - userId: user.id, - 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 dense-kind - // 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 - // most `days` rows regardless of sample density. Bound parameters - // mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate - // (measurement-rollups.ts), and the rows are the ladder-winning source per - // day, the same rule the rollup tier and sleep apply. - const [userTz, priorityJson] = await Promise.all([ - resolveUserTimezone(user.id), - loadUserSourcePriority(user.id), - ]); - const type = KIND_TO_TYPE[kind]; - const seriesRows = seriesRowsFrom(priorityJson, type, days); - // A day of a type with an activity-dependent sampling rate (pulse) is the - // weighted mean of its local hours' means, so a workout hour with hundreds - // of times the readings does not outweigh the rest of the day (see - // `day-statistic.ts`). The hour grain is already one mean per hour. - const { bucketRows, rawAgg } = await readDenseSeries({ - userId: user.id, - type, - grain, - userTz, - since, - seriesRows, - }); - const round2 = (v: number) => Math.round(v * 100) / 100; - 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. - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { glucoseUnit: true }, - }); - const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); - unit = glucoseUnit; - points = bucketRows.map((r) => ({ - id: dayId(r.bucket_start), - at: r.bucket_start.toISOString(), - value: convertGlucose(r.mean, glucoseUnit), - secondary: null, - })); - if ( - rawAgg !== undefined && - rawAgg.n > 0 && - rawAgg.mean !== null && - rawAgg.min !== null && - rawAgg.max !== null - ) { - // Mirrors the raw branch's v1.16.16 parity: round the mg/dL - // aggregate to 2 decimals (summarize()'s convention), then - // convert each figure once. - statsOverride = { - mean: convertGlucose(round2(rawAgg.mean), glucoseUnit), - min: convertGlucose(rawAgg.min, glucoseUnit), - max: convertGlucose(rawAgg.max, glucoseUnit), - stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit), - count: rawAgg.n, - }; - } - } else { - // `value` is the bucket's average. For pulse, 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. The other dense kinds keep the bare point shape their raw rows - // have, so a client reads a bucket the way it reads a reading. - points = bucketRows.map((r) => ({ - id: dayId(r.bucket_start), - at: r.bucket_start.toISOString(), - value: round2(r.mean), - secondary: null, - ...(kind === "pulse" - ? { valueMin: r.min_value, valueMax: r.max_value } - : {}), - })); - if ( - rawAgg !== undefined && - rawAgg.n > 0 && - rawAgg.mean !== null && - rawAgg.min !== null && - rawAgg.max !== null - ) { - statsOverride = { - mean: round2(rawAgg.mean), - min: rawAgg.min, - max: rawAgg.max, - stdDev: round2(rawAgg.sd ?? 0), - count: rawAgg.n, - }; - } - } - } else { - const type = KIND_TO_TYPE[kind]; - // v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the - // heart-rate kind so the chart can render a low/high band around the - // hourly average. Only PULSE rows ever carry a non-null spread (the - // hourly HR bucket); every other kind selects the bare value shape. - const includeSpread = kind === "pulse"; - // The ladder-winning source per day (see `seriesRowsFrom`), so a second - // provider neither blends into nor duplicates the series. Bounded as before: - // every dense kind reaches this read only inside 90 days and under 10 000 - // rows (counted first), and the sparse kinds are a few readings a day. - const sourcePriority = await loadUserSourcePriority(user.id); - const rawRows = await prisma.$queryRawUnsafe< - Array<{ - id: string; - value: number; - measured_at: Date; - value_min: number | null; - value_max: number | null; - }> - >( - ` - SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max" - FROM ${seriesRowsFrom(sourcePriority, type, days)} - WHERE m."measured_at" >= $2 - ORDER BY m."measured_at" ASC, m."id" ASC - `, - user.id, - since, - ); - const rows = rawRows.map((r) => ({ - id: r.id, - value: r.value, - measuredAt: r.measured_at, - ...(includeSpread - ? { valueMin: r.value_min, valueMax: r.value_max } - : {}), - })); - if (kind === "glucose") { - // v1.16.16 — glucose is stored canonical mg/dL; convert each point to - // the user's display unit AT SERIALIZATION so the wire DTO is unit- - // coherent with the CSV + FHIR exports (one number, one engine). The - // top-level `unit` token follows. mg/dL-preference users are unchanged - // (convertGlucose rounds the integer); mmol/L users see 1-decimal - // values (100 → 5.5) and `unit: "mmol/L"`. - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { glucoseUnit: true }, - }); - const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); - unit = glucoseUnit; - points = rows.map((r) => ({ - id: r.id, - at: r.measuredAt.toISOString(), - value: convertGlucose(r.value, glucoseUnit), - secondary: null, - })); - // v1.16.16 — stat parity. The stat strip's mean/min/max/stdDev must - // match the detail-page + FHIR convention: aggregate over RAW mg/dL, - // then convert each resulting figure ONCE. Deriving the stats from the - // already-converted+rounded points double-rounds the mean and stdDev - // for mmol/L users, drifting the last decimal off the detail page. - // Single-reading sets stay identical (100 → 5.5). Hand back stats here - // so the shared block below leaves a populated `statsOverride` alone. - const rawDataPoints: DataPoint[] = rows.map((r) => ({ - date: r.measuredAt, - value: r.value, - })); - if (rawDataPoints.length > 0) { - const rawSummary = summarize(rawDataPoints); - const rawValues = rawDataPoints.map((p) => p.value); - statsOverride = { - mean: - rawSummary.mean === null - ? 0 - : convertGlucose(rawSummary.mean, glucoseUnit), - min: - rawSummary.min === null - ? 0 - : convertGlucose(rawSummary.min, glucoseUnit), - max: - rawSummary.max === null - ? 0 - : convertGlucose(rawSummary.max, glucoseUnit), - // Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a - // spread converts with the same factor as a level. - stdDev: convertGlucose(stdDev(rawValues), glucoseUnit), - count: rawSummary.count, - }; - } - } else if (includeSpread) { - // v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket - // spread. A per-sample PULSE row has null min/max; an hourly HR bucket - // carries the hour's range around the average. - points = rows.map((r) => { - const row = r as typeof r & { - valueMin: number | null; - valueMax: number | null; - }; - return { - id: row.id, - at: row.measuredAt.toISOString(), - value: row.value, - secondary: null, - valueMin: row.valueMin ?? null, - valueMax: row.valueMax ?? null, - }; - }); - // An hourly-mean type's strip mean is the mean of its local days' - // values, each day the mean of its local hours' means, so a dense - // workout hour does not outweigh the rest of the window. Count, min, - // max and the spread stay over every reading. - if (usesHourlyMeanDay(type) && rows.length > 0) { - const userTz = await resolveUserTimezone(user.id); - const values = rows.map((r) => r.value); - const rawSummary = summarize( - rows.map((r) => ({ date: r.measuredAt, value: r.value })), - ); - const mean = readingsMean(type, rows, userTz); - statsOverride = { - mean: mean === null ? 0 : Math.round(mean * 100) / 100, - min: rawSummary.min ?? 0, - max: rawSummary.max ?? 0, - stdDev: stdDev(values), - count: rawSummary.count, - }; - } - } else { - points = rows.map((r) => ({ - id: r.id, - at: r.measuredAt.toISOString(), - value: r.value, - secondary: null, - })); - } - } - - const dataPoints: DataPoint[] = points.map((p) => ({ - date: new Date(p.at), - value: p.value, - })); - const summary = dataPoints.length > 0 ? summarize(dataPoints) : null; - const values = dataPoints.map((p) => p.value); - - // v1.43 — the resolved judgements. The profile and targets are the - // RECORD's (the chart is theirs); the labels are in the language of the - // account reading it. - const [record, reader, seriesTz] = await Promise.all([ - prisma.user.findUnique({ - where: { id: user.id }, - select: { - dateOfBirth: true, - gender: true, - heightCm: true, - thresholdsJson: true, - glucoseUnit: true, - locale: true, - }, - }), - actor.id === user.id - ? null - : prisma.user.findUnique({ - where: { id: actor.id }, - select: { locale: true }, - }), - resolveUserTimezone(user.id), - ]); - const rangeProfile = { - dateOfBirth: record?.dateOfBirth ?? null, - gender: record?.gender ?? null, - heightCm: record?.heightCm ?? null, - }; - const verdictType = KIND_TO_TYPE[kind]; - const dayValues = seriesDayValues(verdictType, points, seriesTz); - const reference = seriesReferenceValue(dayValues); - const direction = resolveTrendVerdict(verdictType, { - profile: rangeProfile, - thresholdsJson: record?.thresholdsJson ?? null, - // The verdict reads stored units: sleep is served in hours here and - // stored in minutes. Glucose has no band of its own on this chart. - referenceValue: - reference !== null && kind === "sleep" ? reference * 60 : reference, - }); - const translator = getServerTranslator( - await resolveJobLocale((reader ?? record)?.locale ?? null), + return apiSuccess( + await readSeries({ userId: user.id, actorId: actor.id, kind, days }), ); - const referenceLines = resolveSeriesReferenceLines(kind, { - profile: rangeProfile, - thresholdsJson: record?.thresholdsJson ?? null, - glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null), - t: (key) => translator.t(key), - }); - - annotate({ - action: { name: "measurements.series" }, - meta: { kind, days, count: points.length }, - }); - - return apiSuccess({ - kind, - unit, - points, - direction, - change: computeSeriesChange(dayValues, direction), - referenceLines, - stats: - statsOverride ?? - (summary - ? { - mean: summary.mean, - min: summary.min, - max: summary.max, - stdDev: stdDev(values), - count: summary.count, - } - : { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }), - }); }); diff --git a/src/lib/measurements/series/kinds.ts b/src/lib/measurements/series/kinds.ts new file mode 100644 index 000000000..e9a1f76cc --- /dev/null +++ b/src/lib/measurements/series/kinds.ts @@ -0,0 +1,167 @@ +/** + * The vocabulary of `GET /api/measurements/series`: the camelCase kinds the + * route serves, the stored type and wire unit behind each, the point shape, + * and the thresholds that decide when a sample-dense kind is bucketed. + */ +import { z } from "zod/v4"; + +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread"; + +/** + * Sample-dense kinds read raw rows only up to this window. + * A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and + * per-sample PULSE piles up similarly, so the 365 / 3650-day requests + * walked a six-figure row set into JS, mapped it twice, and serialized + * everything. Mirrors the sleep precedent above, but instead of silently + * clamping the window, requests beyond the cap keep their full range and + * are day-bucketed in SQL (one point per day, mean + min/max band) — the + * chart still shows the whole requested history, just at daily grain. + * The short windows users actually browse (7 / 30 / 90 d) stay raw and + * byte-identical. Sparse kinds (weight, BP, …) are untouched. + */ +export const DENSE_SERIES_RAW_WINDOW_DAYS = 90; +/** + * The kinds a device can write at sampling rate. Heart-rate variability and + * blood oxygen joined pulse and glucose once watches and rings began writing + * them every few minutes overnight: their 3650-day "Alle" range read every + * raw row, which is the read this rule exists to stop. + */ +export const DENSE_SERIES_KINDS: ReadonlySet = new Set([ + "glucose", + "pulse", + "heartRateVariability", + "oxygenSaturation", +]); + +/** + * #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV, + * blood oxygen) 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. 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. + */ +export const DENSE_SERIES_RAW_ROW_CAP = 10_000; + +export const kindEnum = z.enum([ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", + // The iOS app surfaces these as series-capable, but + // the route used to reject the trend/detail view request with a + // 422. The underlying MeasurementType enum already carries each + // value; this list extends the camelCase wire shape so the route + // matches the data it can serve. + "restingHeartRate", + "heartRateVariability", + "vo2Max", +]); + +export type SeriesKind = z.infer; + +export const KIND_TO_TYPE: Record = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", + restingHeartRate: "RESTING_HEART_RATE", + heartRateVariability: "HEART_RATE_VARIABILITY", + vo2Max: "VO2_MAX", +}; + +/** + * Explicit per-kind unit token returned at the top level so + * the client never has to infer the value's unit. `bloodPressure` reuses + * the systolic type's unit (mmHg, same for both lines). `sleep` is + * overridden at request time to `"h"` because the route returns per-night + * TIME-ASLEEP in hours rather than the canonical per-stage minutes. + * + * `glucose` is the canonical-stored unit (mg/dL) here; it is + * overridden at request time to the user's `glucoseUnit` preference and the + * point values are converted to match, so this wire DTO reads in the SAME + * unit as the CSV + FHIR exports and the blood-glucose detail page (one + * number, one engine). + */ +export const SERIES_UNIT: Record = { + weight: "kg", + bloodPressure: "mmHg", + pulse: "bpm", + bodyFat: "%", + glucose: "mg/dL", + sleep: "h", + steps: "steps", + totalBodyWater: "kg", + boneMass: "kg", + oxygenSaturation: "%", + restingHeartRate: "bpm", + heartRateVariability: "ms", + vo2Max: "mL/(kg·min)", +}; + +export interface SeriesPoint { + id: string; + at: string; + value: number; + secondary: number | null; + /** + * Per-stage minutes for a sleep night point. `null` for + * every non-sleep kind. Lets a sleep detail view render the night's + * stage breakdown without a second fetch; the headline `value` stays + * the night's TIME-ASLEEP total. + */ + sleepStages?: Partial> | null; + /** + * (iOS #34 extension) — per-point spread for `kind=pulse`. On an + * aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm + * and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row + * (no bucket spread) and absent for every non-pulse kind, so a client can + * render a low/high band only where the data supports it. + */ + valueMin?: number | null; + valueMax?: number | null; +} + +export function stdDev(values: number[]): number { + if (values.length < 2) return 0; + const sd = standardDeviation(values, READING_SPREAD) ?? 0; + return Math.round(sd * 100) / 100; +} + +/** The stat strip, when a reader computes it from the raw rows itself. */ +export interface SeriesStats { + mean: number; + min: number; + max: number; + stdDev: number; + count: number; +} + +/** What one reader hands back: the points, and the unit and stats it fixed. */ +export interface SeriesRead { + points: SeriesPoint[]; + unit?: string; + statsOverride?: SeriesStats | null; +} diff --git a/src/lib/measurements/series/read-blood-pressure.ts b/src/lib/measurements/series/read-blood-pressure.ts new file mode 100644 index 000000000..89c1f5917 --- /dev/null +++ b/src/lib/measurements/series/read-blood-pressure.ts @@ -0,0 +1,127 @@ +/** + * The blood-pressure series: systolic points paired with the diastolic of + * the same source within five minutes, from the ladder's one source per day. + */ +import { prisma } from "@/lib/db"; +import { VALUE_RANGES } from "@/lib/validations/measurement"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; + +import type { SeriesRead } from "./kinds"; + +export async function readBloodPressureSeries( + userId: string, + since: Date, +): Promise { + // Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the + // series AND the implicit "latest" (the last point). BP is two rows paired + // client-side, so a corrupt row that slipped in before the input floor was + // enforced — a seed-era systolic-0, iOS #33 — must never surface as the + // latest reading. Floors mirror VALUE_RANGES (the input validator's min); + // this is a read-side selection guard, not a tightening of the write floor. + // + // The ladder picks ONE source per day for the reading, not one per type: + // the source is chosen from the day's systolic rows that pass the floor, + // and both halves are read from it. Two per-type picks diverge whenever a + // source holds only half of a reading on a day (a deleted diastolic, a + // sub-floor systolic, an orphaned sample), and the pairing below then + // joined one source's systolic to another source's diastolic. Picking on + // floor-passing systolic rows also keeps a corrupt row from claiming a day + // it then contributes nothing to. The day is the UTC day, as in every + // other ladder read (`canonicalMeasurementsCte`), and the pick looks at + // the whole edge day, not only the slice after `since`. + // + // `rank` is the closed-enum whitelist splice from `buildSourceRankCase`; + // every request value is a positional parameter. + const rank = buildSourceRankCase( + await loadUserSourcePriority(userId), + '"type"', + '"source"', + ); + const bpRows = await prisma.$queryRawUnsafe< + Array<{ + id: string; + type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA"; + source: string; + value: number; + measured_at: Date; + }> + >( + ` + SELECT m."id", m."type"::text AS type, m."source"::text AS source, + m."value", m."measured_at" + FROM measurements m + JOIN ( + SELECT DISTINCT ON (date_trunc('day', "measured_at")) + date_trunc('day', "measured_at") AS d, + "source" AS canon + FROM measurements + WHERE "user_id" = $1 + AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" + AND "deleted_at" IS NULL + AND "value" >= $3 + AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') + ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text + ) c + ON c.d = date_trunc('day', m."measured_at") + AND c.canon = m."source" + WHERE m."user_id" = $1 + AND m."deleted_at" IS NULL + AND m."measured_at" >= $2 + AND ( + (m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3) + OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4) + ) + ORDER BY m."measured_at" ASC, m."id" ASC + `, + userId, + since, + VALUE_RANGES.BLOOD_PRESSURE_SYS.min, + VALUE_RANGES.BLOOD_PRESSURE_DIA.min, + ); + const sys = bpRows.filter((r) => r.type === "BLOOD_PRESSURE_SYS"); + // Diastolic rows per source: a pair is only ever made inside one source, + // which also holds across midnight, where the two days may have picked + // different sources. + const diaBySource = new Map(); + for (const r of bpRows) { + if (r.type !== "BLOOD_PRESSURE_DIA") continue; + const list = diaBySource.get(r.source); + if (list) list.push(r); + else diaBySource.set(r.source, [r]); + } + + // Pair by closest timestamp within ±5 minutes. Both reads arrive + // sorted ascending, so the nearest-diastolic index is non-decreasing + // across the systolic walk — a two-pointer merge (one pointer per + // source) finds each pair in O(sys + dia) instead of the previous full + // diastolic rescan per systolic row (O(sys × dia) — quadratic on dense + // imports). Ties keep the earlier diastolic row, matching the old + // scan's strict-`<` keep. + const PAIR_WINDOW_MS = 5 * 60_000; + const diaIdx = new Map(); + const points = sys.map((s) => { + const sysMs = s.measured_at.getTime(); + const dia = diaBySource.get(s.source) ?? []; + let idx = diaIdx.get(s.source) ?? 0; + while ( + idx + 1 < dia.length && + Math.abs(dia[idx + 1].measured_at.getTime() - sysMs) < + Math.abs(dia[idx].measured_at.getTime() - sysMs) + ) { + idx += 1; + } + diaIdx.set(s.source, idx); + const best = dia[idx]; + const paired = + best !== undefined && + Math.abs(best.measured_at.getTime() - sysMs) <= PAIR_WINDOW_MS; + return { + id: s.id, + at: s.measured_at.toISOString(), + value: s.value, + secondary: paired ? best.value : null, + }; + }); + return { points }; +} diff --git a/src/lib/measurements/series/read-bucketed.ts b/src/lib/measurements/series/read-bucketed.ts new file mode 100644 index 000000000..9dc49a0d5 --- /dev/null +++ b/src/lib/measurements/series/read-bucketed.ts @@ -0,0 +1,152 @@ +/** + * The bucketed series of a sample-dense kind: day buckets past the raw + * window, hour buckets for a stream too dense to send raw inside it, with + * the stat strip aggregated over every raw reading in SQL. + */ +import { prisma } from "@/lib/db"; +import { readDenseSeries } from "@/lib/measurements/dense-series-read"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; +import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; + +import { + DENSE_SERIES_KINDS, + DENSE_SERIES_RAW_ROW_CAP, + DENSE_SERIES_RAW_WINDOW_DAYS, + KIND_TO_TYPE, + type SeriesKind, + type SeriesPoint, + type SeriesRead, + type SeriesStats, +} from "./kinds"; + +/** Whether this request takes the bucketed read. */ +export async function takesBucketedRead( + userId: string, + kind: SeriesKind, + days: number, + since: Date, +): Promise { + return ( + (DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) || + (DENSE_SERIES_KINDS.has(kind) && + (await prisma.measurement.count({ + where: { + userId, + type: KIND_TO_TYPE[kind], + measuredAt: { gte: since }, + deletedAt: null, + }, + })) > DENSE_SERIES_RAW_ROW_CAP) + ); +} + +export async function readBucketedSeries( + userId: string, + kind: SeriesKind, + days: number, + since: Date, +): Promise { + let points: SeriesPoint[] = []; + let unit: string | undefined; + let statsOverride: SeriesStats | null = null; + // Day buckets past the raw window; hour buckets for a dense-kind + // stream too dense to send raw inside it. + const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour"; + // 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 + // most `days` rows regardless of sample density. Bound parameters + // mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate + // (measurement-rollups.ts), and the rows are the ladder-winning source per + // day, the same rule the rollup tier and sleep apply. + const [userTz, priorityJson] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); + const type = KIND_TO_TYPE[kind]; + const seriesRows = seriesRowsFrom(priorityJson, type, days); + // A day of a type with an activity-dependent sampling rate (pulse) is the + // weighted mean of its local hours' means, so a workout hour with hundreds + // of times the readings does not outweigh the rest of the day (see + // `day-statistic.ts`). The hour grain is already one mean per hour. + const { bucketRows, rawAgg } = await readDenseSeries({ + userId: userId, + type, + grain, + userTz, + since, + seriesRows, + }); + const round2 = (v: number) => Math.round(v * 100) / 100; + 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. + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { glucoseUnit: true }, + }); + const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); + unit = glucoseUnit; + points = bucketRows.map((r) => ({ + id: dayId(r.bucket_start), + at: r.bucket_start.toISOString(), + value: convertGlucose(r.mean, glucoseUnit), + secondary: null, + })); + if ( + rawAgg !== undefined && + rawAgg.n > 0 && + rawAgg.mean !== null && + rawAgg.min !== null && + rawAgg.max !== null + ) { + // Mirrors the raw branch's v1.16.16 parity: round the mg/dL + // aggregate to 2 decimals (summarize()'s convention), then + // convert each figure once. + statsOverride = { + mean: convertGlucose(round2(rawAgg.mean), glucoseUnit), + min: convertGlucose(rawAgg.min, glucoseUnit), + max: convertGlucose(rawAgg.max, glucoseUnit), + stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit), + count: rawAgg.n, + }; + } + } else { + // `value` is the bucket's average. For pulse, 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. The other dense kinds keep the bare point shape their raw rows + // have, so a client reads a bucket the way it reads a reading. + points = bucketRows.map((r) => ({ + id: dayId(r.bucket_start), + at: r.bucket_start.toISOString(), + value: round2(r.mean), + secondary: null, + ...(kind === "pulse" + ? { valueMin: r.min_value, valueMax: r.max_value } + : {}), + })); + if ( + rawAgg !== undefined && + rawAgg.n > 0 && + rawAgg.mean !== null && + rawAgg.min !== null && + rawAgg.max !== null + ) { + statsOverride = { + mean: round2(rawAgg.mean), + min: rawAgg.min, + max: rawAgg.max, + stdDev: round2(rawAgg.sd ?? 0), + count: rawAgg.n, + }; + } + } + return { points, unit, statsOverride }; +} diff --git a/src/lib/measurements/series/read-raw.ts b/src/lib/measurements/series/read-raw.ts new file mode 100644 index 000000000..39a1db0b6 --- /dev/null +++ b/src/lib/measurements/series/read-raw.ts @@ -0,0 +1,165 @@ +/** + * The raw series: the ladder-winning source per day, row by row, with the + * glucose unit and the hourly-mean stat strip applied where they belong. + */ +import { prisma } from "@/lib/db"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; +import { readingsMean } from "@/lib/measurements/day-mean"; +import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; + +import { + KIND_TO_TYPE, + stdDev, + type SeriesKind, + type SeriesPoint, + type SeriesRead, + type SeriesStats, +} from "./kinds"; + +export async function readRawSeries( + userId: string, + kind: SeriesKind, + days: number, + since: Date, +): Promise { + let points: SeriesPoint[] = []; + let unit: string | undefined; + let statsOverride: SeriesStats | null = null; + const type = KIND_TO_TYPE[kind]; + // (iOS #34 extension) — pull the per-bucket spread for the + // heart-rate kind so the chart can render a low/high band around the + // hourly average. Only PULSE rows ever carry a non-null spread (the + // hourly HR bucket); every other kind selects the bare value shape. + const includeSpread = kind === "pulse"; + // The ladder-winning source per day (see `seriesRowsFrom`), so a second + // provider neither blends into nor duplicates the series. Bounded as before: + // every dense kind reaches this read only inside 90 days and under 10 000 + // rows (counted first), and the sparse kinds are a few readings a day. + const sourcePriority = await loadUserSourcePriority(userId); + const rawRows = await prisma.$queryRawUnsafe< + Array<{ + id: string; + value: number; + measured_at: Date; + value_min: number | null; + value_max: number | null; + }> + >( + ` + SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max" + FROM ${seriesRowsFrom(sourcePriority, type, days)} + WHERE m."measured_at" >= $2 + ORDER BY m."measured_at" ASC, m."id" ASC + `, + userId, + since, + ); + const rows = rawRows.map((r) => ({ + id: r.id, + value: r.value, + measuredAt: r.measured_at, + ...(includeSpread ? { valueMin: r.value_min, valueMax: r.value_max } : {}), + })); + if (kind === "glucose") { + // Glucose is stored canonical mg/dL; convert each point to + // the user's display unit AT SERIALIZATION so the wire DTO is unit- + // coherent with the CSV + FHIR exports (one number, one engine). The + // top-level `unit` token follows. mg/dL-preference users are unchanged + // (convertGlucose rounds the integer); mmol/L users see 1-decimal + // values (100 → 5.5) and `unit: "mmol/L"`. + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { glucoseUnit: true }, + }); + const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); + unit = glucoseUnit; + points = rows.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: convertGlucose(r.value, glucoseUnit), + secondary: null, + })); + // Stat parity. The stat strip's mean/min/max/stdDev must + // match the detail-page + FHIR convention: aggregate over RAW mg/dL, + // then convert each resulting figure ONCE. Deriving the stats from the + // already-converted+rounded points double-rounds the mean and stdDev + // for mmol/L users, drifting the last decimal off the detail page. + // Single-reading sets stay identical (100 → 5.5). Hand back stats here + // so the shared block below leaves a populated `statsOverride` alone. + const rawDataPoints: DataPoint[] = rows.map((r) => ({ + date: r.measuredAt, + value: r.value, + })); + if (rawDataPoints.length > 0) { + const rawSummary = summarize(rawDataPoints); + const rawValues = rawDataPoints.map((p) => p.value); + statsOverride = { + mean: + rawSummary.mean === null + ? 0 + : convertGlucose(rawSummary.mean, glucoseUnit), + min: + rawSummary.min === null + ? 0 + : convertGlucose(rawSummary.min, glucoseUnit), + max: + rawSummary.max === null + ? 0 + : convertGlucose(rawSummary.max, glucoseUnit), + // Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a + // spread converts with the same factor as a level. + stdDev: convertGlucose(stdDev(rawValues), glucoseUnit), + count: rawSummary.count, + }; + } + } else if (includeSpread) { + // (iOS #34 extension) — pulse points carry the per-bucket + // spread. A per-sample PULSE row has null min/max; an hourly HR bucket + // carries the hour's range around the average. + points = rows.map((r) => { + const row = r as typeof r & { + valueMin: number | null; + valueMax: number | null; + }; + return { + id: row.id, + at: row.measuredAt.toISOString(), + value: row.value, + secondary: null, + valueMin: row.valueMin ?? null, + valueMax: row.valueMax ?? null, + }; + }); + // An hourly-mean type's strip mean is the mean of its local days' + // values, each day the mean of its local hours' means, so a dense + // workout hour does not outweigh the rest of the window. Count, min, + // max and the spread stay over every reading. + if (usesHourlyMeanDay(type) && rows.length > 0) { + const userTz = await resolveUserTimezone(userId); + const values = rows.map((r) => r.value); + const rawSummary = summarize( + rows.map((r) => ({ date: r.measuredAt, value: r.value })), + ); + const mean = readingsMean(type, rows, userTz); + statsOverride = { + mean: mean === null ? 0 : Math.round(mean * 100) / 100, + min: rawSummary.min ?? 0, + max: rawSummary.max ?? 0, + stdDev: stdDev(values), + count: rawSummary.count, + }; + } + } else { + points = rows.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: r.value, + secondary: null, + })); + } + return { points, unit, statsOverride }; +} diff --git a/src/lib/measurements/series/read-series.ts b/src/lib/measurements/series/read-series.ts new file mode 100644 index 000000000..3061619ea --- /dev/null +++ b/src/lib/measurements/series/read-series.ts @@ -0,0 +1,148 @@ +/** + * `GET /api/measurements/series` after its query has been validated: pick the + * reader the kind and window call for, then add the judgements a client would + * otherwise make itself — the change direction, the change over the window, + * the reference lines — and the stat strip. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveTrendVerdict } from "@/lib/targets/trend-verdict"; +import { + computeSeriesChange, + seriesDayValues, + seriesReferenceValue, +} from "@/lib/measurements/series-change"; +import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; + +import { + KIND_TO_TYPE, + SERIES_UNIT, + stdDev, + type SeriesKind, + type SeriesRead, +} from "./kinds"; +import { readBloodPressureSeries } from "./read-blood-pressure"; +import { readBucketedSeries, takesBucketedRead } from "./read-bucketed"; +import { readRawSeries } from "./read-raw"; +import { readSleepSeries } from "./read-sleep"; + +/** The reader this kind and window call for, in the route's old order. */ +async function readPoints( + userId: string, + kind: SeriesKind, + days: number, + since: Date, +): Promise { + if (kind === "sleep") return readSleepSeries(userId, since, days); + if (kind === "bloodPressure") return readBloodPressureSeries(userId, since); + if (await takesBucketedRead(userId, kind, days, since)) { + return readBucketedSeries(userId, kind, days, since); + } + return readRawSeries(userId, kind, days, since); +} + +export async function readSeries(args: { + /** The record read (the owner under a delegation). */ + userId: string; + /** Who is reading: the labels are in their language. */ + actorId: string; + kind: SeriesKind; + days: number; +}) { + const { userId, actorId, kind, days } = args; + const since = new Date(Date.now() - days * 86_400_000); + + const read = await readPoints(userId, kind, days, since); + // An explicit unit token so the client never infers the unit from the + // kind. Sleep is served in hours and glucose in the user's unit; every + // other kind carries its canonical stored unit. + const unit = read.unit ?? SERIES_UNIT[kind]; + const points = read.points; + const statsOverride = read.statsOverride ?? null; + + const dataPoints: DataPoint[] = points.map((p) => ({ + date: new Date(p.at), + value: p.value, + })); + const summary = dataPoints.length > 0 ? summarize(dataPoints) : null; + const values = dataPoints.map((p) => p.value); + + // The resolved judgements. The profile and targets are the + // RECORD's (the chart is theirs); the labels are in the language of the + // account reading it. + const [record, reader, seriesTz] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + thresholdsJson: true, + glucoseUnit: true, + locale: true, + }, + }), + actorId === userId + ? null + : prisma.user.findUnique({ + where: { id: actorId }, + select: { locale: true }, + }), + resolveUserTimezone(userId), + ]); + const rangeProfile = { + dateOfBirth: record?.dateOfBirth ?? null, + gender: record?.gender ?? null, + heightCm: record?.heightCm ?? null, + }; + const verdictType = KIND_TO_TYPE[kind]; + const dayValues = seriesDayValues(verdictType, points, seriesTz); + const reference = seriesReferenceValue(dayValues); + const direction = resolveTrendVerdict(verdictType, { + profile: rangeProfile, + thresholdsJson: record?.thresholdsJson ?? null, + // The verdict reads stored units: sleep is served in hours here and + // stored in minutes. Glucose has no band of its own on this chart. + referenceValue: + reference !== null && kind === "sleep" ? reference * 60 : reference, + }); + const translator = getServerTranslator( + await resolveJobLocale((reader ?? record)?.locale ?? null), + ); + const referenceLines = resolveSeriesReferenceLines(kind, { + profile: rangeProfile, + thresholdsJson: record?.thresholdsJson ?? null, + glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null), + t: (key) => translator.t(key), + }); + + annotate({ + action: { name: "measurements.series" }, + meta: { kind, days, count: points.length }, + }); + + return { + kind, + unit, + points, + direction, + change: computeSeriesChange(dayValues, direction), + referenceLines, + stats: + statsOverride ?? + (summary + ? { + mean: summary.mean, + min: summary.min, + max: summary.max, + stdDev: stdDev(values), + count: summary.count, + } + : { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }), + }; +} diff --git a/src/lib/measurements/series/read-sleep.ts b/src/lib/measurements/series/read-sleep.ts new file mode 100644 index 000000000..b6f95a008 --- /dev/null +++ b/src/lib/measurements/series/read-sleep.ts @@ -0,0 +1,57 @@ +/** + * The sleep series: one point per night carrying its time asleep in hours. + */ +import { readSleepNights } from "@/lib/measurements/list/sleep-nights"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import type { SleepStage } from "@/generated/prisma/client"; + +import type { SeriesRead } from "./kinds"; + +export async function readSleepSeries( + userId: string, + since: Date, + days: number, +): Promise { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). + // Collapse the stage rows into ONE point per night carrying the + // night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE), + // converted to HOURS. The single-stage rows the legacy path returned + // made the chart show one fragment per stage instead of a nightly + // trend. The per-night reconstruction clusters stages into sessions + // (so a midnight-spanning night stays one point) and collapses a + // dual-source night to one canonical source via the user's `sleep` + // priority ladder. + // + // Sleep is the one kind read row-per-stage rather than from the rollup + // tier, so the nights are named in Postgres and reconstructed a chunk at + // a time (`readSleepNights`): "All" reaches as far back as the history + // does, and the stage rows in the heap stay a chunk's worth. + const [tz, priorityJson] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); + const nights = await readSleepNights(userId, tz, priorityJson, { + from: since, + // A window of `days` days holds at most one night more than that. + maxNights: days + 1, + }); + const points = nights + .filter((n) => n.asleepMinutes > 0) + .map((n) => { + const stageHours = Object.fromEntries( + Object.entries(n.stages).map(([s, m]) => [ + s, + Math.round((m / 60) * 100) / 100, + ]), + ) as Partial>; + return { + id: `sleep:${n.night}`, + at: n.measuredAt.toISOString(), + value: Math.round((n.asleepMinutes / 60) * 100) / 100, + secondary: null, + sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null, + }; + }); + return { points, unit: "h" }; +}