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

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.20 phase B3 — guards for the `/insights` wiring of the new
* Trends row + Correlation row.
*
* The page-source scan pattern mirrors `insights-polish.test.ts` —
* each test pins one load-bearing import / JSX mount so a future
* refactor can't accidentally drop a wave of features without
* breaking a CI lane.
*/
const ROOT = join(__dirname, "../../..");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const ANALYTICS_ROUTE_PATH = join(
ROOT,
"src/app/api/analytics/route.ts",
);
const ADVISOR_HOOK_PATH = join(
ROOT,
"src/components/insights/use-insights-advisor.ts",
);
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
it("imports both new components", () => {
const src = load(INSIGHTS_PATH);
expect(src).toContain(
'from "@/components/insights/correlation-row"',
);
expect(src).toContain(
'from "@/components/insights/trends-row"',
);
});
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
const src = load(INSIGHTS_PATH);
expect(src).toMatch(/analytics\?\.correlations\s*&&\s*\(/);
expect(src).toMatch(/<CorrelationRow\b/);
});
it("mounts <TrendsRow> with annotations from the advisor payload", () => {
const src = load(INSIGHTS_PATH);
expect(src).toMatch(/<TrendsRow[\s\S]*?annotations=/);
expect(src).toMatch(/advisor\.payload\?\.trendAnnotations/);
});
it("legacy advisor payload (null trendAnnotations) is handled with `?? null`", () => {
const src = load(INSIGHTS_PATH);
expect(src).toMatch(/trendAnnotations\s*\?\?\s*null/);
});
});
describe("v1.4.20 B3 — /api/analytics surfaces correlations", () => {
it("imports the three correlation runners", () => {
const src = load(ANALYTICS_ROUTE_PATH);
expect(src).toContain("correlateBpCompliance");
expect(src).toContain("correlateMoodPulse");
expect(src).toContain("correlateWeightWeekday");
});
it("returns a `correlations` block on the success payload", () => {
const src = load(ANALYTICS_ROUTE_PATH);
expect(src).toMatch(/correlations,/);
expect(src).toMatch(/computeCorrelationHypotheses/);
});
it("annotates the wide event with per-hypothesis status", () => {
const src = load(ANALYTICS_ROUTE_PATH);
expect(src).toMatch(
/annotate\(\{[\s\S]*correlations:[\s\S]*bpCompliance:[\s\S]*moodPulse:[\s\S]*weightWeekday:/,
);
});
});
describe("v1.4.20 B3 — useInsightsAdvisorQuery lifts trendAnnotations", () => {
it("imports trendAnnotationsSchema for client-side validation", () => {
const src = load(ADVISOR_HOOK_PATH);
expect(src).toContain("trendAnnotationsSchema");
});
it("the lifted payload exposes `trendAnnotations`", () => {
const src = load(ADVISOR_HOOK_PATH);
expect(src).toMatch(/trendAnnotations\?:/);
expect(src).toMatch(/payload\.trendAnnotations\s*=/);
});
it("malformed cached annotations resolve to null (not a half-render)", () => {
const src = load(ADVISOR_HOOK_PATH);
expect(src).toMatch(/parsed\.success\s*\?\s*parsed\.data\s*:\s*null/);
});
});
+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;
}
+31
View File
@@ -44,7 +44,10 @@ import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
import { HeroStrip } from "@/components/insights/hero-strip";
import { DailyBriefing } from "@/components/insights/daily-briefing";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { TrendsRow } from "@/components/insights/trends-row";
import { CorrelationRow } from "@/components/insights/correlation-row";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { CompareToggle } from "@/components/comparison/compare-toggle";
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
// wrapper so the bundle only lands once a correlation card actually renders.
@@ -155,6 +158,17 @@ interface ComprehensiveData {
interface AnalyticsData {
summaries: Record<string, DataSummary>;
/**
* v1.4.20 phase B3 — three pre-defined correlation hypothesis runners
* computed server-side. Optional + nullable so the page handles the
* v1.4.19-style cached `analytics` payload without flashing an empty
* row before the next refetch.
*/
correlations?: {
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
} | null;
}
interface GeneralStatusData {
@@ -888,6 +902,23 @@ export default function InsightsPage() {
metaSlot={<CompareToggle />}
/>
{/* v1.4.20 phase B3 — Correlation discovery row. Three pre-defined
hypotheses (BP × compliance, mood × pulse, weight × weekday) gated
on n >= 14 + p < 0.05; cards below the bar render a per-card
empty-state. The row-level disclaimer ("Patterns are observational,
not causal …") sits below the grid once. */}
{analytics?.correlations && (
<CorrelationRow results={analytics.correlations} />
)}
{/* v1.4.20 phase B3 — Trends row. Mini BP / Weight / Mood charts
with an inline AI-authored sentence below each. Annotations come
from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+);
legacy cached payloads simply paint the per-metric empty hint. */}
<TrendsRow
annotations={advisor.payload?.trendAnnotations ?? null}
/>
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
`<InsightAdvisorCard>` (severity-ordered recommendations grid +
per-rec rationale expand + confidence meter + thumbs feedback +
@@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { InsightResult } from "@/lib/ai/types";
import {
dailyBriefingSchema,
trendAnnotationsSchema,
type DailyBriefing as DailyBriefingPayload,
type TrendAnnotations,
} from "@/lib/ai/schema";
import { queryKeys } from "@/lib/query-keys";
@@ -39,6 +41,12 @@ export interface InsightAdvisorPayload {
* malformed payload from poisoning the briefing card.
*/
dailyBriefing?: DailyBriefingPayload | null;
/**
* v1.4.20 phase B3 — optional trend annotations for the Trends row.
* Same lift-pattern as `dailyBriefing` — validated client-side and
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
*/
trendAnnotations?: TrendAnnotations | null;
}
async function fetchAdvisor(
@@ -77,6 +85,19 @@ async function fetchAdvisor(
} else {
payload.dailyBriefing = null;
}
// v1.4.20 phase B3 — same lift for `trendAnnotations`. Cached payloads
// from the 4.20.0 line predate the field, so null is the expected
// default. A malformed candidate also resolves to null so the UI
// surfaces the per-metric empty hint instead of a half-rendered card.
const annotationsCandidate = (payload?.insights as Record<string, unknown>)
?.trendAnnotations;
if (annotationsCandidate != null) {
const parsed = trendAnnotationsSchema.safeParse(annotationsCandidate);
payload.trendAnnotations = parsed.success ? parsed.data : null;
} else {
payload.trendAnnotations = null;
}
return payload;
}