feat(analytics): replace health score with reference composite

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:10:15 +02:00
1 parent 666a85d311
commit c91558f18f
87 files changed
+6033 -5868

No files matched your search

+111 -89
View File
@@ -6,7 +6,8 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -16,14 +17,9 @@ import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import {
buildHealthScoreBpInputs,
buildHealthScoreWeightTarget,
} from "@/lib/analytics/health-score-inputs";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -298,7 +294,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// these envelopes inside `buildHealthScoreBpInputs`.
let bpEnvelope: BpInTargetEnvelope | null = null;
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
const bpTargets = resolveEffectiveBpTargets(
{
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
user.thresholdsJson,
);
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
// v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked
@@ -321,29 +326,67 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// pair of `readRollupBuckets` reads on the rollup path and is a
// no-op when the user has no BP rows at all.
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek] = await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
]);
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
[
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
],
);
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
} else if (clinicalBpTargets) {
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
]);
}
// v1.17 W1d — the BD-Zielbereich headline standardises on the
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
// remains carried below for the BP detail page's long view only.
@@ -476,64 +519,48 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
: result;
}
// 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.
//
// v1.4.37 W2 — probe-gated helper. The weight pillar derives from
// DAY-bucket means on `measurement_rollups` when the user has full
// coverage; the source-attribution accordion still pulls a narrow
// 2-column projection from `measurements` for the ingest-path
// pills. Falls back to the legacy 37-day raw read on partial /
// missing coverage. Path annotate sits on `meta.healthScore.path`.
//
// v1.17 W1b — the BP-pillar inputs come from the ONE shared
// `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so
// the ring and the insights card grade the pillar off identical inputs
// (same 90-day window via W1d, same all-time fallback, same graded score,
// same prior-week delta values). The hand-rolled per-surface assembly that
// let the two diverge is gone.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
// account drops the mood-stability pillar from the ring instead of
// being silently scored on data hidden everywhere else. The dashboard
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
// route serves core metrics too, so a disabled-glucose account must NOT
// 403 the whole payload; instead the glucose-only surfaces (per-context
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
// never reach a hidden surface. Mirrors the mood-pillar gate above.
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
// v1.34 — the versioned cardiometabolic reference report. The shared reader
// admits only evidence-backed pillars with their own windows and coverage
// floors, then applies the three-domain breadth rule. Its BP envelopes come
// from the same rollup/live fast path the dashboard uses; every other domain
// resolves through the canonical reader and fails independently.
const [
glucoseEnabled,
labsEnabled,
sleepEnabled,
mentalHealthEnabled,
scoreSourcePriority,
] = await Promise.all([
isModuleEnabled(user.id, "glucose"),
isModuleEnabled(user.id, "labs"),
isModuleEnabled(user.id, "sleep"),
isModuleEnabled(user.id, "mentalHealth"),
loadUserSourcePriority(user.id),
]);
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
// The reference instant the score is graded as-of. Reused below so the Rest
// Mode annotation resolves against the SAME window the score covers, not a
// fresh "now" that could fall outside it.
const scoredAt = new Date();
// v1.34 — the weight yardstick comes from the ONE shared
// `buildHealthScoreWeightTarget` builder the dashboard snapshot also uses,
// so the ring and the card grade weight against the identical band. It hands
// over a target ONLY when the account carries an explicit override; every
// other account scores the bare trend. The fabricated BMI-22 band that stood
// in here is gone.
const weightTargetInputs = buildHealthScoreWeightTarget(
{
heightCm: user.heightCm ?? null,
const healthScore = await computeUserHealthScore({
userId: user.id,
now: scoredAt,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson ?? null,
},
(user.thresholdsJson ?? null) as ThresholdOverridesJson | null,
);
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
...weightTargetInputs,
now: scoredAt,
coverage,
moodEnabled,
modules: {
glucose: glucoseEnabled,
labs: labsEnabled,
sleep: sleepEnabled,
mentalHealth: mentalHealthEnabled,
},
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
});
// v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
@@ -648,6 +675,7 @@ async function computeSleepStageBreakdown(
.map((n) => {
const nightStages: Record<string, number> = {};
for (const [stage, mins] of Object.entries(n.stages)) {
if (mins == null) continue;
nightStages[stage] = mins;
stages[stage] = (stages[stage] ?? 0) + mins;
totalMinutes += mins;
@@ -687,9 +715,3 @@ async function computeSleepStageBreakdown(
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
// used by the weight-weekday + day-key helpers inside the old inline
// correlation builder.
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
// and the inline `computeUserHealthScore` body relocated to
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
// rollup / live dispatcher can be unit-tested independently of the
// route. The route now delegates through `computeUserHealthScoreFastPath`.
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
decryptContextFromBytes,
encryptContextToBytes,
@@ -169,6 +170,7 @@ export const PUT = apiHandler(
}
const updated = await prisma.biomarker.update({ where: { id }, data });
invalidateUserHealthScore(user.id);
await auditLog("biomarker.update", {
userId: user.id,
@@ -206,6 +208,7 @@ export const DELETE = apiHandler(
}),
prisma.biomarker.delete({ where: { id } }),
]);
invalidateUserHealthScore(user.id);
await auditLog("biomarker.delete", {
userId: user.id,
@@ -58,7 +58,14 @@ const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
score: {
value: 82,
band: "good",
delta: 3,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
@@ -30,6 +30,10 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserHealthScore: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -56,6 +60,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -129,11 +134,13 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
it("upserts each approved observational or notice dismissal for the caller", async () => {
for (const itemKey of [
"milestone:record_first:WEIGHT:2026-07-16",
"ecg_new_recording:2026-07-16T08:00:00.000Z",
"tension_window:2026-07-16:afternoon",
"same_time_baseline:2026-07-16:ACTIVITY_STEPS",
"health_score_algorithm:2",
]) {
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
const res = await callPost(makeReq({ itemKey }));
@@ -161,4 +168,12 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(res.status).toBe(429);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("evicts score-bearing server caches after the algorithm notice write", async () => {
const res = await callPost(
makeReq({ itemKey: "health_score_algorithm:2" }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
+9 -6
View File
@@ -1,12 +1,11 @@
/**
* POST /api/daily/digest/dismiss
*
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
* rejects any `itemKey` that doesn't carry one of the three dismissible
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
* "Dismiss / mark seen" for observational Today rail items and versioned
* informational notices. Actionable kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are never reachable here:
* `dismissPriorityItemSchema` rejects any `itemKey` without an approved
* observational or notice prefix before a lookup runs.
*
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
@@ -27,6 +26,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
@@ -72,6 +72,9 @@ export const POST = apiHandler(async (req: NextRequest) => {
update: {},
create: { userId: user.id, itemKey },
});
if (itemKey.startsWith("health_score_algorithm:")) {
invalidateUserHealthScore(user.id);
}
annotate({
action: { name: "daily.digest.item.dismissed" },
-4
View File
@@ -104,11 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
type ResolvedBiomarker,
serialiseLabResult,
@@ -217,6 +218,7 @@ export const PUT = apiHandler(
data,
include: { biomarker: { select: biomarkerSelect } },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.update", {
userId: user.id,
@@ -250,6 +252,7 @@ export const DELETE = apiHandler(
where: { id },
data: { deletedAt: new Date() },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.delete", {
userId: user.id,
+2
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -191,6 +192,7 @@ async function commitOcrRows(request: NextRequest) {
takenAt: created.takenAt,
});
}
if (inserted.length > 0) invalidateUserHealthScore(user.id);
// S9 — cross-link the freshly inserted labs to the vault document the client
// filed the scanned bytes into. Best-effort: the labs are the authoritative
+2
View File
@@ -21,6 +21,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -66,6 +67,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null },
});
if (count > 0) invalidateUserHealthScore(user.id);
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
+2
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -254,6 +255,7 @@ async function postLabResult(request: NextRequest) {
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.create", {
userId: user.id,
@@ -17,6 +17,7 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -338,6 +339,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
externalId: `assessment:${assessment.id}`,
},
});
invalidateUserHealthScore(user.id);
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing