From 98875debf4eb91196504dcf7dbc26af7840b5bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 15:25:20 +0200 Subject: [PATCH] refactor(insights): move the comprehensive overview builder out of its route --- .../pulse-day-mean-reader-guard.test.ts | 2 +- src/__tests__/sharing-surface-guard.test.ts | 31 +- src/app/api/insights/comprehensive/route.ts | 711 +----------------- .../as-needed-compliance-exclusion.test.ts | 2 +- .../insights/comprehensive-overview/build.ts | 198 +++++ .../comprehensive-overview/correlations.ts | 78 ++ .../comprehensive-overview/daily-series.ts | 110 +++ .../medication-compliance.ts | 235 ++++++ .../comprehensive-overview/mood-series.ts | 92 +++ .../comprehensive-overview/overview.ts | 49 ++ .../resting-pulse-daily.ts | 65 ++ 11 files changed, 863 insertions(+), 710 deletions(-) create mode 100644 src/lib/insights/comprehensive-overview/build.ts create mode 100644 src/lib/insights/comprehensive-overview/correlations.ts create mode 100644 src/lib/insights/comprehensive-overview/daily-series.ts create mode 100644 src/lib/insights/comprehensive-overview/medication-compliance.ts create mode 100644 src/lib/insights/comprehensive-overview/mood-series.ts create mode 100644 src/lib/insights/comprehensive-overview/overview.ts create mode 100644 src/lib/insights/comprehensive-overview/resting-pulse-daily.ts diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index d51ae0742..166804cf5 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -177,7 +177,7 @@ const TS_READERS: Record = { "app/api/insights/cards/route.ts": { exempt: "pulse is summarised from day means (readDayMeanSeries)", }, - "app/api/insights/comprehensive/route.ts": { + "lib/insights/comprehensive-overview/build.ts": { exempt: "summarises the mood series only", }, }; diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 65c30785b..e8e67147d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -76,7 +76,7 @@ import type { ShareScope } from "@/lib/sharing/scope"; import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix"; import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards"; -import { readSourceText } from "./helpers/source-files"; +import { readSourceText, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -3316,15 +3316,30 @@ describe("(g) the MANAGE route set is frozen", () => { // and refreshes no token. Credential policy belongs inside every resolver // and presence helper so a delegate or managed system job cannot select a // personal BYOK key or custom base URL first. + // The route hands its body to the overview modules, so the leg reads the + // route and every one of them: the probe is called among them, and no + // resolver is called by any. + const comprehensiveModules = [ + "app/api/insights/comprehensive/route.ts", + ...walkSourceFiles(join(SRC, "lib/insights/comprehensive-overview"), { + floor: 5, + }).map((f) => `lib/insights/comprehensive-overview/${f}`), + ]; + expect(read(comprehensiveModules[0])).toContain( + '"@/lib/insights/comprehensive-overview/overview"', + ); expect( - callsSymbol( - "app/api/insights/comprehensive/route.ts", - "probeProviderPresence", - ), + comprehensiveModules.some((m) => callsSymbol(m, "probeProviderPresence")), ).toBe(true); - expect( - callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"), - ).toBe(false); + for (const m of comprehensiveModules) { + for (const name of [ + "resolveProvider", + "resolveProviderChain", + "probeProviderChain", + ]) { + expect(callsSymbol(m, name), `${m} calls ${name}`).toBe(false); + } + } for (const name of [ "resolveProvider", "resolveProviderChain", diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a170b149c..bc3890f9c 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,59 +1,20 @@ -import { prisma } from "@/lib/db"; -import { probeProviderPresence } from "@/lib/ai/provider"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { apiError, apiSuccess } from "@/lib/api-response"; -import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; -import { summarize } from "@/lib/analytics/trends"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; -import { - classifyBMI, - classifyBP, - generateAlerts, -} from "@/lib/analytics/classifications"; -import { - significantPearsonCorrelation, - type PairedPoint, -} from "@/lib/analytics/correlations"; -import { - buildComplianceMedicationContext, - COURSES_COMPLIANCE_SELECT, - calculateCompliance, - expectsDoses, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { resolveMedicationCategories } from "@/lib/medication-category"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; -import { - apiHandler, - requireRecordAuth, - type AuthContext, -} from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { - cachedSwrWithMeta, - caches, - type ServerCache, -} from "@/lib/cache/server-cache"; -import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only"; +import { readComprehensiveOverview } from "@/lib/insights/comprehensive-overview/overview"; export const dynamic = "force-dynamic"; +/** + * GET /api/insights/comprehensive — the Insights overview's computed read. + * The body is built in `src/lib/insights/comprehensive-overview/`. + */ export const GET = apiHandler(async () => { - // v1.37.0 — MANAGE-level read: computed over the whole record, with no - // provider anywhere on the path. + // MANAGE-level read: computed over the whole record, with no provider + // anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + // Shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); @@ -66,37 +27,7 @@ export const GET = apiHandler(async () => { // provider up, still gets the overview. The `ai` block beside the body says // whether the briefing and status notes that decorate the overview can be // shown, resolved per request and never cached with the body. - - // 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 marks every key under the - // `${userId}|` prefix stale (see `lib/cache/invalidate.ts`). - // - // v1.16.7 — stale-while-revalidate. This is the single heaviest - // SQL-side aggregation on the /insights mount; serving the prior - // body instantly while ONE background recompute refreshes it keeps - // the page interactive across the bucket's 10-minute stale window - // (a measurement sync used to bust the entry into a blocking cold - // rebuild on the very next mount, all day). The truly-cold first - // read of the day still computes inline — there is no prior body to - // serve — but it no longer recurs per visit. - // - // The `revalidating` flag marks a stale-served body honestly so the - // client can poll (bounded) until the background rebuild lands — - // mirroring the `/api/insights/generate` GET contract. It rides on - // the response, never inside the cached body, so a warmed entry - // doesn't freeze the flag. - const { value: body, outcome } = await cachedSwrWithMeta( - caches.analytics as ServerCache< - Awaited> - >, - `${user.id}|comprehensive`, - () => buildComprehensiveResponse(user), - annotate, - ); + const { body, revalidating } = await readComprehensiveOverview(user); const [briefing, statusText] = await Promise.all([ aiCapabilityToServe(user.id, "briefing"), @@ -105,627 +36,7 @@ export const GET = apiHandler(async () => { return apiSuccess({ ...body, - revalidating: outcome === "stale", + revalidating, ai: { briefing, statusText }, }); }); - -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); - // v1.30.3 — the one shared per-user tz for every day-bucketing - // pass in this route (resting-pulse proxy, medication-continuity, BP - // pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each - // call site. - const userTz = user.timezone || DEFAULT_TIMEZONE; - - // Fetch user profile (height + DOB drive BMI + BP targets). - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - }, - }); - - // ── 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); - - // v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany` - // for the persistent mood-rollup DAY tier (audit Critical Finding - // #2). The two consumers downstream — `dailyMoodEntries` for the - // mood × metric correlations and `moodSummary` for the dashboard - // mood block — read at a per-day resolution, which is precisely the - // shape the rollup writer emits. - // - // Coverage-fallback: when the user has mood entries but zero rollup - // rows yet (legacy account before the boot-time backfill has caught - // up), fall back to a bounded 90-day raw walk once and fire the - // warm-up so the next request lands on the rollup tier. Same posture - // as `/api/mood/analytics`. - // - // Since v1.32.12 the mood rollup writer mints `bucketStart` from the - // canonical local `MoodEntry.date` label at UTC midnight, so - // `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day — - // byte-identical to the live fallback's `entry.date`. Both mood paths - // therefore emit local-day keys. The mood × metric pairing partners - // below (sys BP, weight, resting pulse) are consequently derived in the - // user's tz (v1.32.17) so both join sides share ONE local-day key space. - // The measurement-rollup tier itself still buckets UTC (`dailyByType`) - // and migrates to local in a documented follow-up release; the two - // internally-consistent UTC joins (weight × BP, BP-med continuity) are - // left on it deliberately (see their call sites below). - void ensureUserMoodRollupsFresh(userId); - const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo); - let dailyMoodEntries: Array<{ day: string; value: number }>; - let moodDataPoints: DataPoint[]; - let moodEntryCount: number; - if (moodRollupDayRows.length > 0) { - dailyMoodEntries = moodRollupDayRows.map((r) => ({ - day: dateOnlyKey(r.bucketStart), - value: Math.round(r.mean * 100) / 100, - })); - moodDataPoints = moodRollupDayRows.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0); - } else { - // Coverage-fallback: legacy account with mood entries but no - // rollup coverage yet. Bounded by the 90-day window so the walk is - // capped even when the rollup miss happens. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - dailyMoodEntries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: Math.round((stats.sum / stats.count) * 100) / 100, - })); - // Feed per-day means into `summarize()` so the live-fallback path - // shares the rollup-tier semantic (one DataPoint per day, the - // daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix. - moodDataPoints = dailyMoodEntries.map((e) => ({ - date: new Date(`${e.day}T12:00:00.000Z`), - value: e.value, - })); - moodEntryCount = moodEntries.length; - } - - const moodSummary = - moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; - - const summaries: Record = aggregate.summaries; - - // ── BMI ────────────────────────────────────────────────── - let bmi: number | null = null; - let bmiClassification = null; - if (dbUser?.heightCm && summaries.WEIGHT?.latest) { - const heightM = dbUser.heightCm / 100; - bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; - bmiClassification = classifyBMI(bmi); - } - - // ── BP classification (30-day average) ──────────────────── - let bpClassification = null; - const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); - if ( - summaries.BLOOD_PRESSURE_SYS?.avg30 && - summaries.BLOOD_PRESSURE_DIA?.avg30 - ) { - bpClassification = classifyBP( - summaries.BLOOD_PRESSURE_SYS.avg30, - summaries.BLOOD_PRESSURE_DIA.avg30, - ); - } - - // ── BP target adherence (trailing 90 days) ─────────────── - // Route through the canonical `computeBpInTargetPct` so this figure - // shares ONE definition with the dashboard tile, the insight cards, the - // AI snapshot and the targets endpoint (per the bp-in-target.ts - // contract — the inline copy that used to live here drifted: it paired - // sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia - // timestamps drift past 5 min on Withings / Apple-Health imports, which - // skewed the share iOS renders on the Home Health Score). The helper - // adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on - // the user's OWN tz, not a hardcoded Berlin day) so those readings - // pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys - // AND dia at or below the age-band ceiling). Window is the aggregator's - // 90-day raw pull — a "recent control" figure, not the all-time headline. - let bpPctInTarget: number | null = null; - if (bpTargets) { - bpPctInTarget = - computeBpInTargetPct( - aggregate.bpRawRows.sys, - aggregate.bpRawRows.dia, - bpTargets, - userTz, - )?.pct ?? null; - } - - // ── 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). - // - // v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH - // sides come from `dailyByType` (weight × sys), so they share one UTC - // day-key space and are internally consistent. Re-keying it to local - // is R12's read-swap (it migrates with the rollup tier), NOT this - // hotfix — doing it here would mean per-request raw re-aggregation - // without the local-bucketed rollup behind it. - const weightDaily = aggregate.dailyByType.WEIGHT ?? []; - const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; - const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily); - const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs); - const scatterData = weightBpPairs.map((p) => ({ - weight: p.a, - sysBP: p.b, - })); - - // ── Correlations: mood × {sys BP, weight, pulse} ───────── - // v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the - // rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD - // key, so the metric partners MUST also be local-day-keyed or every - // evening logger west of UTC gets a lag-1 pairing sold as same-day. - // Derive the sys / weight partners as exact local-day means from the raw - // rows already on the request path (canonical-source deduped in the - // aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be - // relabeled into a local-day mean. `round2` mirrors the aggregator's - // `ROUND(AVG, 2)` so correlation thresholds don't drift. - const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz); - const weightDailyLocal = deriveDailyLocalMeans( - aggregate.weightRawRows, - userTz, - ); - - const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal); - const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs); - const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); - - const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal); - const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs); - const moodWeightScatterData = moodWeightPairs.map((p) => ({ - mood: p.a, - weight: p.b, - })); - - // v1.2.5 — this correlation is labelled "Resting pulse (bpm)"; - // score it against the RESTING series, not the raw PULSE day-mean. The - // aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout - // HR for Apple-Health users and inflates the "resting" figure. Prefer the - // clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the - // low-percentile daily proxy) when the user has no resting rows — the - // proxy needs the per-day distribution, which the day-mean discards. - // - // v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY - // pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since - // v1.32.12 (the mood rollup writer now mints `bucketStart` from the - // canonical local `MoodEntry.date` label, not a UTC truncation). Keeping - // UTC here would offset every evening logger west of UTC by one day and - // sell a lag-1 correlation as same-day. This is exactly the flip the - // v1.30.3 F8 note said becomes correct once the mood side goes local — - // it now has. (Unlike the mood side, this series builds from raw rows in - // this route, so it re-keys directly with no rollup-tier change.) - const restingPulseRows = await prisma.measurement.findMany({ - where: { - userId, - type: "RESTING_HEART_RATE", - deletedAt: null, - measuredAt: { gte: ninetyDaysAgo }, - }, - select: { measuredAt: true, value: true }, - }); - // - // #1023 — the proxy folds in Postgres. It needs one percentile per day, and - // reading the raw samples to get it walked 130 000 rows per page view on a - // watch that records once a minute, blocking the event loop for over a - // second. `readRestingPulseProxy` returns the same series one row per day. - const localDayKey = (d: Date) => userDayKey(d, userTz); - const restingPulseSeries = - restingPulseRows.length > 0 - ? resolveRestingPulseSeries({ - restingSamples: restingPulseRows, - pulseSamples: [], - dayKeyOf: localDayKey, - }).series - : await readRestingPulseProxy({ - userId, - since: ninetyDaysAgo, - timeZone: userTz, - }); - const restingByDay = new Map(); - for (const s of restingPulseSeries) { - const key = localDayKey(s.measuredAt); - const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; - cur.sum += s.value; - cur.count += 1; - restingByDay.set(key, cur); - } - const restingPulseDaily = Array.from(restingByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); - const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); - const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs); - const moodPulseScatterData = moodPulsePairs.map((p) => ({ - mood: p.a, - pulse: p.b, - })); - - // ── Medication compliance ──────────────────────────────── - // Bounded reads — left untouched per the directive. - const medicationRows = await prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a compliance - // rate (no expected doses). - where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - // v1.40 (#1024) — the courses, so a gap between two expects nothing. - courses: COURSES_COMPLIANCE_SELECT, - }, - }); - // A medication with no schedule expects no dose, and `calculateCompliance` - // answers 100 % for that case. The `asNeeded: false` filter above catches - // only the PRN arm; the empty schedule is reachable on a scheduled - // medication and would otherwise report perfect adherence. - const medications = medicationRows.filter(expectsDoses); - - const resolvedCategories = await resolveMedicationCategories( - medications.map((m) => m.id), - ); - const categoryMap = Object.fromEntries( - Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), - ); - - const medCompliance = []; - const bpMedicationEvents: Array<{ - scheduledFor: Date; - takenAt: Date | null; - skipped: boolean; - }> = []; - const bpMedications = medications.filter( - (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. - const allEvents = medications.length - ? await prisma.medicationIntakeEvent.findMany({ - where: { - medicationId: { in: medications.map((m) => m.id) }, - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - scheduledFor: { gte: ninetyDaysAgo }, - }, - orderBy: { scheduledFor: "desc" }, - }) - : []; - - const eventsByMed = new Map(); - for (const ev of allEvents) { - const list = eventsByMed.get(ev.medicationId); - if (list) list.push(ev); - else eventsByMed.set(ev.medicationId, [ev]); - } - - for (const med of medications) { - const events = eventsByMed.get(med.id) ?? []; - const mapped = events.map((e) => ({ - takenAt: e.takenAt, - skipped: e.skipped, - scheduledFor: e.scheduledFor, - })); - // v1.7.0 — engine-routed denominator. - const medicationContext = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(mapped), - user.timezone || DEFAULT_TIMEZONE, - ); - const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { - medicationContext, - }); - const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, { - medicationContext, - }); - - medCompliance.push({ - id: med.id, - name: med.name, - dose: med.dose, - category: categoryMap[med.id] ?? "OTHER", - // v1.40 (#1041) — the label of a custom category; null for a built-in. - categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, - compliance7: c7.rate, - compliance30: c30.rate, - streak: c7.streak, - taken7: c7.taken, - skipped7: c7.skipped, - missed7: c7.missed, - }); - - if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") { - bpMedicationEvents.push( - ...events.map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - })), - ); - } - } - - // ── 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; - n: number; - medicationCount: number; - } | null = null; - const bpMedicationScatterData: Array<{ - continuityPct: number; - sysBP: number; - }> = []; - - const expectedBpIntakesPerDay = bpMedications.reduce( - (sum, med) => sum + med.schedules.length, - 0, - ); - - if (expectedBpIntakesPerDay > 0) { - // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. - // v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's - // `dailyByType`) and `takenByDay` below (intake `scheduledFor` - // truncated to UTC) both live in the SAME UTC day-key space and are - // internally consistent with each other — this join is NOT broken and - // stays as is. Only the mood pairings above moved to a separately - // derived local-day series (their partner, the mood series, is - // local-day-keyed). This correlation migrates to local when the - // measurement-rollup tier buckets per-user tz (R12), not in this hotfix - // — re-keying it here without the local-bucketed rollup would mean - // per-request raw re-aggregation. - const sysByDay = new Map(); - for (const row of sysDaily) { - sysByDay.set(row.day, row.value); - } - - const takenByDay = new Map(); - // Days that carry an intake record of ANY kind. A logged skip is a real - // zero and belongs in the series; a day the person never touched is not a - // zero, it is nothing, and pairing it against that day's systolic reading - // invented a data point they never produced. Absence is not zero, here as - // everywhere else in this project, so the unlogged day drops out of both - // the scatter and the coefficient rather than dragging continuity down. - const loggedDays = new Set(); - for (const event of bpMedicationEvents) { - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days - const dayKey = event.scheduledFor.toISOString().slice(0, 10); - loggedDays.add(dayKey); - if (event.skipped || !event.takenAt) continue; - takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); - } - - const pairs: Array<{ a: number; b: number; date: Date }> = []; - for (const [dayKey, avgSys] of sysByDay.entries()) { - if (!loggedDays.has(dayKey)) continue; - const taken = takenByDay.get(dayKey) ?? 0; - const continuity = Math.min(1, taken / expectedBpIntakesPerDay); - pairs.push({ - a: continuity, - b: avgSys, - date: dayKeyAsUtcMidnight(dayKey), - }); - bpMedicationScatterData.push({ - continuityPct: Math.round(continuity * 100), - sysBP: Math.round(avgSys * 10) / 10, - }); - } - - const corr = significantPearsonCorrelation(pairs); - if (corr) { - bpMedicationCorrelation = { - ...corr, - medicationCount: bpMedications.length, - }; - } - } - - // Generate alerts - const alerts = generateAlerts({ - bmi, - bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, - bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, - bpPctInTarget, - weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, - pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, - medications: medCompliance.map((m) => ({ - name: m.name, - compliance7: m.compliance7, - compliance30: m.compliance30, - })), - }); - - // Data span — derived from the earliest 90-day measurement. - const dataSpanDays = aggregate.firstMeasurementAt - ? Math.ceil( - (Date.now() - aggregate.firstMeasurementAt.getTime()) / - (24 * 60 * 60 * 1000), - ) - : 0; - - annotate({ - action: { name: "insights.comprehensive" }, - meta: { - totalMeasurements: aggregate.totalMeasurements, - moodEntries: moodEntryCount, - medications: medications.length, - }, - }); - - return { - summaries, - bmi, - bmiClassification, - bpClassification, - bpPctInTarget, - bpTargets, - weightBpCorrelation, - scatterData, - bpMedicationCorrelation, - bpMedicationScatterData, - moodSummary, - moodBpCorrelation, - moodBpScatterData, - moodWeightCorrelation, - moodWeightScatterData, - moodPulseCorrelation, - moodPulseScatterData, - medications: medCompliance, - alerts, - // Presence only: no client is built and no token is refreshed on a read. - hasProvider: await probeProviderPresence(userId), - dataSpanDays, - totalMeasurements: aggregate.totalMeasurements, - }; -} - -// ───────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────── - -/** - * v1.32.17 — derive exact local-day mean series from raw measurement rows, - * keyed by the user's timezone via `userDayKey`. The mood series is - * local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an - * exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too. - * A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day - * mean, so the mean is recomputed here from raw rows already on the request - * path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no - * offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly - * and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the - * aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift. - * Returns `{ day, value }[]` sorted ASC by day, the shape - * `pairMoodWithDaily` expects. - */ -function deriveDailyLocalMeans( - rows: Array<{ measuredAt: Date; value: number }>, - tz: string, -): Array<{ day: string; value: number }> { - const byDay = new Map(); - for (const r of rows) { - const key = userDayKey(r.measuredAt, tz); - const cur = byDay.get(key) ?? { sum: 0, count: 0 }; - cur.sum += r.value; - cur.count += 1; - byDay.set(key, cur); - } - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: Math.round((stats.sum / stats.count) * 100) / 100, - })); -} - -/** - * Daily-key inner join between two daily-aggregated series. Returns - * `PairedPoint` shapes compatible with `significantPearsonCorrelation`. - * - * 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(); - 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; -} diff --git a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts index af4b53233..af62e8406 100644 --- a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts +++ b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts @@ -31,7 +31,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [ surface: "insight targets", }, { - file: "src/app/api/insights/comprehensive/route.ts", + file: "src/lib/insights/comprehensive-overview/medication-compliance.ts", surface: "comprehensive insight", }, { diff --git a/src/lib/insights/comprehensive-overview/build.ts b/src/lib/insights/comprehensive-overview/build.ts new file mode 100644 index 000000000..a96e8b751 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/build.ts @@ -0,0 +1,198 @@ +/** + * The Insights overview's response body, computed from the record. + * + * SQL-side aggregates served by `buildComprehensiveAggregate` replace the + * 100k-row-in-JS aggregation the route once ran; the medication-compliance + * block keeps its bounded Prisma reads (medications + intake events). No + * provider is called anywhere on the path: `hasProvider` is the presence + * probe only. + * + * Every day-bucketing pass (resting-pulse proxy, medication continuity, BP + * pairing) shares the one per-user tz resolved here. + */ +import { prisma } from "@/lib/db"; +import { probeProviderPresence } from "@/lib/ai/provider"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { summarize } from "@/lib/analytics/trends"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; +import { + classifyBMI, + classifyBP, + generateAlerts, +} from "@/lib/analytics/classifications"; +import { annotate } from "@/lib/logging/context"; +import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +import { overviewCorrelations } from "./correlations"; +import { + bpMedicationContinuity, + readMedicationCompliance, +} from "./medication-compliance"; +import { readOverviewMoodSeries } from "./mood-series"; +import { readRestingPulseDaily } from "./resting-pulse-daily"; + +/** Who the overview is computed for: the record, with its zone. */ +export interface OverviewRecord { + id: string; + timezone?: string | null; +} + +export type ComprehensiveOverviewBody = Awaited< + ReturnType +>; + +/** BMI and the 30-day BP classification against the record's profile. */ +function classifyProfile( + summaries: Record, + dbUser: { heightCm: number | null; dateOfBirth: Date | null } | null, +) { + let bmi: number | null = null; + let bmiClassification = null; + if (dbUser?.heightCm && summaries.WEIGHT?.latest) { + const heightM = dbUser.heightCm / 100; + bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; + bmiClassification = classifyBMI(bmi); + } + + let bpClassification = null; + const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + if ( + summaries.BLOOD_PRESSURE_SYS?.avg30 && + summaries.BLOOD_PRESSURE_DIA?.avg30 + ) { + bpClassification = classifyBP( + summaries.BLOOD_PRESSURE_SYS.avg30, + summaries.BLOOD_PRESSURE_DIA.avg30, + ); + } + return { bmi, bmiClassification, bpClassification, bpTargets }; +} + +export async function buildComprehensiveResponse(user: OverviewRecord) { + const userId = user.id; + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const userTz = user.timezone || DEFAULT_TIMEZONE; + + // Height + DOB drive BMI + BP targets. + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + }, + }); + + // SQL-side rollup: 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 correlations. + const aggregate = await buildComprehensiveAggregate(userId); + + const { dailyMoodEntries, moodDataPoints, moodEntryCount } = + await readOverviewMoodSeries(userId, ninetyDaysAgo); + const moodSummary = + moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; + + const summaries: Record = aggregate.summaries; + const { bmi, bmiClassification, bpClassification, bpTargets } = + classifyProfile(summaries, dbUser); + + // BP target adherence over the trailing 90 days, through the canonical + // `computeBpInTargetPct` so this figure shares ONE definition with the + // dashboard tile, the insight cards, the AI snapshot and the targets + // endpoint. The helper pairs sys+dia on the 5-minute window with the + // same-local-day fallback (keyed on the user's OWN tz) and keeps the + // ESH-2023 band + 90/50 hypotension floor. Window is the aggregator's + // 90-day raw pull — a "recent control" figure, not the all-time headline. + let bpPctInTarget: number | null = null; + if (bpTargets) { + bpPctInTarget = + computeBpInTargetPct( + aggregate.bpRawRows.sys, + aggregate.bpRawRows.dia, + bpTargets, + userTz, + )?.pct ?? null; + } + + const restingPulseDaily = await readRestingPulseDaily( + userId, + ninetyDaysAgo, + userTz, + ); + const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; + const correlations = overviewCorrelations({ + aggregate, + dailyMoodEntries, + restingPulseDaily, + userTz, + }); + + const { medications, medCompliance, bpMedications, bpMedicationEvents } = + await readMedicationCompliance({ + userId, + since: ninetyDaysAgo, + timezone: user.timezone || DEFAULT_TIMEZONE, + }); + const { bpMedicationCorrelation, bpMedicationScatterData } = + bpMedicationContinuity({ bpMedications, bpMedicationEvents, sysDaily }); + + const alerts = generateAlerts({ + bmi, + bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, + bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, + bpPctInTarget, + weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, + pulseAvg30: summaries.PULSE?.avg30 ?? null, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, + medications: medCompliance.map((m) => ({ + name: m.name, + compliance7: m.compliance7, + compliance30: m.compliance30, + })), + }); + + // Data span — derived from the earliest 90-day measurement. + const dataSpanDays = aggregate.firstMeasurementAt + ? Math.ceil( + (Date.now() - aggregate.firstMeasurementAt.getTime()) / + (24 * 60 * 60 * 1000), + ) + : 0; + + annotate({ + action: { name: "insights.comprehensive" }, + meta: { + totalMeasurements: aggregate.totalMeasurements, + moodEntries: moodEntryCount, + medications: medications.length, + }, + }); + + return { + summaries, + bmi, + bmiClassification, + bpClassification, + bpPctInTarget, + bpTargets, + weightBpCorrelation: correlations.weightBpCorrelation, + scatterData: correlations.scatterData, + bpMedicationCorrelation, + bpMedicationScatterData, + moodSummary, + moodBpCorrelation: correlations.moodBpCorrelation, + moodBpScatterData: correlations.moodBpScatterData, + moodWeightCorrelation: correlations.moodWeightCorrelation, + moodWeightScatterData: correlations.moodWeightScatterData, + moodPulseCorrelation: correlations.moodPulseCorrelation, + moodPulseScatterData: correlations.moodPulseScatterData, + medications: medCompliance, + alerts, + // Presence only: no client is built and no token is refreshed on a read. + hasProvider: await probeProviderPresence(userId), + dataSpanDays, + totalMeasurements: aggregate.totalMeasurements, + }; +} diff --git a/src/lib/insights/comprehensive-overview/correlations.ts b/src/lib/insights/comprehensive-overview/correlations.ts new file mode 100644 index 000000000..ab9f6ce97 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/correlations.ts @@ -0,0 +1,78 @@ +/** + * The overview's correlations: weight × systolic, and mood against + * systolic, weight and resting pulse. Pure: every series is already read. + */ +import { + significantPearsonCorrelation, + type PairedPoint, +} from "@/lib/analytics/correlations"; +import type { ComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; + +import { + deriveDailyLocalMeans, + joinDailyByDay, + pairMoodWithDaily, + type DailyValue, +} from "./daily-series"; + +export function overviewCorrelations(args: { + aggregate: ComprehensiveAggregate; + dailyMoodEntries: DailyValue[]; + restingPulseDaily: DailyValue[]; + userTz: string; +}) { + const { aggregate, dailyMoodEntries, restingPulseDaily, userTz } = args; + + // Weight × Sys BP, daily-key pairing. Both sides come from the UTC + // `dailyByType` series, so they share one UTC day-key space and are + // internally consistent; re-keying it to local migrates with the rollup + // tier. + const weightDaily = aggregate.dailyByType.WEIGHT ?? []; + const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; + const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily); + const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs); + const scatterData = weightBpPairs.map((p) => ({ + weight: p.a, + sysBP: p.b, + })); + + // Mood × {sys BP, weight, pulse}. The mood series is LOCAL-day-keyed and + // `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD key, so the partners + // are exact local-day means from the raw rows already on the request path + // (canonical-source deduped in the aggregator), NOT the UTC `dailyByType` — + // a UTC-day MEAN cannot be relabeled into a local-day mean. + const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz); + const weightDailyLocal = deriveDailyLocalMeans( + aggregate.weightRawRows, + userTz, + ); + + const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal); + const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs); + const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); + + const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal); + const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs); + const moodWeightScatterData = moodWeightPairs.map((p) => ({ + mood: p.a, + weight: p.b, + })); + + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); + const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs); + const moodPulseScatterData = moodPulsePairs.map((p) => ({ + mood: p.a, + pulse: p.b, + })); + + return { + weightBpCorrelation, + scatterData, + moodBpCorrelation, + moodBpScatterData, + moodWeightCorrelation, + moodWeightScatterData, + moodPulseCorrelation, + moodPulseScatterData, + }; +} diff --git a/src/lib/insights/comprehensive-overview/daily-series.ts b/src/lib/insights/comprehensive-overview/daily-series.ts new file mode 100644 index 000000000..e80d9ba71 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/daily-series.ts @@ -0,0 +1,110 @@ +/** + * Day-keyed series helpers for the Insights overview's correlations: local + * day means from raw rows, and the two joins that pair one daily series with + * another. + */ +import type { PairedPoint } from "@/lib/analytics/correlations"; +import { userDayKey } from "@/lib/tz/format"; + +export interface DailyValue { + day: string; + value: number; +} + +/** + * Derive exact local-day mean series from raw measurement rows, keyed by the + * user's timezone via `userDayKey`. The mood series is local-day-keyed, and + * `pairMoodWithDaily` joins on an exact YYYY-MM-DD key, so its metric + * partners must be local-day-keyed too. A UTC-day mean (from `dailyByType`) + * cannot be relabeled into a local-day mean, so the mean is recomputed here + * from raw rows already on the request path. `userDayKey` formats via a + * memoised `Intl.DateTimeFormat` — no offset arithmetic — so DST-transition + * days (23 h / 25 h) bucket correctly and the result is host-TZ-independent + * (CI runs UTC). `round2` mirrors the aggregator's `ROUND(AVG, 2)` so + * correlation thresholds don't drift. Returns `{ day, value }[]` sorted ASC + * by day, the shape `pairMoodWithDaily` expects. + */ +export function deriveDailyLocalMeans( + rows: Array<{ measuredAt: Date; value: number }>, + tz: string, +): DailyValue[] { + const byDay = new Map(); + for (const r of rows) { + const key = userDayKey(r.measuredAt, tz); + const cur = byDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += r.value; + cur.count += 1; + byDay.set(key, cur); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); +} + +/** + * Daily-key inner join between two daily-aggregated series. Returns + * `PairedPoint` shapes compatible with `significantPearsonCorrelation`. + * + * 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. + */ +export function joinDailyByDay( + a: DailyValue[], + b: DailyValue[], +): 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. + */ +export function pairMoodWithDaily( + dailyMood: DailyValue[], + dailyMetric: DailyValue[], +): PairedPoint[] { + if (dailyMood.length === 0 || dailyMetric.length === 0) return []; + const metricByDay = new Map(); + 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; +} diff --git a/src/lib/insights/comprehensive-overview/medication-compliance.ts b/src/lib/insights/comprehensive-overview/medication-compliance.ts new file mode 100644 index 000000000..43a4567ab --- /dev/null +++ b/src/lib/insights/comprehensive-overview/medication-compliance.ts @@ -0,0 +1,235 @@ +/** + * The overview's medication block: per-medication compliance over 7 and 30 + * days, and the blood-pressure medication continuity × systolic correlation + * that pairs the BP medications' intake days with the daily systolic means. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, + calculateCompliance, + expectsDoses, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import { significantPearsonCorrelation } from "@/lib/analytics/correlations"; +import { resolveMedicationCategories } from "@/lib/medication-category"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; + +import type { DailyValue } from "./daily-series"; + +interface BpIntakeEvent { + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +} + +/** Read the tracked scheduled medications with what compliance needs. */ +async function readScheduledMedications(userId: string) { + const medicationRows = await prisma.medication.findMany({ + // As-needed (PRN) medications never surface a compliance rate (no + // expected doses). + where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, + include: { + // Schedules through the shared compliance select so the configured + // per-dose windows reach this surface like every other. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // Archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // Pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // The courses, so a gap between two expects nothing (#1024). + courses: COURSES_COMPLIANCE_SELECT, + }, + }); + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + return medicationRows.filter(expectsDoses); +} + +/** + * Per-medication compliance, plus the intake events of the blood-pressure + * medications for the continuity correlation. + */ +export async function readMedicationCompliance(args: { + userId: string; + since: Date; + timezone: string; +}) { + const { userId, since, timezone } = args; + const medications = await readScheduledMedications(userId); + + const resolvedCategories = await resolveMedicationCategories( + medications.map((m) => m.id), + ); + const categoryMap = Object.fromEntries( + Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), + ); + + const medCompliance = []; + const bpMedicationEvents: BpIntakeEvent[] = []; + const bpMedications = medications.filter( + (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. + const allEvents = medications.length + ? await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: { in: medications.map((m) => m.id) }, + userId, + // Tombstoned rows are excluded. + deletedAt: null, + scheduledFor: { gte: since }, + }, + orderBy: { scheduledFor: "desc" }, + }) + : []; + + const eventsByMed = new Map(); + for (const ev of allEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + + for (const med of medications) { + const events = eventsByMed.get(med.id) ?? []; + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + // Engine-routed denominator. + const medicationContext = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(mapped), + timezone, + ); + const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { + medicationContext, + }); + const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, { + medicationContext, + }); + + medCompliance.push({ + id: med.id, + name: med.name, + dose: med.dose, + category: categoryMap[med.id] ?? "OTHER", + // The label of a custom category; null for a built-in (#1041). + categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, + compliance7: c7.rate, + compliance30: c30.rate, + streak: c7.streak, + taken7: c7.taken, + skipped7: c7.skipped, + missed7: c7.missed, + }); + + if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") { + bpMedicationEvents.push( + ...events.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + })), + ); + } + } + + return { medications, medCompliance, bpMedications, bpMedicationEvents }; +} + +/** + * 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. + */ +export function bpMedicationContinuity(args: { + bpMedications: Array<{ schedules: unknown[] }>; + bpMedicationEvents: BpIntakeEvent[]; + sysDaily: DailyValue[]; +}) { + const { bpMedications, bpMedicationEvents, sysDaily } = args; + let bpMedicationCorrelation: { + r: number; + strength: string; + n: number; + medicationCount: number; + } | null = null; + const bpMedicationScatterData: Array<{ + continuityPct: number; + sysBP: number; + }> = []; + + const expectedBpIntakesPerDay = bpMedications.reduce( + (sum, med) => sum + med.schedules.length, + 0, + ); + + if (expectedBpIntakesPerDay > 0) { + // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. + // `sysDaily` (UTC `date_trunc` from the aggregator's `dailyByType`) and + // `takenByDay` below (intake `scheduledFor` truncated to UTC) both live + // in the SAME UTC day-key space and are internally consistent with each + // other, so this join stays UTC. Only the mood pairings moved to a + // separately derived local-day series (their partner, the mood series, + // is local-day-keyed). This correlation migrates to local when the + // measurement-rollup tier buckets per-user tz; re-keying it here without + // the local-bucketed rollup would mean per-request raw re-aggregation. + const sysByDay = new Map(); + for (const row of sysDaily) { + sysByDay.set(row.day, row.value); + } + + const takenByDay = new Map(); + // Days that carry an intake record of ANY kind. A logged skip is a real + // zero and belongs in the series; a day the person never touched is not a + // zero, it is nothing, and pairing it against that day's systolic reading + // invented a data point they never produced. Absence is not zero, here as + // everywhere else in this project, so the unlogged day drops out of both + // the scatter and the coefficient rather than dragging continuity down. + const loggedDays = new Set(); + for (const event of bpMedicationEvents) { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days + const dayKey = event.scheduledFor.toISOString().slice(0, 10); + loggedDays.add(dayKey); + if (event.skipped || !event.takenAt) continue; + takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); + } + + const pairs: Array<{ a: number; b: number; date: Date }> = []; + for (const [dayKey, avgSys] of sysByDay.entries()) { + if (!loggedDays.has(dayKey)) continue; + const taken = takenByDay.get(dayKey) ?? 0; + const continuity = Math.min(1, taken / expectedBpIntakesPerDay); + pairs.push({ + a: continuity, + b: avgSys, + date: dayKeyAsUtcMidnight(dayKey), + }); + bpMedicationScatterData.push({ + continuityPct: Math.round(continuity * 100), + sysBP: Math.round(avgSys * 10) / 10, + }); + } + + const corr = significantPearsonCorrelation(pairs); + if (corr) { + bpMedicationCorrelation = { + ...corr, + medicationCount: bpMedications.length, + }; + } + } + + return { bpMedicationCorrelation, bpMedicationScatterData }; +} diff --git a/src/lib/insights/comprehensive-overview/mood-series.ts b/src/lib/insights/comprehensive-overview/mood-series.ts new file mode 100644 index 000000000..40efa5afd --- /dev/null +++ b/src/lib/insights/comprehensive-overview/mood-series.ts @@ -0,0 +1,92 @@ +/** + * The overview's 90-day mood series, read from the mood-rollup DAY tier. + * + * The two consumers downstream — `dailyMoodEntries` for the mood × metric + * correlations and the mood summary for the dashboard mood block — read at a + * per-day resolution, which is precisely the shape the rollup writer emits, + * so the tier replaces an unbounded `prisma.moodEntry.findMany`. + * + * Coverage-fallback: when the user has mood entries but zero rollup rows yet + * (legacy account before the boot-time backfill has caught up), fall back to + * a bounded 90-day raw walk once and fire the warm-up so the next request + * lands on the rollup tier. Same posture as `/api/mood/analytics`. + * + * The mood rollup writer mints `bucketStart` from the canonical local + * `MoodEntry.date` label at UTC midnight, so `.toISOString().slice(0, 10)` + * recovers the user's LOCAL calendar day — byte-identical to the live + * fallback's `entry.date`. Both mood paths therefore emit local-day keys, and + * the mood × metric pairing partners (sys BP, weight, resting pulse) are + * derived in the user's tz so both join sides share ONE local-day key space. + * The measurement-rollup tier itself still buckets UTC (`dailyByType`); the + * two internally-consistent UTC joins (weight × BP, BP-med continuity) are + * left on it deliberately (see their call sites in `build.ts`). + */ +import { prisma } from "@/lib/db"; +import type { DataPoint } from "@/lib/analytics/trends"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +import type { DailyValue } from "./daily-series"; + +export interface OverviewMoodSeries { + dailyMoodEntries: DailyValue[]; + moodDataPoints: DataPoint[]; + moodEntryCount: number; +} + +export async function readOverviewMoodSeries( + userId: string, + since: Date, +): Promise { + void ensureUserMoodRollupsFresh(userId); + const moodRollupDayRows = await readMoodDayRollups(userId, since); + if (moodRollupDayRows.length > 0) { + return { + dailyMoodEntries: moodRollupDayRows.map((r) => ({ + day: dateOnlyKey(r.bucketStart), + value: Math.round(r.mean * 100) / 100, + })), + moodDataPoints: moodRollupDayRows.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })), + moodEntryCount: moodRollupDayRows.reduce((s, r) => s + r.count, 0), + }; + } + // Coverage-fallback: legacy account with mood entries but no + // rollup coverage yet. Bounded by the 90-day window so the walk is + // capped even when the rollup miss happens. + const moodEntries = await prisma.moodEntry.findMany({ + // Tombstoned rows are excluded. + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + const moodByDay = new Map(); + for (const entry of moodEntries) { + const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; + current.sum += entry.score; + current.count += 1; + moodByDay.set(entry.date, current); + } + const dailyMoodEntries = Array.from(moodByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); + return { + dailyMoodEntries, + // Per-day means feed `summarize()` so the live-fallback path shares the + // rollup-tier semantic (one DataPoint per day, the daily mean), the same + // reading mood/analytics uses. + moodDataPoints: dailyMoodEntries.map((e) => ({ + date: new Date(`${e.day}T12:00:00.000Z`), + value: e.value, + })), + moodEntryCount: moodEntries.length, + }; +} diff --git a/src/lib/insights/comprehensive-overview/overview.ts b/src/lib/insights/comprehensive-overview/overview.ts new file mode 100644 index 000000000..15cc843e6 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/overview.ts @@ -0,0 +1,49 @@ +/** + * The Insights overview read: the computed body through the analytics + * cache. The route resolves the per-request AI state beside it, which is + * never cached with the body. + */ +import { annotate } from "@/lib/logging/context"; +import { + cachedSwrWithMeta, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; + +import { + buildComprehensiveResponse, + type ComprehensiveOverviewBody, + type OverviewRecord, +} from "./build"; + +export async function readComprehensiveOverview( + user: OverviewRecord, +): Promise<{ body: ComprehensiveOverviewBody; revalidating: boolean }> { + // 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 marks every key under the + // `${userId}|` prefix stale (see `lib/cache/invalidate.ts`). + // + // Stale-while-revalidate: this is the single heaviest SQL-side aggregation + // on the /insights mount; serving the prior body instantly while ONE + // background recompute refreshes it keeps the page interactive across the + // bucket's 10-minute stale window (a measurement sync used to bust the + // entry into a blocking cold rebuild on the very next mount, all day). The + // truly-cold first read of the day still computes inline — there is no + // prior body to serve — but it no longer recurs per visit. + // + // The `revalidating` flag marks a stale-served body honestly so the client + // can poll (bounded) until the background rebuild lands — mirroring the + // `/api/insights/generate` GET contract. It rides on the response, never + // inside the cached body, so a warmed entry doesn't freeze the flag. + const { value: body, outcome } = await cachedSwrWithMeta( + caches.analytics as ServerCache, + `${user.id}|comprehensive`, + () => buildComprehensiveResponse(user), + annotate, + ); + + return { body, revalidating: outcome === "stale" }; +} diff --git a/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts b/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts new file mode 100644 index 000000000..acb54fc0d --- /dev/null +++ b/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts @@ -0,0 +1,65 @@ +/** + * The overview's daily resting-pulse series, the mood × pulse correlation's + * partner. + * + * The correlation is labelled "Resting pulse (bpm)", so it is scored against + * the RESTING series, not the raw PULSE day-mean: the aggregator's + * `dailyByType.PULSE` is a per-day AVG that folds in workout HR for + * Apple-Health users and inflates the "resting" figure. The clean + * RESTING_HEART_RATE rows win; only without them does the low-percentile + * daily proxy over raw pulse stand in. The proxy folds in Postgres (#1023): + * it needs one percentile per day, and reading the raw samples to get it + * walked 130 000 rows per page view on a watch that records once a minute. + * + * Keyed on `userDayKey(d, userTz)`: its only pairing partner is the mood + * series, which is local-day-keyed, and a UTC key here would offset every + * evening logger west of UTC by one day and sell a lag-1 correlation as + * same-day. Unlike the mood side, this series builds from raw rows, so it + * re-keys directly with no rollup-tier change. + */ +import { prisma } from "@/lib/db"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; +import { userDayKey } from "@/lib/tz/format"; + +import type { DailyValue } from "./daily-series"; + +export async function readRestingPulseDaily( + userId: string, + since: Date, + userTz: string, +): Promise { + const restingPulseRows = await prisma.measurement.findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { measuredAt: true, value: true }, + }); + const localDayKey = (d: Date) => userDayKey(d, userTz); + const restingPulseSeries = + restingPulseRows.length > 0 + ? resolveRestingPulseSeries({ + restingSamples: restingPulseRows, + pulseSamples: [], + dayKeyOf: localDayKey, + }).series + : await readRestingPulseProxy({ + userId, + since, + timeZone: userTz, + }); + const restingByDay = new Map(); + for (const s of restingPulseSeries) { + const key = localDayKey(s.measuredAt); + const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += s.value; + cur.count += 1; + restingByDay.set(key, cur); + } + return Array.from(restingByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); +}