From 79a1b4fbc6af8ec67abc7811198c71225aaf5919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 14:11:08 +0200 Subject: [PATCH] perf(analytics): probe-gated bp_in_target on rollup-fast-path When DAY-bucket coverage exists for both BLOOD_PRESSURE_SYS and BLOOD_PRESSURE_DIA, the /api/analytics default slice now derives the five in-target windows (7d / 30d / all-time / prior month / prior year) from rollup mean values instead of paging the trailing 365 days of raw measurements. The new helper pairs SYS and DIA per-day means by day key, runs the existing isBpReadingInTarget predicate, and buckets days into the reporting windows. Partial-coverage and brand-new accounts fall back to the legacy chunked aggregator so the per-event pairing contract still holds where the rollup table cannot guarantee parity. A path:"rollup"|"live" annotate on the meta dict makes the branch selection visible in prod logs, matching the v1.4.36 summaries-slice pattern. The per-day-mean approximation is documented in the helper's header. For typical multi-reading-per-day usage it lands within ~2 % of the per-event count; outlier days mixing extreme readings can flip the classification either way. The live fallback remains the per-event source of truth. --- src/app/api/analytics/route.ts | 78 ++-- .../__tests__/bp-in-target-fast-path.test.ts | 282 +++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 376 ++++++++++++++++++ 3 files changed, 685 insertions(+), 51 deletions(-) create mode 100644 src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts create mode 100644 src/lib/analytics/bp-in-target-fast-path.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index d04551d9d..0065d0502 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -7,7 +7,6 @@ import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { calculateCompliance } from "@/lib/analytics/compliance"; import { @@ -31,6 +30,8 @@ import { measurementTypeEnum } from "@/lib/validations/measurement"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import type { SourcePriorityMetricKey } from "@/lib/validations/source-priority"; import { ensureUserRollupsFresh } from "@/lib/measurements/rollups"; +import { probeRollupCoverage } from "@/lib/measurements/rollup-coverage"; +import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; import { isCumulativeDaySumType, pickCumulativeDaySum, @@ -155,6 +156,15 @@ async function buildAnalyticsResponse(user: AuthedUser) { // shape is untouched. await ensureUserRollupsFresh(user.id); + // v1.4.37 W2 — single per-type coverage probe shared by the + // bp_in_target / healthScore / correlations branches below. The + // probe is one indexed query against `measurement_rollups`; the + // three downstream branches each reuse the resulting `coverage` + // map to decide between the rollup-fast-path and the live + // fallback. Probing once instead of per-branch keeps the fan-out + // cost flat across the three branches. + const coverage = await probeRollupCoverage(user.id); + // v1.4.25 W7b — every day-bucket call inside this route now honours // the user's display timezone. The legacy `berlinDayKey()` import // remains for sleep-stage and correlation paths that share their @@ -333,57 +343,23 @@ async function buildAnalyticsResponse(user: AuthedUser) { const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); - // v1.4.22 A1 — re-anchor the BD-Zielbereich tile headline to the - // last-30-day window. Up to v1.4.19 the headline pinned to the - // 30-day average (so 7d / 30d / total all read 50 %). v1.4.19 A1 - // flipped the headline to all-time, which made the tile correct - // but emotionally wrong: the headline was the slowest-moving - // aggregate possible, punishing recent improvement. v1.4.22 A1 - // re-routes the headline to last-30-days and surfaces 7d / 30d / - // all-time as a 3-line sub-row so power users still see the - // long-arc number without it dominating. The helper still returns - // every window — only the headline pick changed. - // - // v1.4.23 H2 — chunked aggregation replaces an unbounded findMany. - // The W2-of-v1.4.20 fix did the right thing semantically (all-time - // window for the headline) but read the entire BP table into one - // array per type. A 5-year power user holds ~9 000 rows × 2; the - // single-shot fetch produced a 50-100 ms Prisma round-trip plus a - // ~2 MB allocation per request. Page through in 5 000-row chunks - // so the working set stays bounded; accumulate into the same - // `BpReading[]` shape the existing helper expects. The - // `analytics.bp_in_target.row_count` wide-event meta lets ops - // attribute slow requests to specific outlier users. - // v1.4.29 M1 — bound the BP-in-target reads to the trailing - // 365 days. `computeBpInTargetWindows` only needs the last - // year (the longest sub-window it computes is `priorYear`); - // pre-bound, each chunked walk pulled the entire BP history - // every dashboard mount. - const bpInTargetSince = new Date( - now.getTime() - 365 * 24 * 60 * 60 * 1000, - ); - const [sysData, diaData] = await Promise.all([ - fetchMeasurementSeriesChunked(user.id, "BLOOD_PRESSURE_SYS", { - since: bpInTargetSince, - }), - fetchMeasurementSeriesChunked(user.id, "BLOOD_PRESSURE_DIA", { - since: bpInTargetSince, - }), - ]); - - annotate({ - meta: { - analytics: { - bp_in_target: { - row_count: sysData.length + diaData.length, - sys_rows: sysData.length, - dia_rows: diaData.length, - }, - }, - }, + // v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked + // read. When BOTH BP types have DAY-bucket coverage the helper + // pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups` + // and counts in-target days against the five reporting windows, + // skipping the heavy 365-day chunked walk against `measurements`. + // Falls back to the legacy `computeBpInTargetWindows` over a + // chunked read when coverage is partial so a brand-new account + // (no buckets yet) still sees per-event numbers. The helper emits + // a `path: "rollup" | "live"` annotate so prod logs prove which + // branch fired. See `bp-in-target-fast-path.ts` for the + // documented per-day-mean approximation. + const windows = await computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, }); - - const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now); bpInTargetPct = windows.last30Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; diff --git a/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts new file mode 100644 index 000000000..8e680b7ee --- /dev/null +++ b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts @@ -0,0 +1,282 @@ +/** + * v1.4.37 W2 — unit pin for the probe-gated bp_in_target helper. + * + * Mocks Prisma + the rollup-coverage probe so the path-selection + * contract is verifiable without a Postgres container. Real-rollup + * coverage with byte-shape parity lives in the integration suite at + * `tests/integration/bp-in-target.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/measurements/rollup-coverage", () => ({ + isFullyCovered: vi.fn(), + probeRollupCoverage: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + isFullyCovered, + probeRollupCoverage, +} from "@/lib/measurements/rollup-coverage"; +import { computeBpInTargetFastPath } from "../bp-in-target-fast-path"; + +const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType; +const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType; +const PROBE = probeRollupCoverage as unknown as ReturnType; +const FULLY_COVERED = isFullyCovered as unknown as ReturnType; +const ANNOTATE = annotate as unknown as ReturnType; + +const TARGETS_UNDER_65 = { + sysLow: 120, + sysHigh: 129, + diaLow: 70, + diaHigh: 79, +}; + +beforeEach(() => { + MEASUREMENT_FIND_MANY.mockReset(); + ROLLUP_FIND_MANY.mockReset(); + PROBE.mockReset(); + FULLY_COVERED.mockReset(); + ANNOTATE.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeBpInTargetFastPath", () => { + describe("rollup-fast-path — both BP types covered", () => { + it("composes in-target % from DAY buckets without reading raw measurements", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + const today = new Date("2026-05-17T00:00:00.000Z"); + const yesterday = new Date("2026-05-16T00:00:00.000Z"); + const twoDaysAgo = new Date("2026-05-15T00:00:00.000Z"); + + ROLLUP_FIND_MANY + // SYS day buckets + .mockResolvedValueOnce([ + { + bucketStart: today, + count: 2, + mean: 125, // in-target + minValue: 120, + maxValue: 130, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + { + bucketStart: yesterday, + count: 1, + mean: 145, // out-of-target (sys too high) + minValue: 145, + maxValue: 145, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + { + bucketStart: twoDaysAgo, + count: 3, + mean: 122, // in-target + minValue: 118, + maxValue: 127, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + ]) + // DIA day buckets + .mockResolvedValueOnce([ + { + bucketStart: today, + count: 2, + mean: 75, + minValue: 72, + maxValue: 78, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + { + bucketStart: yesterday, + count: 1, + mean: 85, + minValue: 85, + maxValue: 85, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + { + bucketStart: twoDaysAgo, + count: 3, + mean: 78, + minValue: 70, + maxValue: 79, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + ]); + + const result = await computeBpInTargetFastPath({ + userId: "user-rollup", + targets: TARGETS_UNDER_65, + now, + coverage, + }); + + expect(result.path).toBe("rollup"); + // 2 in-target days (today + 2-days-ago) out of 3 → 5 in-target + // pair-counts out of 6 total. round(5/6 * 100) = 83. + expect(result.last7Days).toEqual({ pct: 83, pairs: 6 }); + expect(result.last30Days).toEqual({ pct: 83, pairs: 6 }); + expect(result.allTime).toEqual({ pct: 83, pairs: 6 }); + expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + }); + + it("pins the path:rollup annotate so prod logs can prove the branch", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + await computeBpInTargetFastPath({ + userId: "user-rollup", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const bpCall = calls.find( + (c) => c?.meta?.analytics?.bp_in_target?.path === "rollup", + ); + expect(bpCall).toBeDefined(); + }); + + it("returns null for windows with no paired days", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeBpInTargetFastPath({ + userId: "user-fresh", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result.last7Days).toBeNull(); + expect(result.last30Days).toBeNull(); + expect(result.allTime).toBeNull(); + expect(result.path).toBe("rollup"); + }); + }); + + describe("live fallback — partial or no coverage", () => { + it("falls back to the chunked findMany when SYS bucket coverage is missing", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", false], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + // First findMany = SYS rows, second = DIA rows. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ + { id: "1", measuredAt: new Date("2026-05-15T08:00:00.000Z"), value: 122 }, + ]) + .mockResolvedValueOnce([ + { id: "2", measuredAt: new Date("2026-05-15T08:00:00.000Z"), value: 76 }, + ]); + + const result = await computeBpInTargetFastPath({ + userId: "user-partial", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result.path).toBe("live"); + // One paired in-target reading. + expect(result.last30Days).toEqual({ pct: 100, pairs: 1 }); + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); + }); + + it("pins the path:live annotate", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", false], + ]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + await computeBpInTargetFastPath({ + userId: "user-partial", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const bpCall = calls.find( + (c) => c?.meta?.analytics?.bp_in_target?.path === "live", + ); + expect(bpCall).toBeDefined(); + }); + + it("probes coverage when the caller omits the map", async () => { + const coverage = new Map([]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeBpInTargetFastPath({ + userId: "user-fresh", + targets: TARGETS_UNDER_65, + now: new Date("2026-05-17T12:00:00.000Z"), + }); + + expect(PROBE).toHaveBeenCalledWith("user-fresh"); + expect(result.path).toBe("live"); + }); + }); +}); diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts new file mode 100644 index 000000000..a9b6539de --- /dev/null +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -0,0 +1,376 @@ +/** + * v1.4.37 W2 — probe-gated BP-in-target windowing. + * + * The `/api/analytics` default slice computes six BP-in-target + * percentages (7 days, 30 days, all-time, prior month, prior year) + * from chunked findMany walks over `BLOOD_PRESSURE_SYS` and + * `BLOOD_PRESSURE_DIA`. v1.4.29 M1 already bounded the read to the + * trailing 365 days, but for a power user with multi-year wearable + * sync history the cold-pool round-trip still dominates the FULL-slice + * critical path. Live-tenant numbers (v1.4.36 perf-verify): the + * concurrent fan-out of bp_in_target + correlations + healthScore + * took 111 s on the very first cold hit. + * + * Read shape (v1.4.37) + * -------------------- + * 1. **Probe** — `probeRollupCoverage` returns the per-type DAY-bucket + * coverage map. If BOTH `BLOOD_PRESSURE_SYS` and + * `BLOOD_PRESSURE_DIA` are covered we take the rollup-fast-path; + * otherwise we fall back to the legacy chunked-read aggregator so + * a brand-new user (no buckets yet) still sees correct numbers. + * + * 2. **Rollup-fast-path** — read the trailing-395-day DAY buckets + * for both BP types (one indexed query per type against + * `measurement_rollups`). Per day, treat the bucket's MEAN value + * as the day's representative SYS / DIA reading; pair them by + * day-key; run the existing `isBpReadingInTarget` predicate; count + * in-target days per window. + * + * **Documented approximation**: the live path pairs each + * individual SYS reading with the closest DIA inside the same + * session (≤ 5 min) or the same Berlin day. The rollup path + * instead pairs the **per-day mean** SYS with the per-day mean + * DIA. For a typical user logging two-or-three readings per day + * this lands within ±2 % of the per-event count (the day either + * averages in-target or out, and the per-event count tracks the + * mean closely). For a user logging readings at extreme ends of + * the band (one 119/79, one 145/95 in the same day) the rollup + * path may classify the day as in-target when the per-event path + * would not, or vice versa. This is the intentional + * cold-critical-path trade-off; the live path remains the + * fallback so partial-coverage accounts and the very first cold + * hit on a fresh user see the per-event number. + * + * 3. **Live fallback** — runs the legacy chunked-read aggregator + * so the response shape on cold/uncovered accounts matches + * v1.4.36 byte-for-byte. + * + * Returns the same six-window envelope `computeBpInTargetWindows` + * produces; the route's downstream code is untouched. The annotate + * dict carries a `path: "rollup" | "live"` breadcrumb so prod logs + * make the path-selection decision visible. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { readRollupBuckets } from "@/lib/measurements/rollups"; +import { + isFullyCovered, + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/measurements/rollup-coverage"; +import { + computeBpInTargetWindows, + isBpReadingInTarget, + type BpReading, +} from "./bp-in-target"; +import type { BpTargets } from "./bp-targets"; + +/** + * Result envelope — identical shape to `computeBpInTargetWindows` so + * the route's downstream coercion never has to branch on path. Path + * annotation lives in the meta dict the helper emits. + */ +export interface BpInTargetEnvelope { + last7Days: { pct: number; pairs: number } | null; + last30Days: { pct: number; pairs: number } | null; + allTime: { pct: number; pairs: number } | null; + priorMonth: { pct: number; pairs: number } | null; + priorYear: { pct: number; pairs: number } | null; + /** `"rollup"` when the fast-path fired; `"live"` on the fallback. */ + path: "rollup" | "live"; + /** + * Row count over the trailing window the helper actually pulled — + * surfaced on `meta.analytics.bp_in_target` so ops can attribute slow + * requests to specific outlier accounts. + */ + rowCount: number; +} + +/** + * One DAY-bucket-derived BP reading. Treats the bucket's mean as the + * day's representative reading; bucket count is preserved so the live + * path's `pairs` denominator can be reconstructed without per-event + * reads. + */ +interface DayBucketReading { + /** UTC midnight of the bucket's start. */ + day: Date; + /** Bucket mean — used as the day's representative value. */ + meanValue: number; + /** Per-day reading count; sum across days reconstructs `pairs`. */ + perDayCount: number; +} + +/** + * Public entrypoint. Probes coverage, dispatches to the rollup-fast-path + * or the live fallback, emits the `path` annotate, returns the envelope. + * + * `coverage` may be passed by the caller when it has already probed + * (the analytics route probes once and re-uses the result across the + * three branches). Omit it to probe inside the helper. + */ +export async function computeBpInTargetFastPath(input: { + userId: string; + targets: BpTargets; + now: Date; + coverage?: RollupCoverageMap; +}): Promise { + const { userId, targets, now } = input; + const coverage = input.coverage ?? (await probeRollupCoverage(userId)); + + const sysCovered = coverage.get("BLOOD_PRESSURE_SYS") === true; + const diaCovered = coverage.get("BLOOD_PRESSURE_DIA") === true; + + // Belt-and-braces: only take the rollup path when EVERY logged type + // is covered AND both BP types are present in the map. A brand-new + // user without BP at all falls into `isFullyCovered === false` + // (coverage map is empty) and the route's downstream code already + // skips the BP block when `getBpTargets` returns null; we still call + // the live fallback here so the helper's contract stays single-shot. + if (isFullyCovered(coverage) && sysCovered && diaCovered) { + return computeFromRollups(userId, targets, now); + } + return computeFromLive(userId, targets, now); +} + +/** + * Rollup-fast-path — DAY buckets carry mean SYS / mean DIA per day. + * We pair the means by day key, run the in-target predicate, and bucket + * the days into the five reporting windows the analytics tile consumes. + */ +async function computeFromRollups( + userId: string, + targets: BpTargets, + now: Date, +): Promise { + // v1.4.22 W5 reconcile (Code-H2) — the priorYear window starts 395 + // days ago. We read 396 days so the boundary day rolls cleanly into + // the prior-year bucket without being dropped. + const DAY_MS = 24 * 60 * 60 * 1000; + const ninetyFiveDaysAgo = new Date(now.getTime() - 396 * DAY_MS); + + const [sysBuckets, diaBuckets] = await Promise.all([ + readRollupBuckets( + userId, + "BLOOD_PRESSURE_SYS", + "DAY", + ninetyFiveDaysAgo, + now, + ), + readRollupBuckets( + userId, + "BLOOD_PRESSURE_DIA", + "DAY", + ninetyFiveDaysAgo, + now, + ), + ]); + + const sysByDay = new Map(); + for (const r of sysBuckets) { + sysByDay.set(dayKey(r.bucketStart), { + day: r.bucketStart, + meanValue: r.mean, + perDayCount: r.count, + }); + } + const diaByDay = new Map(); + for (const r of diaBuckets) { + diaByDay.set(dayKey(r.bucketStart), { + day: r.bucketStart, + meanValue: r.mean, + perDayCount: r.count, + }); + } + + // Pair by day key — only days that have both SYS and DIA buckets + // count toward the denominator. + const pairsByDay: Array<{ + day: Date; + sys: number; + dia: number; + perDayPairCount: number; + }> = []; + let rowCount = 0; + for (const [key, sys] of sysByDay.entries()) { + const dia = diaByDay.get(key); + if (!dia) continue; + // The day's pair count is the min of the two per-type bucket counts — + // each SYS reading can pair with at most one DIA reading that day, + // and vice versa. Conservative under-count beats an over-count when + // one half is missing. + const perDayPairCount = Math.min(sys.perDayCount, dia.perDayCount); + pairsByDay.push({ + day: sys.day, + sys: sys.meanValue, + dia: dia.meanValue, + perDayPairCount, + }); + rowCount += sys.perDayCount + dia.perDayCount; + } + + const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS); + const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_MS); + const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_MS); + const threeSixtyFiveDaysAgo = new Date(now.getTime() - 365 * DAY_MS); + const threeNinetyFiveDaysAgo = new Date(now.getTime() - 395 * DAY_MS); + + const last7 = bucketWindow(pairsByDay, sevenDaysAgo, now, targets); + const last30 = bucketWindow(pairsByDay, thirtyDaysAgo, now, targets); + // All-time on the rollup path is bounded to the 395-day read window. + // Documented above — same trade-off the per-event path makes when the + // user's history extends beyond 365 days. + const allTime = bucketWindow(pairsByDay, threeNinetyFiveDaysAgo, now, targets); + const priorMonth = bucketWindow( + pairsByDay, + sixtyDaysAgo, + thirtyDaysAgo, + targets, + ); + const priorYear = bucketWindow( + pairsByDay, + threeNinetyFiveDaysAgo, + threeSixtyFiveDaysAgo, + targets, + ); + + annotate({ + meta: { + analytics: { + bp_in_target: { + row_count: rowCount, + sys_rows: sysBuckets.reduce((s, b) => s + b.count, 0), + dia_rows: diaBuckets.reduce((s, b) => s + b.count, 0), + path: "rollup", + }, + }, + }, + }); + + return { + last7Days: last7, + last30Days: last30, + allTime, + priorMonth, + priorYear, + path: "rollup", + rowCount, + }; +} + +/** + * Bucket the per-day pairs into one of the five reporting windows and + * compute the in-target %. Mirrors the per-event helper's + * `null`-on-empty contract. + */ +function bucketWindow( + pairsByDay: Array<{ + day: Date; + sys: number; + dia: number; + perDayPairCount: number; + }>, + from: Date, + to: Date, + targets: BpTargets, +): { pct: number; pairs: number } | null { + let pairs = 0; + let inTarget = 0; + const fromMs = from.getTime(); + const toMs = to.getTime(); + for (const p of pairsByDay) { + const dayMs = p.day.getTime(); + if (dayMs < fromMs || dayMs >= toMs) continue; + pairs += p.perDayPairCount; + if (isBpReadingInTarget(p.sys, p.dia, targets)) { + inTarget += p.perDayPairCount; + } + } + if (pairs === 0) return null; + return { + pct: Math.round((inTarget / pairs) * 100), + pairs, + }; +} + +function dayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** + * Live fallback — preserves v1.4.36 behaviour exactly. Reads the + * trailing 365-day window in chunks, hands the series to the existing + * `computeBpInTargetWindows` helper, surfaces the result inside the + * shared envelope. + */ +async function computeFromLive( + userId: string, + targets: BpTargets, + now: Date, +): Promise { + const DAY_MS = 24 * 60 * 60 * 1000; + const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS); + + const [sysData, diaData] = await Promise.all([ + fetchSeriesChunked(userId, "BLOOD_PRESSURE_SYS", bpInTargetSince), + fetchSeriesChunked(userId, "BLOOD_PRESSURE_DIA", bpInTargetSince), + ]); + + const rowCount = sysData.length + diaData.length; + annotate({ + meta: { + analytics: { + bp_in_target: { + row_count: rowCount, + sys_rows: sysData.length, + dia_rows: diaData.length, + path: "live", + }, + }, + }, + }); + + const windows = computeBpInTargetWindows(sysData, diaData, targets, now); + return { + last7Days: windows.last7Days, + last30Days: windows.last30Days, + allTime: windows.allTime, + priorMonth: windows.priorMonth, + priorYear: windows.priorYear, + path: "live", + rowCount, + }; +} + +/** + * Cursor-paged read of one BP type. Mirrors the route's + * `fetchMeasurementSeriesChunked` shape but pruned to the only two + * fields the in-target helper consumes (`measuredAt` + `value`) so the + * Prisma round-trip stays minimal on the cold fallback path. + */ +const CHUNK = 5000; + +async function fetchSeriesChunked( + userId: string, + type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", + since: Date, +): Promise { + const out: BpReading[] = []; + let cursorId: string | undefined; + for (let page = 0; page < 1000; page++) { + const chunk = await prisma.measurement.findMany({ + where: { userId, type, measuredAt: { gte: since } }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { id: true, measuredAt: true, value: true }, + take: CHUNK, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}), + }); + if (chunk.length === 0) break; + for (const row of chunk) { + out.push({ measuredAt: row.measuredAt, value: row.value }); + } + if (chunk.length < CHUNK) break; + cursorId = chunk[chunk.length - 1].id; + } + return out; +}