feat(insights): add composite scores, score-anatomy view, and correlation discovery

Build the composite tier of the derived-metrics layer on the shared seam:

- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
  consistency, timing and composition sub-scores over the per-stage sleep
  rows, reweighting around any sub-score the data cannot support (a legacy
  asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
  blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
  deviation-from-baseline components; returns insufficient below two present
  components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
  the latest day and fires when two or more coincide, listing the
  contributing vitals as possible factors, never a cause or a diagnosis.

Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.

Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.

Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.

Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 21:19:56 +02:00
1 parent 663a3ee09d
commit 95a2a7692f
29 files changed
+3087 -30

No files matched your search

@@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
const SESSION_OK = {
@@ -47,6 +49,16 @@ beforeEach(() => {
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
// v1.10.0 — the discovery engine reads the profile timezone + the
// measurement / mood series. Default to an empty corpus so the engine
// returns no discovered pairs (the n ≥ 20 gate trivially fails).
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
timezone: "Europe/Berlin",
});
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => {
expect(res.status).toBe(401);
});
it("returns an empty array as the placeholder", async () => {
it("returns the discovery result shape (no pairs on an empty corpus)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[] };
expect(body.data).toEqual([]);
});
it("writes the empty-state audit log", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(auditLog).toHaveBeenCalledWith(
"insights.correlations.empty",
expect.objectContaining({ userId: "user-1" }),
);
const body = (await res.json()) as {
data: { discovered: unknown[]; pairsTested: number; fdrQ: number };
};
expect(body.data.discovered).toEqual([]);
expect(body.data.pairsTested).toBe(0);
expect(body.data.fdrQ).toBeGreaterThan(0);
});
it("returns 403 + errorCode when the correlations flag is off", async () => {
+125 -12
View File
@@ -1,29 +1,142 @@
/**
* GET /api/insights/correlations — placeholder iOS endpoint.
* GET /api/insights/correlations — FDR-controlled correlation discovery.
*
* The full correlations engine is part of `/api/insights/comprehensive`
* and is intentionally not re-exposed yet — iOS only needs a stable
* shape to render the empty state. We return `[]` and emit an audit log
* so the gap is visible until Phase 7 fills it in.
* v1.10.0 — promotes the former placeholder to the real discovery engine.
* Scans a curated behaviour × outcome matrix (daylight / mood / glucose /
* BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour
* day to the NEXT day's outcome, runs Pearson with the exact Student-t
* p-value, and applies Benjamini-Hochberg FDR control across every tested
* pair so only statistically-defensible patterns surface. Every surfaced
* pair carries n, r, p, and the BH-adjusted q, framed descriptive — never
* causal.
*
* Reads daily series bounded to a trailing window, day-keyed in the user's
* display timezone (late-night readings mis-bucket under UTC). Pure compute
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
export const dynamic = "force-dynamic";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing window for the discovery scan (days). */
const WINDOW_DAYS = 180;
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = getLocalDateParts(at, tz);
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
}
/** Collapse rows to per-day means keyed in the user's tz. */
function toDailyMeans(
rows: Array<{ value: number; at: Date }>,
tz: string,
): DailySeriesPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
if (!Number.isFinite(r.value)) continue;
const day = tzDayKey(r.at, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.31 — operator can hide the correlation narration tile.
// Operator can hide the correlation surface entirely.
await requireAssistantSurface("correlations");
annotate({ action: { name: "insights.correlations" } });
await auditLog("insights.correlations.empty", {
userId: user.id,
details: { reason: "phase7_pending" },
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
// Behaviour channels are measurement-backed except MOOD (mood entries).
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
(k) => k !== "MOOD",
) as MeasurementType[];
const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: { type: true, value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
]);
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
for (const m of measurements) {
const list = measurementsByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
measurementsByType.set(m.type, list);
}
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD"
? toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
)
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
}
for (const key of DISCOVERY_OUTCOMES) {
series.push({
key,
role: "outcome",
points: toDailyMeans(measurementsByType.get(key) ?? [], tz),
});
}
const result = discoverCorrelations(series);
annotate({
action: { name: "insights.correlations.discover" },
meta: {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
},
});
return apiSuccess([]);
return apiSuccess(result);
});