mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
perf(insights): SQL-aggregator + cache for comprehensive route
The /api/insights/comprehensive handler used to walk every measurement
of every type from the trailing 90-day window into JS, then ran
summarize(), BMI, BP classification, target adherence, and four Pearson
correlations against the same pile of rows. Post Apple Health import the
unbounded findMany routinely returned 100 000+ rows, dominated request
time (~29 s in a production HAR capture), and starved the connection
pool — sibling endpoints cascaded into Cloudflare 503s.
Replace with a SQL-side aggregator in
src/lib/insights/comprehensive-aggregator.ts:
- Per-type DataSummary built via $queryRaw with AVG / MIN / MAX /
STDDEV_POP / REGR_SLOPE / REGR_R2 on the
measurements (user_id, type, measured_at) index. Anomaly count uses
STDDEV_POP to match the JS divisor (n, not n-1) and the same
round-then-threshold ordering as summarize().
- DISTINCT ON (type) for the latest raw value per type.
- date_trunc('day') daily means for correlation pairings.
- Two bounded findMany for BP sys/dia raw rows so the 5-minute
pairByTimestamp contract for bpPctInTarget survives byte-identical.
- One conditional MIN(measured_at) for dataSpanDays.
Route handler split into a buildComprehensiveResponse() helper wrapped
through caches.analytics (60 s TTL) keyed on `${userId}|comprehensive`.
The 60 s TTL converts every consumer within the window — page mount,
Coach drawer, recommendations grid — to a Map lookup; cold path drops
to a few hundred ms against fixture data matching the production volume
that triggered the bug.
One sanctioned semantic divergence: weight × BP correlation switches
from pairByTimestamp(24 h tolerance) to a daily-key join. The directive
authorised the swap for parity with the daily-mean shape mood × X
correlations already used; consumers comparing exact r/n against legacy
output may observe small drift.
Unit + integration tests cover the aggregator shape, cache hit / miss
counters, wide-event annotations, and end-to-end envelope parity.
This commit is contained in:
1 parent
322d0844ee
commit
49e599b04e
5 files changed
+1315
-114
No files matched your search
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* v1.4.35 — unit-level shape-parity pin for the comprehensive route.
|
||||
*
|
||||
* The integration coverage (`tests/integration/insights-comprehensive-cache.test.ts`)
|
||||
* exercises the route end-to-end against the real Postgres testcontainer
|
||||
* including cache hit/miss. This file mocks the SQL aggregator so the
|
||||
* envelope shape — exactly what the /insights page consumes — is
|
||||
* pinned without a container.
|
||||
*
|
||||
* What this test covers:
|
||||
* - Every key the legacy route emitted is still on the response.
|
||||
* - Empty user → bmi=null, every correlation null, scatter arrays
|
||||
* empty, totalMeasurements=0, alerts is an array.
|
||||
* - The route reads from the aggregator (not a 100k-row findMany).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the aggregator so we feed a controlled fixture without going
|
||||
// near Postgres. The route's own assembly logic (correlations,
|
||||
// classifications, alerts) is what we want to pin.
|
||||
vi.mock("@/lib/insights/comprehensive-aggregator", () => ({
|
||||
buildComprehensiveAggregate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(async () => ({ type: "none" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-comp-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/comprehensive");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("emits every legacy envelope key for an empty user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
// Every legacy key — the consumer (`/insights` page) reads each of
|
||||
// these. If any one disappears the page silently breaks.
|
||||
const requiredKeys = [
|
||||
"summaries",
|
||||
"bmi",
|
||||
"bmiClassification",
|
||||
"bpClassification",
|
||||
"bpPctInTarget",
|
||||
"bpTargets",
|
||||
"weightBpCorrelation",
|
||||
"scatterData",
|
||||
"bpMedicationCorrelation",
|
||||
"bpMedicationScatterData",
|
||||
"moodSummary",
|
||||
"moodBpCorrelation",
|
||||
"moodBpScatterData",
|
||||
"moodWeightCorrelation",
|
||||
"moodWeightScatterData",
|
||||
"moodPulseCorrelation",
|
||||
"moodPulseScatterData",
|
||||
"medications",
|
||||
"alerts",
|
||||
"hasProvider",
|
||||
"dataSpanDays",
|
||||
"totalMeasurements",
|
||||
];
|
||||
for (const key of requiredKeys) {
|
||||
expect(body.data).toHaveProperty(key);
|
||||
}
|
||||
|
||||
expect(body.data.bmi).toBeNull();
|
||||
expect(body.data.bmiClassification).toBeNull();
|
||||
expect(body.data.scatterData).toEqual([]);
|
||||
expect(body.data.bpMedicationScatterData).toEqual([]);
|
||||
expect(body.data.medications).toEqual([]);
|
||||
expect(Array.isArray(body.data.alerts)).toBe(true);
|
||||
expect(body.data.totalMeasurements).toBe(0);
|
||||
expect(body.data.dataSpanDays).toBe(0);
|
||||
});
|
||||
|
||||
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
count: 1,
|
||||
latest: 81.0,
|
||||
min: 81.0,
|
||||
max: 81.0,
|
||||
mean: 81.0,
|
||||
avg7: 81.0,
|
||||
avg30: 81.0,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 1,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { bmi: number | null; bmiClassification: { category: string } };
|
||||
};
|
||||
// BMI = 81 / (1.80 ^ 2) = 25.0 → "Overweight" in classifyBMI.
|
||||
expect(body.data.bmi).toBeCloseTo(25, 1);
|
||||
expect(body.data.bmiClassification.category).toBe("Overweight");
|
||||
});
|
||||
|
||||
it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const at = new Date("2026-05-10T08:00:00Z");
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
count: 1,
|
||||
latest: 125,
|
||||
min: 125,
|
||||
max: 125,
|
||||
mean: 125,
|
||||
avg7: 125,
|
||||
avg30: 125,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
count: 1,
|
||||
latest: 75,
|
||||
min: 75,
|
||||
max: 75,
|
||||
mean: 75,
|
||||
avg7: 75,
|
||||
avg30: 75,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: at, value: 125 }],
|
||||
dia: [{ measuredAt: at, value: 75 }],
|
||||
},
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: at,
|
||||
totalMeasurements: 2,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
bpPctInTarget: number | null;
|
||||
bpClassification: { category: string } | null;
|
||||
};
|
||||
};
|
||||
// 125/75 sits inside the under-65 target ceiling (sysHigh=129,
|
||||
// diaHigh=79) and above the hypotension floor → 100% in target.
|
||||
expect(body.data.bpPctInTarget).toBe(100);
|
||||
// avg30 sys=125, dia=75 → ESH "Normal" band.
|
||||
expect(body.data.bpClassification?.category).toBe("Normal");
|
||||
});
|
||||
|
||||
it("derives weight × BP scatter from daily-bucketed series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
|
||||
const days = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
scatterData: Array<{ weight: number; sysBP: number }>;
|
||||
weightBpCorrelation: { r: number; n: number } | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.scatterData).toHaveLength(5);
|
||||
expect(body.data.scatterData[0]).toEqual({ weight: 80, sysBP: 120 });
|
||||
// Perfect linear relationship → r === 1.
|
||||
expect(body.data.weightBpCorrelation?.r).toBe(1);
|
||||
expect(body.data.weightBpCorrelation?.n).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
@@ -16,11 +17,11 @@ import {
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,10 +31,41 @@ export const GET = apiHandler(async () => {
|
||||
// recommendations grid that share the Coach gate.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
// fans out to this endpoint alongside the Coach drawer + the
|
||||
// recommendations grid; the 60s TTL converts every duplicate
|
||||
// mount within the window to a Map lookup. Invalidation is handled
|
||||
// by `invalidateUserMeasurements` which already evicts every key
|
||||
// under the `${userId}|` prefix (see `lib/cache/invalidate.ts`).
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
|
||||
/**
|
||||
* v1.4.35 — comprehensive insights response body, lifted out of the
|
||||
* route handler so `cached()` can wrap it. Replaces the previous
|
||||
* 100k-row-in-JS aggregation with SQL-side aggregates served by
|
||||
* `buildComprehensiveAggregate`. The medication-compliance block keeps
|
||||
* its bounded Prisma reads (medications + intake events) — the directive
|
||||
* called those out as already on the safe path. The byte-shape of the
|
||||
* returned envelope is unchanged from the legacy route.
|
||||
*/
|
||||
export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch user profile
|
||||
// Fetch user profile (height + DOB drive BMI + BP targets).
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -42,24 +74,26 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Derived from canonical enum so adding a new measurement type does not
|
||||
// require touching this file (V3 audit finding: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
// ── Aggregate-first reads ─────────────────────────────────
|
||||
// SQL-side rollup replaces the 100k-row findMany on the legacy
|
||||
// route. Returns per-type DataSummary identical to the JS
|
||||
// `summarize()` semantics over the 90-day window, plus the bounded
|
||||
// raw rows the BP target adherence pairing needs, plus daily-mean
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
const allMeasurements = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Fetch mood entries (90 days)
|
||||
// Mood entries are user-recorded; the table is small and the
|
||||
// `moodSummary` field requires raw scores (`summarize()` over the
|
||||
// raw score series). The findMany stays.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages
|
||||
// Aggregate mood to daily averages (drives the mood × metric
|
||||
// correlations). Bucket key matches the legacy code's `date` column
|
||||
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
@@ -74,7 +108,9 @@ export const GET = apiHandler(async () => {
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
|
||||
// Build DataPoint array for mood summary
|
||||
// moodSummary uses the same `summarize()` helper over raw scores —
|
||||
// the table is bounded by user-recorded entries so the in-JS path
|
||||
// is safe.
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
@@ -82,21 +118,9 @@ export const GET = apiHandler(async () => {
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const byType = (t: MeasurementType): DataPoint[] =>
|
||||
allMeasurements
|
||||
.filter((m) => m.type === t)
|
||||
.map((m) => ({ date: m.measuredAt, value: m.value }));
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
|
||||
// Summaries
|
||||
const summaries: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const t of types) {
|
||||
const data = byType(t);
|
||||
if (data.length > 0) {
|
||||
summaries[t] = summarize(data);
|
||||
}
|
||||
}
|
||||
|
||||
// BMI
|
||||
// ── BMI ──────────────────────────────────────────────────
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
@@ -105,7 +129,7 @@ export const GET = apiHandler(async () => {
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
// BP classification (30-day average)
|
||||
// ── BP classification (30-day average) ────────────────────
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
@@ -118,11 +142,21 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
// BP target adherence
|
||||
// ── BP target adherence ──────────────────────────────────
|
||||
// Keeps the 5-minute tolerance contract — sys + dia must pair on
|
||||
// the same reading session. The aggregator pulled the bounded raw
|
||||
// rows; we reuse `pairByTimestamp` against them so the output is
|
||||
// byte-identical to the legacy path.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||||
const sysData: DataPoint[] = aggregate.bpRawRows.sys.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const diaData: DataPoint[] = aggregate.bpRawRows.dia.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000);
|
||||
|
||||
if (sysPairs.length > 0) {
|
||||
@@ -135,82 +169,48 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Correlations: Weight vs Sys BP
|
||||
const weightData = byType("WEIGHT");
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const weightBpPairs = pairByTimestamp(weightData, sysData);
|
||||
// ── Correlations: weight × Sys BP ─────────────────────────
|
||||
// Daily-key pairing (the legacy `pairByTimestamp` ran with a
|
||||
// 24-hour default tolerance — daily-mean buckets are the
|
||||
// SQL-side equivalent and the directive explicitly accepts the
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(
|
||||
weightDaily,
|
||||
sysDaily,
|
||||
);
|
||||
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// Correlations: Mood vs metrics (using daily averages matched by date)
|
||||
function buildMoodMetricPairs(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
measurements: Array<{ measuredAt: Date; value: number }>,
|
||||
): PairedPoint[] {
|
||||
// Group measurements by day
|
||||
const metricByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of measurements) {
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const current = metricByDay.get(dayKey) ?? { sum: 0, count: 0 };
|
||||
current.sum += m.value;
|
||||
current.count += 1;
|
||||
metricByDay.set(dayKey, current);
|
||||
}
|
||||
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: Math.round((metric.sum / metric.count) * 100) / 100,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// Mood vs Systolic BP
|
||||
const moodBpPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
|
||||
// Daily-key matches the legacy `buildMoodMetricPairs` exactly —
|
||||
// mood is already per-day in the legacy code and we just align the
|
||||
// metric side to the same daily bucket.
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily);
|
||||
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
// Mood vs Weight
|
||||
const moodWeightPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily);
|
||||
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
// Mood vs Pulse
|
||||
const moodPulsePairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const pulseDaily = aggregate.dailyByType.PULSE ?? [];
|
||||
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily);
|
||||
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
// Medication compliance
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
@@ -229,9 +229,7 @@ export const GET = apiHandler(async () => {
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
@@ -284,7 +282,11 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Correlation: continuity of BP medications vs systolic BP
|
||||
// ── Correlation: BP medication continuity × systolic BP ──
|
||||
// Uses the same daily BP_SYS series the aggregator pre-computed —
|
||||
// no second round-trip. The intake-side aggregation matches the
|
||||
// legacy code: continuity = min(1, taken / expectedPerDay), pairing
|
||||
// every day that had a sys reading.
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
@@ -302,13 +304,10 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
const sysByDay = new Map<string, number[]>();
|
||||
for (const m of allMeasurements) {
|
||||
if (m.type !== "BLOOD_PRESSURE_SYS") continue;
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const list = sysByDay.get(dayKey) ?? [];
|
||||
list.push(m.value);
|
||||
sysByDay.set(dayKey, list);
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
@@ -319,11 +318,9 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, sysValues] of sysByDay.entries()) {
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
const avgSys =
|
||||
sysValues.reduce((sum, value) => sum + value, 0) / sysValues.length;
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
@@ -360,25 +357,24 @@ export const GET = apiHandler(async () => {
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span
|
||||
const firstMeasurement =
|
||||
allMeasurements.length > 0 ? allMeasurements[0].measuredAt : null;
|
||||
const dataSpanDays = firstMeasurement
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - firstMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: allMeasurements.length,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntries.length,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
@@ -400,6 +396,75 @@ export const GET = apiHandler(async () => {
|
||||
alerts,
|
||||
hasProvider: (await resolveProvider(userId)).type !== "none",
|
||||
dataSpanDays,
|
||||
totalMeasurements: allMeasurements.length,
|
||||
});
|
||||
});
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `pearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
function joinDailyByDay(
|
||||
a: Array<{ day: string; value: number }>,
|
||||
b: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
function pairMoodWithDaily(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
dailyMetric: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* v1.4.35 — unit pin for `buildComprehensiveAggregate`.
|
||||
*
|
||||
* Heavier integration coverage (real Postgres, real `regr_slope`,
|
||||
* cache hit/miss assertions) lives in
|
||||
* `tests/integration/insights-comprehensive-cache.test.ts`. This file
|
||||
* mocks `$queryRaw` and Prisma's `measurement.findMany` so the shape +
|
||||
* rounding + slope-direction contracts are pinned without a container.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "../comprehensive-aggregator";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const FIND_MANY =
|
||||
prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
FIND_MANY.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("buildComprehensiveAggregate", () => {
|
||||
it("returns an empty bundle for a user with no measurements", async () => {
|
||||
// Pass order: aggregates, latests, daily, sysRaw, diaRaw. The
|
||||
// `firstMeasurementAt` query is skipped when totalMeasurements === 0.
|
||||
RAW.mockResolvedValueOnce([]) // aggregates
|
||||
.mockResolvedValueOnce([]) // latests
|
||||
.mockResolvedValueOnce([]); // daily
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
|
||||
expect(result.summaries).toEqual({});
|
||||
expect(result.bpRawRows.sys).toEqual([]);
|
||||
expect(result.bpRawRows.dia).toEqual([]);
|
||||
expect(result.dailyByType).toEqual({});
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
// 3 $queryRaw calls (aggregates, latests, daily) + 2 findMany.
|
||||
// No firstMeasurementAt query when total === 0.
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("maps a populated WEIGHT aggregate into the DataSummary shape", async () => {
|
||||
const now = new Date();
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
stddev_value: 1.2,
|
||||
anomaly_count: BigInt(3),
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
avg30_last_month: 83.0,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4, measured_at: now }])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", day: "2026-05-10", mean_value: 82.0 },
|
||||
{ type: "WEIGHT", day: "2026-05-11", mean_value: 82.2 },
|
||||
])
|
||||
.mockResolvedValueOnce([{ first_at: new Date(now.getTime() - 86400000) }]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-pop");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.avg30LastMonth).toBe(83.0);
|
||||
// Always null in the 90-day window — preserved legacy semantics.
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
expect(weight.anomalyCount).toBe(3);
|
||||
// Slope direction matches the JS threshold rules:
|
||||
// -0.014 → "down" (|s| >= 0.01)
|
||||
// -0.005 → "stable" (|s| < 0.01)
|
||||
// 0.001 → "stable"
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
|
||||
expect(result.totalMeasurements).toBe(42);
|
||||
expect(result.firstMeasurementAt).toBeInstanceOf(Date);
|
||||
expect(result.dailyByType.WEIGHT).toEqual([
|
||||
{ day: "2026-05-10", value: 82.0 },
|
||||
{ day: "2026-05-11", value: 82.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("threads sys/dia raw rows through bpRawRows so 5-min pairing survives", async () => {
|
||||
const measuredAt = new Date("2026-05-10T08:00:00Z");
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
count: BigInt(1),
|
||||
min_value: 120,
|
||||
max_value: 120,
|
||||
mean_value: 120,
|
||||
stddev_value: 0,
|
||||
anomaly_count: BigInt(0),
|
||||
avg7: 120,
|
||||
avg30: 120,
|
||||
avg30_last_month: null,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120,
|
||||
measured_at: measuredAt,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ first_at: measuredAt }]);
|
||||
FIND_MANY.mockResolvedValueOnce([
|
||||
{ measuredAt, value: 120 },
|
||||
]).mockResolvedValueOnce([{ measuredAt, value: 80 }]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-bp");
|
||||
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
|
||||
expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* v1.4.35 — SQL-driven aggregator for `/api/insights/comprehensive`.
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* The route previously pulled every measurement of every type from the
|
||||
* trailing 90 days into JS, then ran `summarize()` per type plus a
|
||||
* pile of in-JS correlation pairings. Post Apple-Health import the
|
||||
* unbounded `findMany` on line 49 of the old route routinely returned
|
||||
* 100 000+ rows and dominated request time (29 s in the v1.4.34 prod
|
||||
* HAR capture, with sibling endpoints cascading into 503s on pool
|
||||
* starvation).
|
||||
*
|
||||
* The new shape mirrors the `summaries-slice` aggregator
|
||||
* (`src/lib/analytics/summaries-slice.ts`) the v1.4.33 C1 dashboard
|
||||
* tile strip already proved out: a single `$queryRaw` per surface,
|
||||
* grouped by `type`, with Postgres-side `AVG` / `MIN` / `MAX` /
|
||||
* `REGR_SLOPE` / `REGR_R2` against the index path
|
||||
* `measurements (user_id, type, measured_at)`. Difference vs. the
|
||||
* slim slice: the comprehensive endpoint also needs `anomalyCount`,
|
||||
* `avg30LastMonth`, `avg30LastYear`, and the 90-day window cap.
|
||||
*
|
||||
* Scope notes
|
||||
* -----------
|
||||
* - The 90-day window is preserved (the legacy `findMany` clamped on
|
||||
* `measuredAt >= ninetyDaysAgo`). `min` / `max` / `mean` therefore
|
||||
* describe the 90-day window, NOT the user's all-time range —
|
||||
* identical to the old behaviour.
|
||||
*
|
||||
* - For correlations:
|
||||
* - **weight × BP**: directive accepts a daily-key swap from the
|
||||
* old `pairByTimestamp` (24h default tolerance). Postgres
|
||||
* joins on `date_trunc('day', measured_at)`.
|
||||
* - **mood × {BP, weight, pulse}**: same daily-key shape the
|
||||
* legacy code already used (the `buildMoodMetricPairs` helper
|
||||
* bucketed by `toISOString().slice(0,10)`).
|
||||
*
|
||||
* - BP target adherence (`bpPctInTarget`) keeps the 5-minute
|
||||
* tolerance contract — that pairing is sys/dia within the *same*
|
||||
* reading session. The aggregator pulls sys+dia raw rows (90d,
|
||||
* bounded — typically <2k rows even for power users) so
|
||||
* `pairByTimestamp` semantics survive byte-identical.
|
||||
*
|
||||
* - `bpMedicationCorrelation` is computed in the route itself; the
|
||||
* aggregator surfaces daily BP_SYS means via the same daily-join
|
||||
* query so the medication block can pair compliance × systolic
|
||||
* without a second findMany.
|
||||
*
|
||||
* - Mood data still loads via `prisma.moodEntry.findMany` — that
|
||||
* table is small (a row per user-recorded entry, bounded) and the
|
||||
* `moodSummary` field calls `summarize()` against raw scores. The
|
||||
* legacy route's `moodSummary` was built off raw rows so we keep
|
||||
* the raw fetch.
|
||||
*
|
||||
* Output shape
|
||||
* ------------
|
||||
* The aggregator returns the bundle of pre-computed values the route
|
||||
* stitches into the final response. Helper functions inside the
|
||||
* aggregator collapse `$queryRaw` rows into the same `DataSummary`
|
||||
* shape `summarize()` would return.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* Raw row from the aggregate query. Keyed on `MeasurementType`, with
|
||||
* windowed AVG/SLOPE/R2 expressions plus anomaly-detection inputs
|
||||
* (window-wide mean + stddev so we can compute the |z| > 2 count
|
||||
* client-side without a second pass over the raw rows).
|
||||
*/
|
||||
interface AggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
mean_value: number | null;
|
||||
stddev_value: number | null;
|
||||
anomaly_count: bigint;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg30_last_month: number | null;
|
||||
slope7: number | null;
|
||||
r2_7: number | null;
|
||||
slope30: number | null;
|
||||
r2_30: number | null;
|
||||
slope90: number | null;
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
/** Latest raw value per type (DISTINCT ON over the 90-day window). */
|
||||
interface LatestRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** Daily-mean per (type, day) for correlation pairings. */
|
||||
export interface DailyAggregateRow {
|
||||
type: string;
|
||||
day: string;
|
||||
mean_value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result bundle. Everything the route handler needs to assemble the
|
||||
* comprehensive response without holding the 100k-row raw measurement
|
||||
* array in memory.
|
||||
*/
|
||||
export interface ComprehensiveAggregate {
|
||||
/** Per-type DataSummary, 90-day window. Only types with rows are
|
||||
* populated — matches the legacy route's behaviour (only types
|
||||
* with rows landed on `summaries[t]`). */
|
||||
summaries: Record<string, DataSummary>;
|
||||
/** Sys/dia raw rows for the BP target adherence path. Bounded:
|
||||
* typically <2k rows per user over 90 days even with Apple Health. */
|
||||
bpRawRows: {
|
||||
sys: Array<{ measuredAt: Date; value: number }>;
|
||||
dia: Array<{ measuredAt: Date; value: number }>;
|
||||
};
|
||||
/** Daily means per (type, day) for the correlation pairings. The
|
||||
* route filters down to the types it needs (WEIGHT, BLOOD_PRESSURE_SYS,
|
||||
* PULSE) when joining against mood / weight / bp. */
|
||||
dailyByType: Record<string, Array<{ day: string; value: number }>>;
|
||||
/** Earliest measurement timestamp across the 90-day window — feeds
|
||||
* the route's `dataSpanDays` field. NULL when the user has no rows. */
|
||||
firstMeasurementAt: Date | null;
|
||||
/** Total row count across all types in the 90-day window — feeds the
|
||||
* wide-event meta + the response's `totalMeasurements` field. */
|
||||
totalMeasurements: number;
|
||||
}
|
||||
|
||||
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
|
||||
function buildSlope(
|
||||
slope: number | null,
|
||||
rSquared: number | null,
|
||||
): DataSummary["slope7"] {
|
||||
if (slope === null) return null;
|
||||
const threshold = 0.01;
|
||||
const direction: "up" | "down" | "stable" =
|
||||
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
|
||||
return {
|
||||
slope: Math.round(slope * 1000) / 1000,
|
||||
direction,
|
||||
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
function round2(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-type `DataSummary` bundle for the comprehensive route.
|
||||
*
|
||||
* Three SQL passes against the same `(user_id, type, measured_at)`
|
||||
* index, all targeting the trailing 90 days:
|
||||
*
|
||||
* 1. Per-type aggregate (count + min/max/mean/stddev + windowed
|
||||
* avgs + regr_slope/r2 for the 7/30/90-day slopes).
|
||||
* 2. `DISTINCT ON (type)` for the most-recent raw value per type.
|
||||
* 3. Per-type-per-day mean (`date_trunc('day', measured_at)`) for the
|
||||
* correlation pairings and the daily-bucket consumers.
|
||||
*
|
||||
* Plus two bounded raw-row queries for sys / dia (BP target pairing
|
||||
* needs sub-day timestamps, can't be replaced by a daily mean).
|
||||
*/
|
||||
export async function buildComprehensiveAggregate(
|
||||
userId: string,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [aggregates, latests, dailyRows, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<AggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
AVG(m."value") AS mean_value,
|
||||
STDDEV_POP(m."value") AS stddev_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
ws.stddev_value::double precision AS stddev_value,
|
||||
-- |z| > 2 anomaly count over the 90-day window. STDDEV_POP
|
||||
-- matches the JS helper which divides by n (not n-1).
|
||||
COUNT(*) FILTER (
|
||||
WHERE ws.stddev_value IS NOT NULL
|
||||
AND ws.stddev_value > 0
|
||||
AND ABS(
|
||||
(ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2))
|
||||
) > 2
|
||||
) AS anomaly_count,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
-- Prior 30d window (days [30, 60) ago) used by tile delta callout.
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS slope7,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS r2_7,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS slope30,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS r2_30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS slope90,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS r2_90
|
||||
FROM measurements m
|
||||
JOIN window_stats ws ON ws."type" = m."type"
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
prisma.$queryRaw<DailyAggregateRow[]>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
TO_CHAR(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') AS day,
|
||||
(ROUND((AVG(m."value"))::numeric, 2))::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
AND m."type" IN ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'PULSE')
|
||||
GROUP BY m."type", date_trunc('day', m."measured_at")
|
||||
ORDER BY m."type", day ASC
|
||||
`,
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── avg30LastYear ─────────────────────────────────────────
|
||||
// The 90-day window guarantees this is null (no rows >= 365 days
|
||||
// old will appear) — preserves legacy semantics.
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
measuredAt: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
|
||||
// Seed only types that actually have rows — the legacy route only
|
||||
// populated `summaries[t]` when `data.length > 0`.
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
let totalMeasurements = 0;
|
||||
let firstMeasurementAt: Date | null = null;
|
||||
|
||||
for (const row of aggregates) {
|
||||
const count = Number(row.count);
|
||||
totalMeasurements += count;
|
||||
const latest = latestByType.get(row.type);
|
||||
summaries[row.type] = {
|
||||
count,
|
||||
latest: latest?.value ?? null,
|
||||
min: round2(row.min_value),
|
||||
max: round2(row.max_value),
|
||||
mean: round2(row.mean_value),
|
||||
avg7: round2(row.avg7),
|
||||
avg30: round2(row.avg30),
|
||||
slope7: buildSlope(row.slope7, row.r2_7),
|
||||
slope30: buildSlope(row.slope30, row.r2_30),
|
||||
slope90: buildSlope(row.slope90, row.r2_90),
|
||||
anomalyCount: Number(row.anomaly_count),
|
||||
avg30LastMonth: round2(row.avg30_last_month),
|
||||
// Legacy semantics: the 90-day findMany window guarantees no
|
||||
// rows from a year ago. Always null.
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── firstMeasurementAt ───────────────────────────────────
|
||||
// Take the earliest `measuredAt` across the user's 90-day rows.
|
||||
// One extra cheap aggregate query keeps the route's `dataSpanDays`
|
||||
// truthful. Run lazily: skip when no aggregate rows came back
|
||||
// (totalMeasurements === 0).
|
||||
if (totalMeasurements > 0) {
|
||||
const firstRows = await prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`;
|
||||
firstMeasurementAt = firstRows[0]?.first_at
|
||||
? new Date(firstRows[0].first_at)
|
||||
: null;
|
||||
}
|
||||
|
||||
// ── daily-by-type ────────────────────────────────────────
|
||||
const dailyByType: Record<string, Array<{ day: string; value: number }>> = {};
|
||||
for (const row of dailyRows) {
|
||||
const list = dailyByType[row.type] ?? [];
|
||||
list.push({ day: row.day, value: Number(row.mean_value) });
|
||||
dailyByType[row.type] = list;
|
||||
}
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
dailyByType,
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* v1.4.35 — integration coverage for the comprehensive insights cache.
|
||||
*
|
||||
* Pins two contracts the unit suite cannot reach:
|
||||
*
|
||||
* 1. **Read-through cache.** Priming with one call counts as a
|
||||
* miss; the second call inside the 60-second TTL counts as a hit
|
||||
* and annotates `cache.analytics.outcome === "hit"` on the route's
|
||||
* wide event.
|
||||
*
|
||||
* 2. **SQL aggregation parity end-to-end.** The route's envelope
|
||||
* includes every legacy key (summaries / bmi / bp* / mood* /
|
||||
* medications / alerts / hasProvider / dataSpanDays /
|
||||
* totalMeasurements) computed from a fixture of ~50 measurement
|
||||
* rows. The byte-shape matches what the consumer (/insights page)
|
||||
* reads on first paint.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// `resolveProvider` reads `app_settings.default_locale` which trails the
|
||||
// production schema in this integration env. Mock the leaf: the route
|
||||
// only uses `provider.type !== "none"` to set the `hasProvider` flag.
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
|
||||
}));
|
||||
|
||||
import { clearLogBuffer, readLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
clearLogBuffer();
|
||||
const { __resetAllCachesForTests } = await import(
|
||||
"@/lib/cache/server-cache"
|
||||
);
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function seedSession(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("GET /api/insights/comprehensive — server cache", () => {
|
||||
it("returns a hit on the second call inside the TTL", async () => {
|
||||
const user = await seedSession("comp-cache-hit-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Seed ~50 rows so the aggregator has data to return.
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const weightRows = Array.from({ length: 30 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + (i % 5) * 0.2,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
const sysRows = Array.from({ length: 10 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value: 120 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
const diaRows = Array.from({ length: 10 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA" as const,
|
||||
value: 75 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
await prisma.measurement.createMany({
|
||||
data: [...weightRows, ...sysRows, ...diaRows],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/insights/comprehensive/route");
|
||||
const { caches } = await import("@/lib/cache/server-cache");
|
||||
|
||||
const first = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(first.status).toBe(200);
|
||||
expect(caches.analytics.stats().misses).toBe(1);
|
||||
expect(caches.analytics.stats().hits).toBe(0);
|
||||
|
||||
// Second call inside the TTL — hit.
|
||||
const second = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(second.status).toBe(200);
|
||||
expect(caches.analytics.stats().hits).toBe(1);
|
||||
|
||||
// The wide-event annotation carries the cache outcome on the hit.
|
||||
const events = readLogBuffer({});
|
||||
const cacheEvent = events.find(
|
||||
(e) =>
|
||||
(e?.meta as Record<string, unknown> | undefined)?.[
|
||||
"cache.analytics.outcome"
|
||||
] === "hit",
|
||||
);
|
||||
expect(cacheEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits every legacy envelope key with real SQL aggregation", async () => {
|
||||
const user = await seedSession("comp-cache-shape-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// A tight fixture: 5 paired BP readings + 5 weight rows + 5 mood
|
||||
// entries spread across 5 distinct days. Enough to populate every
|
||||
// correlation block (n >= 5 is the pearsonCorrelation gate).
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const dayDates = Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => new Date(now - i * DAY_MS),
|
||||
);
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
// Sys + dia at exactly the same instant (5-min tolerance auto-passes)
|
||||
...dayDates.flatMap((d, i) => [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value: 120 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA" as const,
|
||||
value: 75 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
},
|
||||
]),
|
||||
...dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + i,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
})),
|
||||
...dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
type: "PULSE" as const,
|
||||
value: 65 + i,
|
||||
unit: "bpm",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.moodEntry.createMany({
|
||||
data: dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
date: d.toISOString().slice(0, 10),
|
||||
mood: "GUT",
|
||||
score: 3 + (i % 3),
|
||||
moodLoggedAt: d,
|
||||
})),
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/insights/comprehensive/route");
|
||||
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: Record<string, unknown> & {
|
||||
summaries: Record<
|
||||
string,
|
||||
{ count: number; latest: number | null; mean: number | null } | undefined
|
||||
>;
|
||||
totalMeasurements: number;
|
||||
bpPctInTarget: number | null;
|
||||
bpClassification: { category: string } | null;
|
||||
bmi: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
// Every legacy key still present.
|
||||
const legacyKeys = [
|
||||
"summaries",
|
||||
"bmi",
|
||||
"bmiClassification",
|
||||
"bpClassification",
|
||||
"bpPctInTarget",
|
||||
"bpTargets",
|
||||
"weightBpCorrelation",
|
||||
"scatterData",
|
||||
"bpMedicationCorrelation",
|
||||
"bpMedicationScatterData",
|
||||
"moodSummary",
|
||||
"moodBpCorrelation",
|
||||
"moodBpScatterData",
|
||||
"moodWeightCorrelation",
|
||||
"moodWeightScatterData",
|
||||
"moodPulseCorrelation",
|
||||
"moodPulseScatterData",
|
||||
"medications",
|
||||
"alerts",
|
||||
"hasProvider",
|
||||
"dataSpanDays",
|
||||
"totalMeasurements",
|
||||
];
|
||||
for (const key of legacyKeys) {
|
||||
expect(body.data).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// Sanity-check the aggregator's numbers.
|
||||
expect(body.data.summaries.WEIGHT?.count).toBe(5);
|
||||
expect(body.data.summaries.BLOOD_PRESSURE_SYS?.count).toBe(5);
|
||||
expect(body.data.summaries.PULSE?.count).toBe(5);
|
||||
// 5 SYS + 5 DIA + 5 WEIGHT + 5 PULSE = 20 measurement rows. Mood
|
||||
// entries live on `moodEntries` and don't count toward this field
|
||||
// (matches legacy semantics).
|
||||
expect(body.data.totalMeasurements).toBe(20);
|
||||
// bpPctInTarget: 120-124/75-79 all inside under-65 ceiling.
|
||||
expect(body.data.bpPctInTarget).toBe(100);
|
||||
// BMI: latest weight = 80 (newest day, smallest i) at 1.80m → 24.7.
|
||||
expect(body.data.bmi).toBeCloseTo(24.7, 1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user