feat(insights): wire CorrelationRow + TrendsRow into /insights

Compute the three pre-defined correlation hypotheses
(BP × compliance, mood × pulse, weight × weekday) on the trailing
30-day window in `/api/analytics` and surface the typed result
shapes on the response payload. Each runner is gated on n >= 14
+ p < 0.05; below the bar the result.status === "insufficient"
and the per-card EmptyState paints in place of a misleading
near-zero scatter.

The insights page now mounts:

- `<CorrelationRow>` between the Daily Briefing and the Advisor
  card — the layout slot the artboard reserves for the patterns
  surface.
- `<TrendsRow>` immediately below it, fed from
  `advisor.payload.trendAnnotations` (lifted client-side via
  `trendAnnotationsSchema.safeParse`). Legacy cached payloads
  predating PROMPT_VERSION 4.20.1 surface the per-metric
  empty-state hint instead of a half-rendered annotation.

The wide-event annotation captures per-hypothesis status so admin
observability can attribute coverage gaps without re-querying
the DB.

10 source-scan tests pin the load-bearing imports, the JSX mounts,
the analytics-route surfacing, and the malformed-payload null
fallback in the advisor hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:53:29 +02:00
1 parent 42591c6cec
commit c83cb2311d
4 files changed
+359

No files matched your search

+210
View File
@@ -5,6 +5,12 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import {
correlateBpCompliance,
correlateMoodPulse,
correlateWeightWeekday,
type CorrelationResult,
} from "@/lib/insights/correlations";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -103,6 +109,13 @@ export const GET = apiHandler(async () => {
}
}
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
// All three run on the trailing 30 days so a sparse account doesn't
// burn the n >= 14 gate on a stale window. Each runner gates on
// n >= 14 + p < 0.05; below the bar the result.status === "insufficient"
// and the UI paints an EmptyState.
const correlations = await computeCorrelationHypotheses(user.id);
return apiSuccess({
summaries: results,
bmi,
@@ -110,5 +123,202 @@ export const GET = apiHandler(async () => {
bpInTargetPct7d,
bpInTargetPct30d,
glucoseByContext,
correlations,
});
});
/**
* Build inputs for the three pre-defined hypotheses + run them.
* Pure-ish — only Prisma reads, no external calls.
*
* Window: trailing 30 days. Anything older falls outside the surface
* because the user-facing "based on N paired readings · last 30 days"
* source-chip has to remain truthful.
*/
async function computeCorrelationHypotheses(userId: string): Promise<{
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
}> {
const DAY_MS = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 30 * DAY_MS);
const [sysRows, diaRows, pulseRows, weightRows, moodRows, intakeRows] =
await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since },
},
select: { value: true, measuredAt: true },
}),
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_DIA",
measuredAt: { gte: since },
},
select: { value: true, measuredAt: true },
}),
prisma.measurement.findMany({
where: { userId, type: "PULSE", measuredAt: { gte: since } },
select: { value: true, measuredAt: true },
}),
prisma.measurement.findMany({
where: { userId, type: "WEIGHT", measuredAt: { gte: since } },
select: { value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: since } },
select: { score: true, moodLoggedAt: true, date: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: since } },
select: { scheduledFor: true, takenAt: true, skipped: true },
}),
]);
// ── Hypothesis 1: BP × medication compliance ────────────────
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
// a day's readings. The day's "compliance %" is taken / expected for
// that calendar day across all medications.
const dayKey = (d: Date): string => berlinDayKey(d);
const dailySys = new Map<string, number[]>();
for (const row of sysRows) {
const key = dayKey(row.measuredAt);
const list = dailySys.get(key) ?? [];
list.push(row.value);
dailySys.set(key, list);
}
const dailyCompliance = new Map<string, { expected: number; taken: number }>();
for (const event of intakeRows) {
const key = dayKey(event.scheduledFor);
const slot = dailyCompliance.get(key) ?? { expected: 0, taken: 0 };
slot.expected += 1;
if (event.takenAt && !event.skipped) slot.taken += 1;
dailyCompliance.set(key, slot);
}
const bpCompliancePairs: Array<{
date: Date;
systolic: number;
compliancePct: number;
}> = [];
for (const [key, sysValues] of dailySys.entries()) {
const slot = dailyCompliance.get(key);
if (!slot || slot.expected === 0) continue;
const compliancePct = (slot.taken / slot.expected) * 100;
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
bpCompliancePairs.push({
date: dateFromBerlinKey(key),
systolic: meanSys,
compliancePct,
});
}
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs });
// ── Hypothesis 2: Mood × resting pulse ──────────────────────
// Same-day pairing: take the day's mean mood vs the day's mean pulse.
// "Resting" is approximated by mean — HealthLog has no separate
// resting-pulse field, so we accept the noise rather than skip.
const dailyMood = new Map<string, number[]>();
for (const row of moodRows) {
const key = berlinDayKey(row.moodLoggedAt);
const list = dailyMood.get(key) ?? [];
list.push(row.score);
dailyMood.set(key, list);
}
const dailyPulse = new Map<string, number[]>();
for (const row of pulseRows) {
const key = berlinDayKey(row.measuredAt);
const list = dailyPulse.get(key) ?? [];
list.push(row.value);
dailyPulse.set(key, list);
}
const moodPulsePairs: Array<{
date: Date;
mood: number;
restingPulse: number;
}> = [];
for (const [key, moodScores] of dailyMood.entries()) {
const pulseValues = dailyPulse.get(key);
if (!pulseValues || pulseValues.length === 0) continue;
const meanMood =
moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
const meanPulse =
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
moodPulsePairs.push({
date: dateFromBerlinKey(key),
mood: meanMood,
restingPulse: meanPulse,
});
}
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs });
// ── Hypothesis 3: Weight × weekday ──────────────────────────
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
for (const row of weightRows) {
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
weightWeekdayPairs.push({
weekday: isoWeekday - 1,
weight: row.value,
});
}
const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs });
// Annotate so admin observability can attribute coverage to the
// corresponding wide-event rather than chasing it via DB queries.
annotate({
meta: {
correlations: {
bpCompliance: bpCompliance.status,
moodPulse: moodPulse.status,
weightWeekday: weightWeekday.status,
},
},
});
return { bpCompliance, moodPulse, weightWeekday };
}
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
function dateFromBerlinKey(key: string): Date {
// Anchor to UTC midnight — the date is a sortable bucket label rather
// than a wall-clock timestamp, so DST drift is irrelevant.
return new Date(`${key}T00:00:00.000Z`);
}
const ISO_WEEKDAY: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
function berlinIsoWeekday(d: Date): number {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
return ISO_WEEKDAY[weekday] ?? 1;
}