diff --git a/src/app/__tests__/insights-b3-wiring.test.ts b/src/app/__tests__/insights-b3-wiring.test.ts new file mode 100644 index 000000000..90d13c64a --- /dev/null +++ b/src/app/__tests__/insights-b3-wiring.test.ts @@ -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 behind a non-null analytics.correlations guard", () => { + const src = load(INSIGHTS_PATH); + expect(src).toMatch(/analytics\?\.correlations\s*&&\s*\(/); + expect(src).toMatch(/ with annotations from the advisor payload", () => { + const src = load(INSIGHTS_PATH); + expect(src).toMatch(/ { + 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/); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 4b2b59eec..8a016d320 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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(); + 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(); + 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(); + 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(); + 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 = { + 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; +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 1feb81d4c..df2b81cf0 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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; + /** + * 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={} /> + {/* 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 && ( + + )} + + {/* 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. */} + + {/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished `` (severity-ordered recommendations grid + per-rec rationale expand + confidence meter + thumbs feedback + diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index a00e2f031..105fab680 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -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) + ?.trendAnnotations; + if (annotationsCandidate != null) { + const parsed = trendAnnotationsSchema.safeParse(annotationsCandidate); + payload.trendAnnotations = parsed.success ? parsed.data : null; + } else { + payload.trendAnnotations = null; + } return payload; }