mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
badbac9ec9
commit
79a1b4fbc6
3 files changed
+685
-51
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user