mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user