From 7f2349e56f944174394213ae8d676cc3352ec68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:47:18 +0200 Subject: [PATCH] fix(targets): fold the glucose year and the heart-rate month in SQL The targets page read a year of raw glucose readings to keep the newest one per meal context, and thirty days of raw heart rate to derive one resting figure per day. A sensor reporting every five minutes puts 105 000 glucose readings in that year and a watch recording once a minute puts 43 000 heart-rate readings in the month, and the page is read on every visit from the web and the app. Glucose now reads one row per context (DISTINCT ON) for the newest value and one row per context and local day for the thirty-day mean and the consistency strip; the strip takes day sums directly through rollupConsistencyFromDays. Heart rate reaches the resting-pulse card through readRestingPulseProxy, the SQL fold the insights page already uses, merged per day with the native resting rows exactly as before. The cards are unchanged: the integration test rebuilds them from the raw readings through the in-memory folds and compares. --- src/lib/analytics/resting-pulse.ts | 29 +- src/lib/targets/__tests__/builders.test.ts | 32 +- src/lib/targets/build-response.ts | 40 ++- src/lib/targets/consistency.ts | 62 ++-- src/lib/targets/glucose-builder.ts | 56 ++-- src/lib/targets/glucose-read.ts | 148 +++++++++ src/lib/targets/vitals-builder.ts | 23 +- tests/integration/dense-targets-reads.test.ts | 283 ++++++++++++++++++ 8 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 src/lib/targets/glucose-read.ts create mode 100644 tests/integration/dense-targets-reads.test.ts diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 8f8deaacc..ba26c914c 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -139,6 +139,26 @@ export function resolveRestingPulseSeries(input: { pulseSamples: ReadonlyArray; /** Day key for the proxy buckets: the user's own day. */ dayKeyOf: (d: Date) => string; +}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { + return mergeRestingWithProxy({ + restingSamples: input.restingSamples, + proxySamples: deriveRestingProxyFromPulse( + input.pulseSamples, + input.dayKeyOf, + ), + dayKeyOf: input.dayKeyOf, + }); +} + +/** + * {@link resolveRestingPulseSeries} for a caller that already holds the + * proxy, one point per day, as `readRestingPulseProxy` folds it in Postgres. + * The per-day merge and the label are the same. + */ +export function mergeRestingWithProxy(input: { + restingSamples: ReadonlyArray; + proxySamples: ReadonlyArray; + dayKeyOf: (d: Date) => string; }): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { const dayKeyOf = input.dayKeyOf; const byTime = (a: PulseSample, b: PulseSample) => @@ -148,14 +168,13 @@ export function resolveRestingPulseSeries(input: { // Days already covered by a real resting row — the proxy must not // second-guess them. const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt))); - const proxy = deriveRestingProxyFromPulse( - input.pulseSamples, - dayKeyOf, - ).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt))); + const proxy = input.proxySamples.filter( + (p) => !restingDays.has(dayKeyOf(p.measuredAt)), + ); if (resting.length === 0) { return proxy.length > 0 - ? { series: proxy, which: "proxy" } + ? { series: [...proxy], which: "proxy" } : { series: [], which: "none" }; } if (proxy.length === 0) return { series: resting, which: "resting" }; diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index 8362cda40..9a765e531 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildGlucoseTargets } from "../glucose-builder"; +import { foldGlucoseTargetRows } from "../glucose-read"; import { buildMedicationTarget } from "../medication-builder"; import { buildMoodTargets } from "../mood-builder"; import { buildSleepTarget } from "../sleep-builder"; @@ -8,11 +9,13 @@ import { buildVitalTargets } from "../vitals-builder"; const NOW = new Date("2026-07-21T12:00:00.000Z"); const TZ = "UTC"; +const RECENT = new Date(NOW.getTime() - 30 * 86_400_000); describe("target section builders", () => { it("keeps the vital target order and profile-gated cards on an empty profile", () => { const result = buildVitalTargets({ recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: null, @@ -108,7 +111,7 @@ describe("target section builders", () => { }, ]; const targets = buildGlucoseTargets({ - rows, + summaries: foldGlucoseTargetRows(rows, RECENT, TZ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -140,10 +143,14 @@ describe("target section builders", () => { // metadata, so every row arrives with a null context. Iterating the four // named contexts alone returned no glucose target at all. const targets = buildGlucoseTargets({ - rows: [ - { value: 112, measuredAt: NOW, glucoseContext: null }, - { value: 104, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 112, measuredAt: NOW, glucoseContext: null }, + { value: 104, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -173,11 +180,15 @@ describe("target section builders", () => { it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => { const targets = buildGlucoseTargets({ - rows: [ - { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, - { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, - { value: 111, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, + { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, + { value: 111, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -235,6 +246,7 @@ describe("target section builders", () => { describe("buildVitalTargets — user weight target", () => { const base = { recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: 178, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6addbc725..74903024b 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -26,7 +26,9 @@ import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { buildGlucoseTargets } from "./glucose-builder"; +import { readGlucoseTargetSummaries } from "./glucose-read"; import { buildMedicationTarget } from "./medication-builder"; import { buildMoodTargets } from "./mood-builder"; import { buildSleepTarget } from "./sleep-builder"; @@ -75,6 +77,10 @@ export async function buildTargetsResponse(user: AuthedUser) { "BODY_FAT", "ACTIVITY_STEPS", ]; + // Heart rate is the one vital a device streams. Its thirty days reach the + // resting-pulse card as one proxy point per day, folded in SQL below; every + // other vital is written a few times a day and is read as rows. + const recentTypes = types.filter((type) => type !== "PULSE"); const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000); const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000); const moodSince = new Date( @@ -95,7 +101,8 @@ export async function buildTargetsResponse(user: AuthedUser) { medicationBundle, moodRollups, latestMoodEntry, - glucoseRows, + glucoseSummaries, + restingPulseProxy, ] = await Promise.all([ limit(() => prisma.user.findUnique({ @@ -115,7 +122,7 @@ export async function buildTargetsResponse(user: AuthedUser) { prisma.measurement.findMany({ where: { userId, - type: { in: types }, + type: { in: recentTypes }, measuredAt: { gte: thirtyDaysAgo }, deletedAt: null, }, @@ -211,16 +218,22 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { score: true, moodLoggedAt: true }, }), ), + // A sensor reporting every five minutes writes 105 000 glucose readings + // a year. The cards need the newest per meal context and the last thirty + // days per local day, so both fold in SQL. limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, + readGlucoseTargetSummaries({ + userId, + since: oneYearAgo, + recentSince: thirtyDaysAgo, + timeZone: timezone, + }), + ), + limit(() => + readRestingPulseProxy({ + userId, + since: thirtyDaysAgo, + timeZone: timezone, }), ), ]); @@ -251,6 +264,8 @@ export async function buildTargetsResponse(user: AuthedUser) { for (const type of types) { latestByType[type] = latestEverByType.find((row) => row.type === type)?.value ?? null; + } + for (const type of recentTypes) { const recent = recentMeasurements.filter( (measurement) => measurement.type === type, ); @@ -266,6 +281,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const vitalSection = buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -314,7 +330,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); targets.push( ...buildGlucoseTargets({ - rows: glucoseRows, + summaries: glucoseSummaries, profile: { heightCm, dateOfBirth: dbUser?.dateOfBirth ?? null, diff --git a/src/lib/targets/consistency.ts b/src/lib/targets/consistency.ts index 66de155d2..a7c994d41 100644 --- a/src/lib/targets/consistency.ts +++ b/src/lib/targets/consistency.ts @@ -43,12 +43,13 @@ function last7DayKeys(now: Date, timezone: string): string[] { return keys; } -function computeStrip( +/** A day's readings folded to their sum and count, keyed by local day. */ +export type DaySums = ReadonlyMap; + +function sumByDay( events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, timezone: string, - now: Date, -) { +): Map { const byDay = new Map(); for (const event of events) { const key = userDayKey(event.measuredAt, timezone); @@ -57,7 +58,15 @@ function computeStrip( bucket.count += 1; byDay.set(key, bucket); } + return byDay; +} +function computeStrip( + byDay: DaySums, + classify: (mean: number) => DayBand | null, + timezone: string, + now: Date, +) { const consistency7d: Array = []; let daysInRange7d = 0; let daysLogged7d = 0; @@ -77,13 +86,40 @@ function computeStrip( export function rollupConsistency({ events, + ...rest +}: RollupConsistencyInput): TargetConsistency { + return rollupConsistencyFromDays({ + ...rest, + byDay: sumByDay(events, rest.timezone), + readingCount: events.length, + }); +} + +interface RollupDaySumsInput extends ConsistencyClock { + /** Each local day's readings as sum and count. */ + byDay: DaySums; + /** Readings behind `byDay`, for the thin-data threshold. */ + readingCount: number; + classify: (mean: number) => DayBand | null; + totalReadingsThreshold?: number; +} + +/** + * The same consistency as {@link rollupConsistency}, from readings already + * folded per local day. Every figure here is a function of each day's mean + * and of the reading count, so a reader that folds in SQL hands over one row + * per day instead of every reading and gets the same answer. + */ +export function rollupConsistencyFromDays({ + byDay, + readingCount, classify, timezone, now, totalReadingsThreshold = 3, -}: RollupConsistencyInput): TargetConsistency { - if (events.length < totalReadingsThreshold) { - const partial = computeStrip(events, classify, timezone, now); +}: RollupDaySumsInput): TargetConsistency { + if (readingCount < totalReadingsThreshold) { + const partial = computeStrip(byDay, classify, timezone, now); return { ...EMPTY_CONSISTENCY, ...partial, @@ -91,15 +127,7 @@ export function rollupConsistency({ }; } - const byDay = new Map(); - for (const event of events) { - const key = userDayKey(event.measuredAt, timezone); - const bucket = byDay.get(key) ?? { sum: 0, count: 0 }; - bucket.sum += event.value; - bucket.count += 1; - byDay.set(key, bucket); - } - const strip = computeStrip(events, classify, timezone, now); + const strip = computeStrip(byDay, classify, timezone, now); let daysInRange30d = 0; let daysLogged30d = 0; @@ -136,7 +164,7 @@ export function rollupConsistency({ lastMetGoalAt, streakDays, insufficientData: - events.length < totalReadingsThreshold || strip.daysLogged7d < 1, + readingCount < totalReadingsThreshold || strip.daysLogged7d < 1, consistency7d: strip.consistency7d, }; } diff --git a/src/lib/targets/glucose-builder.ts b/src/lib/targets/glucose-builder.ts index cea599c92..7d3dad20f 100644 --- a/src/lib/targets/glucose-builder.ts +++ b/src/lib/targets/glucose-builder.ts @@ -4,8 +4,9 @@ import { type GlucoseContextBucket, } from "@/lib/glucose"; import { resolveGlucoseTarget } from "./glucose-targets"; -import { makeRangeClassifier, rollupConsistency } from "./consistency"; -import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types"; +import { makeRangeClassifier, rollupConsistencyFromDays } from "./consistency"; +import type { TargetGlucoseContextSummary } from "./glucose-read"; +import type { TargetItem, TargetProfile } from "./types"; const LABEL_BY_CONTEXT: Record = { FASTING: "targets.glucoseFasting", @@ -16,38 +17,53 @@ const LABEL_BY_CONTEXT: Record = { }; interface GlucoseTargetsInput { - rows: TargetGlucoseRow[]; + /** + * One summary per stored meal context: the newest reading of the year and + * the last thirty days per local day (`readGlucoseTargetSummaries`). + */ + summaries: TargetGlucoseContextSummary[]; profile: TargetProfile; timezone: string; now: Date; } export function buildGlucoseTargets({ - rows, + summaries, profile, timezone, now, }: GlucoseTargetsInput): TargetItem[] { const targets: TargetItem[] = []; const unit = resolveGlucoseUnit(profile.glucoseUnit); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // #943 — the untagged bucket rides the same loop. Iterating the four named // contexts alone left an account whose source writes no meal-time tag with // no glucose target at all, the same gap the dashboard tile had. - for (const [context, contextRows] of groupByGlucoseContext( - rows, - (row) => row.glucoseContext, + for (const [context, contextSummaries] of groupByGlucoseContext( + summaries, + (summary) => summary.glucoseContext, )) { - const latest = contextRows[0].value; - const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo); + // A bucket is one stored context today; folding several keeps the newest + // reading and adds the days up, should two ever share a bucket. + const newest = contextSummaries.reduce((a, b) => + b.latestAt > a.latestAt ? b : a, + ); + const latest = newest.latest; + const recentByDay = new Map(); + let recentCount = 0; + let recentSum = 0; + for (const summary of contextSummaries) { + recentCount += summary.recentCount; + for (const [day, bucket] of summary.recentByDay) { + const current = recentByDay.get(day) ?? { sum: 0, count: 0 }; + current.sum += bucket.sum; + current.count += bucket.count; + recentByDay.set(day, current); + recentSum += bucket.sum; + } + } const average30 = - recent.length > 0 - ? Math.round( - (recent.reduce((sum, row) => sum + row.value, 0) / recent.length) * - 10, - ) / 10 - : null; + recentCount > 0 ? Math.round((recentSum / recentCount) * 10) / 10 : null; const resolved = resolveGlucoseTarget({ context, hasDiabetes: profile.hasDiabetes, @@ -97,11 +113,9 @@ export function buildGlucoseTargets({ : resolved.source === "ADA goal (diabetes)" ? "ADA goal (diabetes)" : "ADA 2024 / DDG", - ...rollupConsistency({ - events: recent.map((row) => ({ - measuredAt: row.measuredAt, - value: row.value, - })), + ...rollupConsistencyFromDays({ + byDay: recentByDay, + readingCount: recentCount, classify: makeRangeClassifier( range, effectiveRange diff --git a/src/lib/targets/glucose-read.ts b/src/lib/targets/glucose-read.ts new file mode 100644 index 000000000..0496f8cef --- /dev/null +++ b/src/lib/targets/glucose-read.ts @@ -0,0 +1,148 @@ +/** + * The glucose rows the targets page needs, folded in Postgres. + * + * The glucose targets used to read every glucose reading of the last year and + * keep three things from them per meal context: the newest value, and for the + * last thirty days each local day's mean and the number of readings. A sensor + * that reports every five minutes puts 105 000 readings in that year, and the + * targets page is read on every visit, on the web and in the app. So the year + * is reduced to one row per context (`DISTINCT ON`) and the thirty days to one + * row per context and local day, and only those rows cross the wire. + * + * Output matches {@link foldGlucoseTargetRows} over the raw readings: the same + * newest value per context (ties broken by id, newest first), and the same + * per-day sums and counts, with days cut in the zone the caller passes by the + * expression every other day fold uses. + */ +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import type { GlucoseContext } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isValidTimezone, userDayKey } from "@/lib/tz/format"; +import type { TargetGlucoseRow } from "./types"; + +/** One meal context's newest reading and its last thirty days, per day. */ +export interface TargetGlucoseContextSummary { + glucoseContext: GlucoseContext | null; + latest: number; + latestAt: Date; + /** Local day → the day's readings as sum and count, since `recentSince`. */ + recentByDay: Map; + /** Readings behind `recentByDay`. */ + recentCount: number; +} + +/** + * The in-memory fold over raw readings, newest first. Pure; the reference the + * SQL reader is tested against. + */ +export function foldGlucoseTargetRows( + rows: readonly TargetGlucoseRow[], + recentSince: Date, + timeZone: string, +): TargetGlucoseContextSummary[] { + const byContext = new Map(); + for (const row of rows) { + const key = row.glucoseContext ?? ""; + let summary = byContext.get(key); + if (!summary) { + summary = { + glucoseContext: row.glucoseContext, + latest: row.value, + latestAt: row.measuredAt, + recentByDay: new Map(), + recentCount: 0, + }; + byContext.set(key, summary); + } + if (row.measuredAt < recentSince) continue; + const day = userDayKey(row.measuredAt, timeZone); + const bucket = summary.recentByDay.get(day) ?? { sum: 0, count: 0 }; + bucket.sum += row.value; + bucket.count += 1; + summary.recentByDay.set(day, bucket); + summary.recentCount += 1; + } + return [...byContext.values()]; +} + +export async function readGlucoseTargetSummaries(opts: { + userId: string; + /** Inclusive lower bound for the newest reading per context. */ + since: Date; + /** Inclusive lower bound for the per-day fold. */ + recentSince: Date; + /** IANA zone the day boundary is cut in. */ + timeZone: string; + db?: Pick; +}): Promise { + const db = opts.db ?? prisma; + const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC"; + const [latestRows, dayRows] = await Promise.all([ + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + value: number; + measured_at: Date; + }> + >(Prisma.sql` + SELECT DISTINCT ON (m."glucose_context") + m."glucose_context" AS glucose_context, + m."value" AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.since} + ORDER BY m."glucose_context", m."measured_at" DESC, m."id" DESC + `), + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + day: string; + n: number; + sum: number; + }> + >(Prisma.sql` + WITH src AS ( + SELECT + m."glucose_context" AS glucose_context, + to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.recentSince} + ) + SELECT + glucose_context, + day, + COUNT(*)::int AS n, + SUM(value)::double precision AS sum + FROM src + GROUP BY glucose_context, day + `), + ]); + + const byContext = new Map(); + for (const row of latestRows) { + byContext.set(row.glucose_context ?? "", { + glucoseContext: row.glucose_context, + latest: Number(row.value), + latestAt: row.measured_at, + recentByDay: new Map(), + recentCount: 0, + }); + } + for (const row of dayRows) { + // Every recent reading is also inside the year, so its context has a + // newest row unless `recentSince` predates `since`, which no caller does. + const summary = byContext.get(row.glucose_context ?? ""); + if (!summary) continue; + const n = Number(row.n); + summary.recentByDay.set(row.day, { sum: Number(row.sum), count: n }); + summary.recentCount += n; + } + return [...byContext.values()]; +} diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 05c6eccaf..69b8b589d 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -13,7 +13,10 @@ import { classifyPulseByTarget, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { + mergeRestingWithProxy, + type PulseSample, +} from "@/lib/analytics/resting-pulse"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { userDayKey } from "@/lib/tz/resolver"; @@ -38,7 +41,15 @@ export interface BloodPressureTargetRange { } interface VitalTargetsInput { + /** The last thirty days of every vital type except `PULSE`. */ recentMeasurements: TargetMeasurement[]; + /** + * The resting-pulse proxy over the same thirty days, one point per local + * day, folded from raw `PULSE` in Postgres (`readRestingPulseProxy`). Raw + * heart rate is never read here: a watch that records it once a minute puts + * 43 000 readings in thirty days, and the card needs one figure per day. + */ + restingPulseProxy: PulseSample[]; latestByType: TargetValueByType; average30ByType: TargetValueByType; heightCm: number | null; @@ -140,6 +151,7 @@ function buildBpPairsByDay( export function buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -273,19 +285,14 @@ export function buildVitalTargets({ } const pulseTarget = getPersonalizedPulseTarget(age, referenceSex); - const restingResolved = resolveRestingPulseSeries({ + const restingResolved = mergeRestingWithProxy({ restingSamples: recentMeasurements .filter((measurement) => measurement.type === "RESTING_HEART_RATE") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - pulseSamples: recentMeasurements - .filter((measurement) => measurement.type === "PULSE") - .map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), + proxySamples: restingPulseProxy, dayKeyOf: (date) => userDayKey(date, timezone), }); const restingEvents = restingResolved.series; diff --git a/tests/integration/dense-targets-reads.test.ts b/tests/integration/dense-targets-reads.test.ts new file mode 100644 index 000000000..e3a4a2aae --- /dev/null +++ b/tests/integration/dense-targets-reads.test.ts @@ -0,0 +1,283 @@ +/** + * The targets page on a dense account: a glucose sensor reporting every five + * minutes and a watch recording heart rate once a minute. + * + * `buildTargetsResponse` read a year of raw glucose readings to keep the + * newest one per meal context, and thirty days of raw heart rate to derive one + * resting figure per day. Both now fold in SQL. These cases pin that neither + * stream is read as rows any more, and that the cards come out exactly as the + * in-memory fold over the raw readings makes them, on a dense account and on + * a sparse one with readings next to the local midnight. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse"; +import { buildTargetsResponse } from "@/lib/targets/build-response"; +import { buildGlucoseTargets } from "@/lib/targets/glucose-builder"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { buildVitalTargets } from "@/lib/targets/vitals-builder"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "America/New_York"; +const DENSE = "dense-targets-owner"; +const SPARSE = "sparse-targets-owner"; +const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000); +const DAY = 86_400_000; + +beforeAll(async () => { + await truncateAllTables(prisma); + for (const id of [DENSE, SPARSE]) { + await prisma.user.create({ + data: { + id, + username: id, + timezone: TZ, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + heightCm: 170, + }, + }); + invalidateUserTimezone(id); + } + // 200 days of a sensor every five minutes, untagged, with every 37th + // reading tagged fasting so two contexts are present. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, glucose_context, measured_at, + created_at, updated_at, sync_version) + SELECT 'dg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type, + 70 + (g % 110) + (g % 3) * 0.25, 'mg/dL', 'NIGHTSCOUT'::measurement_source, + CASE WHEN g % 37 = 0 THEN 'FASTING'::glucose_context ELSE NULL END, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'), + now(), now(), 1 + FROM generate_series(0, 200 * 288 - 1) g`, + DENSE, + NOW.toISOString(), + ); + // Ten days of heart rate once a minute. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT 'dp' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type, + 55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, 10 * 24 * 60 - 1) g`, + DENSE, + NOW.toISOString(), + ); + + // Sparse: fingerstick readings in three contexts, a few a day, some older + // than thirty days, some within minutes of the New York midnight; a cuff a + // few times a day; a resting heart rate on alternate days. + const contexts = ["FASTING", "POSTPRANDIAL", null] as const; + const sparse: Array<{ + type: "BLOOD_GLUCOSE" | "PULSE" | "RESTING_HEART_RATE"; + value: number; + at: Date; + ctx?: (typeof contexts)[number]; + }> = []; + for (let day = 0; day < 60; day++) { + for (let i = 0; i < 1 + (day % 3); i++) { + sparse.push({ + type: "BLOOD_GLUCOSE", + value: 85 + ((day * 13 + i * 29) % 90) + (i % 2 ? 0.5 : 0), + at: new Date(NOW.getTime() - day * DAY - (i * 311 + 7) * 60_000), + ctx: contexts[(day + i) % 3], + }); + } + if (day < 25) { + for (let i = 0; i < 2 + (day % 4); i++) { + sparse.push({ + type: "PULSE", + value: 54 + ((day * 7 + i * 11) % 40), + at: new Date(NOW.getTime() - day * DAY - (i * 173 + 3) * 60_000), + }); + } + } + if (day % 2 === 0 && day < 30) { + sparse.push({ + type: "RESTING_HEART_RATE", + value: 58 + (day % 6), + at: new Date(NOW.getTime() - day * DAY - 4 * 3_600_000), + }); + } + } + await prisma.measurement.createMany({ + data: sparse.map((r, i) => ({ + id: `st${String(i).padStart(5, "0")}`, + userId: SPARSE, + type: r.type, + value: r.value, + unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "bpm", + source: "MANUAL" as const, + glucoseContext: r.ctx ?? null, + measuredAt: r.at, + })), + }); + await prisma.$executeRawUnsafe(`ANALYZE measurements`); +}, 180_000); + +function readsDenseRows(args: unknown): boolean { + const type = (args as { where?: { type?: unknown } } | undefined)?.where + ?.type; + if (type === "BLOOD_GLUCOSE" || type === "PULSE") return true; + const list = (type as { in?: unknown } | undefined)?.in; + return ( + Array.isArray(list) && + (list.includes("PULSE") || list.includes("BLOOD_GLUCOSE")) + ); +} + +describe("targets on a dense account", () => { + it("never reads the glucose or heart-rate stream as rows", async () => { + const spy = vi.spyOn(prisma.measurement, "findMany"); + try { + const started = performance.now(); + const res = await buildTargetsResponse({ id: DENSE, timezone: TZ }); + const elapsed = performance.now() - started; + process.stderr.write( + `[dense-targets] buildTargetsResponse ${elapsed.toFixed(0)} ms\n`, + ); + expect(res.targets.length).toBeGreaterThan(0); + const denseReads = spy.mock.calls.filter(([args]) => + readsDenseRows(args), + ); + expect(denseReads).toEqual([]); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("targets fold equals the raw-reading fold", () => { + for (const userId of [SPARSE, DENSE]) { + it(`glucose summaries match the in-memory fold (${userId})`, async () => { + const since = new Date(NOW.getTime() - 365 * DAY); + const recentSince = new Date(NOW.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expected = foldGlucoseTargetRows(raw, recentSince, TZ); + const actual = await readGlucoseTargetSummaries({ + userId, + since, + recentSince, + timeZone: TZ, + }); + const byCtx = (xs: T[]) => + [...xs].sort((a, b) => + (a.glucoseContext ?? "").localeCompare(b.glucoseContext ?? ""), + ); + expect(actual.length).toBeGreaterThanOrEqual(2); + const exp = byCtx(expected); + const act = byCtx(actual); + expect(act.map((s) => s.glucoseContext)).toEqual( + exp.map((s) => s.glucoseContext), + ); + for (let i = 0; i < exp.length; i++) { + expect(act[i].latest).toBe(exp[i].latest); + expect(act[i].latestAt).toEqual(exp[i].latestAt); + expect(act[i].recentCount).toBe(exp[i].recentCount); + expect([...act[i].recentByDay.keys()].sort()).toEqual( + [...exp[i].recentByDay.keys()].sort(), + ); + for (const [day, bucket] of exp[i].recentByDay) { + const got = act[i].recentByDay.get(day)!; + expect(got.count, day).toBe(bucket.count); + expect(got.sum, day).toBeCloseTo(bucket.sum, 6); + } + } + }); + + it(`glucose and resting-pulse cards equal the raw path (${userId})`, async () => { + const res = await buildTargetsResponse({ id: userId, timezone: TZ }); + // The builder takes its own clock after the reads; rebuild the raw + // expectation against the same thirty-day cut. + const now = new Date(); + const recentSince = new Date(now.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: new Date(now.getTime() - 365 * DAY) }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expectedGlucose = buildGlucoseTargets({ + summaries: foldGlucoseTargetRows(raw, recentSince, TZ), + profile: { + heightCm: 170, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + glucoseUnit: null, + hasDiabetes: false, + thresholdsJson: null, + }, + timezone: TZ, + now, + }); + const glucose = res.targets.filter((t) => + t.type.startsWith("BLOOD_GLUCOSE_"), + ); + expect(glucose.length).toBe(expectedGlucose.length); + for (let i = 0; i < glucose.length; i++) { + const { average30, ...rest } = glucose[i]; + const { average30: expAvg, ...expRest } = expectedGlucose[i]; + expect(rest).toEqual(expRest); + if (expAvg === null) expect(average30).toBeNull(); + else expect(average30).toBeCloseTo(expAvg, 6); + } + + const recent = await prisma.measurement.findMany({ + where: { + userId, + type: { in: ["PULSE", "RESTING_HEART_RATE"] }, + deletedAt: null, + measuredAt: { gte: recentSince }, + }, + select: { type: true, value: true, measuredAt: true }, + }); + const expectedPulse = buildVitalTargets({ + recentMeasurements: recent.filter( + (r) => r.type === "RESTING_HEART_RATE", + ), + restingPulseProxy: deriveRestingProxyFromPulse( + recent.filter((r) => r.type === "PULSE"), + (d) => userDayKey(d, TZ), + ), + latestByType: {}, + average30ByType: {}, + heightCm: 170, + age: res.profile.age, + gender: "FEMALE", + timezone: TZ, + now, + weightTargetOverride: null, + }).targets.find((t) => t.type === "PULSE"); + const pulse = res.targets.find((t) => t.type === "PULSE"); + expect(pulse?.current).not.toBeNull(); + expect(pulse).toEqual(expectedPulse); + }); + } +});