diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index ca9b9c5ad..b08795389 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -20,7 +20,7 @@ import { computeBpInTargetFastPath, type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; -import { computeUserHealthScore } from "@/lib/analytics/score/reader"; +import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; import { isModuleEnabled } from "@/lib/modules/gate"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; @@ -554,7 +554,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null; const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null; const scoredAt = new Date(); - const healthScore = await computeUserHealthScore({ + const healthScore = await computeAndRecordUserHealthScore({ userId: user.id, now: scoredAt, profile: { diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts new file mode 100644 index 000000000..11b61c1f2 --- /dev/null +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -0,0 +1,355 @@ +/** + * The shape of a stored score day, and the promise never to restate one. + * + * The database half of that promise is proved against real Postgres in + * `tests/integration/health-score-record.test.ts`; this file covers the parts + * a database cannot see: what the fingerprint covers, which pillars reach the + * row, and that the write path reaches for an insert that yields rather than + * an update that overwrites. + */ +import { describe, expect, it, vi } from "vitest"; + +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +import { computeComposite } from "../composite"; + +import { + buildHealthScoreRecord, + healthScoreInputFingerprint, + recordHealthScore, + SCORE_BANDS, +} from "../record"; +import type { + HealthScoreReport, + PillarValue, + ScorePillarId, + ScorePillarResult, +} from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +const DOMAIN_BY_PILLAR: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + +function pillar( + id: ScorePillarId, + score: number, + options: { deltaIdentity?: string } = {}, +): ScorePillarResult { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + const value: PillarValue = { + score, + observed: { + label: `${score}`, + value: score, + unit: "score", + asOf: NOW.toISOString(), + sources: ["MANUAL"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 100, + label: "test reference", + source: "Test 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: options.deltaIdentity ?? id, + }; + const result: Derived = buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }); + return { id, domain: DOMAIN_BY_PILLAR[id], result }; +} + +/** A report built through the real composite, not a hand-written envelope. */ +function report( + pillars: ScorePillarResult[], + available = pillars.map((p) => p.id), +): HealthScoreReport { + const composite = computeComposite({ + pillars, + availablePillars: available, + asOf: NOW, + }); + return { + composite, + pillars, + delta: null, + deltaReason: "no_previous_window", + scoreVersion: 2, + weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"], + algorithmNotice: null, + }; +} + +const HEALTHY = [ + pillar("BLOOD_PRESSURE", 88), + pillar("SLEEP", 74), + pillar("ADIPOSITY", 66), +]; + +describe("stored bands", () => { + it("lists every band the composite can produce, and nothing else", () => { + // The migration's CHECK constraint spells out the same three. A band the + // composite can reach and the column refuses is a runtime constraint + // violation on a write path that swallows its own failures, so the two + // lists have to be the same list. + const produced = new Set(); + for (const score of [95, 85, 65, 55, 40, 20, 0]) { + const built = report([ + pillar("BLOOD_PRESSURE", score), + pillar("SLEEP", score), + pillar("ADIPOSITY", score), + ]); + if (built.composite.status === "ok") + produced.add(built.composite.value.band); + } + expect([...produced].sort()).toEqual([...SCORE_BANDS].sort()); + }); +}); + +describe("input fingerprint", () => { + const base = { + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[], + pillars: HEALTHY, + }; + + it("is stable for the same standing", () => { + expect(healthScoreInputFingerprint(base)).toBe( + healthScoreInputFingerprint({ ...base, pillars: [...HEALTHY].reverse() }), + ); + }); + + it("moves when the algorithm version moves", () => { + expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when the composition moves", () => { + const narrowed = { + ...base, + composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[], + }; + expect(healthScoreInputFingerprint(narrowed)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when a counted pillar's score moves", () => { + const shifted = { + ...base, + pillars: [pillar("BLOOD_PRESSURE", 87), HEALTHY[1], HEALTHY[2]], + }; + expect(healthScoreInputFingerprint(shifted)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("moves when a pillar's scoring mode changes under an unchanged score", () => { + // The number is identical; what produced it is not. Without the scoring + // identity in the hash the two standings would be indistinguishable, and + // the seam that a scoring-mode change deserves would never be drawn. + const remoded = { + ...base, + pillars: [ + pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "bp:personal-target" }), + HEALTHY[1], + HEALTHY[2], + ], + }; + expect(healthScoreInputFingerprint(remoded)).not.toBe( + healthScoreInputFingerprint(base), + ); + }); + + it("ignores a pillar the composite did not count", () => { + const withSpectator = { + ...base, + pillars: [...HEALTHY, pillar("ACTIVITY", 12)], + }; + expect(healthScoreInputFingerprint(withSpectator)).toBe( + healthScoreInputFingerprint(base), + ); + }); +}); + +describe("building the row", () => { + it("records the composite, the band, and every counted pillar", () => { + const built = report(HEALTHY); + const draft = buildHealthScoreRecord(built, { + timezone: "Europe/Berlin", + now: NOW, + }); + expect(draft).not.toBeNull(); + expect(built.composite.status).toBe("ok"); + if (built.composite.status !== "ok") throw new Error("unreachable"); + expect(draft!.composite).toBe(built.composite.value.score); + expect(draft!.band).toBe(built.composite.value.band); + expect(draft!.composition).toEqual(built.composite.value.composition); + expect(draft!.pillarScores).toEqual({ + BLOOD_PRESSURE: 88, + SLEEP: 74, + ADIPOSITY: 66, + }); + expect(draft!.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); + }); + + it("cuts the day on the account's clock, not on UTC", () => { + // 23:30 UTC on the 20th is already the 21st in Auckland. A row filed under + // the UTC day would put the score on a day the person never saw it. + const lateEvening = new Date("2026-08-20T23:30:00.000Z"); + const built = report(HEALTHY); + expect( + buildHealthScoreRecord(built, { + timezone: "Pacific/Auckland", + now: lateEvening, + })!.dayKey, + ).toBe("2026-08-21"); + expect( + buildHealthScoreRecord(built, { + timezone: "UTC", + now: lateEvening, + })!.dayKey, + ).toBe("2026-08-20"); + }); + + it("leaves out a pillar that was graded but not counted", () => { + // ACTIVITY is available and graded, but the composite is built from the + // three that were eligible. Storing the spectator would report a pillar + // as contributing when it did not. + const built = report( + [...HEALTHY, pillar("ACTIVITY", 12)], + ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], + ); + const draft = buildHealthScoreRecord(built, { + timezone: "UTC", + now: NOW, + })!; + expect(Object.keys(draft.pillarScores)).not.toContain("ACTIVITY"); + expect(draft.composition).not.toContain("ACTIVITY"); + }); + + it("records nothing when the composite did not resolve", () => { + // Two domains: below the breadth floor, so there is no number. Absence has + // to reach the table as absence — a stored 0 would be a day the person is + // told they scored zero. + const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); + expect(thin.composite.status).toBe("insufficient"); + expect( + buildHealthScoreRecord(thin, { timezone: "UTC", now: NOW }), + ).toBeNull(); + }); + + it("carries the configuration version and its change date through", () => { + const changedAt = new Date("2026-08-19T09:00:00.000Z"); + const draft = buildHealthScoreRecord(report(HEALTHY), { + timezone: "UTC", + now: NOW, + configVersion: 4, + configChangedAt: changedAt, + })!; + expect(draft.configVersion).toBe(4); + expect(draft.configChangedAt).toEqual(changedAt); + }); + + it("defaults the configuration columns to absent, never to a made-up version", () => { + const draft = buildHealthScoreRecord(report(HEALTHY), { + timezone: "UTC", + now: NOW, + })!; + expect(draft.configVersion).toBeNull(); + expect(draft.configChangedAt).toBeNull(); + }); +}); + +describe("writing the row", () => { + /** + * A delegate that answers `createMany` and nothing else. Reaching for + * `upsert` or `update` throws here rather than quietly restating a day, so + * the never-restate promise is a compile-and-run property of this file and + * not a comment. + */ + function delegate(count: number) { + const createMany = vi.fn(async () => ({ count })); + return { + db: { healthScoreRecord: { createMany } } as never, + createMany, + }; + } + + it("inserts with a conflict that yields, and never updates", async () => { + const { db, createMany } = delegate(1); + const outcome = await recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }); + expect(outcome).toBe("written"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(createMany.mock.calls[0][0]).toMatchObject({ skipDuplicates: true }); + }); + + it("reports the day as already recorded when the insert yielded", async () => { + const { db } = delegate(0); + expect( + await recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }), + ).toBe("already_recorded"); + }); + + it("never touches the database when there is no score to record", async () => { + const { db, createMany } = delegate(1); + const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); + expect( + await recordHealthScore(db, "user-1", thin, { + timezone: "UTC", + now: NOW, + }), + ).toBe("no_score"); + expect(createMany).not.toHaveBeenCalled(); + }); + + it("reports a failed write as failed, and does not take the read down", async () => { + const db = { + healthScoreRecord: { + createMany: vi.fn(async () => { + throw new Error("connection reset"); + }), + }, + } as never; + await expect( + recordHealthScore(db, "user-1", report(HEALTHY), { + timezone: "UTC", + now: NOW, + }), + ).resolves.toBe("failed"); + }); +}); diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4ae00e227..2b801f0a9 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -6,7 +6,7 @@ import { resolveModuleMap } from "@/lib/modules/gate"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { computeUserHealthScore } from "./reader"; +import { computeAndRecordUserHealthScore } from "./record"; import type { CompositeValue } from "./types"; const DAY_MS = 86_400_000; @@ -50,7 +50,7 @@ export async function computeHealthScoreDerived( bpAt(new Date(now.getTime() - 7 * DAY_MS)), bpAt(new Date(now.getTime() - 14 * DAY_MS)), ]); - const report = await computeUserHealthScore({ + const report = await computeAndRecordUserHealthScore({ userId, now, profile: { diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts new file mode 100644 index 000000000..fbfff908e --- /dev/null +++ b/src/lib/analytics/score/record.ts @@ -0,0 +1,303 @@ +/** + * Writing the health score down. + * + * The score is computed live, every time, from the rows standing behind it at + * that moment. That is the right thing for today and it is why nothing older + * than today survives: correct a reading from last month, finish a sync that + * had stalled, or simply add the fourteenth night of sleep that made a pillar + * eligible, and the score the person saw back then can no longer be + * reproduced. There is nothing dishonest about that, but it does mean the + * "history" a surface can offer today is two numbers computed seconds apart. + * + * So each local day's score is written down once, the first time it is + * computed, and then left alone. Three properties follow, and all three are + * deliberate: + * + * * **Append-only.** A second computation of the same local day finds the + * row and does nothing. Not an upsert with a cleverer conflict target, + * not a "refresh if newer" — the number the person saw stays the number + * they saw. Restating it would make the stored series agree with whatever + * the data happens to look like now, which is precisely the property that + * makes it worth nothing. + * * **Never authoritative.** No surface reads this instead of computing. + * The record answers questions about past days; today's number always + * comes from today's rows. + * * **Never fatal.** The write hangs off a read path. A score that renders + * must not fail because a bookkeeping insert did, so the failure is + * annotated onto the wide event and swallowed. The integration round trip + * is what proves the write actually happens; a unit test that only + * watches this function return would be watching it succeed at nothing. + * + * Server-only — reaches for the Prisma client. + */ +import { createHash } from "node:crypto"; + +import { prisma as globalPrisma } from "@/lib/db"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { userDayKey } from "@/lib/tz/format"; + +import { computeUserHealthScore, type UserHealthScoreInput } from "./reader"; +import type { + HealthScoreReport, + ScoreBand, + ScorePillarId, + ScorePillarResult, +} from "./types"; + +/** + * The bands a stored row may carry, and the same closed set the migration's + * CHECK constraint spells out. + * + * Pinned to {@link ScoreBand} in BOTH directions at compile time: `satisfies` + * rejects a member that is not a band, and `_BandsAreExhaustive` fails to + * compile if a band is ever added to the union without being added here. A + * one-directional check would let a fourth band reach a column that refuses + * it, which is a runtime constraint violation on a write path that is + * supposed to be unable to fail loudly. + */ +export const SCORE_BANDS = [ + "green", + "yellow", + "red", +] as const satisfies readonly ScoreBand[]; + +type _BandsAreExhaustive = + Exclude extends never ? true : never; +const _bandsAreExhaustive: _BandsAreExhaustive = true; +void _bandsAreExhaustive; + +/** Fingerprint format version. Bump when the covered set below changes. */ +const FINGERPRINT_VERSION = 1; + +/** The row a computed report turns into, before it reaches the database. */ +export interface HealthScoreRecordDraft { + dayKey: string; + timezone: string; + composite: number; + band: ScoreBand; + scoreVersion: number; + composition: ScorePillarId[]; + pillarScores: Record; + inputFingerprint: string; + configVersion: number | null; + configChangedAt: Date | null; + computedAt: Date; +} + +/** What the caller knows that the report itself does not carry. */ +export interface HealthScoreRecordContext { + /** The account's IANA zone — the clock the local day is cut on. */ + timezone: string; + /** The instant the score was computed for. */ + now: Date; + /** + * The account's health-score configuration version in force for this + * computation, and when it last changed. Both stay `null` until the + * per-user configuration exists; the parameters are here so threading them + * through later is a caller change and not a migration. + */ + configVersion?: number | null; + configChangedAt?: Date | null; +} + +/** + * Every pillar that actually counted, in the composition's own order, with + * the score and the scoring-mode identity that produced it. + * + * Reads the composition rather than the pillar list, because the pillar list + * carries pillars that were graded and then not counted (insufficient + * coverage, module off). Storing those would make the fingerprint move for + * reasons the composite never saw. + */ +function countedPillars( + composition: readonly ScorePillarId[], + pillars: readonly ScorePillarResult[], +): Array<{ id: ScorePillarId; score: number; deltaIdentity: string }> { + const byId = new Map(pillars.map((pillar) => [pillar.id, pillar])); + return composition.flatMap((id) => { + const pillar = byId.get(id); + if (!pillar || pillar.result.status !== "ok") return []; + return [ + { + id, + score: pillar.result.value.score, + deltaIdentity: pillar.result.value.deltaIdentity, + }, + ]; + }); +} + +/** + * SHA-256 over exactly what the stored composite and band depend on. + * + * The covered set is the whole point, so it is spelled out rather than + * "hash the report": the algorithm version, the composition, and each counted + * pillar's score together with its scoring-mode identity. Drop any one of + * them and two genuinely different standings collapse onto one fingerprint — + * a key missing a dimension its value depends on, which is a defect class + * this codebase has shipped before. + * + * What is deliberately NOT covered: the account's configuration version. A + * configuration decides the composition, and the composition is already here. + * A configuration change that leaves the composition alone leaves the score + * alone too, and a fingerprint that moved anyway would report a break that + * did not happen. + */ +export function healthScoreInputFingerprint(input: { + scoreVersion: number; + composition: readonly ScorePillarId[]; + pillars: readonly ScorePillarResult[]; +}): string { + const counted = countedPillars(input.composition, input.pillars); + const canonical = JSON.stringify({ + v: FINGERPRINT_VERSION, + scoreVersion: input.scoreVersion, + composition: [...input.composition], + pillars: counted.map((pillar) => [ + pillar.id, + pillar.score, + pillar.deltaIdentity, + ]), + }); + return createHash("sha256").update(canonical).digest("hex"); +} + +/** + * Turn a computed report into the row it would be stored as, or `null` when + * there is nothing to record. + * + * `null` means the composite did not resolve — too few domains, no + * physiological pillar, a read that failed. That is honest absence and it is + * NOT a row: a stored zero, or a stored "insufficient" placeholder, would put + * a value on a day that never had one, and every reader downstream would have + * to know to disbelieve it. + * + * Pure, so the fingerprint and the shape can be tested without a database. + */ +export function buildHealthScoreRecord( + report: HealthScoreReport, + context: HealthScoreRecordContext, +): HealthScoreRecordDraft | null { + if (report.composite.status !== "ok") return null; + const composite = report.composite.value; + const counted = countedPillars(composite.composition, report.pillars); + if (counted.length === 0) return null; + + return { + dayKey: userDayKey(context.now, context.timezone), + timezone: context.timezone, + composite: composite.score, + band: composite.band, + scoreVersion: composite.scoreVersion, + composition: [...composite.composition], + pillarScores: Object.fromEntries( + counted.map((pillar) => [pillar.id, pillar.score]), + ), + inputFingerprint: healthScoreInputFingerprint({ + scoreVersion: composite.scoreVersion, + composition: composite.composition, + pillars: report.pillars, + }), + configVersion: context.configVersion ?? null, + configChangedAt: context.configChangedAt ?? null, + computedAt: context.now, + }; +} + +/** What the write did, so a caller or a test can tell the three apart. */ +export type HealthScoreRecordOutcome = + /** A new day was recorded. */ + | "written" + /** The day already had a record, which was left exactly as it was. */ + | "already_recorded" + /** Nothing to record — the composite did not resolve. */ + | "no_score" + /** The insert failed. Annotated, not thrown. */ + | "failed"; + +type RecordDelegate = Pick; + +/** + * Record one local day's score, once. + * + * `createMany` with `skipDuplicates` compiles to `ON CONFLICT DO NOTHING` + * against the `(user_id, day_key)` unique index, so two concurrent requests + * on the same day race into the same outcome the second request would have + * reached anyway: the first row stands. An `upsert` would have been the + * obvious call here and would have been wrong — its update branch is exactly + * the restatement this table must not do. + */ +export async function recordHealthScore( + db: RecordDelegate, + userId: string, + report: HealthScoreReport, + context: HealthScoreRecordContext, +): Promise { + const draft = buildHealthScoreRecord(report, context); + if (!draft) return "no_score"; + + try { + const written = await db.healthScoreRecord.createMany({ + data: [ + { + userId, + dayKey: draft.dayKey, + timezone: draft.timezone, + composite: draft.composite, + band: draft.band, + scoreVersion: draft.scoreVersion, + composition: draft.composition, + pillarScores: draft.pillarScores as Prisma.InputJsonValue, + inputFingerprint: draft.inputFingerprint, + configVersion: draft.configVersion, + configChangedAt: draft.configChangedAt, + computedAt: draft.computedAt, + }, + ], + skipDuplicates: true, + }); + if (written.count === 0) return "already_recorded"; + annotate({ + action: { name: "score.record.write" }, + meta: { + dayKey: draft.dayKey, + scoreVersion: draft.scoreVersion, + pillars: draft.composition.length, + }, + }); + return "written"; + } catch (err) { + // A failed bookkeeping write must not take a rendered score down with it, + // but it must not pass for silence either: the warning rides the wide + // event so an operator sees the day that went unrecorded. + getEvent()?.addWarning( + `health score record write failed for ${draft.dayKey}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return "failed"; + } +} + +/** + * Compute the account's health score and record the day it was computed for. + * + * One entry point rather than a write bolted onto each of the surfaces that + * compute a score. Three callers each remembering to record is three places + * that can independently stop remembering, and the one that forgets leaves a + * gap in the series that nothing reports. + */ +export async function computeAndRecordUserHealthScore( + input: UserHealthScoreInput, + record?: Pick, +): Promise { + const report = await computeUserHealthScore(input); + await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, { + timezone: input.profile.timezone, + now: input.now, + configVersion: record?.configVersion ?? null, + configChangedAt: record?.configChangedAt ?? null, + }); + return report; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index f45bde80a..b4a8f81b2 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -77,7 +77,7 @@ import { buildMedsTodayBlock, type MedsTodayBlock, } from "@/lib/dashboard/meds-today"; -import { computeUserHealthScore } from "@/lib/analytics/score/reader"; +import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; import { buildDashboardBands as buildTargetBands, type DashboardTargetBands, @@ -736,7 +736,7 @@ async function buildExtras( // prior-week delta values). Closes the dashboard-vs-insights divergence. const scoreSourcePriority = user.sourcePriorityJson ?? null; const scoreResult = await time("healthScore", () => - computeUserHealthScore({ + computeAndRecordUserHealthScore({ prisma, userId: user.id, now,