mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user