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" }; +}