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