refactor(insights): move the comprehensive overview builder out of its route

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 15:25:20 +02:00
1 parent 2e13b7754e
commit 98875debf4
11 files changed
+863 -710

No files matched your search

@@ -177,7 +177,7 @@ const TS_READERS: Record<string, Entry> = {
"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",
},
};
+23 -8
View File
@@ -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",
+11 -700
View File
@@ -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<ReturnType<typeof buildComprehensiveResponse>>
>,
`${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<string, { sum: number; count: number }>();
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<string, DataSummary> = 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<string, { sum: number; count: number }>();
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<string, typeof allEvents>();
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<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
}
const takenByDay = new Map<string, number>();
// 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<string>();
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<string, { sum: number; count: number }>();
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<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;
}
@@ -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",
},
{
@@ -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<typeof buildComprehensiveResponse>
>;
/** BMI and the 30-day BP classification against the record's profile. */
function classifyProfile(
summaries: Record<string, DataSummary>,
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<string, DataSummary> = 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,
};
}
@@ -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,
};
}
@@ -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<string, { sum: number; count: number }>();
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<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,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<string, typeof allEvents>();
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<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
}
const takenByDay = new Map<string, number>();
// 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<string>();
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 };
}
@@ -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<OverviewMoodSeries> {
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<string, { sum: number; count: number }>();
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,
};
}
@@ -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<ComprehensiveOverviewBody>,
`${user.id}|comprehensive`,
() => buildComprehensiveResponse(user),
annotate,
);
return { body, revalidating: outcome === "stale" };
}
@@ -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<DailyValue[]> {
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<string, { sum: number; count: number }>();
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 }));
}