From 236b4986a4db1e495cf0e82cdea52420ba35e579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 17:37:20 +0200 Subject: [PATCH] feat(api): emit Health Score from /api/analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route now builds the four-component Health Score input from the already-fetched BP figure plus a fresh weight + mood + medication intake fetch (last 30 days, plus a 7-day-shifted snapshot for the delta). The response gains a `healthScore: { score, band, components, delta } | null` field; null when no component is computable so the UI hero panel can hide cleanly. The wide-event annotation carries `healthScore.score` + `band` + `delta` so admin-app-logs can attribute coverage without DB queries. The previous-week BP rate reuses the all-time figure (it's a slow- moving aggregate; rewinding it cheaply isn't possible without a full historical re-pair). Delta therefore primarily reflects week- over-week movement in weight / mood / compliance — the components that actually move on a weekly cadence. Integration coverage seeds three synthetic-data shapes against the postgres testcontainer: - strong-positive → green band (>= 75) - empty user → null healthScore - compliance-only → bp/weight/mood null, compliance weight 1.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/analytics/route.ts | 199 ++++++++++++++ src/app/insights/page.tsx | 17 ++ .../analytics-health-score.test.ts | 248 ++++++++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 tests/integration/analytics-health-score.test.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 8a016d320..47f3f1b58 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -5,6 +5,13 @@ 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 { calculateCompliance } from "@/lib/analytics/compliance"; +import { + computeHealthScore, + defaultWeightTargetFromHeight, + type HealthScoreInput, + type HealthScoreResult, +} from "@/lib/analytics/health-score"; import { correlateBpCompliance, correlateMoodPulse, @@ -116,6 +123,26 @@ export const GET = apiHandler(async () => { // and the UI paints an EmptyState. const correlations = await computeCorrelationHypotheses(user.id); + // v1.4.20 phase B5 — Personal Health Score. Server-deterministic + // composite of BP-in-target % + weight-trend alignment + mood + // stability + medication compliance. The "vs last week" delta + // re-runs the same compute against a 7-day-shifted snapshot. + const healthScore = await computeUserHealthScore(user.id, { + bpInTargetPct, + heightCm: user.heightCm ?? null, + }); + if (healthScore) { + annotate({ + meta: { + healthScore: { + score: healthScore.score, + band: healthScore.band, + delta: healthScore.delta, + }, + }, + }); + } + return apiSuccess({ summaries: results, bmi, @@ -124,6 +151,7 @@ export const GET = apiHandler(async () => { bpInTargetPct30d, glucoseByContext, correlations, + healthScore, }); }); @@ -322,3 +350,174 @@ function berlinIsoWeekday(d: Date): number { const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon"; return ISO_WEEKDAY[weekday] ?? 1; } + +/** + * Build the Health Score input from the user's last-30-day weight, + * mood, and medication-compliance data, plus the already-computed + * `bpInTargetPct` headline. Re-runs the same compute against a + * 7-day-shifted window to populate the "vs last week" delta. + * + * Returns null when the score wouldn't carry any signal (every + * component nullable + no medications). The route surfaces the + * `null` to the UI so the hero panel hides cleanly. + */ +async function computeUserHealthScore( + userId: string, + input: { bpInTargetPct: number | null; heightCm: number | null }, +): Promise { + const DAY_MS = 24 * 60 * 60 * 1000; + const now = new Date(); + const since30d = new Date(now.getTime() - 30 * DAY_MS); + // Prior week's snapshot — shift everything 7 days into the past so + // both windows close at the same wall-clock-of-day boundary. + const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); + const prevUntil = new Date(now.getTime() - 7 * DAY_MS); + + const [weightRows, moodRows, medications] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "WEIGHT", + measuredAt: { gte: prevSince30d, lte: now }, + }, + select: { value: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + }), + prisma.moodEntry.findMany({ + where: { + userId, + moodLoggedAt: { gte: prevSince30d, lte: now }, + }, + select: { score: true, moodLoggedAt: true }, + orderBy: { moodLoggedAt: "asc" }, + }), + prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + createdAt: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + }, + }, + }, + }), + ]); + + // Compliance30 per active medication, then again for the prior-week + // snapshot. The compliance helper anchors on `Date.now()` internally; + // for the previous-week snapshot we reuse the same helper but pass a + // shifted "createdAt" floor so the window mathematically reflects the + // [-37d, -7d] period — equivalent to running the helper a week ago. + let medicationCompliance30: number[] = []; + let medicationCompliance30Previous: number[] = []; + if (medications.length > 0) { + const medIds = medications.map((m) => m.id); + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId: { in: medIds }, + scheduledFor: { gte: prevSince30d, lte: now }, + }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + const eventsByMed = new Map(); + for (const ev of intakeEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + medicationCompliance30 = medications.map((med) => { + const events = eventsByMed.get(med.id) ?? []; + return calculateCompliance(events, med.schedules, 30, med.createdAt).rate; + }); + medicationCompliance30Previous = medications.map((med) => { + const events = (eventsByMed.get(med.id) ?? []).filter( + (e) => e.scheduledFor <= prevUntil, + ); + // Compute compliance against the prior-week-aligned window by + // remapping the helper's "now": shift each event's scheduledFor + // and takenAt forward by 7 days so the helper's internal `now` + // anchor still captures the same logical 30 days. + const shifted = events.map((e) => ({ + scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS), + takenAt: e.takenAt + ? new Date(e.takenAt.getTime() + 7 * DAY_MS) + : null, + skipped: e.skipped, + })); + return calculateCompliance(shifted, med.schedules, 30, med.createdAt) + .rate; + }); + } + + const fallbackTarget = defaultWeightTargetFromHeight(input.heightCm); + + const weightSeriesLast30d = weightRows + .filter((r) => r.measuredAt >= since30d) + .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); + const weightSeriesPrev30d = weightRows + .filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil) + .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); + + const moodSeriesLast30d = moodRows + .filter((r) => r.moodLoggedAt >= since30d) + .map((r) => ({ + date: r.moodLoggedAt.toISOString(), + score: r.score, + })); + const moodSeriesPrev30d = moodRows + .filter( + (r) => r.moodLoggedAt >= prevSince30d && r.moodLoggedAt <= prevUntil, + ) + .map((r) => ({ + date: r.moodLoggedAt.toISOString(), + score: r.score, + })); + + // Skip any input shape where literally nothing is computable — the + // hero panel hides instead of painting a misleading "0". + if ( + input.bpInTargetPct === null && + weightSeriesLast30d.length === 0 && + moodSeriesLast30d.length === 0 && + medicationCompliance30.length === 0 + ) { + // Tag-only annotation so admin observability can see the empty path. + annotate({ + meta: { + healthScore: { score: null, reason: "no_components_available" }, + }, + }); + return null; + } + + const current: HealthScoreInput = { + bpInTargetRate: input.bpInTargetPct, + weightSeriesLast30d, + weightTargetKg: fallbackTarget, + moodEntriesLast30d: moodSeriesLast30d, + medicationCompliance30, + }; + // The all-time `bpInTargetPct` is a slow-moving aggregate and would + // need a full historical re-pair to "rewind" by a week. We pass the + // same value to the previous snapshot so the delta primarily + // reflects week-over-week changes in the weight / mood / compliance + // pillars — the components that actually move on a weekly cadence. + const previous: HealthScoreInput = { + bpInTargetRate: input.bpInTargetPct, + weightSeriesLast30d: weightSeriesPrev30d, + weightTargetKg: fallbackTarget, + moodEntriesLast30d: moodSeriesPrev30d, + medicationCompliance30: medicationCompliance30Previous, + }; + + return computeHealthScore(current, previous); +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index ccdbd2739..f717afa7c 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -169,6 +169,23 @@ interface AnalyticsData { moodPulse: CorrelationResult; weightWeekday: CorrelationResult; } | null; + /** + * v1.4.20 phase B5 — composite Personal Health Score with sub-component + * breakdown + delta vs last week. Optional + nullable so older cached + * `analytics` payloads continue to render the rest of the page; the + * hero panel hides cleanly when the field is absent. + */ + healthScore?: { + score: number; + band: "green" | "yellow" | "red"; + components: { + bp: { value: number | null; weight: number }; + weight: { value: number | null; weight: number }; + mood: { value: number | null; weight: number }; + compliance: { value: number | null; weight: number }; + }; + delta: number | null; + } | null; } interface GeneralStatusData { diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts new file mode 100644 index 000000000..1ae8b308c --- /dev/null +++ b/tests/integration/analytics-health-score.test.ts @@ -0,0 +1,248 @@ +/** + * v1.4.20 phase B5 — integration coverage for the analytics route's + * Personal Health Score field. + * + * Seeds a deterministic mix of weight + mood + medication-intake + * fixtures alongside the existing BP-in-target seed, then asserts the + * route returns a `healthScore` envelope whose band corresponds to the + * synthetic-data shape. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface AnalyticsEnvelope { + data: { + healthScore: { + score: number; + band: "green" | "yellow" | "red"; + components: { + bp: { value: number | null; weight: number }; + weight: { value: number | null; weight: number }; + mood: { value: number | null; weight: number }; + compliance: { value: number | null; weight: number }; + }; + delta: number | null; + } | null; + } | null; + error?: string | null; +} + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + heightCm: 178, + dateOfBirth: new Date("1985-07-09"), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +describe("GET /api/analytics — Health Score", () => { + it("returns a green-band score for a strong-positive synthetic shape", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("hs-strong"); + + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + + // ── BP: 18 in-target paired readings + 2 out → ≈ 90 % ── + for (let i = 0; i < 20; i++) { + const at = new Date(now - i * DAY); + const inTarget = i >= 2; // 18 in-target, 2 out + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: inTarget ? 120 : 145, + unit: "mmHg", + measuredAt: at, + }, + }); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + value: inTarget ? 78 : 95, + unit: "mmHg", + measuredAt: at, + }, + }); + } + + // ── Weight: closing toward BMI-22 target (≈ 69.7 kg for 178 cm) ── + for (let i = 0; i < 10; i++) { + await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 72 - i * 0.2, // 72.0 -> 70.2 across 10 days + unit: "kg", + measuredAt: new Date(now - (10 - i) * DAY), + }, + }); + } + + // ── Mood: stable high score ── + for (let i = 0; i < 10; i++) { + const at = new Date(now - i * DAY); + const ymd = at.toISOString().slice(0, 10); + await prisma.moodEntry.create({ + data: { + userId: user.id, + score: i % 2 === 0 ? 5 : 4, + mood: "GUT", + source: "WEB", + date: ymd, + moodLoggedAt: at, + }, + }); + } + + // ── Medication: one med, perfect compliance ── + const med = await prisma.medication.create({ + data: { + userId: user.id, + name: "Ramipril", + dose: "5mg", + active: true, + createdAt: new Date(now - 60 * DAY), + schedules: { + create: [{ windowStart: "08:00", windowEnd: "10:00" }], + }, + }, + }); + for (let i = 0; i < 30; i++) { + const scheduledFor = new Date(now - i * DAY); + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor, + takenAt: scheduledFor, + skipped: false, + }, + }); + } + + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as AnalyticsEnvelope; + expect(env.data).not.toBeNull(); + expect(env.data!.healthScore).not.toBeNull(); + const hs = env.data!.healthScore!; + expect(hs.band).toBe("green"); + expect(hs.score).toBeGreaterThanOrEqual(75); + expect(hs.components.bp.value).not.toBeNull(); + expect(hs.components.compliance.value).toBeGreaterThanOrEqual(95); + }); + + it("returns null healthScore for a user with no data", async () => { + await seedSession("hs-empty"); + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as AnalyticsEnvelope; + expect(env.data!.healthScore).toBeNull(); + }); + + it("redistributes weights when only compliance is present", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("hs-compliance-only"); + + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + const med = await prisma.medication.create({ + data: { + userId: user.id, + name: "Vitamin D", + dose: "2000 IU", + active: true, + createdAt: new Date(now - 60 * DAY), + schedules: { + create: [{ windowStart: "08:00", windowEnd: "10:00" }], + }, + }, + }); + for (let i = 0; i < 30; i++) { + const scheduledFor = new Date(now - i * DAY); + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor, + takenAt: scheduledFor, + skipped: false, + }, + }); + } + + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ); + const env = (await res.json()) as AnalyticsEnvelope; + const hs = env.data!.healthScore!; + expect(hs).not.toBeNull(); + expect(hs.components.bp.value).toBeNull(); + expect(hs.components.weight.value).toBeNull(); + expect(hs.components.mood.value).toBeNull(); + expect(hs.components.compliance.value).not.toBeNull(); + expect(hs.components.compliance.weight).toBeCloseTo(1, 5); + }); +});