mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user