From f9ff450e0843fae147347d823f9c8e1092a7fe84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:11:57 +0200 Subject: [PATCH] perf(insights): batch the medication-compliance and per-type latest queries (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two N+1 patterns the v1.4 discovery audit caught — the same shape v1.3 fixed in /api/insights/comprehensive but in adjacent code paths: 1. **`extractFeatures` (used by every AI-insight route)** issued one `prisma.medicationIntakeEvent.findMany` per active medication, in-loop. Replaced with a single windowed query (`medicationId IN (...)` + `scheduledFor >= 90 days`) and an in-memory Map group, mirroring the V3 fix in `/api/insights/ comprehensive`. Latency now scales with total recent intakes, not `medications × intakes-per-medication`. The 90-day cutoff matches the longest window `calculateCompliance` consults. 2. **`/api/insights/targets`** issued one `prisma.measurement.findFirst` per `MeasurementType` (×~10) to fetch each type's most recent value. Replaced with a single `findMany({ distinct: ["type"], orderBy: { measuredAt: "desc" } })`. One round-trip instead of N. Behaviour identical, just fewer round-trips. Tests stay at 371/371. Note: the eager Recharts import in `app/insights/page.tsx` (the audit also flagged it as a bundle bloat that neutralises the existing `dynamic()` wrappers) is its own refactor — every ScatterChart usage inside the page needs to be lifted into its own component before dynamic-loading helps. Tracked for a follow-up perf PR so this one stays scoped to query-side wins. Co-authored-by: Marc-André Bombeck --- src/app/api/insights/targets/route.ts | 20 ++-- src/lib/insights/features.ts | 163 ++++++++++++++++++++------ 2 files changed, 136 insertions(+), 47 deletions(-) diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index c0618104d..c92386aee 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -109,20 +109,24 @@ export const GET = apiHandler(async () => { select: { type: true, value: true, measuredAt: true }, }); - // Also get the absolute latest measurement per type (even if older than 30 days) + // Also get the absolute latest measurement per type (even if older than 30 days). + // Single grouped query replaces the previous N×findFirst loop — DISTINCT ON + // (Postgres) emulated via a windowed orderBy + take-1 per type would still be + // N round-trips, so we sort once on the wide query and pick per-type below. + const latestEverByType = await prisma.measurement.findMany({ + where: { userId, type: { in: types } }, + orderBy: { measuredAt: "desc" }, + distinct: ["type"], + select: { type: true, value: true }, + }); + const latestByType: Record = {}; const avg30ByType: Record = {}; for (const t of types) { - // Latest value - const latest = await prisma.measurement.findFirst({ - where: { userId, type: t }, - orderBy: { measuredAt: "desc" }, - select: { value: true }, - }); + const latest = latestEverByType.find((row) => row.type === t); latestByType[t] = latest?.value ?? null; - // 30-day average const recentOfType = recentMeasurements.filter((m) => m.type === t); if (recentOfType.length > 0) { const sum = recentOfType.reduce((acc, m) => acc + m.value, 0); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 974875b7c..d3d08bc9d 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -97,7 +97,7 @@ export interface AggregatedFeatures { ratePressureProduct?: { rpp7: number | null; rpp30: number | null; - risk: 'normal' | 'elevated' | null; + risk: "normal" | "elevated" | null; }; bodyCompositionDivergence?: { weightStable: boolean; @@ -109,7 +109,7 @@ export interface AggregatedFeatures { winterAvgSys: number | null; summerAvgSys: number | null; delta: number | null; - significance: 'normal' | 'elevated' | null; + significance: "normal" | "elevated" | null; }; correlations?: { weightVsSystolic: CorrelationResult | null; @@ -176,7 +176,8 @@ export interface RawFeatures extends AggregatedFeatures { function stdDev(values: number[]): number | null { if (values.length < 2) return null; const mean = values.reduce((s, v) => s + v, 0) / values.length; - const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / (values.length - 1); + const variance = + values.reduce((s, v) => s + (v - mean) ** 2, 0) / (values.length - 1); return Math.round(Math.sqrt(variance) * 10) / 10; } @@ -237,7 +238,11 @@ function avgInWindow( function computeHistoricalComparison( records: Array<{ value: number; measuredAt: Date }>, now: number, -): { current7dAvg: number | null; previous30dAvg: number | null; change: number | null } { +): { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; +} { const current7dAvg = avgInWindow(records, now, 7, 0); const previous30dAvg = avgInWindow(records, now, 37, 7); const change = @@ -355,19 +360,28 @@ export async function extractFeatures( let pctInTarget: number | null = null; if (bpTargets) { - const sysByTime = new Map(sysData.map(m => [m.measuredAt.getTime(), m.value])); + const sysByTime = new Map( + sysData.map((m) => [m.measuredAt.getTime(), m.value]), + ); let inTargetCount = 0; let pairedCount = 0; for (const dia of diaData) { const sysVal = sysByTime.get(dia.measuredAt.getTime()); if (sysVal === undefined) continue; pairedCount++; - if (sysVal >= bpTargets.sysLow && sysVal <= bpTargets.sysHigh && - dia.value >= bpTargets.diaLow && dia.value <= bpTargets.diaHigh) { + if ( + sysVal >= bpTargets.sysLow && + sysVal <= bpTargets.sysHigh && + dia.value >= bpTargets.diaLow && + dia.value <= bpTargets.diaHigh + ) { inTargetCount++; } } - pctInTarget = pairedCount > 0 ? Math.round((inTargetCount / pairedCount) * 100) : null; + pctInTarget = + pairedCount > 0 + ? Math.round((inTargetCount / pairedCount) * 100) + : null; } features.bloodPressure = { @@ -385,12 +399,16 @@ export async function extractFeatures( slopeDia30: diaSummary?.slope30?.slope ?? null, sdSys30: (() => { const fromMs = now - 30 * 24 * 60 * 60 * 1000; - const vals = sysData.filter(m => m.measuredAt.getTime() >= fromMs).map(m => m.value); + const vals = sysData + .filter((m) => m.measuredAt.getTime() >= fromMs) + .map((m) => m.value); return stdDev(vals); })(), sdDia30: (() => { const fromMs = now - 30 * 24 * 60 * 60 * 1000; - const vals = diaData.filter(m => m.measuredAt.getTime() >= fromMs).map(m => m.value); + const vals = diaData + .filter((m) => m.measuredAt.getTime() >= fromMs) + .map((m) => m.value); return stdDev(vals); })(), pulsePressure30: (() => { @@ -556,15 +574,20 @@ export async function extractFeatures( moodVsPulse: computeCorr(moodPoints, pulsePoints), moodVsSystolic: computeCorr(moodPoints, sysPoints), moodVsWeight: computeCorr(moodPoints, weightPoints), - sleepVsPulse: sleepData.length > 0 ? computeCorr(sleepPoints, pulsePoints) : null, - sleepVsSystolic: sleepData.length > 0 ? computeCorr(sleepPoints, sysPoints) : null, + sleepVsPulse: + sleepData.length > 0 ? computeCorr(sleepPoints, pulsePoints) : null, + sleepVsSystolic: + sleepData.length > 0 ? computeCorr(sleepPoints, sysPoints) : null, }; // Rate-Pressure Product (RPP) — myocardial oxygen demand indicator if (features.pulse && features.bloodPressure) { const rpp7 = features.pulse.avg7 !== null && features.bloodPressure.avgSys30 !== null - ? Math.round(features.pulse.avg7 * (avgInWindow(sysData, now, 7) ?? features.bloodPressure.avgSys30)) + ? Math.round( + features.pulse.avg7 * + (avgInWindow(sysData, now, 7) ?? features.bloodPressure.avgSys30), + ) : null; const rpp30 = features.pulse.avg30 !== null && features.bloodPressure.avgSys30 !== null @@ -574,14 +597,17 @@ export async function extractFeatures( features.ratePressureProduct = { rpp7, rpp30, - risk: rppRef !== null ? (rppRef > 12000 ? 'elevated' : 'normal') : null, + risk: rppRef !== null ? (rppRef > 12000 ? "elevated" : "normal") : null, }; } // Body Composition Divergence if (features.weight && features.bodyFat) { - const weightStable = features.weight.slope30 !== null && Math.abs(features.weight.slope30) < 0.01; - const bodyFatRising = features.bodyFat.slope30 !== null && features.bodyFat.slope30 > 0; + const weightStable = + features.weight.slope30 !== null && + Math.abs(features.weight.slope30) < 0.01; + const bodyFatRising = + features.bodyFat.slope30 !== null && features.bodyFat.slope30 > 0; features.bodyCompositionDivergence = { weightStable, bodyFatRising, @@ -590,43 +616,77 @@ export async function extractFeatures( } // Mood-Adherence Risk Flag - if (features.mood && features.medications && features.medications.length > 0) { + if ( + features.mood && + features.medications && + features.medications.length > 0 + ) { features.moodAdherenceRisk = features.mood.avg7 !== null && features.mood.avg7 <= 2.5 && - features.mood.trend30 === 'declining'; + features.mood.trend30 === "declining"; } // Seasonal BP Variation (only if > 180 days of data) if (features.context.dataSpanDays > 180 && sysData.length > 0) { const winterMonths = [11, 0, 1]; // Dec, Jan, Feb (0-indexed) const summerMonths = [5, 6, 7]; // Jun, Jul, Aug - const winterVals = sysData.filter(m => winterMonths.includes(m.measuredAt.getMonth())).map(m => m.value); - const summerVals = sysData.filter(m => summerMonths.includes(m.measuredAt.getMonth())).map(m => m.value); - const winterAvg = winterVals.length > 0 ? Math.round((winterVals.reduce((s, v) => s + v, 0) / winterVals.length) * 10) / 10 : null; - const summerAvg = summerVals.length > 0 ? Math.round((summerVals.reduce((s, v) => s + v, 0) / summerVals.length) * 10) / 10 : null; - const delta = winterAvg !== null && summerAvg !== null ? Math.round((winterAvg - summerAvg) * 10) / 10 : null; + const winterVals = sysData + .filter((m) => winterMonths.includes(m.measuredAt.getMonth())) + .map((m) => m.value); + const summerVals = sysData + .filter((m) => summerMonths.includes(m.measuredAt.getMonth())) + .map((m) => m.value); + const winterAvg = + winterVals.length > 0 + ? Math.round( + (winterVals.reduce((s, v) => s + v, 0) / winterVals.length) * 10, + ) / 10 + : null; + const summerAvg = + summerVals.length > 0 + ? Math.round( + (summerVals.reduce((s, v) => s + v, 0) / summerVals.length) * 10, + ) / 10 + : null; + const delta = + winterAvg !== null && summerAvg !== null + ? Math.round((winterAvg - summerAvg) * 10) / 10 + : null; features.seasonalVariation = { winterAvgSys: winterAvg, summerAvgSys: summerAvg, delta, - significance: delta !== null ? (Math.abs(delta) > 5 ? 'elevated' : 'normal') : null, + significance: + delta !== null ? (Math.abs(delta) > 5 ? "elevated" : "normal") : null, }; } // Historical comparison: current 7d avg vs previous 30d avg (days 7-37) features.historicalComparison = {}; if (weightData.length > 0) { - features.historicalComparison.weight = computeHistoricalComparison(weightData, now); + features.historicalComparison.weight = computeHistoricalComparison( + weightData, + now, + ); } if (sysData.length > 0) { - features.historicalComparison.systolic = computeHistoricalComparison(sysData, now); + features.historicalComparison.systolic = computeHistoricalComparison( + sysData, + now, + ); } if (diaData.length > 0) { - features.historicalComparison.diastolic = computeHistoricalComparison(diaData, now); + features.historicalComparison.diastolic = computeHistoricalComparison( + diaData, + now, + ); } if (pulseData.length > 0) { - features.historicalComparison.pulse = computeHistoricalComparison(pulseData, now); + features.historicalComparison.pulse = computeHistoricalComparison( + pulseData, + now, + ); } // Medications @@ -640,22 +700,47 @@ export async function extractFeatures( medications.map((med) => med.id), ); - features.medications = []; - for (const med of medications) { - const events = await prisma.medicationIntakeEvent.findMany({ - where: { medicationId: med.id, userId }, - orderBy: { scheduledFor: "desc" }, - }); - const mapped = events.map((e) => ({ + // Single batched fetch + in-memory grouping replaces the per-medication + // findMany loop. Same shape as the v1.3.0 fix to /api/insights/comprehensive + // (the previous N+1 the v3 audit closed). 90 days is the longest window + // calculateCompliance uses below, so we don't need the full intake history. + const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); + const allEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId: { in: medications.map((med) => med.id) }, + scheduledFor: { gte: ninetyDaysAgo }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + medicationId: true, + takenAt: true, + skipped: true, + scheduledFor: true, + }, + }); + + const eventsByMed = new Map< + string, + { takenAt: Date | null; skipped: boolean; scheduledFor: Date }[] + >(); + for (const e of allEvents) { + const list = eventsByMed.get(e.medicationId) ?? []; + list.push({ takenAt: e.takenAt, skipped: e.skipped, scheduledFor: e.scheduledFor, - })); + }); + eventsByMed.set(e.medicationId, list); + } + + features.medications = medications.map((med) => { + const mapped = eventsByMed.get(med.id) ?? []; const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt); const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt); const c90 = calculateCompliance(mapped, med.schedules, 90, med.createdAt); - features.medications.push({ + return { name: med.name, dose: med.dose, category: categoryMap[med.id] ?? "OTHER", @@ -664,8 +749,8 @@ export async function extractFeatures( compliance90: c90.rate, streak: c30.streak, missedLast7: c7.missed, - }); - } + }; + }); } // Raw mode: add anonymized raw data points