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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:11:08 +02:00
1 parent badbac9ec9
commit 79a1b4fbc6
3 files changed
+685 -51

No files matched your search

+27 -51
View File
@@ -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;
@@ -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<typeof vi.fn>;
const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
const FULLY_COVERED = isFullyCovered as unknown as ReturnType<typeof vi.fn>;
const ANNOTATE = annotate as unknown as ReturnType<typeof vi.fn>;
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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([]);
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");
});
});
});
+376
View File
@@ -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<BpInTargetEnvelope> {
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<BpInTargetEnvelope> {
// 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<string, DayBucketReading>();
for (const r of sysBuckets) {
sysByDay.set(dayKey(r.bucketStart), {
day: r.bucketStart,
meanValue: r.mean,
perDayCount: r.count,
});
}
const diaByDay = new Map<string, DayBucketReading>();
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<BpInTargetEnvelope> {
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<BpReading[]> {
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;
}