mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(api): emit Health Score from /api/analytics
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
73d21881a8
commit
236b4986a4
3 files changed
+464
No files matched your search
@@ -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<HealthScoreResult | null> {
|
||||
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<string, typeof intakeEvents>();
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user