From c574ef78fb7b4a3d9db6f0e897af0cb66da2ce6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:01:23 +0200 Subject: [PATCH 01/26] Add the per-user health-score composition column A nullable JSONB column on users carrying which pillars the person chose to count toward the composite score. Stored as a deselection list so a pillar shipped later counts by default and cannot be confused with one the person removed. NULL keeps its own meaning: never chose, which is not the same as counting nothing. --- .../0289_health_score_config/migration.sql | 35 +++++++++++++++++++ prisma/schema.prisma | 24 +++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 prisma/migrations/0289_health_score_config/migration.sql diff --git a/prisma/migrations/0289_health_score_config/migration.sql b/prisma/migrations/0289_health_score_config/migration.sql new file mode 100644 index 000000000..f9d0397fd --- /dev/null +++ b/prisma/migrations/0289_health_score_config/migration.sql @@ -0,0 +1,35 @@ +-- v1.35.0 — per-user Health Score composition. +-- +-- A single nullable JSONB column on `users` carrying which pillars the +-- person chose to count toward the composite Health Score. Follows the +-- established per-user JSON-column convention (`module_preferences_json`, +-- `dashboard_widgets_json`, `coach_prefs_json`). +-- +-- Shape (validated by `src/lib/analytics/score/config.ts`): +-- { "excludedPillars": ["SLEEP", "LIPIDS"], +-- "version": 3, +-- "changedAt": "2026-07-31T09:12:44.000Z" } +-- +-- The stored list is a DESELECTION list, the same posture as +-- `module_preferences_json`, and it is stored that way on purpose. A +-- pillar shipped after the person made their choice is simply absent +-- from the list, so it counts by default and the score keeps its +-- "everything eligible" starting point without a backfill. Storing the +-- positive selection instead would make a newly shipped pillar +-- indistinguishable from one the person deliberately removed. +-- +-- NULL = the person never chose, which is NOT the same as choosing to +-- count nothing (an `excludedPillars` list naming every pillar). The +-- resolver keeps those two apart because only the first one may inherit +-- a later change of defaults. +-- +-- `version` is the per-user recipe version: it increments on every +-- accepted write and, with `changedAt`, is what a score record and the +-- weekly delta key off to tell "you changed what counts" apart from a +-- real movement in the person's health. +-- +-- Additive + nullable: every existing account keeps NULL and the +-- resolver treats NULL as "counts every pillar", which is exactly what +-- those accounts get today. + +ALTER TABLE "users" ADD COLUMN "health_score_config_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6cd6fa6a8..6b6fc2ec3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -602,6 +602,30 @@ model User { // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") + // v1.35.0 — which pillars count toward the composite Health Score. + // + // A DESELECTION list, the same posture as `modulePreferencesJson`: + // { "excludedPillars": ["SLEEP"], "version": 2, "changedAt": "" } + // + // A pillar shipped after the person made their choice is absent from + // the list and therefore counts, so the score keeps its "everything + // eligible" starting point with no backfill. Storing the positive + // selection instead would make a newly shipped pillar look exactly + // like one the person deliberately removed. + // + // NULL means the person never chose and is deliberately distinct from + // a list naming every pillar ("count nothing"): only the first may + // inherit a later change of defaults. + // + // `version` increments on every accepted write and, with `changedAt`, + // is the per-user recipe identity a score record and the weekly delta + // read to tell an authored change apart from a health movement. + // + // Schema + resolver: `src/lib/analytics/score/config.ts`. Written by + // `PATCH /api/auth/me/health-score-config` with field-by-field merge + // (no mass assignment) under the write-time breadth rule. + healthScoreConfigJson Json? @map("health_score_config_json") + // Document vault — per-user storage-quota override in bytes. NULL = the // instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set // only (the admin user PUT); resolved by From cb75986eb5e6cfd8cdb7b468ef82d7796e660cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:02:01 +0200 Subject: [PATCH 02/26] Add the health-score record table The score is computed live from today's rows, so once the readings behind a past day change the number the person actually saw that day is gone. Nothing wrote it down. This adds the table that does: one row per account per local day, holding the composite, its band, the algorithm version, the composition that produced it, every counted pillar's own score, and a fingerprint over exactly what those depend on. Append-only by construction. A unique index on account plus local day gives the writer something to conflict against, and there is no updated_at, because a later computation of the same day must leave the earlier row exactly as it stood. --- .../0290_health_score_record/migration.sql | 56 +++++++++++++ prisma/schema.prisma | 82 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 prisma/migrations/0290_health_score_record/migration.sql diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql new file mode 100644 index 000000000..6bcd13b9b --- /dev/null +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -0,0 +1,56 @@ +-- v1.35.0 — the health score as it was shown, one row per account per local day. +-- +-- Nothing can rebuild this. The live computation always answers for today from +-- today's rows, so once the readings behind a past day have been corrected, +-- re-synced or simply grown, the number the person saw on that day is gone +-- unless it was written down. This table is the writing down. +-- +-- Two properties are enforced here rather than left to the application: +-- +-- * one row per account per local day, so a second computation on the same +-- day cannot mint a rival record of the same day, and the writer's +-- ON CONFLICT DO NOTHING has something to conflict against; +-- * a closed set of bands, so a row can never carry a band no surface knows +-- how to render. +-- +-- There is deliberately no updated_at column. The row is written once and +-- never rewritten: a later computation under different readings, or under a +-- different configuration, leaves the earlier day exactly as it stood. + +CREATE TABLE "health_score_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "day_key" VARCHAR(10) NOT NULL, + "timezone" TEXT NOT NULL, + "composite" INTEGER NOT NULL, + "band" VARCHAR(8) NOT NULL, + "score_version" INTEGER NOT NULL, + "composition" TEXT[] NOT NULL, + "pillar_scores" JSONB NOT NULL, + "input_fingerprint" VARCHAR(64) NOT NULL, + "config_version" INTEGER, + "config_changed_at" TIMESTAMPTZ(3), + "computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), + CONSTRAINT "health_score_records_band_check" + CHECK ("band" IN ('green', 'yellow', 'red')), + CONSTRAINT "health_score_records_composite_range_check" + CHECK ("composite" >= 0 AND "composite" <= 100), + CONSTRAINT "health_score_records_day_key_check" + CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), + CONSTRAINT "health_score_records_composition_not_empty_check" + CHECK (array_length("composition", 1) >= 1) +); + +CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" + ON "health_score_records" ("user_id", "day_key"); + +CREATE INDEX "health_score_records_user_id_day_key_desc_idx" + ON "health_score_records" ("user_id", "day_key" DESC); + +ALTER TABLE "health_score_records" + ADD CONSTRAINT "health_score_records_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6cd6fa6a8..8d7af385f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -846,6 +846,10 @@ model User { customMetrics CustomMetric[] customMetricEntries CustomMetricEntry[] correlationPatterns CorrelationPattern[] + // v1.35.0 — one row per local day holding the health score as it was shown + // that day. Append-only: a later recomputation of the same day never + // rewrites the row it finds. + healthScoreRecords HealthScoreRecord[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. @@ -5050,6 +5054,84 @@ model UserAchievement { @@map("user_achievements") } +// ─── Health Score record ────── + +/// The health score as it was SHOWN to the account on one local day. +/// +/// Not a cache and not a derived tier: nothing can rebuild it. The live +/// computation always answers for today from today's rows, so once the rows +/// behind an old day have been edited, re-synced, corrected or simply grown, +/// the number the person actually saw that day is gone unless it was written +/// down. That is what this table is, and it is why the write is append-only: +/// the FIRST score computed on a local day is the one that is kept, and a +/// later computation of the same day leaves the row exactly as it found it. +/// +/// It never overrides the live computation either. Every surface keeps +/// reading the live score; this table is read only where a past day is the +/// question. +model HealthScoreRecord { + id String @id @default(cuid()) + userId String @map("user_id") + /// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in + /// `timezone`. Lexicographically ordered, so a trailing-window read is a + /// range scan. + dayKey String @map("day_key") @db.VarChar(10) + /// The IANA zone the day was cut in. An account that moves zones leaves + /// behind days cut on the old clock; carrying the zone is what lets a + /// reader see that rather than silently compare days of different lengths. + timezone String + + /// The composite 0-100 as shown. Integer, matching the composite's own + /// rounding. + composite Int + /// The band as shown (`green` / `yellow` / `red`). Stored rather than + /// re-derived from `composite`, because the worst pillar can pull the band + /// below the one the mean alone would give, so the number does not + /// determine the band. + band String @db.VarChar(8) + /// Algorithm identity the row was computed under. + scoreVersion Int @map("score_version") + /// Registry-ordered pillar ids that produced the composite. Part of the + /// number's identity: the same value under a different composition is a + /// different measurement. + composition String[] + /// Each counted pillar's own 0-100 score, keyed by pillar id. The composite + /// breaks across a composition change; these stay comparable through one, + /// which is the whole reason they are stored beside it rather than folded + /// away. + pillarScores Json @map("pillar_scores") + /// SHA-256 over exactly what the stored composite and band depend on: the + /// score version, the composition, and every counted pillar's score with + /// its scoring-mode identity. Two rows with the same fingerprint were + /// computed from the same standing; two rows that differ, were not. + inputFingerprint String @db.VarChar(64) @map("input_fingerprint") + + /// The account's health-score configuration version in force when the row + /// was written, and when that configuration last changed. Written by + /// whoever computes the score; today nothing supplies them, because the + /// per-user configuration itself lands beside this table and its delta + /// guard lands after it. They are here now so that guard does not need a + /// second migration over a table that will already hold rows. + /// @pending-consumer(score delta guard) suppresses the week-over-week delta + /// across a configuration change instead of narrating a settings action as + /// a health event. + configVersion Int? @map("config_version") + /// @pending-consumer(score delta guard) dates the series break on the first + /// record written after a configuration change. + configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) + + /// When the score was computed. Distinct from `dayKey`: the day says which + /// day was scored, this says when the scoring ran. + computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, dayKey]) + @@index([userId, dayKey(sort: Desc)]) + @@map("health_score_records") +} + // ─── Host Metrics (admin system-status host-load chart) ────── /// Per-minute snapshot of host-process load + memory captured by the From d92be0d0bd5f508e55fd409c3540c7da5d534980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:03:20 +0200 Subject: [PATCH 03/26] Lift the score breadth rule into one shared place The composite needs three distinct domains and one physiological pillar. Until now only the scorer could narrow the pillar set, so the rule lived inside it. A settings write can narrow it too, and a second copy of the rule would drift, so both layers now call the same function over the same pillar-to-domain map. --- src/lib/analytics/score/breadth.ts | 71 ++++++++++++++++++++++++++++ src/lib/analytics/score/composite.ts | 24 ++++------ src/lib/analytics/score/index.ts | 17 +++---- src/lib/analytics/score/types.ts | 21 ++++++++ 4 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 src/lib/analytics/score/breadth.ts diff --git a/src/lib/analytics/score/breadth.ts b/src/lib/analytics/score/breadth.ts new file mode 100644 index 000000000..6ab9029ed --- /dev/null +++ b/src/lib/analytics/score/breadth.ts @@ -0,0 +1,71 @@ +/** + * v1.35.0 — the breadth rule, in one place. + * + * A composite Health Score exists only when the pillars behind it span + * at least three distinct domains and include at least one physiological + * measure. Before per-user composition the rule lived inside + * `computeComposite` alone, because nothing else could narrow the set. + * Now two layers apply it: the settings write refuses a selection that + * cannot produce a score, and the scorer refuses to produce one anyway. + * That is deliberate defence in depth, and it is only honest if both + * layers ask the same question, so both call this function. + */ +import { + SCORE_PILLAR_DOMAINS, + type ScoreDomain, + type ScorePillarId, +} from "./types"; + +export const SCORE_MIN_ELIGIBLE_DOMAINS = 3; + +/** + * Pillars that rest on a physiological measurement. `ACTIVITY` and + * `WELLBEING` are the only two that do not, which is why a selection of + * those two alone yields no score however many domains it spans. + */ +export const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "SLEEP", + "ADIPOSITY", + "FITNESS", + "LIPIDS", +]); + +export type ScoreBreadthFailure = + "three_domains_required" | "measured_physiological_domain_required"; + +/** + * Discriminated on `ok` so a caller that has checked cannot then read a + * reason that is not there, and cannot skip the check to get one. + * `domains` carries the distinct domains the set spans, for a caller + * that has to explain the refusal. + */ +export type ScoreBreadthVerdict = + | { ok: true; reason: null; domains: ScoreDomain[] } + | { ok: false; reason: ScoreBreadthFailure; domains: ScoreDomain[] }; + +/** + * Judge a pillar set against the breadth rule. + * + * The missing-physiological reason wins when both fail, because it is + * the more specific thing to tell someone: adding a third domain that + * is neither blood pressure nor a lab still leaves them without a score. + */ +export function evaluateScoreBreadth( + ids: readonly ScorePillarId[], +): ScoreBreadthVerdict { + const domains = [...new Set(ids.map((id) => SCORE_PILLAR_DOMAINS[id]))]; + const hasPhysiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id)); + if (!hasPhysiological) { + return { + ok: false, + reason: "measured_physiological_domain_required", + domains, + }; + } + if (domains.length < SCORE_MIN_ELIGIBLE_DOMAINS) { + return { ok: false, reason: "three_domains_required", domains }; + } + return { ok: true, reason: null, domains }; +} diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 9ce934be4..c47bf3781 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -15,18 +15,14 @@ import { type ScorePillarResult, } from "./types"; import { mean, provenance, scoreBand } from "./shared"; +import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth"; export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z"; -export const SCORE_MIN_ELIGIBLE_DOMAINS = 3; -const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ - "BLOOD_PRESSURE", - "GLYCAEMIA", - "SLEEP", - "ADIPOSITY", - "FITNESS", - "LIPIDS", -]); +// The rule itself lives in `./breadth`, where the settings write reads it +// too. Re-exported here because this is where every existing caller looks +// for it. +export { SCORE_MIN_ELIGIBLE_DOMAINS }; const BAND_RANK: Record = { green: 2, @@ -57,9 +53,7 @@ export function computeComposite( ); const composition = eligible.map((pillar) => pillar.id); const eligibleDomains = new Set(eligible.map((pillar) => pillar.domain)); - const hasPhysiological = composition.some((id) => - PHYSIOLOGICAL_PILLARS.has(id), - ); + const breadth = evaluateScoreBreadth(composition); const availableDomains = new Set( input.pillars .filter((pillar) => available.includes(pillar.id)) @@ -102,13 +96,11 @@ export function computeComposite( asOf: input.asOf, }); - if (eligibleDomains.size < SCORE_MIN_ELIGIBLE_DOMAINS || !hasPhysiological) { + if (!breadth.ok) { return buildInsufficient({ coverage, provenance: compositeProvenance, - reason: !hasPhysiological - ? "measured_physiological_domain_required" - : "three_domains_required", + reason: breadth.reason, }); } diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts index 3d5f6ffb2..59ac59640 100644 --- a/src/lib/analytics/score/index.ts +++ b/src/lib/analytics/score/index.ts @@ -9,6 +9,7 @@ import { computeGlycaemiaPillar } from "./glycaemia"; import { computeLipidsPillar } from "./lipids"; import { computeSleepPillar } from "./sleep"; import { + SCORE_PILLAR_DOMAINS, SCORE_PILLAR_IDS, SCORE_VERSION, type CompositeValue, @@ -35,42 +36,42 @@ export function computeHealthScore( const byId: Record = { BLOOD_PRESSURE: { id: "BLOOD_PRESSURE", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE, result: computeBloodPressurePillar(input.pillars.BLOOD_PRESSURE), }, GLYCAEMIA: { id: "GLYCAEMIA", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.GLYCAEMIA, result: computeGlycaemiaPillar(input.pillars.GLYCAEMIA), }, ACTIVITY: { id: "ACTIVITY", - domain: "activity", + domain: SCORE_PILLAR_DOMAINS.ACTIVITY, result: computeActivityPillar(input.pillars.ACTIVITY), }, SLEEP: { id: "SLEEP", - domain: "sleep", + domain: SCORE_PILLAR_DOMAINS.SLEEP, result: computeSleepPillar(input.pillars.SLEEP), }, ADIPOSITY: { id: "ADIPOSITY", - domain: "adiposity", + domain: SCORE_PILLAR_DOMAINS.ADIPOSITY, result: computeAdiposityPillar(input.pillars.ADIPOSITY), }, WELLBEING: { id: "WELLBEING", - domain: "wellbeing", + domain: SCORE_PILLAR_DOMAINS.WELLBEING, result: computeWellbeingPillar(input.pillars.WELLBEING), }, FITNESS: { id: "FITNESS", - domain: "fitness", + domain: SCORE_PILLAR_DOMAINS.FITNESS, result: computeFitnessPillar(input.pillars.FITNESS), }, LIPIDS: { id: "LIPIDS", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.LIPIDS, result: computeLipidsPillar(input.pillars.LIPIDS), }, }; diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 086749b95..83ee83941 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -29,6 +29,27 @@ export type ScoreDomain = | "wellbeing" | "fitness"; +/** + * Which domain each pillar speaks to. The composite needs three + * distinct domains, so this map is part of the breadth rule and not + * decoration: three of the eight pillars share the cardiometabolic + * domain, which is why a selection of five pillars can still fail. + * + * One map, read by the scorer when it builds a pillar result and by the + * breadth rule when it judges a selection, so the two can never drift + * into disagreeing about what counts as a distinct area. + */ +export const SCORE_PILLAR_DOMAINS: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + export type PillarReferenceKind = "clinical-threshold" | "population-percentile" | "guideline-band"; From 860a1fa299364f74d59c6de3cd35f358d20d5100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:05:51 +0200 Subject: [PATCH 04/26] Record each local day's score on the path that computes it Every surface that shows a score now goes through one entry point that computes it and writes the day down, rather than three surfaces each remembering to. The write is an insert that yields on conflict, so a second computation of the same day leaves the first row alone, and a failure is annotated rather than thrown: a score that renders must not disappear because a bookkeeping insert did. The fingerprint covers exactly what the stored composite and band depend on - the algorithm version, the composition, and each counted pillar's score with its scoring-mode identity. A pillar that was graded but not counted stays out of both the fingerprint and the row, because it did not contribute to the number being stored. --- src/app/api/analytics/route.ts | 4 +- .../analytics/score/__tests__/record.test.ts | 355 ++++++++++++++++++ src/lib/analytics/score/derived.ts | 4 +- src/lib/analytics/score/record.ts | 303 +++++++++++++++ src/lib/dashboard/snapshot.ts | 4 +- 5 files changed, 664 insertions(+), 6 deletions(-) create mode 100644 src/lib/analytics/score/__tests__/record.test.ts create mode 100644 src/lib/analytics/score/record.ts 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, From 4e649de4f73e644b5f21a133001e9bb2ba5c739f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:06:55 +0200 Subject: [PATCH 05/26] Add the health-score composition resolver Reads the stored deselection list into the pillar set the scorer and the settings surface both work from. An absent list means the person never chose and keeps counting everything; a present list is an authored recipe and is never widened. Ids outside the catalogue are dropped on read rather than failing the whole blob, so one unknown string cannot discard a recipe someone wrote. --- .../analytics/score/__tests__/breadth.test.ts | 171 ++++++++++++++++ .../analytics/score/__tests__/config.test.ts | 191 ++++++++++++++++++ src/lib/analytics/score/config.ts | 161 +++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 src/lib/analytics/score/__tests__/breadth.test.ts create mode 100644 src/lib/analytics/score/__tests__/config.test.ts create mode 100644 src/lib/analytics/score/config.ts diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts new file mode 100644 index 000000000..27a3272a6 --- /dev/null +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -0,0 +1,171 @@ +/** + * v1.35.0 — the breadth rule, and the proof that both layers apply the + * same one. + * + * The settings write refuses a selection that cannot produce a score; + * the scorer refuses to produce one from a set that fails the same rule. + * Two layers, on purpose. The danger in two layers is that they drift, + * so the last block below drives the real `computeComposite` and asserts + * it refuses exactly the sets `evaluateScoreBreadth` refuses, with the + * same reason. + */ +import { describe, expect, it } from "vitest"; + +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +import { evaluateScoreBreadth } from "../breadth"; +import { computeComposite } from "../composite"; +import { + SCORE_PILLAR_DOMAINS, + SCORE_PILLAR_IDS, + type PillarValue, + type ScorePillarId, + type ScorePillarResult, +} from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +function pillar(id: ScorePillarId, score = 80): 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: id, + }; + const result: Derived = buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }); + return { id, domain: SCORE_PILLAR_DOMAINS[id], result }; +} + +/** The sets the rule has an opinion about, each with the expected verdict. */ +const CASES: Array<{ + what: string; + ids: ScorePillarId[]; + reason: string | null; +}> = [ + { + what: "the two non-physiological pillars alone", + ids: ["ACTIVITY", "WELLBEING"], + reason: "measured_physiological_domain_required", + }, + { + what: "activity and wellbeing plus nothing measured", + ids: ["ACTIVITY"], + reason: "measured_physiological_domain_required", + }, + { + what: "an empty selection", + ids: [], + reason: "measured_physiological_domain_required", + }, + { + what: "the whole cardiometabolic triple, which is one domain", + ids: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"], + reason: "three_domains_required", + }, + { + what: "two domains with a physiological pillar", + ids: ["BLOOD_PRESSURE", "ACTIVITY"], + reason: "three_domains_required", + }, + { + what: "three domains including a physiological pillar", + ids: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + reason: null, + }, + { + what: "three domains where two pillars share one", + ids: ["BLOOD_PRESSURE", "LIPIDS", "ACTIVITY", "SLEEP"], + reason: null, + }, + { what: "every pillar", ids: [...SCORE_PILLAR_IDS], reason: null }, +]; + +describe("evaluateScoreBreadth", () => { + for (const { what, ids, reason } of CASES) { + it(`${reason ? "refuses" : "accepts"} ${what}`, () => { + const verdict = evaluateScoreBreadth(ids); + expect(verdict.ok).toBe(reason === null); + expect(verdict.reason).toBe(reason); + }); + } + + it("names the missing physiological measure before the missing domain", () => { + // Three domains, none of them a physiological measure is impossible + // with the current registry, so the precedence shows on the set that + // fails both: one non-physiological pillar, one domain. + expect(evaluateScoreBreadth(["ACTIVITY"]).reason).toBe( + "measured_physiological_domain_required", + ); + }); + + it("reports the distinct domains the set spans", () => { + expect( + evaluateScoreBreadth(["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"]).domains, + ).toEqual(["cardiometabolic"]); + }); +}); + +describe("the scorer refuses exactly what the write refuses", () => { + for (const { what, ids, reason } of CASES) { + it(`agrees on ${what}`, () => { + const composite = computeComposite({ + pillars: ids.map((id) => pillar(id)), + availablePillars: ids, + asOf: NOW, + }); + const verdict = evaluateScoreBreadth(ids); + expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient"); + if (composite.status === "insufficient") { + expect(composite.reason).toBe(verdict.reason); + } + }); + } +}); + +describe("the pillar-to-domain map", () => { + it("covers every pillar in the registry", () => { + for (const id of SCORE_PILLAR_IDS) { + expect(SCORE_PILLAR_DOMAINS[id]).toBeTruthy(); + } + }); + + it("keeps the three cardiometabolic pillars in one domain", () => { + // The triple-count is the reason a five-pillar selection can still + // fail the rule, so it is pinned rather than left to the map's shape. + expect(SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE).toBe("cardiometabolic"); + expect(SCORE_PILLAR_DOMAINS.GLYCAEMIA).toBe("cardiometabolic"); + expect(SCORE_PILLAR_DOMAINS.LIPIDS).toBe("cardiometabolic"); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config.test.ts b/src/lib/analytics/score/__tests__/config.test.ts new file mode 100644 index 000000000..b85d72744 --- /dev/null +++ b/src/lib/analytics/score/__tests__/config.test.ts @@ -0,0 +1,191 @@ +/** + * v1.35.0 — the per-user Health Score composition resolver. + * + * The failure this file exists to catch is silent by nature: a resolver + * that folds "never chose" into "chose nothing", or that quietly widens + * an authored recipe, changes people's numbers without anyone seeing a + * stack trace. Every assertion below is about a distinction staying a + * distinction. + */ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_HEALTH_SCORE_CONFIG, + healthScoreConfigFromSelection, + resolveHealthScoreConfig, +} from "../config"; +import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types"; + +const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS]; + +describe("resolveHealthScoreConfig — never chose", () => { + it("treats a NULL row as counting every pillar, with no selection", () => { + const resolved = resolveHealthScoreConfig(null); + expect(resolved.pillars).toEqual(ALL); + expect(resolved.excludedPillars).toEqual([]); + expect(resolved.hasSelection).toBe(false); + expect(resolved.version).toBe(0); + expect(resolved.changedAt).toBeNull(); + }); + + it("treats an empty object and a blob without the key the same way", () => { + for (const raw of [{}, { version: 4, changedAt: "2026-07-31" }]) { + const resolved = resolveHealthScoreConfig(raw); + expect(resolved.hasSelection).toBe(false); + expect(resolved.pillars).toEqual(ALL); + expect(resolved.version).toBe(0); + } + }); + + it("falls back to counting everything when the blob no longer parses", () => { + for (const raw of [ + { excludedPillars: "SLEEP" }, + { excludedPillars: [17] }, + "not an object", + 42, + ]) { + const resolved = resolveHealthScoreConfig(raw); + expect(resolved.pillars).toEqual(ALL); + expect(resolved.hasSelection).toBe(false); + } + }); + + it("hands back a copy, so a caller cannot mutate the shared defaults", () => { + const resolved = resolveHealthScoreConfig(null); + resolved.pillars.pop(); + expect(DEFAULT_HEALTH_SCORE_CONFIG.pillars).toEqual(ALL); + expect(resolveHealthScoreConfig(null).pillars).toEqual(ALL); + }); +}); + +describe("resolveHealthScoreConfig — never chose is not chose nothing", () => { + it("keeps an empty selection empty instead of reading it as no choice", () => { + const choseNothing = resolveHealthScoreConfig({ + excludedPillars: ALL, + version: 2, + changedAt: "2026-07-31T09:00:00.000Z", + }); + expect(choseNothing.pillars).toEqual([]); + expect(choseNothing.hasSelection).toBe(true); + expect(choseNothing.version).toBe(2); + + const neverChose = resolveHealthScoreConfig(null); + expect(neverChose.pillars).not.toEqual(choseNothing.pillars); + expect(neverChose.hasSelection).not.toBe(choseNothing.hasSelection); + }); + + it("counts a kept-everything selection as an authored choice", () => { + const kept = resolveHealthScoreConfig({ + excludedPillars: [], + version: 1, + changedAt: "2026-07-31T09:00:00.000Z", + }); + // Same composition as an account that never chose, different state: + // this person opened the surface and kept every pillar. + expect(kept.pillars).toEqual(ALL); + expect(kept.hasSelection).toBe(true); + expect(kept.version).toBe(1); + expect(resolveHealthScoreConfig(null).hasSelection).toBe(false); + }); +}); + +describe("resolveHealthScoreConfig — the catalogue wins", () => { + it("drops ids the build does not know", () => { + const resolved = resolveHealthScoreConfig({ + excludedPillars: ["SLEEP", "MOON_PHASE", "LIPIDS"], + version: 1, + }); + expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); + expect(resolved.pillars).not.toContain("SLEEP"); + expect(resolved.pillars).not.toContain("LIPIDS"); + expect(resolved.pillars).toContain("BLOOD_PRESSURE"); + }); + + it("de-duplicates and restores registry order", () => { + const resolved = resolveHealthScoreConfig({ + excludedPillars: ["LIPIDS", "SLEEP", "LIPIDS"], + version: 1, + }); + expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); + expect(resolved.pillars).toEqual( + ALL.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), + ); + }); + + it("partitions the catalogue exactly: counted plus excluded is everything", () => { + const resolved = resolveHealthScoreConfig({ + excludedPillars: ["WELLBEING"], + version: 1, + }); + expect([...resolved.pillars, ...resolved.excludedPillars].sort()).toEqual( + [...ALL].sort(), + ); + }); +}); + +/** + * The reconciliation the deselection list buys: a pillar that did not + * exist when the person chose is absent from their stored list, so it + * counts. The blob below is one written in a smaller world, naming only + * ids that existed then. Every later pillar must arrive counted, and no + * choice the person actually made may be undone in the process. + */ +describe("resolveHealthScoreConfig — a pillar shipped later", () => { + const STORED_IN_A_SMALLER_WORLD = { + excludedPillars: ["SLEEP"], + version: 3, + changedAt: "2026-01-04T08:00:00.000Z", + }; + + it("counts every pillar the person never had the chance to remove", () => { + const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD); + for (const id of ALL) { + if (id === "SLEEP") continue; + expect(resolved.pillars).toContain(id); + } + }); + + it("still honours the one removal the person did make", () => { + const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD); + expect(resolved.pillars).not.toContain("SLEEP"); + expect(resolved.excludedPillars).toEqual(["SLEEP"]); + expect(resolved.version).toBe(3); + expect(resolved.changedAt).toBe("2026-01-04T08:00:00.000Z"); + }); +}); + +describe("healthScoreConfigFromSelection", () => { + it("stores the complement of the selection and round-trips it", () => { + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const blob = healthScoreConfigFromSelection({ + selection, + version: 5, + changedAt: new Date("2026-07-31T10:15:00.000Z"), + }); + expect(blob.excludedPillars).toEqual( + ALL.filter((id) => !selection.includes(id)), + ); + expect(blob.version).toBe(5); + expect(blob.changedAt).toBe("2026-07-31T10:15:00.000Z"); + + const resolved = resolveHealthScoreConfig(blob); + expect(resolved.pillars).toEqual(selection); + expect(resolved.hasSelection).toBe(true); + expect(resolved.version).toBe(5); + }); + + it("stores every pillar as excluded when the selection is empty", () => { + const blob = healthScoreConfigFromSelection({ + selection: [], + version: 1, + changedAt: new Date("2026-07-31T10:15:00.000Z"), + }); + expect(blob.excludedPillars).toEqual(ALL); + expect(resolveHealthScoreConfig(blob).pillars).toEqual([]); + }); +}); diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts new file mode 100644 index 000000000..8842b08ee --- /dev/null +++ b/src/lib/analytics/score/config.ts @@ -0,0 +1,161 @@ +/** + * v1.35.0 — per-user Health Score composition: the stored shape, the + * schema that guards it, and the resolver every reader goes through. + * + * Three things this file is careful about, each because getting it + * wrong is silent rather than loud. + * + * 1. **"Never chose" is not "chose nothing."** `excludedPillars` is + * `.optional()` and deliberately not `.default([])`. An absent key + * means the person never opened the surface, and such an account may + * still inherit a change of defaults later without its number + * jumping. A present list, even a long one, is an authored recipe + * and is never widened behind the person's back. Collapse the two + * and every never-configured account drifts the day defaults move. + * + * 2. **The stored list is a deselection list.** It records what the + * person took out, not what they left in. A pillar shipped after + * they chose is simply not in the list, so it counts, which is the + * "everything eligible" starting point applied to an existing + * account with no backfill. Had the column stored the positive + * selection, a newly shipped pillar would be indistinguishable from + * one the person deliberately removed, and the resolver would have + * to guess. This is the same posture as `modulePreferencesJson`. + * + * 3. **The catalogue wins on read.** Ids outside `SCORE_PILLAR_IDS` are + * dropped rather than trusted, so a retired pillar, a hand-edited + * blob, or a row written by a newer build can never put an unknown + * id in front of the scorer. + * + * The write route (`PATCH /api/auth/me/health-score-config`) speaks the + * positive selection, because that is what a person sees and what the + * score's published composition means. The inversion happens once, at + * the boundary, in `healthScoreConfigFromSelection`. + */ +import { z } from "zod"; + +import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types"; + +/** Ordered, de-duplicated, unknown ids dropped. Registry order is the score's own. */ +export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] { + const present = new Set(ids); + return SCORE_PILLAR_IDS.filter((id) => present.has(id)); +} + +export const scorePillarIdSchema = z.enum(SCORE_PILLAR_IDS); + +/** + * The persisted blob. + * + * Deliberately not `.strict()`, and deliberately loose about the ids: + * a row written by a newer build, or carrying a pillar this build has + * retired, must degrade to what this build understands rather than + * failing the parse. A failed parse reads as "never chose", which would + * silently discard an authored recipe over one unrecognised string. The + * ids are filtered against the catalogue on the way out instead. The + * REQUEST body is the strict end: an unknown id there is a 422, because + * a person's client has no business sending one. + */ +export const healthScoreConfigSchema = z.object({ + excludedPillars: z.array(z.string().max(64)).max(64).optional(), + /** Per-user recipe version. Increments on every accepted write. */ + version: z.number().int().min(1).optional(), + /** When the recipe last changed, ISO 8601. */ + changedAt: z.string().max(40).optional(), +}); + +export type HealthScoreConfig = z.infer; + +/** + * What an account that never chose gets: every pillar counts, no + * recipe version, no change date. Read-time reconciliation is against + * this, so a pillar added to `SCORE_PILLAR_IDS` later appears here and + * in every resolved config without a backfill. + */ +export const DEFAULT_HEALTH_SCORE_CONFIG: Readonly = + Object.freeze({ + pillars: Object.freeze([...SCORE_PILLAR_IDS]) as ScorePillarId[], + excludedPillars: Object.freeze([] as ScorePillarId[]) as ScorePillarId[], + hasSelection: false, + version: 0, + changedAt: null, + }); + +/** A fresh, caller-owned copy of the defaults. */ +function defaults(): ResolvedHealthScoreConfig { + return { + ...DEFAULT_HEALTH_SCORE_CONFIG, + pillars: [...DEFAULT_HEALTH_SCORE_CONFIG.pillars], + excludedPillars: [], + }; +} + +export interface ResolvedHealthScoreConfig { + /** + * Registry-ordered pillars the person counts toward the score. This + * is the composition the scorer starts from; data availability + * narrows it further, and that narrowing is not a choice. + */ + pillars: ScorePillarId[]; + /** Registry-ordered pillars the person took out. */ + excludedPillars: ScorePillarId[]; + /** + * True once a selection has been written. It says the person chose, + * not that their choice differs from the default: an account that + * opened the surface and kept every pillar has a selection. + */ + hasSelection: boolean; + /** Per-user recipe version. 0 while no selection has been written. */ + version: number; + /** When the recipe last changed. Null while no selection exists. */ + changedAt: string | null; +} + +/** + * Resolve a stored `healthScoreConfigJson` blob into the composition + * the scorer and the settings surface both read. + * + * A null row, a non-object, or a shape that no longer parses all + * resolve to the defaults, which is the honest fallback: the account + * counts everything, exactly as it did before the column existed. + */ +export function resolveHealthScoreConfig( + raw: unknown, +): ResolvedHealthScoreConfig { + if (raw == null || typeof raw !== "object") return defaults(); + const parsed = healthScoreConfigSchema.safeParse(raw); + if (!parsed.success) return defaults(); + if (parsed.data.excludedPillars === undefined) return defaults(); + + const excludedPillars = orderedUniquePillars(parsed.data.excludedPillars); + const excluded = new Set(excludedPillars); + return { + pillars: SCORE_PILLAR_IDS.filter((id) => !excluded.has(id)), + excludedPillars, + hasSelection: true, + version: parsed.data.version ?? 1, + changedAt: parsed.data.changedAt ?? null, + }; +} + +/** + * Turn the positive selection a person made into the blob the column + * stores. Unknown ids are dropped on the way in, so the stored + * deselection list only ever names pillars this build knows. + * + * `version` is supplied by the caller, which reads the previous version + * off the row and increments it: the recipe version has to be monotonic + * per account for a score record to say which recipe produced it. + */ +export function healthScoreConfigFromSelection(input: { + selection: readonly ScorePillarId[]; + version: number; + changedAt: Date; +}): HealthScoreConfig { + const selected = new Set(orderedUniquePillars(input.selection)); + return { + excludedPillars: SCORE_PILLAR_IDS.filter((id) => !selected.has(id)), + version: input.version, + changedAt: input.changedAt.toISOString(), + }; +} From d89b90da4bd61ee661edbbc3707f33f049af7246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:07:35 +0200 Subject: [PATCH 06/26] Hash what the score actually depends on, and nothing that cannot move The fingerprint carried the composition twice: once as its own key and once as the ordered pillar ids inside the per-pillar triples. No input could make those two disagree, so the term could not be broken and the test that claimed to cover it passed with the term removed. Dropped the duplicate and replaced the test with one that pins what the ids are for: a different set of pillars producing the same numbers in the same order is a different standing, and now reads as one. --- .../analytics/score/__tests__/record.test.ts | 29 ++++++++++++++++++- src/lib/analytics/score/record.ts | 17 +++++++---- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 11b61c1f2..eba7ea28a 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -152,7 +152,7 @@ describe("input fingerprint", () => { ); }); - it("moves when the composition moves", () => { + it("moves when the composition narrows", () => { const narrowed = { ...base, composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[], @@ -162,6 +162,33 @@ describe("input fingerprint", () => { ); }); + it("moves when a different pillar set produces the same numbers", () => { + // Same count, same scores, same order, same scoring modes — only WHICH + // pillars counted differs. Without the pillar id in the hash this is the + // case that reads as an unchanged standing, and it is exactly the change + // a configuration edit makes. + const swapped = { + ...base, + composition: ["GLYCAEMIA", "SLEEP", "ADIPOSITY"] as ScorePillarId[], + pillars: [ + pillar("GLYCAEMIA", 88, { deltaIdentity: "shared" }), + pillar("SLEEP", 74, { deltaIdentity: "shared" }), + pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }), + ], + }; + const original = { + ...base, + pillars: [ + pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "shared" }), + pillar("SLEEP", 74, { deltaIdentity: "shared" }), + pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }), + ], + }; + expect(healthScoreInputFingerprint(swapped)).not.toBe( + healthScoreInputFingerprint(original), + ); + }); + it("moves when a counted pillar's score moves", () => { const shifted = { ...base, diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts index fbfff908e..930829612 100644 --- a/src/lib/analytics/score/record.ts +++ b/src/lib/analytics/score/record.ts @@ -132,11 +132,17 @@ function countedPillars( * 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. + * "hash the report": the algorithm version, and then one ordered triple per + * counted pillar — its id, its score, and the scoring mode that produced that + * score. Drop any one of those and two genuinely different standings collapse + * onto one fingerprint, which is a key missing a dimension its value depends + * on, a defect class this codebase has shipped before. + * + * The composition is not hashed separately. It IS the ordered list of ids in + * those triples, and an earlier draft carried it twice: a key that no input + * could ever make disagree with its neighbour, and therefore a key no test + * could make fail. A hash term nothing can move is not defence in depth, it + * is decoration on a guard. * * What is deliberately NOT covered: the account's configuration version. A * configuration decides the composition, and the composition is already here. @@ -153,7 +159,6 @@ export function healthScoreInputFingerprint(input: { const canonical = JSON.stringify({ v: FINGERPRINT_VERSION, scoreVersion: input.scoreVersion, - composition: [...input.composition], pillars: counted.map((pillar) => [ pillar.id, pillar.score, From 2f6f3573a213c64bddf2c079f168f912e3c4ad7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:12:47 +0200 Subject: [PATCH 07/26] Add the route that chooses what counts toward the score PATCH /api/auth/me/health-score-config takes the pillars a person wants counted and stores the rest as the deselection list. A selection that cannot produce a score is refused with a sentence naming what is missing, using the same rule the scorer applies at read time. The write evicts the score caches, and so does the modules route, which changes the same composition and never did. --- .../__tests__/route.test.ts | 400 ++++++++++++++++++ .../api/auth/me/health-score-config/route.ts | 219 ++++++++++ .../auth/me/modules/__tests__/route.test.ts | 40 ++ src/app/api/auth/me/modules/route.ts | 7 + .../analytics/score/__tests__/breadth.test.ts | 2 +- src/lib/data-wipe/wipe-plan.ts | 1 + 6 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/health-score-config/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/health-score-config/route.ts diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts new file mode 100644 index 000000000..7cb6f40f6 --- /dev/null +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -0,0 +1,400 @@ +/** + * v1.35.0 — GET/PATCH /api/auth/me/health-score-config. + * + * Covers the read projection, the positive-selection-in / + * deselection-out inversion, the write-time breadth refusal, the + * monotonic recipe version, and the score-cache eviction. The + * invalidation assertion is here because a missing one is invisible: + * the write succeeds, the response is right, and the person keeps + * seeing the old number for an hour. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserHealthScore: vi.fn() }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import type { ScorePillarId } from "@/lib/analytics/score/types"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const ALL_EIGHT: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", +]; + +interface ResolvedBody { + pillars: ScorePillarId[]; + excludedPillars: ScorePillarId[]; + hasSelection: boolean; + version: number; + changedAt: string | null; + updatedAt?: string; +} + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/health-score-config", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +/** Mutable row, so a write is visible to the read that follows it. */ +function primeUser(healthScoreConfigJson: unknown = null) { + const row: { healthScoreConfigJson: unknown; updatedAt: Date } = { + healthScoreConfigJson, + updatedAt: new Date("2026-07-31T09:00:00.000Z"), + }; + vi.mocked(prisma.user.findUnique).mockImplementation((() => + Promise.resolve({ ...row })) as never); + vi.mocked(prisma.user.update).mockImplementation(((args: { + data?: { healthScoreConfigJson?: unknown }; + }) => { + if (args.data && "healthScoreConfigJson" in args.data) { + row.healthScoreConfigJson = args.data.healthScoreConfigJson; + } + return Promise.resolve({ updatedAt: row.updatedAt }); + }) as never); + return row; +} + +function writtenConfig(): { + excludedPillars: ScorePillarId[]; + version: number; + changedAt: string; +} { + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as { + data: { + healthScoreConfigJson: { + excludedPillars: ScorePillarId[]; + version: number; + changedAt: string; + }; + }; + }; + return call.data.healthScoreConfigJson; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/health-score-config", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + expect(res.status).toBe(401); + }); + + it("reports every pillar counting, and no selection, for a fresh account", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual(ALL_EIGHT); + expect(env.data.hasSelection).toBe(false); + expect(env.data.version).toBe(0); + expect(env.data.updatedAt).toBeUndefined(); + }); + + it("reports a stored selection as the pillars it leaves counting", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ + excludedPillars: ["SLEEP", "LIPIDS"], + version: 2, + changedAt: "2026-07-30T08:00:00.000Z", + }); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual( + ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), + ); + expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); + expect(env.data.hasSelection).toBe(true); + expect(env.data.version).toBe(2); + expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z"); + }); +}); + +describe("PATCH /api/auth/me/health-score-config", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("stores the complement of the selection and returns the selection", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: selection }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual(selection); + expect(env.data.hasSelection).toBe(true); + + const stored = writtenConfig(); + expect(stored.excludedPillars).toEqual( + ALL_EIGHT.filter((id) => !selection.includes(id)), + ); + expect(stored.version).toBe(1); + expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(auditLog).toHaveBeenCalledWith( + "user.health_score_config.update", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("increments the recipe version on every accepted write", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ + excludedPillars: ["LIPIDS"], + version: 7, + changedAt: "2026-07-01T00:00:00.000Z", + }); + + await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(writtenConfig().version).toBe(8); + }); + + it("keeps a kept-everything selection as an authored choice", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + const env = (await res.json()) as { data: ResolvedBody }; + expect(writtenConfig().excludedPillars).toEqual([]); + expect(env.data.hasSelection).toBe(true); + }); + + it("evicts the score caches so the old number is not served on", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); + }); +}); + +describe("PATCH — the breadth rule", () => { + it("refuses activity and wellbeing alone, in words a person can act on", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }), + ); + expect(res.status).toBe(422); + const env = (await res.json()) as { + error: string; + meta?: { errorCode?: string; reason?: string }; + }; + expect(env.meta?.errorCode).toBe("health_score_config.too_narrow"); + expect(env.meta?.reason).toBe("measured_physiological_domain_required"); + expect(env.error).toContain("at least one physical measurement"); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); + + it("refuses the cardiometabolic triple, which is three pillars in one area", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }), + ); + expect(res.status).toBe(422); + const env = (await res.json()) as { + error: string; + meta?: { reason?: string }; + }; + expect(env.meta?.reason).toBe("three_domains_required"); + expect(env.error).toContain("three different areas"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses an empty selection rather than storing a score that cannot exist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: [] }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("accepts the smallest selection that clears the rule", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); +}); + +describe("PATCH — envelope and transport", () => { + it("rejects an unknown pillar id with 422 and writes nothing", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an unknown body key with 422 (strict stays strict)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a userId in the body", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request( + "http://localhost/api/auth/me/health-score-config", + { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }, + ); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("guards the write on the base token and 409s a stale one", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ + pillars: ALL_EIGHT, + baseUpdatedAt: "2026-07-24T08:00:00.000Z", + }), + ); + expect(res.status).toBe(409); + const env = (await res.json()) as { meta?: { errorCode?: string } }; + expect(env.meta?.errorCode).toBe("health_score_config_conflict"); + expect(prisma.user.update).not.toHaveBeenCalled(); + // A write that never landed must not evict the caches either. + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/health-score-config/route.ts b/src/app/api/auth/me/health-score-config/route.ts new file mode 100644 index 000000000..4020933dc --- /dev/null +++ b/src/app/api/auth/me/health-score-config/route.ts @@ -0,0 +1,219 @@ +/** + * v1.35.0 — which pillars count toward the Health Score. + * + * GET /api/auth/me/health-score-config — the resolved composition: + * which pillars count, which + * the person took out, whether + * they have chosen at all, and + * the recipe version. + * PATCH /api/auth/me/health-score-config — body `{ pillars: [...] }`, + * the pillars that should + * count. Field-by-field write + * of the columns this route + * owns; no mass assignment. + * + * The body speaks the positive selection because that is what a person + * chooses. The column stores its complement (see + * `src/lib/analytics/score/config.ts`), so the inversion happens once, + * here, on the way in. + * + * Two things this route is responsible for beyond storing a list. + * + * **It refuses a selection that cannot produce a score.** Three distinct + * areas of health and at least one physical measurement, or the + * composite does not exist. Saving such a selection would leave the + * person with a settings page that says one thing and a score that has + * silently vanished, so the write is refused with a sentence they can + * act on. The scorer applies the same rule again at read time through + * the same function, deliberately: a config written before a rule change + * or edited outside the app still meets an honest refusal rather than a + * missing number. + * + * **It evicts the score caches.** The person just changed what the + * number means; serving the old one for the next hour would make the + * change look broken. `invalidateUserHealthScore` is the same call the + * lab and assessment write paths make. + * + * `userId` is always narrowed from `requireAuth()`; the body never + * carries it. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + takeBaseToken, + guardedUserUpdate, + invalidBaseTokenError, +} from "@/lib/optimistic-lock"; +import { evaluateScoreBreadth } from "@/lib/analytics/score/breadth"; +import { + healthScoreConfigFromSelection, + resolveHealthScoreConfig, + scorePillarIdSchema, +} from "@/lib/analytics/score/config"; +import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +/** + * The request body. `.strict()` and enum-typed: a client sending an id + * this build does not know is a mistake worth reporting, unlike a + * stored blob, which is filtered instead so one stale string cannot + * discard a recipe (see the resolver). + */ +const healthScoreConfigPatchSchema = z + .object({ + pillars: z.array(scorePillarIdSchema).max(SCORE_PILLAR_IDS.length), + }) + .strict(); + +/** + * Why a selection cannot produce a score, said the way a person would + * say it. The cardiometabolic sentence is the one that surprises people: + * blood pressure, glucose and cholesterol are three pillars but one area. + */ +const BREADTH_REFUSAL = { + measured_physiological_domain_required: + "A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.", + three_domains_required: + "A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one.", +} as const; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.health-score-config.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthScoreConfigJson: true, updatedAt: true }, + }); + const resolved = resolveHealthScoreConfig(row?.healthScoreConfigJson); + // A person who never chose has nothing to guard a write against, so + // they get no token — the first save is the unconditional write. + if (row?.healthScoreConfigJson == null) return apiSuccess(resolved); + return apiSuccess({ ...resolved, updatedAt: row.updatedAt?.toISOString() }); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `health-score-config:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "health-score-config.body.invalid_json"); + } + + // Strip the optimistic-concurrency token before the strict parse, the + // way the modules route does: the schema must stay strict, so the + // transport token can never reach it. + const taken = takeBaseToken(body ?? {}); + if ("invalid" in taken) return invalidBaseTokenError(); + const base = taken.base; + + const parsed = healthScoreConfigPatchSchema.safeParse(taken.rest ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.health-score-config.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "health_score_config.invalid", + }); + } + + const breadth = evaluateScoreBreadth(parsed.data.pillars); + if (!breadth.ok) { + annotate({ + action: { name: "auth.me.health-score-config.patch.too_narrow" }, + meta: { + reason: breadth.reason, + selected: parsed.data.pillars.length, + domains: breadth.domains.length, + }, + }); + return apiError(BREADTH_REFUSAL[breadth.reason], 422, { + errorCode: "health_score_config.too_narrow", + reason: breadth.reason, + }); + } + + // The recipe version is monotonic per account: a score record has to + // be able to say which recipe produced it, and a comparison between + // two windows has to be able to see that the recipe moved between + // them. Read the previous version, increment, write. + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthScoreConfigJson: true }, + }); + const previous = resolveHealthScoreConfig(existing?.healthScoreConfigJson); + const config = healthScoreConfigFromSelection({ + selection: parsed.data.pillars, + version: previous.version + 1, + changedAt: new Date(), + }); + + const guarded = await guardedUserUpdate({ + userId: user.id, + base, + data: { healthScoreConfigJson: config }, + conflict: { + action: "auth.me.health-score-config.conflict", + errorCode: "health_score_config_conflict", + message: "The score selection changed since it was loaded", + }, + }); + if ("conflict" in guarded) return guarded.conflict; + + // The composite the caches hold was computed from the old selection. + invalidateUserHealthScore(user.id); + + const resolved = resolveHealthScoreConfig(config); + + await auditLog("user.health_score_config.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + counted: resolved.pillars, + excluded: resolved.excludedPillars, + version: resolved.version, + }, + }); + + annotate({ + action: { name: "auth.me.health-score-config.patch" }, + meta: { + counted: resolved.pillars.length, + excluded: resolved.excludedPillars.length, + version: resolved.version, + }, + }); + + return apiSuccess({ ...resolved, updatedAt: guarded.updatedAt }); +}); diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 3bc33635d..1e23957ac 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -51,6 +51,12 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => { }; }); +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserHealthScore: vi.fn() }; +}); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -66,6 +72,7 @@ import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -320,6 +327,39 @@ describe("PATCH /api/auth/me/modules", () => { }); }); +/** + * v1.35.0 — four of the score's pillars come and go with a module + * toggle, so a toggle changes what the composite is made of. The score + * caches hold the old composition and are served for up to an hour, so + * the person who just turned a module off keeps seeing a number + * computed from it. Nothing about that is visible in a test that does + * not assert the eviction, which is why it went unnoticed for so long. + */ +describe("PATCH /api/auth/me/modules — the score caches", () => { + it("evicts the score caches after a module toggle lands", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ sleep: false }), + ); + expect(res.status).toBe(200); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); + }); + + it("does not evict them when the write never landed", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }), + ); + expect(res.status).toBe(409); + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE * the `.strict()` schema parse, so it never trips strict validation; a stale diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 637876743..05060be90 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -35,6 +35,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { takeBaseToken, @@ -137,6 +138,12 @@ export const PATCH = apiHandler(async (req: Request) => { }); if ("conflict" in guarded) return guarded.conflict; + // A module toggle can add or remove a Health Score pillar, so the + // cached composite was computed from a composition that no longer + // holds. Without this the score stays stale for up to an hour after a + // change the person made on purpose. + invalidateUserHealthScore(user.id); + const modules = await resolveModuleMap(user.id); await auditLog("user.modules.update", { diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts index 27a3272a6..e26683bf1 100644 --- a/src/lib/analytics/score/__tests__/breadth.test.ts +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -138,7 +138,7 @@ describe("evaluateScoreBreadth", () => { }); describe("the scorer refuses exactly what the write refuses", () => { - for (const { what, ids, reason } of CASES) { + for (const { what, ids } of CASES) { it(`agrees on ${what}`, () => { const composite = computeComposite({ pillars: ids.map((id) => pillar(id)), diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 1e631c218..a0abfff9e 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -330,6 +330,7 @@ export const USER_RESET = { moodTagLayoutJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, + healthScoreConfigJson: Prisma.DbNull, globalExcludedInjectionSites: [], moodReminderEnabled: false, notificationPrefs: Prisma.DbNull, From ab8e9a164def281f1b13a4d94b9d6cb89649e701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:13:47 +0200 Subject: [PATCH 08/26] Carry the recorded score days through wipe, backup and restore A new user-scoped table is outside all three by default, and the two structural guards said so the moment the model landed: the wipe plan and the backup verdict list both named it as unclassified. It now joins the wipe list, rides the backup payload as its own spread section, and comes back through the restore route. The classification guards only prove a helper exists in a declared file, so they would pass an unreferenced restore helper. The integration round trip goes through the real admin restore route instead, with the table emptied first so a restore that carries nothing cannot pass by leaving what was already there. --- .../api/admin/backups/[id]/restore/route.ts | 11 + .../analytics/score/__tests__/record.test.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 6 + src/lib/export/backup-plan.ts | 9 + src/lib/export/full-backup-payload.ts | 20 +- src/lib/export/health-score-backup.ts | 206 +++++++++ src/lib/validations/backup.ts | 31 ++ tests/integration/health-score-record.test.ts | 407 ++++++++++++++++++ 8 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 src/lib/export/health-score-backup.ts create mode 100644 tests/integration/health-score-record.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 03d749045..f83ef5a35 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; +import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -71,6 +72,7 @@ interface RestoreResponse { customMetrics: number; correlationPatterns: number; intradayProfiles: number; + healthScoreRecords: number; }; } @@ -764,6 +766,14 @@ const handler = apiHandler( payload, ); + // The score as it was shown on each local day. Both ends live in + // `src/lib/export/health-score-backup.ts`, same as the two above. + const healthScoreCleared = await restoreHealthScoreData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1128,6 +1138,7 @@ const handler = apiHandler( customMetrics: profileCleared.customMetrics, correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, + healthScoreRecords: healthScoreCleared.healthScoreRecords, }; }, { diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index eba7ea28a..96842301d 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -324,7 +324,9 @@ describe("writing the row", () => { * not a comment. */ function delegate(count: number) { - const createMany = vi.fn(async () => ({ count })); + const createMany = vi.fn(async (_args: { skipDuplicates?: boolean }) => ({ + count, + })); return { db: { healthScoreRecord: { createMany } } as never, createMany, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 1e631c218..d5b5003bc 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -54,6 +54,12 @@ export const WIPE_MODELS = [ // record and nothing else holds it, so a wipe that leaves it behind leaves // behind an account's activity pattern. "IntradayCumulativeProfile", + // The health score as it was shown, one row per local day. Not a cache of + // the live computation — nothing can rebuild a past day once the readings + // behind it have moved — so leaving it behind leaves behind a record of how + // the account was doing, day by day, after a confirmation that said + // otherwise. + "HealthScoreRecord", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6c78f9096..07f0505c6 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -103,6 +103,12 @@ export const BACKED_UP_MODELS = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + // Reads like a derived tier and is not one. The live computation always + // answers for today from today's rows, so once the readings behind a past + // day have been corrected, re-synced or simply grown, nothing can reproduce + // what that day said. The row is the only copy of the number the account + // actually saw, which is exactly the test for BACKED_UP rather than DERIVED. + "HealthScoreRecord", // ── Activity and nutrition ──────────────────────────────────────────────── "Workout", @@ -157,6 +163,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/records-backup.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -164,6 +171,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/app/api/admin/backups/[id]/restore/route.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -206,6 +214,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + "HealthScoreRecord", "Workout", "NutrientIntakeDay", "InboundDocument", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 4a8d970ea..e0452a9f1 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -31,6 +31,12 @@ import { type ProfileBackupCounts, type ProfileBackupSection, } from "@/lib/export/profile-backup"; +import { + buildHealthScoreBackupSection, + countHealthScoreBackupSection, + type HealthScoreBackupCounts, + type HealthScoreBackupSection, +} from "@/lib/export/health-score-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -42,7 +48,8 @@ export interface FullBackupCounts extends RecordsBackupCounts, ProfileBackupCounts, - IntradayProfileBackupCounts { + IntradayProfileBackupCounts, + HealthScoreBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -117,6 +124,7 @@ export async function buildFullBackupPayload( records, profile, intradayProfiles, + healthScoreRecords, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -225,6 +233,13 @@ export async function buildFullBackupPayload( buildIntradayProfileBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The score as it was SHOWN on each local day. Reads like a derived tier + // and is not one: today's number always comes from today's rows, so once + // the readings behind a past day have moved, nothing can recompute what + // that day said. The row is the only copy. + buildHealthScoreBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -250,6 +265,7 @@ export async function buildFullBackupPayload( const recordsSection: RecordsBackupSection = records; const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; + const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -396,6 +412,7 @@ export async function buildFullBackupPayload( ...recordsSection, ...profileSection, ...intradaySection, + ...healthScoreSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -418,6 +435,7 @@ export async function buildFullBackupPayload( ...countRecordsBackupSection(records), ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), + ...countHealthScoreBackupSection(healthScoreRecords), }, }; } diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts new file mode 100644 index 000000000..c8b7f9985 --- /dev/null +++ b/src/lib/export/health-score-backup.ts @@ -0,0 +1,206 @@ +/** + * The stored health score days, with both backup ends in one file. + * + * `HealthScoreRecord` looks derived and is not. The live computation answers + * for today from today's rows, so a day whose readings have since been + * corrected, re-synced or simply extended cannot be recomputed as it stood; + * the row is the only copy of the number the account actually saw. Classifying + * it `DERIVED` and leaving it out would produce a restore that comes back with + * a blank history and reports success, which is the shape of defect the + * nutrient day totals, the custom mood tags and the custom cycle symptoms each + * shipped in turn. + * + * The builder and the restore live together deliberately, the way + * `intraday-profile-backup.ts` and `src/lib/cycle/backup.ts` do: a reader + * asking "is this carried at both ends?" answers it in one file, and a reader + * who greps only the restore ROUTE gets a false negative, because the route + * delegates. + * + * Nothing here is encrypted, so there is no portable/disaster-recovery split + * on the values. The only difference between the two purposes is identity: a + * canonical disaster-recovery payload carries the row id and its timestamps so + * the row comes back as itself, a portable export carries the day alone. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import type { ScoreBand } from "@/lib/analytics/score/types"; + +export interface HealthScoreBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One local day's score, exactly as it was shown. */ +export interface HealthScoreRecordBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** Local calendar day (`YYYY-MM-DD`) in `timezone`. */ + dayKey: string; + /** + * The zone the day was cut on. Carried for the same reason the intraday + * curves carry theirs: an account that moved zones has days of different + * lengths behind it, and a restore that guessed would make them look + * comparable. + */ + timezone: string; + composite: number; + band: ScoreBand; + scoreVersion: number; + composition: string[]; + /** Each counted pillar's own score, keyed by pillar id. */ + pillarScores: Record; + inputFingerprint: string; + configVersion: number | null; + configChangedAt: string | null; + computedAt: string; + createdAt?: string; +} + +export interface HealthScoreBackupSection { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +export interface HealthScoreBackupCounts { + healthScoreRecords: number; +} + +/** + * Read the stored score days as JSON-safe entries. + * + * `pillarScores` arrives as an opaque `JsonValue`; it is narrowed here rather + * than cast, so a row holding something other than a flat id-to-number map + * reaches the payload as an empty map instead of as a shape the restore's + * schema would reject at the worst possible moment. + */ +function readPillarScores(value: Prisma.JsonValue): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "number") out[key] = entry; + } + return out; +} + +/** + * Build the stored-score slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders, so the route's global client and the worker's local + * one share one read. + */ +export async function buildHealthScoreBackupSection( + prisma: Pick, + userId: string, + options: HealthScoreBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + // The table carries no tombstone — a day is written once and never deleted + // short of a wipe — so both purposes read the same set. + const rows = await prisma.healthScoreRecord.findMany({ + where: { userId }, + orderBy: { dayKey: "desc" }, + }); + + return { + healthScoreRecords: rows.map((row) => ({ + ...(disasterRecovery + ? { id: row.id, createdAt: row.createdAt.toISOString() } + : {}), + dayKey: row.dayKey, + timezone: row.timezone, + composite: row.composite, + band: row.band as ScoreBand, + scoreVersion: row.scoreVersion, + composition: row.composition, + pillarScores: readPillarScores(row.pillarScores), + inputFingerprint: row.inputFingerprint, + configVersion: row.configVersion, + configChangedAt: row.configChangedAt?.toISOString() ?? null, + computedAt: row.computedAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countHealthScoreBackupSection( + section: HealthScoreBackupSection, +): HealthScoreBackupCounts { + return { healthScoreRecords: section.healthScoreRecords.length }; +} + +/** Counts the score-record restore wiped, for the audit trail. */ +export interface HealthScoreRestoreCleared { + healthScoreRecords: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: `BackupPayload` defaults it, so every caller + * already satisfies this, and a caller that stops satisfying it fails to + * compile instead of passing `undefined` into a loop that iterates zero times + * and reports success. + */ +export interface HealthScoreRestoreInput { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +/** + * Re-create the account's stored score days. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Note the asymmetry with the live write path next door: THAT one + * yields on conflict, because a second computation of a day it already holds + * must change nothing. This one does not, because the file is the authority + * here and a day it carries twice is a corrupt file, not a race. Two entries + * claiming the same local day therefore throw with a sentence rather than + * letting `skipDuplicates` pick a winner and report success. + */ +export async function restoreHealthScoreData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: HealthScoreRestoreInput, +): Promise { + const cleared = await tx.healthScoreRecord.deleteMany({ + where: { userId: ownerId }, + }); + + const seen = new Set(); + for (const record of payload.healthScoreRecords) { + if (seen.has(record.dayKey)) { + throw new Error( + `Duplicate health score record for ${record.dayKey}. One account has ` + + "at most one score per local day, and picking a winner here would " + + "silently discard the other reading of that day.", + ); + } + seen.add(record.dayKey); + } + + if (payload.healthScoreRecords.length > 0) { + await tx.healthScoreRecord.createMany({ + data: payload.healthScoreRecords.map((record) => ({ + ...(record.id ? { id: record.id } : {}), + userId: ownerId, + dayKey: record.dayKey, + timezone: record.timezone, + composite: record.composite, + band: record.band, + scoreVersion: record.scoreVersion, + composition: record.composition, + pillarScores: record.pillarScores as Prisma.InputJsonValue, + inputFingerprint: record.inputFingerprint, + configVersion: record.configVersion, + configChangedAt: record.configChangedAt + ? new Date(record.configChangedAt) + : null, + computedAt: new Date(record.computedAt), + ...(record.createdAt ? { createdAt: new Date(record.createdAt) } : {}), + })), + }); + } + + return { healthScoreRecords: cleared.count }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b7917266e..07cf9d86c 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -577,6 +577,30 @@ const intradayProfileBackupSchema = z }) .passthrough(); +const healthScoreRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day was cut on. An account that moved zones has days of + // different lengths behind it, and a restore that guessed would make them + // look comparable. + timezone: z.string().min(1), + composite: z.number().int().min(0).max(100), + // The same closed set the column's CHECK constraint carries. A band the + // schema let through and the database refused would fail the whole + // restore transaction on one bad row. + band: z.enum(["green", "yellow", "red"]), + scoreVersion: z.number().int(), + composition: z.array(z.string().min(1)).min(1), + pillarScores: z.record(z.string(), z.number()), + inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + configVersion: z.number().int().nullable().default(null), + configChangedAt: isoDateTime.nullable().default(null), + computedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -849,6 +873,10 @@ export const backupPayloadSchema = z // The hourly shape of a cumulative day. Defaulted for the same reason as // the pair above: a file written before the table existed carries no key. intradayProfiles: z.array(intradayProfileBackupSchema).default([]), + // The score as it was shown, day by day. Defaulted for the same reason as + // the sections above: a file written before the table existed carries no + // key, and an account whose score never resolved writes []. + healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -913,6 +941,8 @@ export interface BackupSummary { correlationPatterns: number; /** Stored day curves for the cumulative metrics, across every metric. */ intradayProfiles: number; + /** Local days whose health score was written down as it was shown. */ + healthScoreRecords: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -947,6 +977,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { ), correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, + healthScoreRecords: payload.healthScoreRecords.length, }; } diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts new file mode 100644 index 000000000..b17ecbe6f --- /dev/null +++ b/tests/integration/health-score-record.test.ts @@ -0,0 +1,407 @@ +/** + * The persisted health score day, end to end against real Postgres. + * + * A mocked Prisma proves nothing here. Every claim this file makes is about + * what is actually in the table after a real request: that computing a score + * writes the day down, that computing it again under different readings + * leaves the earlier day exactly as it stood, and that the row survives a + * backup and a restore rather than coming back blank while the restore + * reports success. + * + * The three lifecycle paths a new user-scoped table has to join, and where + * each is proved: + * + * - delete-all-data — `tests/integration/settings-data-wipe.test.ts` seeds + * every table from `information_schema` and asserts every model in the + * wipe plan is empty afterwards, so this table is covered there the + * moment it enters the plan. `src/__tests__/data-wipe-completeness.test.ts` + * is what forces it into the plan in the first place. + * - the backup writer — proved below over `buildFullBackupPayload`, which + * is the builder the weekly worker and both export routes share. + * - the restore path — proved below over the real admin restore ROUTE, not + * over the helper it delegates to. That distinction is the whole point: + * the structural guard in `backup-plan-classification.test.ts` reads the + * declared restore FILES, so a helper that exists and is never called + * passes it. Only a round trip through the route catches that. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { caches } from "@/lib/cache/server-cache"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +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), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), + invalidateUserHealthScore: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +const DAY = 24 * 60 * 60 * 1000; + +interface AnalyticsEnvelope { + data: { + healthScore: { + composite: { + status: "ok" | "insufficient"; + value?: { score: number; band: string; composition: string[] }; + }; + } | null; + } | null; +} + +async function seedSession(username: string, role: "USER" | "ADMIN" = "USER") { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role, + heightCm: 178, + dateOfBirth: new Date("1985-07-09"), + // Pinned so the local day the row is filed under is a fact of the + // fixture rather than a property of the machine running the suite. + timezone: "UTC", + }, + }); + 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; +} + +/** BP needs twelve paired readings inside the trailing 90-day window. */ +async function seedBp(userId: string, now: number, days: number, sys: number) { + const prisma = getPrismaClient(); + for (let i = 0; i < days; i++) { + const at = new Date(now - i * DAY); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_SYS", + value: sys, + unit: "mmHg", + measuredAt: at, + }, + }); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 78, + unit: "mmHg", + measuredAt: at, + }, + }); + } +} + +/** SLEEP needs fourteen distinct wake-day nights. */ +async function seedSleep(userId: string, now: number, nights: number) { + const prisma = getPrismaClient(); + for (let i = 0; i < nights; i++) { + const wake = new Date(now - i * DAY); + wake.setUTCHours(6, 0, 0, 0); + await prisma.measurement.create({ + data: { + userId, + type: "SLEEP_DURATION", + value: 450, + unit: "min", + measuredAt: wake, + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + }, + }); + } +} + +/** ADIPOSITY needs one waist reading against the seeded height. */ +async function seedWaist(userId: string, now: number) { + await getPrismaClient().measurement.create({ + data: { + userId, + type: "WAIST_CIRCUMFERENCE", + value: 82, + unit: "cm", + measuredAt: new Date(now), + }, + }); +} + +async function readAnalytics(): Promise { + 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); + return (await res.json()) as AnalyticsEnvelope; +} + +describe("the score a surface shows is the score that gets written down", () => { + it("persists the composite, band and composition the analytics route returned", async () => { + const user = await seedSession("hsr-roundtrip"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + + const shown = (await readAnalytics()).data!.healthScore!; + expect(shown.composite.status).toBe("ok"); + + const rows = await getPrismaClient().healthScoreRecord.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(1); + const row = rows[0]; + // The shown value and the stored value are not compared through a fixture + // in the middle — the number asserted here came back over the wire from + // the same request that wrote the row. + expect(row.composite).toBe(shown.composite.value!.score); + expect(row.band).toBe(shown.composite.value!.band); + expect(row.composition).toEqual(shown.composite.value!.composition); + expect(row.pillarScores).toBeTruthy(); + // JSONB does not preserve insertion order, so the SET of keys is the + // claim, not their order. The order that matters lives in `composition`, + // which is a text array and asserted above. + expect( + Object.keys(row.pillarScores as Record).sort(), + ).toEqual([...shown.composite.value!.composition].sort()); + expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(row.timezone).toBe("UTC"); + expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10)); + // Nothing supplies these yet, and an invented default would be worse than + // an honest null the day the delta guard starts reading them. + expect(row.configVersion).toBeNull(); + expect(row.configChangedAt).toBeNull(); + }); + + it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => { + const user = await seedSession("hsr-never-restate"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + + const first = (await readAnalytics()).data!.healthScore!; + expect(first.composite.status).toBe("ok"); + const before = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + + // Change what the score is made of, two ways at once: a fourth pillar + // joins the composition, and blood pressure grades worse. Either alone + // moves the live number. + await getPrismaClient().measurement.deleteMany({ + where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" }, + }); + await getPrismaClient().measurement.deleteMany({ + where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" }, + }); + await seedBp(user.id, now, 20, 168); + for (let i = 0; i < 30; i++) { + await getPrismaClient().measurement.create({ + data: { + userId: user.id, + type: "ACTIVITY_STEPS", + value: 11_000, + unit: "steps", + measuredAt: new Date(now - i * DAY), + source: "APPLE_HEALTH", + }, + }); + } + + // `/api/analytics` reads through a 60-second cache, so without this the + // second request would replay the first request's body and nothing would + // recompute. The assertion below is what caught it. + caches.analytics.deleteByPrefix(`${user.id}|`); + caches.insightsDerived.deleteByPrefix(`${user.id}|`); + + const second = (await readAnalytics()).data!.healthScore!; + expect(second.composite.status).toBe("ok"); + // The live number really did move — without this the test below would + // pass against a recomputation that changed nothing. + expect(second.composite.value!.score).not.toBe( + first.composite.value!.score, + ); + + const after = await getPrismaClient().healthScoreRecord.findMany({ + where: { userId: user.id }, + }); + expect(after).toHaveLength(1); + expect(after[0]).toEqual(before); + }); +}); + +describe("the recorded day survives a backup and a restore", () => { + it("carries the row out through the shared backup builder", async () => { + const user = await seedSession("hsr-backup"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + await readAnalytics(); + + const stored = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + const { payload, counts } = await buildFullBackupPayload( + getPrismaClient(), + user.id, + { purpose: "disaster-recovery" }, + ); + expect(counts.healthScoreRecords).toBe(1); + const carried = (payload as { healthScoreRecords: unknown[] }) + .healthScoreRecords; + expect(carried).toHaveLength(1); + expect(carried[0]).toMatchObject({ + id: stored.id, + dayKey: stored.dayKey, + timezone: stored.timezone, + composite: stored.composite, + band: stored.band, + scoreVersion: stored.scoreVersion, + composition: stored.composition, + inputFingerprint: stored.inputFingerprint, + }); + // And the file it produced is one the restore side will accept, rather + // than one that parses everywhere except at the moment it is needed. + expect( + parseBackupPayload(JSON.stringify(payload)).healthScoreRecords, + ).toHaveLength(1); + }); + + it("brings the row back through the admin restore route", async () => { + const prisma = getPrismaClient(); + const admin = await seedSession("hsr-restore", "ADMIN"); + const now = Date.now(); + await seedBp(admin.id, now, 20, 122); + await seedSleep(admin.id, now, 14); + await seedWaist(admin.id, now); + await readAnalytics(); + + const before = await prisma.healthScoreRecord.findFirstOrThrow({ + where: { userId: admin.id }, + }); + const { payload } = await buildFullBackupPayload(prisma, admin.id, { + purpose: "disaster-recovery", + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "HEALTH_SCORE_RECORD_RESTORE_TEST", + data: encrypt(JSON.stringify(payload)), + }, + }); + + // Wipe the table so a restore that carries nothing cannot pass by leaving + // the row that was already there. + await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } }); + expect( + await prisma.healthScoreRecord.count({ where: { userId: admin.id } }), + ).toBe(0); + + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const res = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = (await res.json()) as { + data: { summary: { healthScoreRecords: number } } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(res.status).toBe(200); + expect(body.data!.summary.healthScoreRecords).toBe(1); + + const restored = await prisma.healthScoreRecord.findMany({ + where: { userId: admin.id }, + }); + expect(restored).toHaveLength(1); + expect(restored[0].id).toBe(before.id); + expect(restored[0].dayKey).toBe(before.dayKey); + expect(restored[0].composite).toBe(before.composite); + expect(restored[0].band).toBe(before.band); + expect(restored[0].composition).toEqual(before.composition); + expect(restored[0].pillarScores).toEqual(before.pillarScores); + expect(restored[0].inputFingerprint).toBe(before.inputFingerprint); + expect(restored[0].computedAt.toISOString()).toBe( + before.computedAt.toISOString(), + ); + }); +}); + +describe("delete all data", () => { + it("removes the recorded days along with the rest of the record", async () => { + const user = await seedSession("hsr-wipe"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + await readAnalytics(); + expect( + await getPrismaClient().healthScoreRecord.count({ + where: { userId: user.id }, + }), + ).toBe(1); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const res = await ( + DELETE as unknown as (req: Request) => Promise + )( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE" }), + }), + ); + expect(res.status).toBe(200); + expect( + await getPrismaClient().healthScoreRecord.count({ + where: { userId: user.id }, + }), + ).toBe(0); + }); +}); From cd7c6f44c8b6d4386eae73f41d25fb2fd0ad2460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:31:47 +0200 Subject: [PATCH 09/26] Teach the export test mocks about the recorded score days Four suites hand-roll a Prisma stand-in for the backup builder, so a new section reaches them as an undefined delegate rather than as an empty result. Each gains the delegate, and the admin restore round trip gains an assertion that the day comes back with its composition and per-pillar scores, not just its headline: a row restored without those looks restored and answers nothing. --- .../[id]/restore/__tests__/round-trip.test.ts | 61 +++++++++++++++++++ .../export/__tests__/per-type-routes.test.ts | 2 + .../export/encrypted/__tests__/route.test.ts | 1 + .../__tests__/full-backup-payload.test.ts | 1 + 4 files changed, 65 insertions(+) diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 228c20474..1305d3123 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -257,6 +257,29 @@ function sourceClient() { }, ]), }, + // The score as it was SHOWN on a local day. Not recomputable: today's + // number always comes from today's rows, so once the readings behind this + // day moved, nothing can reproduce what it said. + healthScoreRecord: { + findMany: vi.fn().mockResolvedValue([ + { + id: "score-day-1", + userId: OWNER, + dayKey: "2026-07-18", + timezone: "Europe/Berlin", + composite: 76, + band: "yellow", + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], + pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 }, + inputFingerprint: "a".repeat(64), + configVersion: null, + configChangedAt: null, + computedAt: new Date("2026-07-18T20:00:00.000Z"), + createdAt: new Date("2026-07-18T20:00:00.000Z"), + }, + ]), + }, customMetric: { findMany: vi.fn().mockResolvedValue([ { @@ -586,6 +609,44 @@ describe("backup round trip — export, wire schema, restore", () => { expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE); }); + it("writes the recorded score day back with the number it showed", async () => { + const { written } = await roundTrip(); + + const write = written.find( + (w) => w.model === "healthScoreRecord" && w.op === "createMany", + ); + expect( + write, + "the recorded score day reached the file and nothing wrote it back", + ).toBeDefined(); + + const rows = write!.data as unknown as Array>; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "score-day-1", + userId: OWNER, + dayKey: "2026-07-18", + timezone: "Europe/Berlin", + composite: 76, + band: "yellow", + scoreVersion: 2, + inputFingerprint: "a".repeat(64), + }); + // The composition and the per-pillar scores are what keep the stored day + // readable across a later change to what counts. A row that came back + // with only its headline would look restored and answer nothing. + expect(rows[0].composition).toEqual([ + "BLOOD_PRESSURE", + "SLEEP", + "ADIPOSITY", + ]); + expect(rows[0].pillarScores).toEqual({ + BLOOD_PRESSURE: 86, + SLEEP: 100, + ADIPOSITY: 42, + }); + }); + it("writes the account's own cycle symptom back before resolving its links", async () => { const { written, res } = await roundTrip(); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 7bb572d87..b1823aecb 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({ // The hourly shape of a cumulative day, read by // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -96,6 +97,7 @@ beforeEach(() => { vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 4a741cf58..4ac966527 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ // The hourly shape of a cumulative day, read by // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index eb0e5794d..810174ed8 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -204,6 +204,7 @@ function makePrisma() { }, ]), }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. From 956746fe7db54278c2179dce9157d6d9b18bb1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:37:04 +0200 Subject: [PATCH 10/26] Keep one sanitiser for pillar ids The scorer and the stored composition both had to answer what a valid pillar list looks like, and both had their own copy of the answer. One function next to the catalogue now, used by both. --- src/lib/analytics/score/composite.ts | 9 ++------- src/lib/analytics/score/config.ts | 12 +++++------- src/lib/analytics/score/types.ts | 12 ++++++++++++ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index c47bf3781..6dde302a5 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -6,7 +6,7 @@ import { import type { Derived } from "@/lib/insights/derived/types"; import { - SCORE_PILLAR_IDS, + orderedUniquePillars, SCORE_VERSION, type CompositeValue, type HealthScoreReport, @@ -36,15 +36,10 @@ export interface CompositeInput { asOf: Date; } -function orderedUnique(ids: readonly ScorePillarId[]): ScorePillarId[] { - const present = new Set(ids); - return SCORE_PILLAR_IDS.filter((id) => present.has(id)); -} - export function computeComposite( input: CompositeInput, ): Derived { - const available = orderedUnique(input.availablePillars); + const available = orderedUniquePillars(input.availablePillars); const byId = new Map(input.pillars.map((pillar) => [pillar.id, pillar])); const eligible = available .map((id) => byId.get(id)) diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index 8842b08ee..015796fc0 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -34,13 +34,11 @@ */ import { z } from "zod"; -import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types"; - -/** Ordered, de-duplicated, unknown ids dropped. Registry order is the score's own. */ -export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] { - const present = new Set(ids); - return SCORE_PILLAR_IDS.filter((id) => present.has(id)); -} +import { + orderedUniquePillars, + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "./types"; export const scorePillarIdSchema = z.enum(SCORE_PILLAR_IDS); diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 83ee83941..6cf094734 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -20,6 +20,18 @@ export const SCORE_PILLAR_IDS = [ ] as const; export type ScorePillarId = (typeof SCORE_PILLAR_IDS)[number]; + +/** + * Sanitise a list of pillar ids: registry order, no duplicates, and + * anything outside the catalogue dropped. Everything that narrows the + * score's composition goes through here, so a stored blob, a caller's + * argument and the scorer itself cannot disagree about what a valid + * pillar set looks like. + */ +export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] { + const present = new Set(ids); + return SCORE_PILLAR_IDS.filter((id) => present.has(id)); +} export type ScoreBand = "green" | "yellow" | "red"; export type ScoreDomain = | "cardiometabolic" From 5fd544706988eb12b96e046c9a45ef31d681dd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:38:50 +0200 Subject: [PATCH 11/26] Make the band a database type instead of a text check The delete-all-data guard seeds every table straight from information_schema so a table added next year is asserted without anyone remembering. A CHECK constraint defeats that: the generic seeder has no way to guess a value the constraint accepts, and the whole suite failed on the new table. A Postgres enum carries the same closed set, the seeder reads it from pg_enum, and Prisma now types the column instead of trusting a string. Dropped the day-key format and non-empty-composition checks with it. The writer builds the key through the timezone helper and refuses an empty composition before inserting, and the restore validates both against the wire schema before the transaction opens, so a third copy in the database bought nothing and cost a hand-written line in a fixture whose point is not having one. The composite range check stays: free to check, and a number outside nought to a hundred renders as a score nobody can read. --- .../0290_health_score_record/migration.sql | 27 ++++++++++++------- prisma/schema.prisma | 20 ++++++++++---- .../analytics/score/__tests__/record.test.ts | 16 ++++++++--- src/lib/export/health-score-backup.ts | 2 +- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/prisma/migrations/0290_health_score_record/migration.sql b/prisma/migrations/0290_health_score_record/migration.sql index 6bcd13b9b..6796babd6 100644 --- a/prisma/migrations/0290_health_score_record/migration.sql +++ b/prisma/migrations/0290_health_score_record/migration.sql @@ -10,20 +10,33 @@ -- * one row per account per local day, so a second computation on the same -- day cannot mint a rival record of the same day, and the writer's -- ON CONFLICT DO NOTHING has something to conflict against; --- * a closed set of bands, so a row can never carry a band no surface knows --- how to render. +-- * a closed set of bands, as a type rather than as a CHECK. The value the +-- application speaks is lowercase, so the enum carries the same labels and +-- needs no translation layer. A type also keeps the schema-driven wipe +-- seeder working: it reads `pg_enum` for a value it cannot otherwise +-- guess, where a CHECK would have forced a hand-written entry into a +-- fixture whose whole point is not having one. +-- +-- The composite is range-checked because it is free to check and a number +-- outside 0-100 would render as a score nobody can read. The day key's format +-- and the composition's non-emptiness are NOT checked here: the writer builds +-- the key through the timezone helper and refuses an empty composition before +-- it inserts, and the restore validates both against the wire schema before +-- the transaction opens. A third copy of those two rules would buy nothing. -- -- There is deliberately no updated_at column. The row is written once and -- never rewritten: a later computation under different readings, or under a -- different configuration, leaves the earlier day exactly as it stood. +CREATE TYPE "health_score_band" AS ENUM ('green', 'yellow', 'red'); + CREATE TABLE "health_score_records" ( "id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "day_key" VARCHAR(10) NOT NULL, "timezone" TEXT NOT NULL, "composite" INTEGER NOT NULL, - "band" VARCHAR(8) NOT NULL, + "band" "health_score_band" NOT NULL, "score_version" INTEGER NOT NULL, "composition" TEXT[] NOT NULL, "pillar_scores" JSONB NOT NULL, @@ -34,14 +47,8 @@ CREATE TABLE "health_score_records" ( "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"), - CONSTRAINT "health_score_records_band_check" - CHECK ("band" IN ('green', 'yellow', 'red')), CONSTRAINT "health_score_records_composite_range_check" - CHECK ("composite" >= 0 AND "composite" <= 100), - CONSTRAINT "health_score_records_day_key_check" - CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'), - CONSTRAINT "health_score_records_composition_not_empty_check" - CHECK (array_length("composition", 1) >= 1) + CHECK ("composite" >= 0 AND "composite" <= 100) ); CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d7af385f..8ea9df74b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5056,6 +5056,17 @@ model UserAchievement { // ─── Health Score record ────── +/// The three bands a composite can land in, lowercase because that is the +/// value every surface already speaks. Closed on purpose: the column refuses +/// a band no renderer knows. +enum HealthScoreBand { + green + yellow + red + + @@map("health_score_band") +} + /// The health score as it was SHOWN to the account on one local day. /// /// Not a cache and not a derived tier: nothing can rebuild it. The live @@ -5084,11 +5095,10 @@ model HealthScoreRecord { /// The composite 0-100 as shown. Integer, matching the composite's own /// rounding. composite Int - /// The band as shown (`green` / `yellow` / `red`). Stored rather than - /// re-derived from `composite`, because the worst pillar can pull the band - /// below the one the mean alone would give, so the number does not - /// determine the band. - band String @db.VarChar(8) + /// The band as shown. Stored rather than re-derived from `composite`, + /// because the worst pillar can pull the band below the one the mean alone + /// would give, so the number does not determine the band. + band HealthScoreBand /// Algorithm identity the row was computed under. scoreVersion Int @map("score_version") /// Registry-ordered pillar ids that produced the composite. Part of the diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 96842301d..3f95d979e 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -9,6 +9,7 @@ */ import { describe, expect, it, vi } from "vitest"; +import { HealthScoreBand } from "@/generated/prisma/client"; import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; import type { Derived } from "@/lib/insights/derived/types"; @@ -114,11 +115,18 @@ const HEALTHY = [ ]; describe("stored bands", () => { + it("matches the band type the column actually accepts", () => { + // The column is a Postgres enum, so a band the composite can produce and + // the type does not carry is a constraint violation on a write path that + // swallows its own failures — the score would keep rendering and the day + // would silently go unrecorded. Both ends are literals: the generated + // enum on one side, the list the writer uses on the other. + expect(Object.values(HealthScoreBand).sort()).toEqual( + [...SCORE_BANDS].sort(), + ); + }); + 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([ diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts index c8b7f9985..a0a70a0b8 100644 --- a/src/lib/export/health-score-backup.ts +++ b/src/lib/export/health-score-backup.ts @@ -111,7 +111,7 @@ export async function buildHealthScoreBackupSection( dayKey: row.dayKey, timezone: row.timezone, composite: row.composite, - band: row.band as ScoreBand, + band: row.band, scoreVersion: row.scoreVersion, composition: row.composition, pillarScores: readPillarScores(row.pillarScores), From 54fed258597735b71c44763c45118e83049e3e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:39:24 +0200 Subject: [PATCH 12/26] Silence an unused-parameter warning in the record write test --- src/lib/analytics/score/__tests__/record.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 3f95d979e..be4572072 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -332,9 +332,10 @@ describe("writing the row", () => { * not a comment. */ function delegate(count: number) { - const createMany = vi.fn(async (_args: { skipDuplicates?: boolean }) => ({ - count, - })); + const createMany = vi.fn(async (args: { skipDuplicates?: boolean }) => { + void args; + return { count }; + }); return { db: { healthScoreRecord: { createMany } } as never, createMany, From c66aafe9c8949614bb6e9b7a07ca8ec589b11eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:45:22 +0200 Subject: [PATCH 13/26] Read the deferred configuration columns in one line in the audit report The audit script prints the annotation verbatim, so a note wrapped across three schema comment lines came out with the comment markers folded into the middle of the sentence. --- prisma/schema.prisma | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ea9df74b..a41b30687 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5117,17 +5117,15 @@ model HealthScoreRecord { inputFingerprint String @db.VarChar(64) @map("input_fingerprint") /// The account's health-score configuration version in force when the row - /// was written, and when that configuration last changed. Written by - /// whoever computes the score; today nothing supplies them, because the - /// per-user configuration itself lands beside this table and its delta - /// guard lands after it. They are here now so that guard does not need a - /// second migration over a table that will already hold rows. - /// @pending-consumer(score delta guard) suppresses the week-over-week delta - /// across a configuration change instead of narrating a settings action as - /// a health event. + /// was written, and when that configuration last changed. The write path + /// takes both as parameters and persists whatever it is given; today + /// nothing supplies them, because the per-user configuration itself lands + /// beside this table and the guard that reads them lands after it. They are + /// here now so that guard does not need a second migration over a table + /// that will already hold rows. + /// @pending-consumer(score delta guard) suppresses the week-over-week delta across a configuration change, so a settings action is never narrated as a health event. configVersion Int? @map("config_version") - /// @pending-consumer(score delta guard) dates the series break on the first - /// record written after a configuration change. + /// @pending-consumer(score delta guard) dates the series break on the first record written after a configuration change. configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) /// When the score was computed. Distinct from `dayKey`: the day says which From 76e68f0247bab2c96cc8de19545070d7d39df7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:47:12 +0200 Subject: [PATCH 14/26] Let the score's recipe decide what counts, not the module switches Until now the set of pillars behind the Health Score was assembled from the module switches alone, in a stack of ternaries inside the reader. A person who turned the sleep module off lost the sleep pillar from their number as a side effect of a setting about recording data, and there was nowhere else to say what should count. The reader now reads the stored recipe and narrows it to the pillars a module is actually recording. The mapping from module to pillar moves out of the ternaries into a named map beside the reader, where it can be read and tested. Glycaemia stays fed by either glucose or labs, which was the one entry a one-module-per-pillar map would have lost. Nobody's number moves on upgrade. An account that never opened the new surface resolves to every pillar, and every pillar narrowed by module availability is exactly the set the ternaries built, so the inheritance holds by construction with no backfill. A pillar the recipe selects whose module is off produces no row at all, rather than a "waiting for data" line. Turning a module off is a deliberate act, and asking someone for data they chose to stop recording would be nagging them in the voice of their own settings. --- src/app/api/analytics/route.ts | 1 + src/lib/analytics/score/derived.ts | 2 + src/lib/analytics/score/modules.ts | 80 ++++++++++++++++++++ src/lib/analytics/score/reader.ts | 52 ++++++++----- src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/dashboard/snapshot-read.ts | 1 + src/lib/dashboard/snapshot.ts | 7 ++ 7 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 src/lib/analytics/score/modules.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index ca9b9c5ad..9bbfa031b 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -571,6 +571,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { sleep: sleepEnabled, mentalHealth: mentalHealthEnabled, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets: clinicalBpTargets, bpEnvelope, bpEnvelopePriorWeek, diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4ae00e227..1c61420c1 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -23,6 +23,7 @@ export async function computeHealthScoreDerived( heightCm: true, timezone: true, thresholdsJson: true, + healthScoreConfigJson: true, }, }); const now = new Date(); @@ -67,6 +68,7 @@ export async function computeHealthScoreDerived( sleep: modules.sleep !== false, mentalHealth: modules.mentalHealth !== false, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets, bpEnvelope, bpEnvelopePriorWeek, diff --git a/src/lib/analytics/score/modules.ts b/src/lib/analytics/score/modules.ts new file mode 100644 index 000000000..091c8e5c1 --- /dev/null +++ b/src/lib/analytics/score/modules.ts @@ -0,0 +1,80 @@ +/** + * v1.35.0 — which modules put data in front of which score pillar. + * + * Until this release the mapping existed only as ternaries inside the + * reader's `availablePillars` expression, where nothing could read it + * and nothing could test it. It is named here because two questions + * that used to have one answer now have two, and confusing them is how + * a person's number moves without them asking for it: + * + * - the CONFIG says which pillars a person counts toward their score; + * - a MODULE says whether that pillar's data is being recorded at all. + * + * The score takes the intersection. A module that is off cannot be + * overridden by a selection, because turning the module off closes the + * data path itself; and a pillar left out of the config stays out + * however much data flows into it. + * + * `GLYCAEMIA` appears under two modules on purpose: it scores off + * fasting glucose OR HbA1c, so either module alone keeps it fed. That + * was the `glucose || labs` ternary, and it is the entry that would be + * lost by assuming one module per pillar. + * + * `BLOOD_PRESSURE`, `ACTIVITY`, `ADIPOSITY` and `FITNESS` are absent + * from the map, which is the same thing the reader has always done: + * they read core measurement domains that carry no module switch, so no + * module can withdraw their data. Absent means ungated, not forgotten — + * the test beside this file pins the four so a silent addition to the + * map cannot quietly start gating one of them. + */ +import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types"; + +/** + * The modules the score reader is handed. Every key here gates at least + * one pillar; a module that gates nothing has no reason to be passed. + */ +export interface ScoreReaderModules { + glucose: boolean; + labs: boolean; + sleep: boolean; + mentalHealth: boolean; +} + +export type ScoreModuleKey = keyof ScoreReaderModules; + +/** Which pillars each module feeds. Registry ids, checked by the tests. */ +export const MODULE_SCORE_PILLARS: Record< + ScoreModuleKey, + readonly ScorePillarId[] +> = { + glucose: ["GLYCAEMIA"], + labs: ["GLYCAEMIA", "LIPIDS"], + sleep: ["SLEEP"], + mentalHealth: ["WELLBEING"], +}; + +const MODULE_KEYS = Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]; + +/** Pillars whose data any module can withdraw. Derived, never restated. */ +export const MODULE_GATED_PILLARS: ReadonlySet = new Set( + MODULE_KEYS.flatMap((key) => MODULE_SCORE_PILLARS[key]), +); + +/** + * The registry-ordered pillars whose data is actually being recorded, + * given the account's modules. This is availability, not choice: it says + * nothing about what the person wants counted, only about what there + * could be data for. + */ +export function pillarsWithModuleData( + modules: ScoreReaderModules, +): ScorePillarId[] { + const fed = new Set(); + for (const key of MODULE_KEYS) { + if (!modules[key]) continue; + for (const id of MODULE_SCORE_PILLARS[key]) fed.add(id); + } + return SCORE_PILLAR_IDS.filter( + (id) => !MODULE_GATED_PILLARS.has(id) || fed.has(id), + ); +} diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index bf2e6ddfd..950f7d39f 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -27,6 +27,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { ACTIVITY_WINDOW_DAYS } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; +import { resolveHealthScoreConfig } from "./config"; +import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; import { computeWeightGoal } from "./weight-goal"; @@ -45,19 +47,20 @@ export interface ScoreReaderProfile { thresholdsJson: unknown; } -export interface ScoreReaderModules { - glucose: boolean; - labs: boolean; - sleep: boolean; - mentalHealth: boolean; -} - export interface UserHealthScoreInput { prisma?: PrismaClient; userId: string; now: Date; profile: ScoreReaderProfile; modules: ScoreReaderModules; + /** + * The raw `User.healthScoreConfigJson` blob. Required, and deliberately + * not defaulted: a caller that forgets it would silently score the + * account on someone else's recipe, and a plausible default is exactly + * how that stays invisible. `null` is a real, meaningful value here — + * it means the person never chose, and every pillar counts. + */ + healthScoreConfigJson: unknown; bpTargets: BpTargets | null; bpEnvelope: BpInTargetEnvelope | null; bpEnvelopePriorWeek: BpInTargetEnvelope | null; @@ -763,18 +766,29 @@ export async function computeUserHealthScore( ), ]); - const availablePillars: ScorePillarId[] = [ - "BLOOD_PRESSURE", - ...(input.modules.glucose || input.modules.labs - ? ["GLYCAEMIA" as const] - : []), - "ACTIVITY", - ...(input.modules.sleep ? ["SLEEP" as const] : []), - "ADIPOSITY", - ...(input.modules.mentalHealth ? ["WELLBEING" as const] : []), - "FITNESS", - ...(input.modules.labs ? ["LIPIDS" as const] : []), - ]; + // v1.35.0 — what counts is the person's choice, narrowed to what there + // can be data for. Before this release the module switches decided the + // composition on their own; now they only say whether a pillar's data + // is being recorded, and the stored recipe says what counts. Two + // things deciding one number is the defect this replaces, so the old + // expression is gone rather than kept beside the new one. + // + // The promise on upgrade is that nobody's number moves, and it holds + // by construction rather than by a backfill: an account that never + // chose resolves to every pillar, and every pillar intersected with + // module availability is precisely the set the ternaries built. + // + // A pillar the config selects but whose module is off drops out here + // and therefore produces no row at all — not a score, not a "waiting + // for data" line. Turning a module off is a deliberate act with its + // own meaning, and asking someone for data they chose to stop + // recording would be nagging in the voice of their own settings. The + // place that pillar comes back is the modules screen. + const config = resolveHealthScoreConfig(input.healthScoreConfigJson); + const recorded = new Set(pillarsWithModuleData(input.modules)); + const availablePillars: ScorePillarId[] = config.pillars.filter((id) => + recorded.has(id), + ); const previousAt = new Date(input.now.getTime() - 7 * DAY_MS); const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS); const currentBp = scoreBpEnvelope({ diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 644e4d108..8077c2b43 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -195,6 +195,7 @@ function baseUser( insightsCachedAt: null, dashboardWidgetsJson: null, thresholdsJson: null, + healthScoreConfigJson: null, ...overrides, }; } diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index da2423aaa..acfcc4b25 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -79,6 +79,7 @@ export async function readDashboardSnapshotCached( dashboardWidgetsJson: user.dashboardWidgetsJson, sourcePriorityJson: user.sourcePriorityJson, thresholdsJson: user.thresholdsJson, + healthScoreConfigJson: user.healthScoreConfigJson, }; // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index f45bde80a..f2f45d9b9 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -490,6 +490,12 @@ export interface SnapshotUserInput { * dashboard reference bands. */ thresholdsJson: unknown; + /** + * Raw per-user Health Score recipe. Part of the number's identity, so + * it is required rather than optional: a snapshot built without it + * would show a score composed from someone else's rules. + */ + healthScoreConfigJson: unknown; } /** Wall-clock hour in `tz`, used for the server-side greeting. */ @@ -754,6 +760,7 @@ async function buildExtras( sleep: modules.sleep !== false, mentalHealth: modules.mentalHealth !== false, }, + healthScoreConfigJson: user.healthScoreConfigJson, bpTargets: clinicalBpTargets, bpEnvelope, bpEnvelopePriorWeek, From 0a877bdf4d487b8198b597800d060c47a2f9ca82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:51:26 +0200 Subject: [PATCH 15/26] Pin what decides the score's composition The module-to-pillar mapping had no test of its own. It lived in ternaries, so the only way to notice it had changed was for someone's score to change. The new file drives the real reader against a fake database and pins three things. The composition for every module combination with no stored recipe, compared against the old expression written out verbatim, which is what makes it a before-and-after rather than a restatement. The difference between a pillar that is selected and has no data yet, which keeps its row, and a pillar whose module is off, which has no row at all. And the breadth refusal at read time, whose reason has to be the shared rule's verdict on the set the reader resolved, so a second copy of that rule would show up as a disagreement. --- .../__tests__/composition-source.test.ts | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 src/lib/analytics/score/__tests__/composition-source.test.ts diff --git a/src/lib/analytics/score/__tests__/composition-source.test.ts b/src/lib/analytics/score/__tests__/composition-source.test.ts new file mode 100644 index 000000000..db8e54a12 --- /dev/null +++ b/src/lib/analytics/score/__tests__/composition-source.test.ts @@ -0,0 +1,506 @@ +/** + * v1.35.0 — what decides the Health Score's composition, pinned. + * + * The module switches used to decide it alone, from a stack of ternaries + * inside the reader that nothing could see and nothing tested. This file + * is the regression test the capability audit found missing, written for + * the NEW semantics: the stored recipe says what counts, the modules say + * what there can be data for, and the score takes the intersection. + * + * Three promises live here. + * + * 1. **Nobody's number moves on upgrade.** Every module combination with + * a never-written recipe resolves to exactly the set the ternaries + * built. The old expression is reproduced verbatim below as + * `compositionBeforeTheSwap`, so this is a real before-and-after + * comparison rather than a restatement of the new code. + * + * 2. **A module that is off removes the pillar entirely.** Not a score, + * not a "waiting for data" line, no row at all. That line belongs to + * a pillar someone selected and could still record today; a module + * they switched off is not waiting for anything. + * + * 3. **One breadth rule, applied twice.** The settings write refuses a + * selection that cannot produce a score; the reader refuses to + * produce one from a set that fails the same rule. The block at the + * bottom drives the REAL reader and asserts its refusal reason is + * `evaluateScoreBreadth`'s verdict on the reader's own resolved set, + * so a second copy of the rule that disagreed would show up here. + * + * The reader runs against a fake Prisma rather than hand-built pillar + * inputs on purpose: the composition is assembled halfway down that + * function, and a test that skips it would prove the wrong end. + */ +import { describe, expect, it } from "vitest"; + +import type { + GlucoseContext, + MeasurementSource, + MeasurementType, + PrismaClient, + SleepStage, +} from "@/generated/prisma/client"; +import { MODULE_KEYS } from "@/lib/modules/registry"; + +import { evaluateScoreBreadth } from "../breadth"; +import { healthScoreConfigFromSelection } from "../config"; +import { + MODULE_GATED_PILLARS, + MODULE_SCORE_PILLARS, + pillarsWithModuleData, + type ScoreModuleKey, + type ScoreReaderModules, +} from "../modules"; +import { computeUserHealthScore } from "../reader"; +import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); +const DAY_MS = 86_400_000; + +/** + * The `availablePillars` expression exactly as it stood before the swap, + * kept here as the "before" side of the inheritance proof. It must never + * be edited to follow the implementation: the day it disagrees with the + * reader is the day somebody's number moved. + */ +function compositionBeforeTheSwap( + modules: ScoreReaderModules, +): ScorePillarId[] { + return [ + "BLOOD_PRESSURE", + ...(modules.glucose || modules.labs ? (["GLYCAEMIA"] as const) : []), + "ACTIVITY", + ...(modules.sleep ? (["SLEEP"] as const) : []), + "ADIPOSITY", + ...(modules.mentalHealth ? (["WELLBEING"] as const) : []), + "FITNESS", + ...(modules.labs ? (["LIPIDS"] as const) : []), + ]; +} + +/** All sixteen on/off states of the four modules the reader is handed. */ +const MODULE_COMBINATIONS: ScoreReaderModules[] = [0, 1].flatMap((glucose) => + [0, 1].flatMap((labs) => + [0, 1].flatMap((sleep) => + [0, 1].map((mentalHealth) => ({ + glucose: glucose === 1, + labs: labs === 1, + sleep: sleep === 1, + mentalHealth: mentalHealth === 1, + })), + ), + ), +); + +const ALL_MODULES_ON: ScoreReaderModules = { + glucose: true, + labs: true, + sleep: true, + mentalHealth: true, +}; + +function describeModules(modules: ScoreReaderModules): string { + const off = (Object.keys(modules) as ScoreModuleKey[]).filter( + (key) => !modules[key], + ); + return off.length === 0 ? "every module on" : `${off.join(" + ")} off`; +} + +type MeasurementRow = { + type: MeasurementType; + value: number; + unit: string; + source: MeasurementSource; + measuredAt: Date; + deviceType: string | null; + glucoseContext: GlucoseContext | null; + sleepStage: SleepStage | null; +}; + +type AssessmentRow = { + instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI"; + totalScore: number; + item9Flagged: boolean; + takenAt: Date; +}; + +function measurement( + type: MeasurementType, + value: number, + measuredAt: Date, + extra: Partial = {}, +): MeasurementRow { + return { + type, + value, + unit: "", + source: "MANUAL", + measuredAt, + deviceType: null, + glucoseContext: null, + sleepStage: null, + ...extra, + }; +} + +/** + * An account with enough data for four pillars to score: steps, sleep, + * a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia, lipids + * and fitness stay honestly absent, which is what lets the same fixture + * show a selected-but-dataless pillar beside a module-withdrawn one. + */ +const MEASUREMENTS: MeasurementRow[] = [ + ...Array.from({ length: 45 }, (_, index) => + measurement( + "ACTIVITY_STEPS", + 9_000, + new Date(NOW.getTime() - (44 - index) * DAY_MS), + ), + ), + ...Array.from({ length: 45 }, (_, index) => + measurement( + "SLEEP_DURATION", + 450, + new Date( + new Date(NOW.getTime() - (44 - index) * DAY_MS).setUTCHours(6, 0, 0, 0), + ), + { sleepStage: "ASLEEP" }, + ), + ), + measurement("WAIST_TO_HEIGHT", 0.45, new Date(NOW.getTime() - 20 * DAY_MS)), +]; + +const ASSESSMENTS: AssessmentRow[] = [ + { + instrument: "WHO5", + totalScore: 80, + item9Flagged: false, + takenAt: new Date(NOW.getTime() - 20 * DAY_MS), + }, +]; + +function fakePrisma(): PrismaClient { + return { + measurement: { + findMany: async (query: { + where: { + type: { in: MeasurementType[] }; + measuredAt: { gte: Date }; + glucoseContext?: GlucoseContext; + }; + }) => + MEASUREMENTS.filter( + (row) => + query.where.type.in.includes(row.type) && + row.measuredAt >= query.where.measuredAt.gte && + (query.where.glucoseContext === undefined || + row.glucoseContext === query.where.glucoseContext), + ), + }, + mentalHealthAssessment: { findMany: async () => ASSESSMENTS }, + labResult: { findMany: async () => [] }, + dismissedPriorityItem: { findUnique: async () => null }, + } as unknown as PrismaClient; +} + +function score(args: { + modules: ScoreReaderModules; + healthScoreConfigJson: unknown; +}) { + return computeUserHealthScore({ + prisma: fakePrisma(), + userId: "user-1", + now: NOW, + profile: { + dateOfBirth: null, + gender: null, + heightCm: null, + timezone: "UTC", + sourcePriorityJson: null, + thresholdsJson: null, + }, + modules: args.modules, + healthScoreConfigJson: args.healthScoreConfigJson, + bpTargets: null, + bpEnvelope: null, + bpEnvelopePriorWeek: null, + bpEnvelopePriorTwoWeeks: null, + }); +} + +/** A stored recipe, built through the same boundary the write route uses. */ +function recipe(selection: readonly ScorePillarId[]): unknown { + return healthScoreConfigFromSelection({ + selection, + version: 1, + changedAt: new Date("2026-08-01T00:00:00.000Z"), + }); +} + +describe("the module-to-pillar map", () => { + it("reproduces the ternaries it replaced, for every module combination", () => { + for (const modules of MODULE_COMBINATIONS) { + expect( + pillarsWithModuleData(modules), + `availability for ${describeModules(modules)}`, + ).toEqual(compositionBeforeTheSwap(modules)); + } + }); + + it("keeps glycaemia fed by either glucose or labs alone", () => { + const both = { ...ALL_MODULES_ON, glucose: false, labs: false }; + expect(pillarsWithModuleData({ ...both, glucose: true })).toContain( + "GLYCAEMIA", + ); + expect(pillarsWithModuleData({ ...both, labs: true })).toContain( + "GLYCAEMIA", + ); + expect(pillarsWithModuleData(both)).not.toContain("GLYCAEMIA"); + }); + + it("drops lipids with labs but never with glucose", () => { + expect( + pillarsWithModuleData({ ...ALL_MODULES_ON, labs: false }), + ).not.toContain("LIPIDS"); + expect( + pillarsWithModuleData({ ...ALL_MODULES_ON, glucose: false }), + ).toContain("LIPIDS"); + }); + + it("leaves the four core-domain pillars ungated", () => { + // These read weight, blood pressure, steps and waist — domains with + // no module switch at all, so no module can withdraw their data. + // Pinned rather than derived so an addition to the map that started + // gating one of them has to be a deliberate edit here too. + const ungated = SCORE_PILLAR_IDS.filter( + (id) => !MODULE_GATED_PILLARS.has(id), + ); + expect(ungated).toEqual([ + "BLOOD_PRESSURE", + "ACTIVITY", + "ADIPOSITY", + "FITNESS", + ]); + const allOff: ScoreReaderModules = { + glucose: false, + labs: false, + sleep: false, + mentalHealth: false, + }; + expect(pillarsWithModuleData(allOff)).toEqual(ungated); + }); + + it("names only real modules and real pillars", () => { + for (const key of Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]) { + expect(MODULE_KEYS as readonly string[]).toContain(key); + for (const id of MODULE_SCORE_PILLARS[key]) { + expect(SCORE_PILLAR_IDS as readonly string[]).toContain(id); + } + } + }); +}); + +describe("inheritance: a never-written recipe keeps the number it had", () => { + it("resolves the pre-swap composition for every module combination", async () => { + for (const modules of MODULE_COMBINATIONS) { + const report = await score({ modules, healthScoreConfigJson: null }); + expect( + report.pillars.map((pillar) => pillar.id), + `composition for ${describeModules(modules)}`, + ).toEqual(compositionBeforeTheSwap(modules)); + } + }); + + it("gives the sleep-module-off account the same composition and the same number as before", async () => { + const modules = { ...ALL_MODULES_ON, sleep: false }; + const report = await score({ modules, healthScoreConfigJson: null }); + + expect(report.pillars.map((pillar) => pillar.id)).toEqual([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ]); + expect(report.composite.status).toBe("ok"); + if (report.composite.status !== "ok") return; + expect(report.composite.value.composition).toEqual([ + "ACTIVITY", + "ADIPOSITY", + "WELLBEING", + ]); + // Steps 9,000/day against the 10,000 plateau scores 90, a + // waist-to-height of 0.45 scores 100, a WHO-5 of 80 scores 80, and + // the mean is 90. The number itself is pinned, because "the + // composition is right" and "the number did not move" are two + // different claims and the second one is the promise. + expect(report.composite.value.score).toBe(90); + }); + + it("cannot be re-included by a recipe that selects everything", async () => { + const modules = { ...ALL_MODULES_ON, sleep: false }; + const inherited = await score({ modules, healthScoreConfigJson: null }); + const authored = await score({ + modules, + healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]), + }); + + expect(authored.pillars.map((pillar) => pillar.id)).toEqual( + inherited.pillars.map((pillar) => pillar.id), + ); + expect(authored.composite.status).toBe("ok"); + if (authored.composite.status !== "ok") return; + if (inherited.composite.status !== "ok") return; + expect(authored.composite.value.score).toBe( + inherited.composite.value.score, + ); + }); +}); + +describe("a module and a recipe answer different questions", () => { + it("changes what is read, not what is composed, when a module is toggled", async () => { + const selection = [...SCORE_PILLAR_IDS]; + const configJson = recipe(selection); + + const on = await score({ + modules: ALL_MODULES_ON, + healthScoreConfigJson: configJson, + }); + const off = await score({ + modules: { ...ALL_MODULES_ON, sleep: false }, + healthScoreConfigJson: configJson, + }); + + // The authored recipe is the same blob either way — the toggle wrote + // nothing to it. What changed is whether sleep is being recorded. + expect(on.pillars.map((pillar) => pillar.id)).toContain("SLEEP"); + expect(off.pillars.map((pillar) => pillar.id)).not.toContain("SLEEP"); + expect(off.pillars.map((pillar) => pillar.id)).toEqual( + on.pillars.map((pillar) => pillar.id).filter((id) => id !== "SLEEP"), + ); + + // Sleep really is scoring in this fixture, so the toggle is moving a + // pillar that counts rather than one that was absent anyway. Without + // this the two assertions above would pass on an empty account. + expect( + on.pillars.find((pillar) => pillar.id === "SLEEP")?.result.status, + ).toBe("ok"); + if (on.composite.status !== "ok" || off.composite.status !== "ok") { + throw new Error("both windows should score"); + } + expect(on.composite.value.composition).toContain("SLEEP"); + expect(off.composite.value.composition).not.toContain("SLEEP"); + }); + + it("drops a deselected pillar even while its module keeps recording", async () => { + const report = await score({ + modules: ALL_MODULES_ON, + healthScoreConfigJson: recipe( + SCORE_PILLAR_IDS.filter((id) => id !== "SLEEP"), + ), + }); + + expect(report.pillars.map((pillar) => pillar.id)).not.toContain("SLEEP"); + expect(report.composite.status).toBe("ok"); + if (report.composite.status !== "ok") return; + expect(report.composite.value.composition).not.toContain("SLEEP"); + }); +}); + +describe("selected and waiting for data versus switched off", () => { + it("keeps a selected pillar with no data as an honestly absent row", async () => { + // Lipids is selected, labs is on, and there is no lab result. That is + // the "selected, waiting for data" state: the row exists, it just has + // nothing to score yet, and recording a lipid panel would fill it. + const report = await score({ + modules: ALL_MODULES_ON, + healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]), + }); + + const lipids = report.pillars.find((pillar) => pillar.id === "LIPIDS"); + expect(lipids).toBeDefined(); + expect(lipids?.result.status).toBe("insufficient"); + }); + + it("shows nothing at all for a pillar whose module is off", async () => { + // The decision this test exists to pin: a module the person switched + // off is not "waiting" for anything. Telling them their score wants + // sleep data they chose to stop recording would be nagging in the + // voice of their own settings, and with a never-written recipe the + // word "selected" would be describing a default they never picked. + const report = await score({ + modules: { ...ALL_MODULES_ON, sleep: false }, + healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]), + }); + + expect(report.pillars.some((pillar) => pillar.id === "SLEEP")).toBe(false); + if (report.composite.status !== "ok") return; + expect(report.composite.value.composition).not.toContain("SLEEP"); + }); + + it("never renders a switched-off module's pillar as a failed read", async () => { + // Absence and failure carry different affordances. A module that is + // off is neither: it produces no row, so it can never be mistaken + // for a read that broke. + const report = await score({ + modules: { ...ALL_MODULES_ON, mentalHealth: false }, + healthScoreConfigJson: null, + }); + + expect(report.pillars.some((pillar) => pillar.id === "WELLBEING")).toBe( + false, + ); + }); +}); + +describe("the breadth rule still refuses at read time", () => { + const NARROW: Array<{ what: string; selection: ScorePillarId[] }> = [ + { + what: "a recipe that leaves two scoring domains", + selection: ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE"], + }, + { + what: "a recipe with nothing physiological that scores", + selection: ["ACTIVITY", "WELLBEING"], + }, + { + what: "a recipe of pillars that have no data at all", + selection: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"], + }, + ]; + + for (const { what, selection } of NARROW) { + it(`refuses ${what}, with the reason the shared rule gives`, async () => { + const report = await score({ + modules: ALL_MODULES_ON, + healthScoreConfigJson: recipe(selection), + }); + + const scoring = report.pillars + .filter((pillar) => pillar.result.status === "ok") + .map((pillar) => pillar.id); + const verdict = evaluateScoreBreadth(scoring); + + expect(verdict.ok).toBe(false); + expect(report.composite.status).toBe("insufficient"); + if (report.composite.status !== "insufficient") return; + expect(report.composite.reason).toBe(verdict.reason); + }); + } + + it("still produces a score when the recipe clears the rule", async () => { + const report = await score({ + modules: ALL_MODULES_ON, + healthScoreConfigJson: recipe([ + "ACTIVITY", + "ADIPOSITY", + "WELLBEING", + "SLEEP", + ]), + }); + + expect(report.composite.status).toBe("ok"); + }); +}); From 087f3f496ba9aecb901de04f3ca547256f6de70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:17:20 +0200 Subject: [PATCH 16/26] Stop a settings change from reading as a score drop Changing which pillars count is an act the person performed, not something that happened to their health. Until now the week-over-week comparison could not tell the difference: both windows are computed in one request under whatever recipe is in force right now, so the pillar sets agree, the subtraction looks legitimate, and the dashboard could announce "your score dropped 14 points" to someone who had just come back from the settings page. The recipe now carries its own dated identity, and the delta guard compares against it the same way it already compares against the global method boundary: a recipe that changed between the two windows suppresses the delta with a new reason, config_changed, and the surfaces that gate on an unexplained delta stay quiet. The score record stores the recipe version and its change date on every write, resolved from the same stored blob the composition came from, so a row can say which recipe produced it. A new series module reads those versions back, marks the first day after a change as a break, refuses to join the composite line across it, and keeps the per-pillar rows running through it: a pillar is graded against its own reference band and stays comparable when the average of the pillars does not. The notice key folds the person's recipe version in beside the method version, so a change in either raises the existing dismissible notice once and dismissing one never dismisses the other. An account that never chose keeps its old key untouched. --- docs/api/openapi.yaml | 1 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + prisma/schema.prisma | 18 +- .../score/__tests__/composite.test.ts | 28 +++- .../analytics/score/__tests__/record.test.ts | 31 +++- src/lib/analytics/score/composite.ts | 47 ++++++ src/lib/analytics/score/config.ts | 34 ++++ src/lib/analytics/score/reader.ts | 16 +- src/lib/analytics/score/record.ts | 42 +++-- src/lib/analytics/score/series.ts | 155 ++++++++++++++++++ src/lib/analytics/score/types.ts | 10 ++ src/lib/daily/priority-item-key.ts | 33 ++++ src/lib/openapi/routes/insights/schemas.ts | 1 + 18 files changed, 389 insertions(+), 33 deletions(-) create mode 100644 src/lib/analytics/score/series.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 81dec2775..e1c32c136 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28117,6 +28117,7 @@ components: - type: string enum: - algorithm_changed + - config_changed - composition_changed - first_eligibility_window - below_noise_floor diff --git a/messages/de.json b/messages/de.json index b8e7cd85c..71774346c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2279,6 +2279,7 @@ "delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum", "deltaReason": { "algorithm_changed": "Kein Vergleich, weil sich die Score-Methode geändert hat.", + "config_changed": "Kein Vergleich, weil du geändert hast, was in deinen Score einfließt.", "composition_changed": "Kein Vergleich, weil sich die einbezogenen Bereiche geändert haben.", "first_eligibility_window": "Kein Vergleich, weil dies der erste geeignete Zeitraum ist.", "below_noise_floor": "Die Veränderung liegt innerhalb der gemeinsamen Messschwankung.", diff --git a/messages/en.json b/messages/en.json index c47ffad22..c8f7ba43e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2279,6 +2279,7 @@ "delta": "{delta} vs the previous comparable window", "deltaReason": { "algorithm_changed": "No comparison because the score method changed.", + "config_changed": "No comparison because you changed what counts toward your score.", "composition_changed": "No comparison because the included pillars changed.", "first_eligibility_window": "No comparison because this is the first eligible window.", "below_noise_floor": "Change is within the combined measurement noise.", diff --git a/messages/es.json b/messages/es.json index ef080d594..dd6ae9b36 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2279,6 +2279,7 @@ "delta": "{delta} frente al periodo comparable anterior", "deltaReason": { "algorithm_changed": "No hay comparación porque cambió el método de puntuación.", + "config_changed": "No hay comparación porque cambiaste lo que cuenta para tu puntuación.", "composition_changed": "No hay comparación porque cambiaron los pilares incluidos.", "first_eligibility_window": "No hay comparación porque este es el primer periodo apto.", "below_noise_floor": "El cambio está dentro de la variación conjunta de las mediciones.", diff --git a/messages/fr.json b/messages/fr.json index e229906e1..446b1a1af 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2279,6 +2279,7 @@ "delta": "{delta} par rapport à la période comparable précédente", "deltaReason": { "algorithm_changed": "Aucune comparaison car la méthode de calcul a changé.", + "config_changed": "Aucune comparaison car vous avez modifié ce qui compte dans votre score.", "composition_changed": "Aucune comparaison car les piliers inclus ont changé.", "first_eligibility_window": "Aucune comparaison car il s'agit de la première période admissible.", "below_noise_floor": "La variation reste dans la marge de fluctuation combinée des mesures.", diff --git a/messages/it.json b/messages/it.json index 3974df24a..b176dfa11 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2279,6 +2279,7 @@ "delta": "{delta} rispetto al periodo precedente confrontabile", "deltaReason": { "algorithm_changed": "Nessun confronto perché è cambiato il metodo di calcolo.", + "config_changed": "Nessun confronto perché hai cambiato ciò che conta per il tuo punteggio.", "composition_changed": "Nessun confronto perché sono cambiati i pilastri inclusi.", "first_eligibility_window": "Nessun confronto perché questo è il primo periodo idoneo.", "below_noise_floor": "La variazione rientra nella fluttuazione combinata delle misurazioni.", diff --git a/messages/pl.json b/messages/pl.json index c49b93879..4b60aa32d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2279,6 +2279,7 @@ "delta": "{delta} względem poprzedniego porównywalnego okresu", "deltaReason": { "algorithm_changed": "Brak porównania, ponieważ zmieniła się metoda obliczeń.", + "config_changed": "Brak porównania, ponieważ zmieniono, co wlicza się do wyniku.", "composition_changed": "Brak porównania, ponieważ zmieniły się uwzględnione filary.", "first_eligibility_window": "Brak porównania, ponieważ to pierwszy kwalifikujący się okres.", "below_noise_floor": "Zmiana mieści się w łącznej zmienności pomiarów.", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78d9faa1e..bb5745087 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5141,15 +5141,17 @@ model HealthScoreRecord { inputFingerprint String @db.VarChar(64) @map("input_fingerprint") /// The account's health-score configuration version in force when the row - /// was written, and when that configuration last changed. The write path - /// takes both as parameters and persists whatever it is given; today - /// nothing supplies them, because the per-user configuration itself lands - /// beside this table and the guard that reads them lands after it. They are - /// here now so that guard does not need a second migration over a table - /// that will already hold rows. - /// @pending-consumer(score delta guard) suppresses the week-over-week delta across a configuration change, so a settings action is never narrated as a health event. + /// was written, and when that configuration last changed. Supplied on every + /// write by `computeAndRecordUserHealthScore`, which resolves them from the + /// same stored recipe the composition came from; an account that never chose + /// records version 0 with no date, which is a real answer and not an absence. + /// + /// Read by `scoreSeriesSeams` (`src/lib/analytics/score/series.ts`), which + /// marks the first row after a version move as a series break, refuses to + /// join the composite line across it, and keeps the per-pillar rows running + /// through it. Nullable only because the columns predate the writer by one + /// release; nothing written since supplies null. configVersion Int? @map("config_version") - /// @pending-consumer(score delta guard) dates the series break on the first record written after a configuration change. configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) /// When the score was computed. Distinct from `dayKey`: the day says which diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index 08c867b69..412d37525 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -7,6 +7,7 @@ import { computeComposite, SCORE_ALGORITHM_CHANGED_AT, } from "../composite"; +import { UNCONFIGURED_SCORE_BOUNDARY } from "../config"; import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types"; const NOW = new Date("2026-08-20T12:00:00.000Z"); @@ -175,7 +176,13 @@ describe("reference-score composite", () => { ], new Date(NOW.getTime() - 7 * 86_400_000), ); - const report = attachScoreDelta(current, previous, previous, NOW); + const report = attachScoreDelta( + current, + previous, + previous, + NOW, + UNCONFIGURED_SCORE_BOUNDARY, + ); expect(report.delta).toBeNull(); expect(report.deltaReason).toBe("composition_changed"); }); @@ -201,7 +208,13 @@ describe("reference-score composite", () => { [pillar("BLOOD_PRESSURE", 70), pillar("ACTIVITY", 70)], new Date(NOW.getTime() - 14 * 86_400_000), ); - const report = attachScoreDelta(current, previous, previousPrevious, NOW); + const report = attachScoreDelta( + current, + previous, + previousPrevious, + NOW, + UNCONFIGURED_SCORE_BOUNDARY, + ); expect(report.delta).toBeNull(); expect(report.deltaReason).toBe("first_eligibility_window"); }); @@ -229,6 +242,7 @@ describe("reference-score composite", () => { beforeBoundary, beforeBoundary, changedAt, + UNCONFIGURED_SCORE_BOUNDARY, ); expect(algorithm.deltaReason).toBe("algorithm_changed"); @@ -256,7 +270,13 @@ describe("reference-score composite", () => { ], new Date(NOW.getTime() - 14 * 86_400_000), ); - const belowNoise = attachScoreDelta(current, previous, stablePrior, NOW); + const belowNoise = attachScoreDelta( + current, + previous, + stablePrior, + NOW, + UNCONFIGURED_SCORE_BOUNDARY, + ); expect(belowNoise.delta).toBeNull(); expect(belowNoise.deltaReason).toBe("below_noise_floor"); }); @@ -294,6 +314,7 @@ describe("reference-score composite", () => { previous, stablePrior, NOW, + UNCONFIGURED_SCORE_BOUNDARY, [ pillar("BLOOD_PRESSURE", 90), pillar("ACTIVITY", 90), @@ -339,6 +360,7 @@ describe("reference-score composite", () => { previous, stablePrior, NOW, + UNCONFIGURED_SCORE_BOUNDARY, currentPillars, previousPillars, ); diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index be4572072..8791d125c 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -15,6 +15,7 @@ import type { Derived } from "@/lib/insights/derived/types"; import { computeComposite } from "../composite"; +import { UNCONFIGURED_SCORE_BOUNDARY } from "../config"; import { buildHealthScoreRecord, healthScoreInputFingerprint, @@ -241,6 +242,7 @@ describe("building the row", () => { const draft = buildHealthScoreRecord(built, { timezone: "Europe/Berlin", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }); expect(draft).not.toBeNull(); expect(built.composite.status).toBe("ok"); @@ -265,12 +267,14 @@ describe("building the row", () => { buildHealthScoreRecord(built, { timezone: "Pacific/Auckland", now: lateEvening, + config: UNCONFIGURED_SCORE_BOUNDARY, })!.dayKey, ).toBe("2026-08-21"); expect( buildHealthScoreRecord(built, { timezone: "UTC", now: lateEvening, + config: UNCONFIGURED_SCORE_BOUNDARY, })!.dayKey, ).toBe("2026-08-20"); }); @@ -286,6 +290,7 @@ describe("building the row", () => { const draft = buildHealthScoreRecord(built, { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, })!; expect(Object.keys(draft.pillarScores)).not.toContain("ACTIVITY"); expect(draft.composition).not.toContain("ACTIVITY"); @@ -298,28 +303,36 @@ describe("building the row", () => { const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); expect(thin.composite.status).toBe("insufficient"); expect( - buildHealthScoreRecord(thin, { timezone: "UTC", now: NOW }), + buildHealthScoreRecord(thin, { + timezone: "UTC", + now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, + }), ).toBeNull(); }); it("carries the configuration version and its change date through", () => { - const changedAt = new Date("2026-08-19T09:00:00.000Z"); + const changedAt = "2026-08-19T09:00:00.000Z"; const draft = buildHealthScoreRecord(report(HEALTHY), { timezone: "UTC", now: NOW, - configVersion: 4, - configChangedAt: changedAt, + config: { version: 4, changedAt }, })!; expect(draft.configVersion).toBe(4); - expect(draft.configChangedAt).toEqual(changedAt); + expect(draft.configChangedAt).toEqual(new Date(changedAt)); }); - it("defaults the configuration columns to absent, never to a made-up version", () => { + it("writes an honest zero for an account that never chose, never a made-up version", () => { + // Version 0 is a real answer — "this person never opened the surface" — + // and it is what the seam derivation compares the first authored day + // against. A null here would make the account's very first recipe look + // like a row whose basis is unknown. const draft = buildHealthScoreRecord(report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, })!; - expect(draft.configVersion).toBeNull(); + expect(draft.configVersion).toBe(0); expect(draft.configChangedAt).toBeNull(); }); }); @@ -347,6 +360,7 @@ describe("writing the row", () => { const outcome = await recordHealthScore(db, "user-1", report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }); expect(outcome).toBe("written"); expect(createMany).toHaveBeenCalledTimes(1); @@ -359,6 +373,7 @@ describe("writing the row", () => { await recordHealthScore(db, "user-1", report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).toBe("already_recorded"); }); @@ -370,6 +385,7 @@ describe("writing the row", () => { await recordHealthScore(db, "user-1", thin, { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).toBe("no_score"); expect(createMany).not.toHaveBeenCalled(); @@ -387,6 +403,7 @@ describe("writing the row", () => { recordHealthScore(db, "user-1", report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).resolves.toBe("failed"); }); diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 6dde302a5..edc42e7e1 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -16,6 +16,7 @@ import { } from "./types"; import { mean, provenance, scoreBand } from "./shared"; import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth"; +import type { ScoreConfigBoundary } from "./config"; export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z"; @@ -208,11 +209,50 @@ function comparableDynamicPillars( }; } +/** + * Whether the person's own recipe changed BETWEEN the two windows being + * compared. + * + * The exact shape of the global algorithm boundary one function below, + * for the same reason and with the same arithmetic: a dated change, and + * a comparison that straddles the date. What makes the per-user version + * necessary at all is that the two windows are computed in ONE request + * under whatever recipe is in force right now. Both sides therefore + * carry the new composition, `sameComposition` agrees, and the + * subtraction looks legitimate. Nothing inside the two composites can + * see the change; only the recipe's own `changedAt` can. + * + * `version` is what says a recipe was ever authored. An account that + * never chose has no boundary to straddle, and a stored blob that + * somehow carries no `changedAt` gives nothing to compare, so neither + * suppresses: refusing every delta forever on an unreadable date would + * trade one silent lie for a permanent silence. + */ +export function crossesConfigBoundary( + boundary: ScoreConfigBoundary, + previousAt: Date, + asOf: Date, +): boolean { + if (boundary.version < 1 || boundary.changedAt === null) return false; + const changedAt = new Date(boundary.changedAt); + if (Number.isNaN(changedAt.getTime())) return false; + return asOf >= changedAt && previousAt < changedAt; +} + +/** + * `config` is required and has no default on purpose. Every caller has + * to state which recipe the two windows were computed under, and the + * account that authored none says so with + * {@link UNCONFIGURED_SCORE_BOUNDARY}. A defaulted boundary would let a + * future caller thread nothing and get the pre-v1.35.0 behaviour back — + * a false drop narrated at a person who had just used the settings page. + */ export function attachScoreDelta( current: Derived, previous: Derived, previousPrevious: Derived, asOf: Date, + config: ScoreConfigBoundary, currentPillars: ScorePillarResult[] = [], previousPillars: ScorePillarResult[] = [], ): Pick { @@ -231,6 +271,13 @@ export function attachScoreDelta( ) { return { delta: null, deltaReason: "algorithm_changed" }; } + // Ahead of the composition check deliberately. When both fire, the + // person changed what counts and that is the truer sentence; "the + // included pillars changed" would describe the consequence and leave + // out the act. + if (crossesConfigBoundary(config, previousAt, asOf)) { + return { delta: null, deltaReason: "config_changed" }; + } if (!sameComposition(current, previous)) { return { delta: null, deltaReason: "composition_changed" }; } diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index 015796fc0..3a3b81db2 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -136,6 +136,40 @@ export function resolveHealthScoreConfig( }; } +/** + * The recipe's identity, reduced to the two things a comparison needs: + * which version of the person's own recipe produced a number, and when + * that recipe last moved. + * + * Carried separately from the full resolved config because the delta + * guard and the stored record have no business seeing the pillar list — + * they compare recipes, they do not apply them. + */ +export interface ScoreConfigBoundary { + /** Per-user recipe version. 0 while the person never chose. */ + version: number; + /** When the recipe last changed, ISO 8601, or null while it never has. */ + changedAt: string | null; +} + +/** + * The boundary of an account that never authored a recipe. + * + * Exported so a caller that genuinely has no configuration says so by + * name instead of leaving an argument out. A guard parameter with a + * plausible default is how a caller stays silent and the guard stays + * quiet about it. + */ +export const UNCONFIGURED_SCORE_BOUNDARY: Readonly = + Object.freeze({ version: 0, changedAt: null }); + +/** Narrow a resolved config to the identity a comparison reads. */ +export function scoreConfigBoundary( + config: Pick, +): ScoreConfigBoundary { + return { version: config.version, changedAt: config.changedAt }; +} + /** * Turn the positive selection a person made into the blob the column * stores. Unknown ids are dropped on the way in, so the stored diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 950f7d39f..0caf068ba 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -21,13 +21,13 @@ import { pickCumulativeDaySum } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/format"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { toProfileSex } from "@/lib/profile/sex"; -import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key"; +import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key"; import { annotate, getEvent } from "@/lib/logging/context"; import { ACTIVITY_WINDOW_DAYS } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; -import { resolveHealthScoreConfig } from "./config"; +import { resolveHealthScoreConfig, scoreConfigBoundary } from "./config"; import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; @@ -868,15 +868,25 @@ export async function computeUserHealthScore( }), weightGoal, }); + // All three windows above were computed under the recipe in force + // right now, which is exactly why the guard needs the recipe's own + // dated identity: nothing in the three composites can show that last + // Tuesday was scored on a different set. Resolved once, from the same + // blob the composition came from, so the number and the boundary that + // judges its movement can never describe two different recipes. const delta = attachScoreDelta( current.composite, previous.composite, previousPrevious.composite, input.now, + scoreConfigBoundary(config), current.pillars, previous.pillars, ); - const itemKey = healthScoreAlgorithmItemKey(current.scoreVersion); + const itemKey = healthScoreNoticeItemKey( + current.scoreVersion, + config.version, + ); const dismissal = await capture( db.dismissedPriorityItem.findUnique({ where: { userId_itemKey: { userId: input.userId, itemKey } }, diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts index 930829612..00a3e216e 100644 --- a/src/lib/analytics/score/record.ts +++ b/src/lib/analytics/score/record.ts @@ -37,6 +37,11 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { annotate, getEvent } from "@/lib/logging/context"; import { userDayKey } from "@/lib/tz/format"; +import { + resolveHealthScoreConfig, + scoreConfigBoundary, + type ScoreConfigBoundary, +} from "./config"; import { computeUserHealthScore, type UserHealthScoreInput } from "./reader"; import type { HealthScoreReport, @@ -80,7 +85,8 @@ export interface HealthScoreRecordDraft { composition: ScorePillarId[]; pillarScores: Record; inputFingerprint: string; - configVersion: number | null; + /** The recipe version in force. 0 when the account never chose. */ + configVersion: number; configChangedAt: Date | null; computedAt: Date; } @@ -92,13 +98,16 @@ export interface HealthScoreRecordContext { /** 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. + * The recipe this day was scored under: the account's own + * configuration version, and when that configuration last changed. + * + * Required, and deliberately not optional. This is what a later + * reader compares one day against the next to find the seam, so a + * caller allowed to stay silent would write a row that claims a + * recipe it does not know. A never-configured account passes + * `{ version: 0, changedAt: null }` — an honest zero, not an absence. */ - configVersion?: number | null; - configChangedAt?: Date | null; + config: ScoreConfigBoundary; } /** @@ -204,8 +213,10 @@ export function buildHealthScoreRecord( composition: composite.composition, pillars: report.pillars, }), - configVersion: context.configVersion ?? null, - configChangedAt: context.configChangedAt ?? null, + configVersion: context.config.version, + configChangedAt: context.config.changedAt + ? new Date(context.config.changedAt) + : null, computedAt: context.now, }; } @@ -295,14 +306,21 @@ export async function recordHealthScore( */ export async function computeAndRecordUserHealthScore( input: UserHealthScoreInput, - record?: Pick, ): Promise { const report = await computeUserHealthScore(input); + // The recipe is resolved here rather than asked of the caller. Three + // routes call this function and every one of them already holds the + // raw blob, so a parameter would have been three chances to pass + // nothing and write a row that names no recipe. `resolveHealthScoreConfig` + // is pure over that same blob, so the version stamped on the row and + // the composition the reader scored are the same recipe by + // construction, not by two callers agreeing. await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, { timezone: input.profile.timezone, now: input.now, - configVersion: record?.configVersion ?? null, - configChangedAt: record?.configChangedAt ?? null, + config: scoreConfigBoundary( + resolveHealthScoreConfig(input.healthScoreConfigJson), + ), }); return report; } diff --git a/src/lib/analytics/score/series.ts b/src/lib/analytics/score/series.ts new file mode 100644 index 000000000..cd05f857e --- /dev/null +++ b/src/lib/analytics/score/series.ts @@ -0,0 +1,155 @@ +/** + * Reading a run of stored score days as a series, and refusing to draw + * a line through the place where the recipe changed. + * + * Official statistics has one convention for this and it is small: a + * single machine-readable flag on the FIRST observation after the break + * (Eurostat's `b`, "break occurring when there is a change in the + * standards for defining and observing a variable over time"). This + * module is that flag, plus the two consequences that follow from it. + * + * **The composite line breaks.** Two segments, visibly two. A recipe + * change fails the equal-construct requirement for a legitimate + * equating by construction: the numbers on either side are averages of + * different things. The stored day under the old recipe is an overlap + * point of exactly one, which is enough to date the seam and enough to + * refuse a comparison across it, and not nearly enough to compute an + * adjustment factor that would rescale one segment onto the other. So + * no adjustment is offered. There is no "adjusted" variant of + * {@link scoreCompositeSegments} and there should not be one: splicing + * two methods into one continuous line is the failure that produced + * "years of sleep tracking might be no more useful than common sense", + * and it always starts as a helpful-looking option. + * + * **The per-pillar rows do not break.** A pillar's own 0-100 score is + * graded against its own published reference band and means the same + * thing on both sides of the seam. Only the aggregate changes basis. + * That is why the record stores the per-pillar scores beside the + * composite rather than folding them away, and it is what lets someone + * look at their sleep across a period that spans a recipe change + * without a gap. + * + * The seam is drawn on the recipe's VERSION, not on whether the + * composition visibly moved. A person who deselects a pillar that had + * no data that week changes what counts even though this week's number + * did not budge, and the delta guard suppresses that week from less + * information than this module has. Two surfaces disagreeing about + * whether the same act was an event is worse than either answer, so + * both key on the recipe's identity and describe one event. + * + * Pure. No database, no clock. The trend view that renders the segments + * is deliberately not in this release — the records start empty, so + * there is nothing yet to show — but the flag and the refusal ship with + * the machinery that creates the seam rather than after it. + */ +import type { ScoreBand, ScorePillarId } from "./types"; + +/** + * One stored score day, in the shape `HealthScoreRecord` rows already + * have, so a Prisma `select` feeds this without a mapping layer in + * between to get wrong. + */ +export interface ScoreSeriesRecord { + /** Local calendar day, `YYYY-MM-DD`. */ + dayKey: string; + composite: number; + band: ScoreBand; + composition: string[]; + pillarScores: Record; + /** The recipe version the day was scored under; null on a row written + * before the writer supplied one. */ + configVersion: number | null; +} + +export interface ScoreSeriesPoint extends ScoreSeriesRecord { + /** + * Eurostat `b`. True on the first observation computed under a + * different recipe than the observation before it. Never true on the + * first observation of the series: a beginning is not a break. + */ + seamBreak: boolean; +} + +/** Ascending by local day. Lexicographic order IS chronological here. */ +function byDay(left: ScoreSeriesRecord, right: ScoreSeriesRecord): number { + return left.dayKey < right.dayKey ? -1 : left.dayKey > right.dayKey ? 1 : 0; +} + +/** + * Whether two consecutive days sit on different bases. + * + * An unknown version on either side counts as a break. The seam's job + * is to refuse a link it cannot justify, and "these two rows were + * scored under the same recipe" is a claim: a row that does not say + * which recipe produced it cannot support it. Only rows predating the + * writer carry null, so in practice this arm is the answer to a + * question production never asks — which is the point of answering it + * deliberately rather than letting `null !== 0` decide by accident. + */ +function breaksFrom( + previous: ScoreSeriesRecord, + current: ScoreSeriesRecord, +): boolean { + if (previous.configVersion === null || current.configVersion === null) { + return true; + } + return previous.configVersion !== current.configVersion; +} + +/** + * Mark every stored day with whether it opens a new basis. + * + * Input order does not matter; the result is ascending by day. + */ +export function scoreSeriesSeams( + records: readonly ScoreSeriesRecord[], +): ScoreSeriesPoint[] { + return [...records].sort(byDay).map((record, index, ordered) => ({ + ...record, + seamBreak: index > 0 && breaksFrom(ordered[index - 1], record), + })); +} + +/** + * The composite series, cut into the segments that may honestly be + * drawn as one line each. + * + * Every seam starts a new segment, and nothing here joins two of them: + * no interpolation across the gap, no offset applied to make the ends + * meet, no single array with a hole in it that a chart library would + * happily bridge. A caller that wants one line gets the segments and + * has to decide, visibly, what to do about the fact that there are two. + */ +export function scoreCompositeSegments( + points: readonly ScoreSeriesPoint[], +): ScoreSeriesPoint[][] { + const segments: ScoreSeriesPoint[][] = []; + for (const point of points) { + if (segments.length === 0 || point.seamBreak) { + segments.push([point]); + continue; + } + segments[segments.length - 1].push(point); + } + return segments; +} + +/** + * One pillar's own scores across the whole run, seams and all. + * + * Continuous on purpose. The pillar is graded against its own published + * reference band on both sides of a recipe change, so it stays + * comparable when the average of the pillars does not. Days on which + * the pillar did not count are absent rather than zero. + */ +export function scorePillarSeries( + points: readonly ScoreSeriesPoint[], + pillar: ScorePillarId, +): Array<{ dayKey: string; score: number }> { + return points.flatMap((point) => { + const score = point.pillarScores[pillar]; + return typeof score === "number" && Number.isFinite(score) + ? [{ dayKey: point.dayKey, score }] + : []; + }); +} diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 6cf094734..c71d0fa1e 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -126,6 +126,16 @@ export interface CompositeValue { export type ScoreDeltaReason = | "algorithm_changed" + /** + * The person changed their own recipe inside the comparison window. + * Distinct from `algorithm_changed` (we changed the method for + * everybody) and from `composition_changed` (the two windows ended up + * with different pillar sets): here both windows are computed under + * the NEW recipe in one request, so the sets agree and the arithmetic + * looks comparable when it is not. Without this reason the settings + * action reads as a health event. + */ + | "config_changed" | "composition_changed" | "first_eligibility_window" | "below_noise_floor" diff --git a/src/lib/daily/priority-item-key.ts b/src/lib/daily/priority-item-key.ts index 9be276fc7..d483b45f8 100644 --- a/src/lib/daily/priority-item-key.ts +++ b/src/lib/daily/priority-item-key.ts @@ -60,3 +60,36 @@ export function sameTimeBaselineItemKey( export function healthScoreAlgorithmItemKey(scoreVersion: number): string { return `health_score_algorithm:${scoreVersion}`; } + +/** + * The Health Score notice key for one account, folding in the person's + * OWN recipe version beside the global method version. + * + * Two things can change what a score means, and the person needs + * telling once about each: we change the method (`scoreVersion`), or + * they change what counts (`configVersion`). One ledger key carrying + * only the first would leave a recipe change unannounced, and a key + * carrying only the second would swallow the next method change for + * everybody who ever opened the settings page. Both dimensions ride the + * key, so a move in either raises the notice exactly once and a + * dismissal of one never dismisses the other. + * + * An account that never chose keeps the bare + * `health_score_algorithm:` key unchanged. That is not cosmetic: + * lengthening the key for everyone would leave every existing + * acknowledgement pointing at a key nothing looks up, and the method + * notice would reappear for every account on upgrade. + * + * The `health_score_algorithm:` prefix is deliberate — it is the + * dismiss ledger's namespace (`isDismissibleItemKey`, the dismiss + * route's score-cache eviction), not user-facing copy, and reusing it + * is what lets the recipe notice ride machinery that already dismisses + * and evicts correctly instead of growing a second one beside it. + */ +export function healthScoreNoticeItemKey( + scoreVersion: number, + configVersion: number, +): string { + const base = healthScoreAlgorithmItemKey(scoreVersion); + return configVersion >= 1 ? `${base}:config:${configVersion}` : base; +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 2e3c51ffd..c6b152647 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1112,6 +1112,7 @@ export const dashboardSnapshotResponse = z deltaReason: z .enum([ "algorithm_changed", + "config_changed", "composition_changed", "first_eligibility_window", "below_noise_floor", From 5d13f0e69c2d2aefb0b93ac5edef057be3bf9287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:48:40 +0200 Subject: [PATCH 17/26] Prove the false drop cannot come back The named failure now has a test that drives the whole chain a request takes: fixture rows through the real reader, the real score record write, the real dashboard snapshot, the real verdict rung, and the real digest that gives the morning push its sentence. Only the IO around that chain is faked. A version built on a hand-written snapshot literal would have stayed green through a release where the assembly between the two ends dropped the field, which has happened here before. Every case runs twice on the same data. Once with the recipe changed three days ago, once with it changed a month ago. The second run is what proves the fixture really does produce an eleven-point fall, so the first run's silence is the guard working rather than an account with nothing to say. The decline is gradual on purpose: a cliff would raise each pillar's own noise floor and the delta would be suppressed for a reason that has nothing to do with the recipe. Beside it, the boundary's edges, the notice key's two dimensions and the seam's shape, each with a case proving the guard stays quiet on innocent data. Against real Postgres, a row now has to come back carrying the recipe version and change date the account's stored configuration supplied. --- ...onfig-change-is-not-a-health-event.test.ts | 556 ++++++++++++++++++ .../__tests__/config-delta-guard.test.ts | 387 ++++++++++++ tests/integration/health-score-record.test.ts | 42 +- 3 files changed, 982 insertions(+), 3 deletions(-) create mode 100644 src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts create mode 100644 src/lib/analytics/score/__tests__/config-delta-guard.test.ts diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts new file mode 100644 index 000000000..f13137cff --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -0,0 +1,556 @@ +/** + * The named failure, driven end to end: someone opens the score + * settings, changes which pillars count, and the app tells them their + * health dropped. + * + * The bug is subtle enough that it survives any test which stops at one + * end. All three comparison windows are computed in ONE request under + * whatever recipe is in force at that moment, so both sides carry the + * same composition, `sameComposition` agrees, and the subtraction looks + * legitimate. What actually moved is the construct: the week-over-week + * delta after a reconfiguration averages a different set of pillars + * than the one before it, so a week that read as a couple of points can + * read as fourteen the moment the person leaves the settings page. + * + * So this file refuses to hand a payload from one end to the other. It + * drives the real chain, in the order a request does: + * + * fixture rows + * → `computeUserHealthScore` (the real reader, real pillars) + * → `computeAndRecordUserHealthScore` (the real write path) + * → `buildDashboardSnapshot` (the real snapshot mapping) + * → `resolveDashboardVerdict` (the real `scoreDrop` rung) + * → `loadDailyDigest` → `buildDailyDigest` (the real morning line) + * + * Only the IO around that chain is faked. A version of this test that + * built a `DashboardSnapshot` literal by hand would have been green + * through a release where the assembly between the two ends dropped the + * field, which is a thing that has happened here. + * + * Every case runs twice with the same data: once with the recipe + * changed inside the comparison window, once with it changed a month + * before. The second run is not decoration. It is what proves the + * fixture really does produce a ten-point-plus drop and that the first + * run's silence is the guard doing work rather than an account with + * nothing to say. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; + +const DAY_MS = 86_400_000; +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +/** + * A steady, month-long decline in steps and sleep. + * + * Gradual on purpose. A cliff would blow up each pillar's own standard + * deviation, the noise floor would rise with it, and `below_noise_floor` + * would suppress the delta for a reason that has nothing to do with the + * recipe — a test that passes because the number never moved proves + * nothing about a guard that stops numbers from moving. + */ +const FIXTURE = vi.hoisted(() => { + const DAY = 86_400_000; + const AT = new Date("2026-08-20T12:00:00.000Z").getTime(); + const rows: Array<{ + type: string; + value: number; + unit: string; + source: string; + measuredAt: Date; + deviceType: string | null; + glucoseContext: string | null; + sleepStage: string | null; + }> = []; + for (let back = 44; back >= 0; back -= 1) { + const measuredAt = new Date(AT - back * DAY); + rows.push({ + type: "ACTIVITY_STEPS", + value: 4_200 + back * 260, + unit: "steps", + source: "MANUAL", + measuredAt, + deviceType: null, + glucoseContext: null, + sleepStage: null, + }); + rows.push({ + type: "SLEEP_DURATION", + value: 300 + back * 5, + unit: "min", + source: "MANUAL", + measuredAt: new Date(new Date(measuredAt).setUTCHours(6, 0, 0, 0)), + deviceType: null, + glucoseContext: null, + sleepStage: "ASLEEP", + }); + } + rows.push({ + type: "WAIST_TO_HEIGHT", + value: 0.45, + unit: "ratio", + source: "MANUAL", + measuredAt: new Date(AT - 20 * DAY), + deviceType: null, + glucoseContext: null, + sleepStage: null, + }); + return rows; +}); + +/** + * One fake client behind BOTH `@/lib/db` and the snapshot builder's + * injected client, so the reader's measurement reads, the score + * record's insert and the digest's own light reads all see the same + * account. + */ +const writtenRecords = vi.hoisted(() => [] as Array>); + +/** The dismiss ledger, as a set of item keys the account has acknowledged. */ +const dismissedKeys = vi.hoisted(() => new Set()); + +const fakeDb = vi.hoisted(() => { + const rows = FIXTURE; + const written = writtenRecords; + const none = async () => []; + return { + measurement: { + // Both callers' `where` shapes: the score reader asks for a set of + // types, the snapshot's glucose read asks for one. + findMany: async (query: { + where: { + type: string | { in: string[] }; + measuredAt?: { gte: Date }; + glucoseContext?: string; + }; + }) => { + const wanted = + typeof query.where.type === "string" + ? [query.where.type] + : query.where.type.in; + const since = query.where.measuredAt?.gte ?? new Date(0); + return rows.filter( + (row) => + wanted.includes(row.type) && + row.measuredAt >= since && + (query.where.glucoseContext === undefined || + row.glucoseContext === query.where.glucoseContext), + ); + }, + }, + mentalHealthAssessment: { findMany: none }, + labResult: { findMany: none }, + dismissedPriorityItem: { + // Honours the composite key. A stub that ignored `where` would + // make "dismissed once per recipe version" untestable: every key + // would read as dismissed, or none would. + findUnique: async (query: { + where: { userId_itemKey: { userId: string; itemKey: string } }; + }) => + dismissedKeys.has(query.where.userId_itemKey.itemKey) + ? { itemKey: query.where.userId_itemKey.itemKey } + : null, + findMany: none, + }, + healthScoreRecord: { + createMany: async (args: { data: Array> }) => { + written.push(...args.data); + return { count: args.data.length }; + }, + }, + illnessEpisode: { findMany: none }, + moodEntry: { findMany: none }, + nutrientIntakeDay: { findMany: none }, + integrationStatus: { findMany: none }, + measurementReminder: { findMany: none }, + coachPlan: { findMany: none }, + ecgRecording: { findFirst: async () => null }, + personalRecord: { findMany: none }, + arrivalReaction: { findMany: none }, + }; +}); + +vi.mock("@/lib/db", () => ({ prisma: fakeDb })); + +// ── Everything around the score, faked at its own seam ────────────── +const computeSummariesSlice = vi.fn(); +const probeRollupCoverage = vi.fn(); +const isFullyCovered = vi.fn(); +const readMoodDayRollups = vi.fn(); +const ensureUserMoodRollupsFresh = vi.fn(); +const computeBpInTargetFastPath = vi.fn(); +const getAssistantFlags = vi.fn(); +const hasAnyConfiguredProvider = vi.fn(); +const buildMedsTodayBlock = vi.fn(); +const resolveModuleMap = vi.fn(); +const buildScoreRingsBlock = vi.fn(); +const readDashboardSnapshotCached = vi.fn(); +const loadIntradayPulse = vi.fn(); +const readDayMeanSeries = vi.fn(); + +vi.mock("@/lib/analytics/summaries-slice", () => ({ + computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a), + isFullyCovered: (...a: unknown[]) => isFullyCovered(...a), +})); +vi.mock("@/lib/rollups/mood-rollups", () => ({ + readMoodDayRollups: (...a: unknown[]) => readMoodDayRollups(...a), + ensureUserMoodRollupsFresh: (...a: unknown[]) => + ensureUserMoodRollupsFresh(...a), +})); +vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ + computeBpInTargetFastPath: (...a: unknown[]) => + computeBpInTargetFastPath(...a), +})); +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), +})); +vi.mock("@/lib/ai/provider", () => ({ + hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), +})); +vi.mock("@/lib/dashboard/meds-today", () => ({ + buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a), +})); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), +})); +vi.mock("@/lib/dashboard/score-rings", () => ({ + buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a), +})); +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: (...a: unknown[]) => + readDashboardSnapshotCached(...a), +})); +vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ + loadIntradayPulse: (...a: unknown[]) => loadIntradayPulse(...a), +})); +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), +})); + +import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot"; +import type { SnapshotUserInput } from "@/lib/dashboard/snapshot"; +import { resolveDashboardVerdict } from "@/lib/dashboard/verdict"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import type { ModuleKey } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; + +import { healthScoreConfigFromSelection } from "../config"; +import { computeUserHealthScore } from "../reader"; +import { SCORE_PILLAR_IDS } from "../types"; + +/** The recipe as the settings write would have stored it. */ +function recipe(changedAt: Date): unknown { + return healthScoreConfigFromSelection({ + // The person keeps the four pillars they have data for and drops the + // rest. The composition this resolves to is the same either way in + // this fixture, which is exactly the case a composition comparison + // cannot see. + selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"), + version: 3, + changedAt, + }); +} + +function moduleMap(): Record { + return Object.fromEntries(MODULE_KEYS.map((key) => [key, true])) as Record< + ModuleKey, + boolean + >; +} + +function user(healthScoreConfigJson: unknown): SnapshotUserInput { + return { + id: "user-1", + username: "tester", + displayName: null, + timezone: "UTC", + heightCm: 180, + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + gender: "MALE", + glucoseUnit: "mg/dL", + onboardingTourCompleted: true, + disableCoach: false, + insightsCachedText: null, + insightsCachedAt: null, + dashboardWidgetsJson: null, + thresholdsJson: null, + healthScoreConfigJson, + } as SnapshotUserInput; +} + +/** The reader's own input, as the snapshot builder assembles it. */ +function scoreInput(healthScoreConfigJson: unknown) { + return { + userId: "user-1", + now: NOW, + profile: { + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + timezone: "UTC", + sourcePriorityJson: null, + thresholdsJson: null, + }, + modules: { + glucose: true, + labs: true, + sleep: true, + mentalHealth: true, + }, + healthScoreConfigJson, + bpTargets: null, + bpEnvelope: null, + bpEnvelopePriorWeek: null, + bpEnvelopePriorTwoWeeks: null, + }; +} + +/** + * The whole chain for one account, from fixture rows to the sentence + * that would ride a morning push. + */ +async function runChain(healthScoreConfigJson: unknown) { + __resetAllCachesForTests(); + writtenRecords.length = 0; + const snapshot = await buildDashboardSnapshot( + fakeDb as never, + user(healthScoreConfigJson), + ); + const verdict = resolveDashboardVerdict(snapshot, NOW); + readDashboardSnapshotCached.mockResolvedValue({ + body: { + ...snapshot, + layout: { + ...snapshot.layout, + enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + }, + }, + locale: "en", + }); + const digest = await loadDailyDigest( + { + id: "user-1", + timezone: "UTC", + morningDigestRefreshedOn: null, + } as unknown as User, + NOW, + ); + return { snapshot, verdict, digest, record: writtenRecords[0] ?? null }; +} + +beforeEach(() => { + // `buildDashboardSnapshot` reads its own clock — it takes no `now`. + // Without a pinned system clock the fixture's dates would sit in the + // future relative to the real one, every window would miss, and the + // suite would go green on an account that has no comparable window + // rather than on a suppressed one. Only `Date` is faked; the SWR cells + // inside the digest loader still need working timers. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + vi.clearAllMocks(); + __resetAllCachesForTests(); + dismissedKeys.clear(); + computeSummariesSlice.mockResolvedValue({ + summaries: {}, + lastSeenByType: {}, + bmi: null, + }); + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]), + ); + isFullyCovered.mockReturnValue(true); + readMoodDayRollups.mockResolvedValue([]); + // No blood pressure in the fixture: the envelope resolves with empty + // windows rather than absent, which is what the builder expects and + // what keeps BLOOD_PRESSURE honestly unscored. + computeBpInTargetFastPath.mockResolvedValue({ + path: "live", + last7Days: null, + last30Days: null, + last90Days: null, + last90EarliestAt: null, + last90LatestAt: null, + allTime: null, + priorMonth: null, + priorYear: null, + graded: null, + representative: null, + }); + getAssistantFlags.mockResolvedValue({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + }); + hasAnyConfiguredProvider.mockResolvedValue(false); + buildMedsTodayBlock.mockResolvedValue({ + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }); + resolveModuleMap.mockResolvedValue(moduleMap()); + buildScoreRingsBlock.mockResolvedValue([]); + loadIntradayPulse.mockResolvedValue({ tension: null }); + readDayMeanSeries.mockResolvedValue({ points: [], source: "none" }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** + * The control. Same rows, same recipe, changed a month ago — outside + * every comparison window — so nothing suppresses and the account's + * genuine decline is narrated in full. + */ +const SETTLED = recipe(new Date(NOW.getTime() - 30 * DAY_MS)); +/** The act: the recipe changed three days ago. */ +const JUST_CHANGED = recipe(new Date(NOW.getTime() - 3 * DAY_MS)); + +describe("a settings change is never narrated as a health event", () => { + it("shows a real ten-point-plus drop when no recipe changed in the window", async () => { + const { snapshot, verdict, digest } = await runChain(SETTLED); + + // If this ever stops holding, every assertion in the next test is + // vacuous: an account with no drop to suppress would pass it while + // the guard did nothing at all. + expect(snapshot.healthScore).not.toBeNull(); + expect(snapshot.healthScore!.deltaReason).toBeNull(); + expect(snapshot.healthScore!.delta).not.toBeNull(); + expect(snapshot.healthScore!.delta!).toBeLessThanOrEqual(-10); + expect(verdict.variant).toBe("scoreDrop"); + expect(digest.score?.delta).toBe(snapshot.healthScore!.delta); + expect(digest.score?.deltaReason).toBeNull(); + }); + + it("fires no scoreDrop verdict when the recipe changed inside the window", async () => { + // The verdict first and on its own line: this is the sentence the + // person would read, and it is what has to go red when the guard + // goes missing. Asserting the reason before it would fail one step + // earlier and never exercise the rung at all. + const { verdict } = await runChain(JUST_CHANGED); + + expect(verdict.variant).not.toBe("scoreDrop"); + }); + + it("suppresses the delta with the reason that names the act", async () => { + const { snapshot } = await runChain(JUST_CHANGED); + + expect(snapshot.healthScore).not.toBeNull(); + expect(snapshot.healthScore!.deltaReason).toBe("config_changed"); + expect(snapshot.healthScore!.delta).toBeNull(); + }); + + it("carries no unexplained drop on the morning line", async () => { + // The digest is the push's only source of words. A delta that + // reached it without its reason is a drop nothing can explain: the + // hero renders the chip on `deltaReason === null` alone. + const { snapshot, digest } = await runChain(JUST_CHANGED); + + expect(digest.score).not.toBeNull(); + expect(digest.score!.delta).toBeNull(); + expect(digest.score!.deltaReason).toBe("config_changed"); + // The push body is `digest.line` verbatim. It states today's number + // and nothing about its movement, which is the only honest sentence + // available across a recipe change. + expect(digest.line).toBe( + `Your health score today is ${snapshot.healthScore!.score}.`, + ); + expect(digest.line).not.toMatch(/[-−]\s?\d/); + // The rail is the other place a drop could reach a person. Nothing + // in it may be narrating the score either. + expect(digest.worthALook.map((item) => item.kind)).not.toContain( + "score_drop", + ); + }); + + it("still shows the number itself, only never its movement", async () => { + // Suppressing the delta must not suppress the score. A person who + // changed what counts should see what the new recipe says today; + // what they must not see is a subtraction across the change. + const { snapshot, digest } = await runChain(JUST_CHANGED); + + expect(snapshot.healthScore!.score).toBeGreaterThan(0); + expect(digest.score!.value).toBe(snapshot.healthScore!.score); + }); + + it("writes the recipe that produced the day onto the stored record", async () => { + // The supply half of the same change. The row is written by the real + // path inside the same request the assertions above read from, so a + // version that never reached the writer would show up here rather + // than a release later when the seam turns out to be underivable. + const { record } = await runChain(JUST_CHANGED); + + expect(record).not.toBeNull(); + expect(record!.configVersion).toBe(3); + expect(record!.configChangedAt).toEqual( + new Date(NOW.getTime() - 3 * DAY_MS), + ); + }); + + it("raises the recipe notice once, under a key the person can dismiss once", async () => { + // The reader is driven directly here because the notice never + // reaches the dashboard snapshot — it rides the analytics report. + // Same account, same fixture, same request path the chain above + // used, so the key asserted is the key the ledger will be asked for. + const configured = await computeUserHealthScore(scoreInput(JUST_CHANGED)); + const unconfigured = await computeUserHealthScore(scoreInput(null)); + + expect(configured.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2:config:3", + ); + expect(configured.algorithmNotice!.dismissed).toBe(false); + // An account that never chose keeps the key its acknowledgement is + // already filed under. + expect(unconfigured.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2", + ); + + dismissedKeys.add("health_score_algorithm:2:config:3"); + expect( + (await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice! + .dismissed, + ).toBe(true); + + // The next recipe change raises it again, on its own key, and the + // earlier dismissal does not carry over. + const next = await computeUserHealthScore( + scoreInput( + healthScoreConfigFromSelection({ + selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"), + version: 4, + changedAt: new Date(NOW.getTime() - DAY_MS), + }), + ), + ); + expect(next.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2:config:4", + ); + expect(next.algorithmNotice!.dismissed).toBe(false); + }); + + it("stamps an honest zero when the account never chose", async () => { + // Not an absence. The first authored recipe has to be recognisable + // as a move from something, and 0 is what it moves from. + const { record } = await runChain(null); + + expect(record).not.toBeNull(); + expect(record!.configVersion).toBe(0); + expect(record!.configChangedAt).toBeNull(); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts new file mode 100644 index 000000000..b0794076b --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -0,0 +1,387 @@ +/** + * The per-user recipe boundary, at the three places it exists. + * + * The end-to-end proof that a settings change is never narrated as a + * health event lives in `config-change-is-not-a-health-event.test.ts`, + * over the real snapshot and digest. This file covers what that one + * cannot reach without a fixture per case: the boundary arithmetic's + * edges, the notice key's two dimensions, and the seam's shape. + * + * Each guard here has a counter-case beside it. A boundary that + * suppressed everything would pass every "no false drop" assertion ever + * written and silence every honest week as well, which is the same + * defect wearing the opposite sign. + */ +import { describe, expect, it } from "vitest"; + +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; +import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key"; +import { isDismissibleItemKey } from "@/lib/daily/priority-item"; +import { dismissPriorityItemSchema } from "@/lib/validations/daily"; + +import { attachScoreDelta, crossesConfigBoundary } from "../composite"; +import { + resolveHealthScoreConfig, + healthScoreConfigFromSelection, + scoreConfigBoundary, + UNCONFIGURED_SCORE_BOUNDARY, +} from "../config"; +import { + scoreCompositeSegments, + scorePillarSeries, + scoreSeriesSeams, + type ScoreSeriesRecord, +} from "../series"; +import type { + CompositeValue, + PillarValue, + ScorePillarId, + ScorePillarResult, +} from "../types"; + +const DAY_MS = 86_400_000; +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): ScorePillarResult { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + const value: PillarValue = { + score, + observed: { + value: score, + unit: "score", + label: `${score}`, + asOf: NOW.toISOString(), + sources: ["MANUAL"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 100, + label: "band", + source: "Test 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: id, + }; + return { + id, + domain: DOMAIN_BY_PILLAR[id], + result: buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }), + }; +} + +/** A composite standing at `score`, computed as of `asOf`. */ +function composite(score: number, asOf: Date): Derived { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + return buildOk({ + value: { + score, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + noiseFloor: 1, + scoreVersion: 2, + }, + coverage, + confidence, + provenance: { + inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + source: "live", + windowDays: 28, + computedAt: asOf.toISOString(), + }, + }); +} + +const CURRENT_PILLARS = [ + pillar("BLOOD_PRESSURE", 60), + pillar("ACTIVITY", 60), + pillar("SLEEP", 60), +]; +const EARLIER_PILLARS = [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), +]; + +/** A twenty-point fall, well clear of the noise floor. */ +function fallingDelta(changedAt: Date | null) { + return attachScoreDelta( + composite(60, NOW), + composite(80, new Date(NOW.getTime() - 7 * DAY_MS)), + composite(80, new Date(NOW.getTime() - 14 * DAY_MS)), + NOW, + { + version: changedAt === null ? 0 : 2, + changedAt: changedAt?.toISOString() ?? null, + }, + CURRENT_PILLARS, + EARLIER_PILLARS, + ); +} + +describe("the recipe boundary", () => { + it("suppresses a fall the person's own settings change straddles", () => { + const report = fallingDelta(new Date(NOW.getTime() - 3 * DAY_MS)); + expect(report.delta).toBeNull(); + expect(report.deltaReason).toBe("config_changed"); + }); + + it("stays out of the way when the recipe has not moved in the window", () => { + // The counter-case. A guard that suppressed here would hide every + // genuine decline an account with a recipe ever has. + const report = fallingDelta(new Date(NOW.getTime() - 30 * DAY_MS)); + expect(report.deltaReason).toBeNull(); + expect(report.delta).toBe(-20); + }); + + it("stays out of the way for an account that never chose", () => { + const report = fallingDelta(null); + expect(report.deltaReason).toBeNull(); + expect(report.delta).toBe(-20); + }); + + it("suppresses at the boundary instant and not one moment past it", () => { + const previousAt = new Date(NOW.getTime() - 7 * DAY_MS); + const boundary = (changedAt: Date) => + crossesConfigBoundary( + { version: 1, changedAt: changedAt.toISOString() }, + previousAt, + NOW, + ); + + // A change on the previous window's own instant is not inside the + // window: that window was scored under it. + expect(boundary(previousAt)).toBe(false); + expect(boundary(new Date(previousAt.getTime() + 1))).toBe(true); + expect(boundary(NOW)).toBe(true); + // A change stamped after the current window is a clock that ran + // ahead, not a comparison to break. + expect(boundary(new Date(NOW.getTime() + 1))).toBe(false); + }); + + it("refuses to read a boundary it cannot parse, rather than suppressing forever", () => { + expect( + crossesConfigBoundary( + { version: 4, changedAt: "not a date" }, + new Date(NOW.getTime() - 7 * DAY_MS), + NOW, + ), + ).toBe(false); + expect( + crossesConfigBoundary( + { version: 4, changedAt: null }, + new Date(NOW.getTime() - 7 * DAY_MS), + NOW, + ), + ).toBe(false); + }); + + it("carries the resolver's own answer, not a second reading of the blob", () => { + const changedAt = new Date(NOW.getTime() - 2 * DAY_MS); + const stored = healthScoreConfigFromSelection({ + selection: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + version: 7, + changedAt, + }); + + expect(scoreConfigBoundary(resolveHealthScoreConfig(stored))).toEqual({ + version: 7, + changedAt: changedAt.toISOString(), + }); + expect(scoreConfigBoundary(resolveHealthScoreConfig(null))).toEqual( + UNCONFIGURED_SCORE_BOUNDARY, + ); + }); +}); + +describe("the notice key", () => { + it("moves when the method moves and when the recipe moves", () => { + const base = healthScoreNoticeItemKey(2, 3); + expect(healthScoreNoticeItemKey(3, 3)).not.toBe(base); + expect(healthScoreNoticeItemKey(2, 4)).not.toBe(base); + // …and only then. The same pair is the same acknowledgement, so the + // notice is raised once and dismissed once per recipe version. + expect(healthScoreNoticeItemKey(2, 3)).toBe(base); + }); + + it("leaves a never-configured account's existing key exactly as it was", () => { + // Lengthening this key for everybody would orphan every dismissal + // already in the ledger and re-raise the method notice for the whole + // install base on upgrade. + expect(healthScoreNoticeItemKey(2, 0)).toBe("health_score_algorithm:2"); + }); + + it("stays a key the dismiss route already accepts", () => { + // The whole reason for folding the recipe version into this key + // rather than minting a second notice system: dismissal, the ledger + // prefix and the score-cache eviction all key on the prefix and need + // no change to carry the new notice. + const key = healthScoreNoticeItemKey(2, 5); + expect(key.startsWith("health_score_algorithm:")).toBe(true); + expect(isDismissibleItemKey(key)).toBe(true); + expect(dismissPriorityItemSchema.safeParse({ itemKey: key }).success).toBe( + true, + ); + }); +}); + +describe("the seam", () => { + function day( + dayKey: string, + configVersion: number | null, + ): ScoreSeriesRecord { + return { + dayKey, + composite: 70, + band: "yellow", + composition: ["ACTIVITY", "SLEEP", "ADIPOSITY"], + pillarScores: { ACTIVITY: 70, SLEEP: 70, ADIPOSITY: 70 }, + configVersion, + }; + } + + it("marks the first day after a change and nothing else", () => { + const points = scoreSeriesSeams([ + day("2026-08-17", 1), + day("2026-08-18", 1), + day("2026-08-19", 2), + day("2026-08-20", 2), + ]); + + expect(points.map((point) => point.seamBreak)).toEqual([ + false, + false, + true, + false, + ]); + }); + + it("never marks the first day of the series", () => { + // A beginning is not a break. Flagging it would put a "you changed + // what counts here" marker on the day someone started. + expect(scoreSeriesSeams([day("2026-08-20", 3)])[0].seamBreak).toBe(false); + expect(scoreSeriesSeams([])).toEqual([]); + }); + + it("stays quiet across days that share a recipe", () => { + const points = scoreSeriesSeams([ + day("2026-08-18", 0), + day("2026-08-19", 0), + day("2026-08-20", 0), + ]); + expect(points.some((point) => point.seamBreak)).toBe(false); + expect(scoreCompositeSegments(points)).toHaveLength(1); + }); + + it("orders by day before comparing, so an unordered read cannot invent a seam", () => { + const points = scoreSeriesSeams([ + day("2026-08-20", 2), + day("2026-08-18", 1), + day("2026-08-19", 2), + ]); + expect(points.map((point) => point.dayKey)).toEqual([ + "2026-08-18", + "2026-08-19", + "2026-08-20", + ]); + expect(points.map((point) => point.seamBreak)).toEqual([ + false, + true, + false, + ]); + }); + + it("treats a day that names no recipe as a break rather than a link", () => { + const points = scoreSeriesSeams([ + day("2026-08-19", null), + day("2026-08-20", 1), + ]); + expect(points[1].seamBreak).toBe(true); + }); + + it("breaks the composite line and never rejoins it", () => { + const points = scoreSeriesSeams([ + day("2026-08-17", 1), + day("2026-08-18", 1), + day("2026-08-19", 2), + day("2026-08-20", 2), + ]); + const segments = scoreCompositeSegments(points); + + expect(segments).toHaveLength(2); + expect(segments[0].map((point) => point.dayKey)).toEqual([ + "2026-08-17", + "2026-08-18", + ]); + expect(segments[1].map((point) => point.dayKey)).toEqual([ + "2026-08-19", + "2026-08-20", + ]); + // Every day is in exactly one segment: no day is repeated to make + // the ends meet, and none is dropped to hide the gap. + expect(segments.flat().map((point) => point.dayKey)).toEqual( + points.map((point) => point.dayKey), + ); + }); + + it("keeps a pillar's own row running straight through the seam", () => { + const points = scoreSeriesSeams([ + { ...day("2026-08-18", 1), pillarScores: { SLEEP: 61 } }, + { ...day("2026-08-19", 2), pillarScores: { SLEEP: 62 } }, + { ...day("2026-08-20", 2), pillarScores: { SLEEP: 63 } }, + ]); + + expect(points[1].seamBreak).toBe(true); + expect(scorePillarSeries(points, "SLEEP")).toEqual([ + { dayKey: "2026-08-18", score: 61 }, + { dayKey: "2026-08-19", score: 62 }, + { dayKey: "2026-08-20", score: 63 }, + ]); + }); + + it("leaves out a day the pillar did not count on, rather than scoring it zero", () => { + const points = scoreSeriesSeams([ + { ...day("2026-08-19", 1), pillarScores: { SLEEP: 61 } }, + { ...day("2026-08-20", 1), pillarScores: { ACTIVITY: 70 } }, + ]); + expect(scorePillarSeries(points, "SLEEP")).toEqual([ + { dayKey: "2026-08-19", score: 61 }, + ]); + }); +}); diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts index b17ecbe6f..3d5f8d5a5 100644 --- a/tests/integration/health-score-record.test.ts +++ b/tests/integration/health-score-record.test.ts @@ -207,12 +207,48 @@ describe("the score a surface shows is the score that gets written down", () => expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(row.timezone).toBe("UTC"); expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10)); - // Nothing supplies these yet, and an invented default would be worse than - // an honest null the day the delta guard starts reading them. - expect(row.configVersion).toBeNull(); + // The recipe the day was scored under, resolved on the write path from + // the account's own stored configuration. This account never opened the + // settings surface, so the honest answer is version 0 with no change + // date — the thing the first authored recipe will later be a move FROM. + expect(row.configVersion).toBe(0); expect(row.configChangedAt).toBeNull(); }); + it("stamps the account's own recipe on the row, straight from the stored configuration", async () => { + // The supply half of the delta guard, over real Postgres. Nothing in + // the request carries the recipe: the write path resolves it from the + // same column the composition came from, so a version that never + // reached the writer shows up as a row that cannot say which recipe + // produced it — and the seam becomes underivable a release later. + const user = await seedSession("hsr-recipe"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + const changedAt = new Date(now - 3 * DAY); + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { + healthScoreConfigJson: { + excludedPillars: ["FITNESS"], + version: 6, + changedAt: changedAt.toISOString(), + }, + }, + }); + + expect((await readAnalytics()).data!.healthScore!.composite.status).toBe( + "ok", + ); + + const row = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(row.configVersion).toBe(6); + expect(row.configChangedAt?.toISOString()).toBe(changedAt.toISOString()); + }); + it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => { const user = await seedSession("hsr-never-restate"); const now = Date.now(); From 3406ed6946d6246b62ad27a585e902f7dd228d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:52:22 +0200 Subject: [PATCH 18/26] Drop a boundary condition no input could reach The recipe boundary also required a version of at least one before it would suppress. That reads like a second lock and is not one: the resolver returns a version below one only on the same path that returns no change date, so nothing could ever reach the condition and no test could make it fail. Removing it leaves the guard asking the one question that has an honest answer, which is whether the recipe moved inside the window. The invariant that makes the removal safe is now pinned instead: a resolved configuration never carries a version without the date beside it. A resolver path that produced one would wave that account's recipe change straight past the guard. Also pins the order of the two rules that can fire together. A change that moved the composition trips both, and the person is told they changed what counts rather than that the included pillars changed, which would describe the consequence and leave out the act. --- .../__tests__/config-delta-guard.test.ts | 75 +++++++++++++++++++ src/lib/analytics/score/composite.ts | 21 ++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts index b0794076b..4eff8ddfc 100644 --- a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -176,6 +176,52 @@ describe("the recipe boundary", () => { expect(report.delta).toBe(-20); }); + it("names the act rather than its consequence when both would fire", () => { + // A change that also moved the composition trips two rules at once. + // The order matters to the sentence a person reads: "the included + // pillars changed" describes what happened to the number and leaves + // out who did it, which is the whole confusion this guard exists to + // remove. + const narrower = buildOk({ + value: { + score: 60, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE", "ACTIVITY"], + noiseFloor: 1, + scoreVersion: 2, + }, + coverage: { + requiredInputs: 3, + presentInputs: 2, + historyDays: 28, + missing: [], + }, + confidence: { score: 80, band: "high" }, + provenance: { + inputs: ["BLOOD_PRESSURE", "ACTIVITY"], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }); + + const report = attachScoreDelta( + narrower, + composite(80, new Date(NOW.getTime() - 7 * DAY_MS)), + composite(80, new Date(NOW.getTime() - 14 * DAY_MS)), + NOW, + { + version: 2, + changedAt: new Date(NOW.getTime() - 3 * DAY_MS).toISOString(), + }, + CURRENT_PILLARS, + EARLIER_PILLARS, + ); + + expect(report.deltaReason).toBe("config_changed"); + }); + it("suppresses at the boundary instant and not one moment past it", () => { const previousAt = new Date(NOW.getTime() - 7 * DAY_MS); const boundary = (changedAt: Date) => @@ -212,6 +258,35 @@ describe("the recipe boundary", () => { ).toBe(false); }); + it("never resolves a recipe version without the date the guard reads", () => { + // The invariant the boundary rests on. `crossesConfigBoundary` asks + // only about the date, which is safe exactly because a resolved + // config never carries a version without one — an authored recipe + // always has both, and a never-authored one has neither. If a + // resolver path ever produced a version with no date, the guard + // would wave that account's recipe change straight through. + const blobs: unknown[] = [ + null, + undefined, + "not an object", + {}, + { excludedPillars: [] }, + { excludedPillars: ["SLEEP"] }, + { excludedPillars: ["SLEEP"], version: 2 }, + { excludedPillars: ["nonsense"], version: 9 }, + { excludedPillars: [], changedAt: "2026-08-01T00:00:00.000Z" }, + { version: 4, changedAt: "2026-08-01T00:00:00.000Z" }, + ]; + + for (const blob of blobs) { + const resolved = resolveHealthScoreConfig(blob); + expect( + resolved.version >= 1 || resolved.changedAt === null, + `version without a change date for ${JSON.stringify(blob)}`, + ).toBe(true); + } + }); + it("carries the resolver's own answer, not a second reading of the blob", () => { const changedAt = new Date(NOW.getTime() - 2 * DAY_MS); const stored = healthScoreConfigFromSelection({ diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index edc42e7e1..dd2b61cda 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -222,18 +222,27 @@ function comparableDynamicPillars( * subtraction looks legitimate. Nothing inside the two composites can * see the change; only the recipe's own `changedAt` can. * - * `version` is what says a recipe was ever authored. An account that - * never chose has no boundary to straddle, and a stored blob that - * somehow carries no `changedAt` gives nothing to compare, so neither - * suppresses: refusing every delta forever on an unreadable date would - * trade one silent lie for a permanent silence. + * The date is the whole signal, and the boundary's `version` is + * deliberately NOT consulted here. An earlier draft also required + * `version >= 1`, which reads like defence in depth and is not: the + * resolver hands back a version below 1 only on the path that also + * hands back a null date, so no input could ever reach that arm and no + * test could make it fail. A condition nothing can trip is decoration + * on a guard. The version earns its keep elsewhere — it identifies the + * recipe on the stored row and in the notice key — but the question + * "did the recipe move inside this window" has one honest answer and it + * is a date. + * + * No date means nothing to compare, and that does not suppress: + * refusing every delta forever over an unreadable timestamp would trade + * one silent lie for a permanent silence. */ export function crossesConfigBoundary( boundary: ScoreConfigBoundary, previousAt: Date, asOf: Date, ): boolean { - if (boundary.version < 1 || boundary.changedAt === null) return false; + if (boundary.changedAt === null) return false; const changedAt = new Date(boundary.changedAt); if (Number.isNaN(changedAt.getTime())) return false; return asOf >= changedAt && previousAt < changedAt; From 98eb609eb161aec81f33c7074f2f83884a7e0545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:57:05 +0200 Subject: [PATCH 19/26] Remove two refusals that could not refuse anything The recipe boundary opened with an early return for a missing date and another for a date it could not read. Deleting either one changed no answer any test or any caller could observe: both cases parse to NaN, and every comparison against NaN is already false, so the refusal was falling out of the arithmetic underneath them. An if that cannot change an outcome is a comment wearing a keyword, and it invites the next reader to trust a lock that is not attached to anything. The behaviour is unchanged and now has one statement instead of three, with the reason written where the next person will look for it. The seam had the same shape in miniature: a day naming no recipe was already broken away from a day that named one, by plain inequality, so the arm only ever mattered when two consecutive days both named none. That case is now covered, and removing the arm makes it fail. --- .../__tests__/config-delta-guard.test.ts | 22 ++++++++++++++----- src/lib/analytics/score/composite.ts | 12 +++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts index 4eff8ddfc..c9609b154 100644 --- a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -403,11 +403,23 @@ describe("the seam", () => { }); it("treats a day that names no recipe as a break rather than a link", () => { - const points = scoreSeriesSeams([ - day("2026-08-19", null), - day("2026-08-20", 1), - ]); - expect(points[1].seamBreak).toBe(true); + // Both directions, including two unknowns in a row. Without the + // second case the arm would be doing nothing that plain inequality + // does not already do: `null !== 1` breaks on its own, while + // `null !== null` would quietly claim two rows share a basis that + // neither of them names. + expect( + scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", 1)])[1] + .seamBreak, + ).toBe(true); + expect( + scoreSeriesSeams([day("2026-08-19", 1), day("2026-08-20", null)])[1] + .seamBreak, + ).toBe(true); + expect( + scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", null)])[1] + .seamBreak, + ).toBe(true); }); it("breaks the composite line and never rejoins it", () => { diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index dd2b61cda..3522d8d47 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -235,17 +235,19 @@ function comparableDynamicPillars( * * No date means nothing to compare, and that does not suppress: * refusing every delta forever over an unreadable timestamp would trade - * one silent lie for a permanent silence. + * one silent lie for a permanent silence. Both cases — an account that + * never chose, and a stored date this build cannot read — parse to NaN, + * and every comparison against NaN is false, so the refusal falls out + * of the arithmetic. Two early returns stood here and neither could + * change an answer, which makes them a comment wearing an `if`. */ export function crossesConfigBoundary( boundary: ScoreConfigBoundary, previousAt: Date, asOf: Date, ): boolean { - if (boundary.changedAt === null) return false; - const changedAt = new Date(boundary.changedAt); - if (Number.isNaN(changedAt.getTime())) return false; - return asOf >= changedAt && previousAt < changedAt; + const changedAt = Date.parse(boundary.changedAt ?? ""); + return asOf.getTime() >= changedAt && previousAt.getTime() < changedAt; } /** From 0a524c542bfef0a166eaa38db389b095a3af9fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:57:42 +0200 Subject: [PATCH 20/26] Write down what an upgrade does to an account that already exists The promise is that nobody's number moves the day they upgrade, and the way it holds is easy to lose: there is no backfill and no migration step, because a null column reads as version zero, version zero counts every pillar, and every pillar narrowed to what the account's modules record is exactly the set the module switches were producing before. The inheritance is the resolver's behaviour, so the next person to touch the resolver needs to know they are holding it. Also records what the first save sets in motion, once each: the delta suppressed while the comparison window still straddles the change, the next stored day marked as a break so the line is drawn in two segments, and the notice raised under a key carrying the recipe version. --- src/lib/analytics/score/config.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index 3a3b81db2..af358a7b4 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -31,6 +31,40 @@ * positive selection, because that is what a person sees and what the * score's published composition means. The inversion happens once, at * the boundary, in `healthScoreConfigFromSelection`. + * + * ## What happens to an account that already exists + * + * Nothing, on the day it upgrades, and that is the whole promise. The + * column starts null, the resolver reads null as version 0 with no + * change date, and version 0 resolves to every pillar — which, + * intersected with what the account's modules record, is exactly the + * composition the module switches were producing before this release. + * The number does not move, no series break is drawn, no notice is + * raised, and no delta is suppressed. There is no backfill because + * there is nothing to back-fill: the inheritance is the resolver's + * behaviour, not a migration's. + * + * The first time someone opens the surface and saves, the version goes + * 0 → 1 and a dated change appears beside it. From that instant three + * things follow, all of them once: + * + * * the week-over-week delta is suppressed for as long as the + * comparison window still straddles the change, with the reason + * `config_changed` — the person changed what counts, and a + * subtraction across that is not a health event; + * * the next stored day is marked as a series break, so the composite + * line is drawn in two segments rather than one line through the + * change (`./series.ts`), while the per-pillar rows run straight + * through it; + * * the notice key gains the recipe version, so the "you changed what + * counts" note is raised once and dismissed once per version, and a + * dismissal of one version never silences the next. + * + * What makes the previously invisible choice visible for the first time + * is the surface itself, which is where the one-time note belongs and + * where it is written. This file owns the behaviour that note describes; + * saying it twice, in two places that can drift, is how a note ends up + * describing something the code stopped doing. */ import { z } from "zod"; From 5d7d00949cba1706cd84b2f598c5a5255d37aa84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:02:22 +0200 Subject: [PATCH 21/26] Resolve the configured flag on the score composite One boolean on the composite: true when the account's resolved composition differs from the composition its defaults would resolve to today, both narrowed by the same modules. Decided once in the score reader, from the same config and module map the composition came from, so no client interprets a config blob to answer it. This is not hasSelection. An account that opened the surface and kept every pillar has written a selection and is not configured; an account whose modules alone narrow the set has authored nothing and is not configured either. --- .../health-score-card-geometry.test.tsx | 1 + .../__tests__/health-score-card.test.tsx | 2 + .../insights/__tests__/hero-strip.test.tsx | 1 + .../analytics/score/__tests__/breadth.test.ts | 1 + .../score/__tests__/composite.test.ts | 1 + .../__tests__/config-delta-guard.test.ts | 2 + .../analytics/score/__tests__/record.test.ts | 1 + src/lib/analytics/score/composite.ts | 9 +++ src/lib/analytics/score/config.ts | 57 +++++++++++++++++++ src/lib/analytics/score/index.ts | 7 +++ src/lib/analytics/score/reader.ts | 18 +++++- src/lib/analytics/score/types.ts | 8 +++ 12 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 9912a2c8c..5ef74d250 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -147,6 +147,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { band: "green", bandSetter: null, composition: pillars.map((p) => p.id), + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index b879bdcf3..9938c6ca1 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -239,6 +239,7 @@ function scoredReport( band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, @@ -368,6 +369,7 @@ describe(" pillars", () => { band: "red", bandSetter: null, composition: ["BLOOD_PRESSURE", "GLYCAEMIA"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 1785b8175..dc4c21e75 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -114,6 +114,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts index e26683bf1..864f07476 100644 --- a/src/lib/analytics/score/__tests__/breadth.test.ts +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -144,6 +144,7 @@ describe("the scorer refuses exactly what the write refuses", () => { pillars: ids.map((id) => pillar(id)), availablePillars: ids, asOf: NOW, + configured: false, }); const verdict = evaluateScoreBreadth(ids); expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient"); diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index 412d37525..4c281957b 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -83,6 +83,7 @@ function evaluation( pillars: entries, availablePillars: available, asOf, + configured: false, }); } diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts index c9609b154..d9d475202 100644 --- a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -114,6 +114,7 @@ function composite(score: number, asOf: Date): Derived { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + configured: false, noiseFloor: 1, scoreVersion: 2, }, @@ -188,6 +189,7 @@ describe("the recipe boundary", () => { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY"], + configured: false, noiseFloor: 1, scoreVersion: 2, }, diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 8791d125c..9840cea82 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -97,6 +97,7 @@ function report( pillars, availablePillars: available, asOf: NOW, + configured: false, }); return { composite, diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 3522d8d47..5df5488b1 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -35,6 +35,14 @@ export interface CompositeInput { pillars: ScorePillarResult[]; availablePillars: ScorePillarId[]; asOf: Date; + /** + * Whether `availablePillars` is an authored recipe rather than the + * account's defaults. Resolved by `resolveScoreConfigured` before the + * pillar list gets here, because the comparison needs the config and + * the modules and the composite has neither. Required, so a caller + * cannot stay silent and publish `false` for a configured account. + */ + configured: boolean; } export function computeComposite( @@ -139,6 +147,7 @@ export function computeComposite( ? composition[bandSetterIndex] : null, composition, + configured: input.configured, noiseFloor, scoreVersion: SCORE_VERSION, }, diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index af358a7b4..5db900a11 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -170,6 +170,63 @@ export function resolveHealthScoreConfig( }; } +/** + * Does this account's score run on an authored recipe? + * + * One boolean, resolved here so that no client ever reads a config blob + * to answer it. The web hero, the iOS widget, the watch complication and + * the public API all read the same value off the wire. + * + * **The definition, in one sentence:** the score is configured when the + * composition it resolves to differs from the composition the account's + * defaults would resolve to today. Both sides are narrowed by the same + * modules, so the comparison is about the person's choice and nothing + * else. + * + * Three consequences, each deliberate: + * + * * An account that opened the surface and kept every pillar is NOT + * configured. It has a selection, and that selection is the default; + * nothing about its number is attributable to an authored recipe. + * This is why `hasSelection` is not this flag — that one answers + * "did the person write something", which is a different question + * and the wrong one to put in front of a widget. + * * An account whose modules alone narrow the set is NOT configured. + * The modules say what is being recorded, not what should count, and + * the defaults resolve through them identically. + * * An account that took out a pillar no module is feeding IS + * configured, because its resolved composition is genuinely + * narrower than the default one. Data availability narrows further + * still, and that narrowing is not a choice — so the comparison runs + * before it, and the flag does not flicker as readings arrive. + * + * The mirror of the third: taking out a pillar whose module is already + * off changes nothing, and the flag says so. The recipe is stored and + * comes back the moment the module returns; today it makes no difference + * to the number, and claiming otherwise would be a surface claiming more + * than it does. + * + * `recordedPillars` is what `pillarsWithModuleData(modules)` returns. + * Required, and deliberately not defaulted: a caller that left it out + * would compare against every pillar in the catalogue and report a + * module-narrowed account as configured, which is precisely the answer + * this flag exists to avoid. + */ +export function resolveScoreConfigured(args: { + config: Pick; + recordedPillars: readonly ScorePillarId[]; +}): boolean { + const recorded = new Set(args.recordedPillars); + const resolved = args.config.pillars.filter((id) => recorded.has(id)); + const byDefault = DEFAULT_HEALTH_SCORE_CONFIG.pillars.filter((id) => + recorded.has(id), + ); + return ( + resolved.length !== byDefault.length || + resolved.some((id, index) => id !== byDefault[index]) + ); +} + /** * The recipe's identity, reduced to the two things a comparison needs: * which version of the person's own recipe produced a number, and when diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts index 59ac59640..11ec16ca5 100644 --- a/src/lib/analytics/score/index.ts +++ b/src/lib/analytics/score/index.ts @@ -24,6 +24,12 @@ import { computeWellbeingPillar } from "./wellbeing"; export interface ComputeHealthScoreInput { asOf: Date; availablePillars: ScorePillarId[]; + /** + * Whether `availablePillars` is an authored recipe rather than the + * account's defaults, resolved by `resolveScoreConfigured`. Rides to + * the composite unchanged; nothing here re-derives it. + */ + configured: boolean; pillars: PillarInputs; weightGoal: Derived; delta?: number | null; @@ -84,6 +90,7 @@ export function computeHealthScore( pillars, availablePillars: input.availablePillars, asOf: input.asOf, + configured: input.configured, }), pillars, delta: input.delta ?? null, diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 0caf068ba..052fb4fad 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -27,7 +27,11 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { ACTIVITY_WINDOW_DAYS } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; -import { resolveHealthScoreConfig, scoreConfigBoundary } from "./config"; +import { + resolveHealthScoreConfig, + resolveScoreConfigured, + scoreConfigBoundary, +} from "./config"; import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; @@ -785,10 +789,17 @@ export async function computeUserHealthScore( // recording would be nagging in the voice of their own settings. The // place that pillar comes back is the modules screen. const config = resolveHealthScoreConfig(input.healthScoreConfigJson); - const recorded = new Set(pillarsWithModuleData(input.modules)); + const recordedPillars = pillarsWithModuleData(input.modules); + const recorded = new Set(recordedPillars); const availablePillars: ScorePillarId[] = config.pillars.filter((id) => recorded.has(id), ); + // v1.35.0 — the one place the "this score is configured" boolean is + // decided, from the same config and the same module map the + // composition came from. Resolved here rather than anywhere a client + // can reach, because a client that re-derived it would be a second + // place deciding one thing. + const configured = resolveScoreConfigured({ config, recordedPillars }); const previousAt = new Date(input.now.getTime() - 7 * DAY_MS); const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS); const currentBp = scoreBpEnvelope({ @@ -841,6 +852,7 @@ export async function computeUserHealthScore( const current = computeHealthScore({ asOf: input.now, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: input.now, @@ -851,6 +863,7 @@ export async function computeUserHealthScore( const previous = computeHealthScore({ asOf: previousAt, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: previousAt, @@ -861,6 +874,7 @@ export async function computeUserHealthScore( const previousPrevious = computeHealthScore({ asOf: previousPreviousAt, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: previousPreviousAt, diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index c71d0fa1e..d6b31de86 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -119,6 +119,14 @@ export interface CompositeValue { bandSetter: ScorePillarId | null; /** Registry-ordered eligible pillar ids. Part of the number's identity. */ composition: ScorePillarId[]; + /** + * v1.35.0 — the resolved "this score is configured" flag: true when the + * account's composition differs from the one its defaults would resolve + * to today. Server-resolved so no client interprets a config blob, and + * never a per-pillar detail. `resolveScoreConfigured` in `./config` + * owns the definition and the reasons behind it. + */ + configured: boolean; /** Equal-weighted floor across delta-eligible pillars. */ noiseFloor: number; scoreVersion: typeof SCORE_VERSION; From c29055be1c0eef047fe960f261127a70b6fd2f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:03:53 +0200 Subject: [PATCH 22/26] Carry the configured flag onto the snapshot and digest wires The dashboard snapshot copies it off the composite, the daily digest copies it off the snapshot, and the derived-metric payload already carries it inside the composite value. Optional on both DTOs like the score identity fields beside it, so older cached bodies stay valid. The digest's score object is additionalProperties:false and had never declared deltaReason, scoreVersion or composition, all of which it has been sending. A generated strict decoder would have rejected the real payload, so they are declared here too rather than left standing next to a field that documents itself correctly. --- docs/api/openapi.yaml | 30 ++++++++++++++++++++- src/lib/daily/digest.ts | 6 +++++ src/lib/daily/load-digest.ts | 1 + src/lib/dashboard/snapshot.ts | 12 +++++++++ src/lib/openapi/routes/daily.ts | 31 +++++++++++++++++++++- src/lib/openapi/routes/insights/schemas.ts | 6 +++++ 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e1c32c136..0e779b136 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28112,6 +28112,13 @@ components: - WELLBEING - FITNESS - LIPIDS + configured: + description: "True when the account's own recipe narrows the score's composition below what its defaults would resolve + to today. Server-resolved, so a client never interprets a configuration blob: an account that kept + every pillar reads false, and so does one whose disabled modules alone narrow the set. The + configuration itself is never on this wire. Optional so older cached snapshots without the field + stay valid." + type: boolean deltaReason: anyOf: - type: string @@ -35262,13 +35269,34 @@ components: anyOf: - type: number - type: "null" + configured: + description: True when the account's own recipe narrows the score's composition below what its defaults would resolve to + today. Resolved on the server and carried straight from the dashboard snapshot; the configuration + itself never rides this wire. Optional so older cached digests without the field stay valid. + type: boolean + deltaReason: + description: Why a delta was suppressed; null when it was not. + anyOf: + - type: string + - type: "null" + scoreVersion: + description: Algorithm version that produced the value. + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + composition: + description: Registry-ordered pillar ids the value was composed of. + type: array + items: + type: string required: - value - band - delta additionalProperties: false - type: "null" - description: Health score + band + week-over-week delta; null when none. + description: Health score + band + week-over-week delta, plus whether the account authored the composition; null when + none. topSignal: anyOf: - type: object diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 1b6f3039b..61696103d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -105,6 +105,12 @@ export interface DailyDigestScore { deltaReason?: ScoreDeltaReason | null; scoreVersion?: number; composition?: ScorePillarId[]; + /** + * v1.35.0 — the resolved "this score is configured" flag, carried + * straight from the snapshot's health-score block. Omitted by older + * cached digests, like its siblings above. + */ + configured?: boolean; } /** A broken integration, deterministically derived from `IntegrationStatus`. */ diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 7864b11a0..7c60aa662 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -431,6 +431,7 @@ export async function loadDailyDigest( deltaReason: snapshot.healthScore.deltaReason, scoreVersion: snapshot.healthScore.scoreVersion, composition: snapshot.healthScore.composition, + configured: snapshot.healthScore.configured, } : null; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index de3438d24..108a8a41e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -329,6 +329,17 @@ export interface DashboardSnapshotHealthScore { delta: number | null; confidence?: DerivedConfidence; composition?: ScorePillarId[]; + /** + * v1.35.0 — the resolved "this score is configured" flag: true when the + * person's own recipe narrows the composition below what the account's + * defaults would resolve to today. Server-resolved so the hero, the + * widget and the complication acknowledge an authored recipe without + * interpreting a config blob; the blob itself never rides this wire, + * and neither does any per-pillar config detail. Optional on the type + * (additive contract) so older cached snapshots stay valid; the live + * builder always sets it. + */ + configured?: boolean; deltaReason?: ScoreDeltaReason | null; scoreVersion?: number; bandSetter?: ScorePillarId | null; @@ -792,6 +803,7 @@ async function buildExtras( delta: scoreResult.delta, confidence: scoreResult.composite.confidence, composition: scoreResult.composite.value.composition, + configured: scoreResult.composite.value.configured, deltaReason: scoreResult.deltaReason, scoreVersion: scoreResult.scoreVersion, bandSetter: scoreResult.composite.value.bandSetter, diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 72213df18..dd4075f4f 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -69,9 +69,38 @@ const dailyDigestResponse = z value: z.number(), band: z.string(), delta: z.number().nullable(), + configured: z + .boolean() + .optional() + .describe( + "True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Resolved on the server and carried straight from the dashboard snapshot; the configuration itself never rides this wire. Optional so older cached digests without the field stay valid.", + ), + // The score's identity fields have ridden this wire since the + // digest first carried a score, but the object is + // `additionalProperties: false` and never declared them — a + // generated strict decoder would have rejected the real payload. + // Declared here rather than left standing beside a new field + // that documents itself correctly. + deltaReason: z + .string() + .nullable() + .optional() + .describe("Why a delta was suppressed; null when it was not."), + scoreVersion: z + .number() + .int() + .positive() + .optional() + .describe("Algorithm version that produced the value."), + composition: z + .array(z.string()) + .optional() + .describe("Registry-ordered pillar ids the value was composed of."), }) .nullable() - .describe("Health score + band + week-over-week delta; null when none."), + .describe( + "Health score + band + week-over-week delta, plus whether the account authored the composition; null when none.", + ), topSignal: z .object({ sourceMetric: z.string(), diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index c6b152647..bd7aae6f1 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1109,6 +1109,12 @@ export const dashboardSnapshotResponse = z ]), ) .optional(), + configured: z + .boolean() + .optional() + .describe( + "True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Server-resolved, so a client never interprets a configuration blob: an account that kept every pillar reads false, and so does one whose disabled modules alone narrow the set. The configuration itself is never on this wire. Optional so older cached snapshots without the field stay valid.", + ), deltaReason: z .enum([ "algorithm_changed", From 0276ca91216752c006ba7c26abd51148b0ad65ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:06:09 +0200 Subject: [PATCH 23/26] State an authored composition in the score's method footer One line under the method version, shown only when the server says the composition is the person's own. It states the fact and stops there: which pillars they took out belongs to the settings surface, and working it out here from a configuration blob would put a second decider behind one sentence. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/health-score-card.test.tsx | 38 +++++++++++++++++++ src/components/insights/health-score-card.tsx | 16 ++++++++ 8 files changed, 60 insertions(+) diff --git a/messages/de.json b/messages/de.json index 71774346c..8cd49fd84 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2275,6 +2275,7 @@ "bandSetter": "Gesamtstufe bestimmt durch {pillar}", "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", "methodVersion": "Methodenversion {version}.", + "configured": "Du hast festgelegt, welche Bereiche in diesen Score einfließen.", "none": "keiner", "delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum", "deltaReason": { diff --git a/messages/en.json b/messages/en.json index c8f7ba43e..671707c0e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2275,6 +2275,7 @@ "bandSetter": "Overall band set by {pillar}", "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", "methodVersion": "Method version {version}.", + "configured": "You chose which pillars count toward this score.", "none": "none", "delta": "{delta} vs the previous comparable window", "deltaReason": { diff --git a/messages/es.json b/messages/es.json index dd6ae9b36..2cf66b4a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2275,6 +2275,7 @@ "bandSetter": "Categoría general determinada por {pillar}", "versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.", "methodVersion": "Versión del método {version}.", + "configured": "Tú has elegido qué pilares cuentan para esta puntuación.", "none": "ninguno", "delta": "{delta} frente al periodo comparable anterior", "deltaReason": { diff --git a/messages/fr.json b/messages/fr.json index 446b1a1af..045397178 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2275,6 +2275,7 @@ "bandSetter": "Catégorie globale fixée par {pillar}", "versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.", "methodVersion": "Version de la méthode {version}.", + "configured": "Vous avez choisi les piliers qui comptent pour ce score.", "none": "aucun", "delta": "{delta} par rapport à la période comparable précédente", "deltaReason": { diff --git a/messages/it.json b/messages/it.json index b176dfa11..9cf858017 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2275,6 +2275,7 @@ "bandSetter": "Fascia complessiva determinata da {pillar}", "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", "methodVersion": "Versione del metodo {version}.", + "configured": "Hai scelto quali pilastri contano per questo punteggio.", "none": "nessuno", "delta": "{delta} rispetto al periodo precedente confrontabile", "deltaReason": { diff --git a/messages/pl.json b/messages/pl.json index 4b60aa32d..373259b63 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2275,6 +2275,7 @@ "bandSetter": "Ogólny zakres wyznacza {pillar}", "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", "methodVersion": "Wersja metody {version}.", + "configured": "Wybrane przez Ciebie filary decydują o tym wyniku.", "none": "brak", "delta": "{delta} względem poprzedniego porównywalnego okresu", "deltaReason": { diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 9938c6ca1..34a181398 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -675,6 +675,44 @@ describe(" footer", () => { expect(region).toContain("equal-weighted average"); }); + it("states an authored composition in the method footer, and only when the server says so", () => { + // The footer reads the resolved flag off the composite and says one + // sentence. It never names the pillars the person took out — that is + // the settings surface's job — and it never appears for an account + // whose composition is the default one. + const configured = render( + ), + value: { + ...( + scoredReport().composite as Extract< + HealthScoreReport["composite"], + { status: "ok" } + > + ).value, + configured: true, + }, + }, + })} + />, + ); + expect(anatomyRegion(configured)).toContain( + 'data-slot="health-score-configured"', + ); + expect(anatomyRegion(configured)).toContain( + "You chose which pillars count toward this score.", + ); + + const inherited = render(); + expect(inherited).not.toContain('data-slot="health-score-configured"'); + expect(inherited).not.toContain("You chose which pillars count"); + }); + it("shows the personal weight goal as explicitly unscored context", () => { const html = render( + {/* + * The server resolved whether this composition is the + * person's own; the footer states it and nothing more. + * Which pillars they took out is the settings surface's + * to show, not this one's, and re-deriving the answer + * from a configuration blob here would put a second + * decider behind one sentence. + */} + {composite.value.configured ? ( +

+ {t("insights.healthScore.configured")} +

+ ) : null} ) : (

From 46146f389cd2ad7942b17b23ce6ccd0dcf3a9831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:10:25 +0200 Subject: [PATCH 24/26] Test the configured flag over the real snapshot and digest assembly Five accounts, each writing its selection through the real PATCH route and reading the flag back off the dashboard snapshot, the daily digest and the derived-metric payload. Nothing is hand-built between the ends: a builder that forgets to carry the field returns undefined and the assertions fail. The four negatives are the ways an account reaches the default composition without having authored it, including one that kept every pillar (hasSelection is true, the flag is not) and one that took out a pillar its modules had already withdrawn. A sixth case walks the same account through a save and an undo, so a value cached under a key that does not include the recipe cannot pass. --- .../health-score-configured-flag.test.ts | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 tests/integration/health-score-configured-flag.test.ts diff --git a/tests/integration/health-score-configured-flag.test.ts b/tests/integration/health-score-configured-flag.test.ts new file mode 100644 index 000000000..456617d81 --- /dev/null +++ b/tests/integration/health-score-configured-flag.test.ts @@ -0,0 +1,445 @@ +/** + * v1.35.0 — the resolved "this score is configured" flag, over the real + * wires. + * + * The flag is one boolean, and the whole point of resolving it on the + * server is that no client ever interprets a configuration blob to know + * it. So the thing worth testing is not the resolver — it has its own + * unit suite — but the pipe: the selection a person saves through the + * real write route, read back by the real score reader, carried by the + * real dashboard-snapshot builder, the real daily-digest builder and the + * real derived-metric dispatcher, out through the three routes an iOS + * widget, a watch complication and the web hero actually call. + * + * Both ends green with a hand-built object between them shipped an inert + * fix in this repo once. Nothing here is hand-built: every scenario + * writes a row, calls the routes, and reads the field off the response + * body. + * + * **The definition under test.** The score is configured when the + * composition it resolves to differs from the composition the account's + * defaults would resolve to today, both narrowed by the same modules. + * Hence the four negatives below, each of which is a way of ending up at + * the default composition without meaning to say "configured": + * + * - an account that never opened the surface; + * - an account that opened it and kept every pillar (it HAS a + * selection — `hasSelection` is true — and is still not configured); + * - an account whose disabled modules alone narrow the set; + * - an account that took out a pillar its modules had already + * withdrawn, so the composition is unchanged. + */ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +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), +})); + +// The provider read touches an `app_settings` column that trails the +// production schema in this environment, and no surface under test needs +// a provider: the digest lifts a cached briefing, it never generates one. +vi.mock("@/lib/ai/provider", () => ({ + resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), + hasAnyConfiguredProvider: vi.fn().mockResolvedValue(false), +})); + +const DAY = 24 * 60 * 60 * 1000; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await resetCaches(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function resetCaches() { + const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); + __resetAllCachesForTests(); +} + +async function seedSession(username: string, modulePreferences?: unknown) { + 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"), + ...(modulePreferences === undefined + ? {} + : { modulePreferencesJson: modulePreferences as object }), + }, + }); + 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; +} + +/** + * Three domains' worth of data, which is the floor the composite needs + * to exist at all: blood pressure (cardiometabolic), sleep, and waist + * (adiposity). Every scenario seeds the same shape, so the only thing + * that moves between them is the recipe. + */ +async function seedThreeScorableDomains(userId: string, now: number) { + const prisma = getPrismaClient(); + for (let i = 0; i < 20; i++) { + const at = new Date(now - i * DAY); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_SYS", + value: 122, + unit: "mmHg", + measuredAt: at, + }, + }); + await prisma.measurement.create({ + data: { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 78, + unit: "mmHg", + measuredAt: at, + }, + }); + } + for (let i = 0; i < 14; i++) { + const wake = new Date(now - i * DAY); + wake.setUTCHours(6, 0, 0, 0); + await prisma.measurement.create({ + data: { + userId, + type: "SLEEP_DURATION", + value: 450, + unit: "min", + measuredAt: wake, + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + }, + }); + } + await prisma.measurement.create({ + data: { + userId, + type: "WAIST_CIRCUMFERENCE", + value: 82, + unit: "cm", + measuredAt: new Date(now), + }, + }); + // The snapshot's thick phase — the one that carries the health score — + // stays null until the rollup tier is warm for the dense types. Without + // the fold this test would read `healthScore: null` on every scenario + // and prove nothing about the flag. + const { recomputeUserRollups } = + await import("@/lib/rollups/measurement-rollups"); + await recomputeUserRollups(userId, { granularities: ["DAY"] }); +} + +/** Save a selection through the route a person's client actually calls. */ +async function saveSelection(pillars: string[]) { + const { PATCH } = await import("@/app/api/auth/me/health-score-config/route"); + const res = await (PATCH as (req: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pillars }), + }), + ); + expect(res.status).toBe(200); + await resetCaches(); +} + +interface Wires { + snapshot: boolean | undefined; + digest: boolean | undefined; + derived: boolean | undefined; +} + +/** + * The three payloads a client reads, each fetched through its own route, + * each returning the flag it resolved for itself. Nothing is copied + * between them here — if the snapshot builder forgot to carry the field, + * or the digest forgot to lift it off the snapshot, this returns + * `undefined` for that wire and the assertions fail. + */ +async function readWires(): Promise { + await resetCaches(); + + const { GET: snapshotGet } = + await import("@/app/api/dashboard/snapshot/route"); + const snapshotRes = await ( + snapshotGet as (req: Request) => Promise + )(new Request("http://localhost/api/dashboard/snapshot")); + expect(snapshotRes.status).toBe(200); + const snapshotBody = (await snapshotRes.json()) as { + data: { healthScore: { configured?: boolean } | null } | null; + }; + expect(snapshotBody.data?.healthScore ?? null).not.toBeNull(); + + const { GET: digestGet } = await import("@/app/api/daily/digest/route"); + const digestRes = await (digestGet as (req: Request) => Promise)( + new Request("http://localhost/api/daily/digest"), + ); + expect(digestRes.status).toBe(200); + const digestBody = (await digestRes.json()) as { + data: { score: { configured?: boolean } | null } | null; + }; + expect(digestBody.data?.score ?? null).not.toBeNull(); + + const { GET: derivedGet } = await import("@/app/api/insights/derived/route"); + const derivedRes = await ( + derivedGet as (req: NextRequest) => Promise + )( + new NextRequest( + "http://localhost/api/insights/derived?metric=HEALTH_SCORE", + ), + ); + expect(derivedRes.status).toBe(200); + const derivedBody = (await derivedRes.json()) as { + data: { + status: string; + value: { configured?: boolean } | null; + } | null; + }; + expect(derivedBody.data?.status).toBe("ok"); + + return { + snapshot: snapshotBody.data!.healthScore!.configured, + digest: digestBody.data!.score!.configured, + derived: derivedBody.data!.value!.configured, + }; +} + +/** Every wire says the same thing, and says it as a boolean. */ +function expectEveryWire(wires: Wires, expected: boolean) { + expect(wires.snapshot).toBe(expected); + expect(wires.digest).toBe(expected); + expect(wires.derived).toBe(expected); +} + +describe("the resolved configured flag, on every wire that carries it", () => { + it("reads false for an account that never chose", async () => { + const user = await seedSession("cfg-never-chose"); + await seedThreeScorableDomains(user.id, Date.now()); + + expectEveryWire(await readWires(), false); + }); + + it("reads false for an account that opened the surface and kept every pillar", async () => { + // The distinction this pins: the resolver's `hasSelection` is TRUE + // here, because the person wrote something. The wire flag is false, + // because what they wrote is the default. A client reading + // `hasSelection` would tell them their score is configured when + // nothing about it differs from an untouched account's. + const user = await seedSession("cfg-kept-everything"); + await seedThreeScorableDomains(user.id, Date.now()); + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ]); + + const stored = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { healthScoreConfigJson: true }, + }); + // The write really happened — otherwise this whole case would be + // testing the never-chose path again under a different name. + expect(stored.healthScoreConfigJson).toMatchObject({ + excludedPillars: [], + version: 1, + }); + + expectEveryWire(await readWires(), false); + }); + + it("reads false when disabled modules alone narrow the set", async () => { + // Glucose, labs and mental health off: GLYCAEMIA, LIPIDS and + // WELLBEING can carry no data, so five pillars are eligible instead + // of eight. The person authored nothing, and both sides of the + // comparison narrow identically. + const user = await seedSession("cfg-modules-narrow", { + glucose: false, + labs: false, + mentalHealth: false, + }); + await seedThreeScorableDomains(user.id, Date.now()); + + expectEveryWire(await readWires(), false); + }); + + it("reads false when a taken-out pillar is one the modules had already withdrawn", async () => { + // The recipe is stored and comes back the moment the module does. + // Today it makes no difference to the composition, so the flag says + // so rather than claiming an authorship the number does not show. + const user = await seedSession("cfg-redundant-exclusion", { + mentalHealth: false, + }); + await seedThreeScorableDomains(user.id, Date.now()); + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "FITNESS", + "LIPIDS", + ]); + + expectEveryWire(await readWires(), false); + }); + + it("reads true once the person's own recipe narrows the composition", async () => { + const user = await seedSession("cfg-authored"); + await seedThreeScorableDomains(user.id, Date.now()); + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "LIPIDS", + ]); + + const stored = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { healthScoreConfigJson: true }, + }); + expect(stored.healthScoreConfigJson).toMatchObject({ + excludedPillars: ["FITNESS"], + }); + + expectEveryWire(await readWires(), true); + }); + + it("turns true the moment a recipe is saved and false again when it is undone", async () => { + // The same account, read three times across two saves. A flag that + // is computed once and cached under a key that does not include the + // recipe would pass every case above and fail this one. + const user = await seedSession("cfg-round-trip"); + await seedThreeScorableDomains(user.id, Date.now()); + + expectEveryWire(await readWires(), false); + + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "LIPIDS", + ]); + expectEveryWire(await readWires(), true); + + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ]); + expectEveryWire(await readWires(), false); + + const stored = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { healthScoreConfigJson: true }, + }); + expect(stored.healthScoreConfigJson).toMatchObject({ version: 2 }); + }); + + it("never puts the configuration itself on any of the three wires", async () => { + // iOS consumes the resolved flag and nothing else. A per-pillar + // configuration detail on one of these payloads would invite a + // client to re-derive the answer, which is the boundary this whole + // field exists to hold. + const user = await seedSession("cfg-no-blob"); + await seedThreeScorableDomains(user.id, Date.now()); + await saveSelection([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "LIPIDS", + ]); + + const { GET: snapshotGet } = + await import("@/app/api/dashboard/snapshot/route"); + const snapshotRes = await ( + snapshotGet as (req: Request) => Promise + )(new Request("http://localhost/api/dashboard/snapshot")); + const snapshotText = await snapshotRes.text(); + + const { GET: digestGet } = await import("@/app/api/daily/digest/route"); + const digestRes = await (digestGet as (req: Request) => Promise)( + new Request("http://localhost/api/daily/digest"), + ); + const digestText = await digestRes.text(); + + const { GET: derivedGet } = + await import("@/app/api/insights/derived/route"); + const derivedRes = await ( + derivedGet as (req: NextRequest) => Promise + )( + new NextRequest( + "http://localhost/api/insights/derived?metric=HEALTH_SCORE", + ), + ); + const derivedText = await derivedRes.text(); + + for (const body of [snapshotText, digestText, derivedText]) { + expect(body).toContain('"configured"'); + expect(body).not.toContain("excludedPillars"); + expect(body).not.toContain("hasSelection"); + expect(body).not.toContain("healthScoreConfig"); + } + }); +}); From f8b51b7994ea5c5ae600cd14228f4a555f21daae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:51:47 +0200 Subject: [PATCH 25/26] Keep the insights end-to-end fixture faithful to the wire The composite fixture mirrors the real payload field for field, so it gains the resolved flag alongside the composition it sits next to. --- e2e/utils/mock-populated-insights.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 378c60208..51baace4a 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -168,6 +168,7 @@ const HEALTH_SCORE = { band: "green" as const, bandSetter: "SLEEP", composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, From 78fe4d8eeaaed1efad317e396c6034ac30632ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 19:03:49 +0200 Subject: [PATCH 26/26] Guard the configured flag's meaning without a container The flag was proven end to end over the real snapshot, digest and derived wires, which is the right place to show that it arrives and the wrong place to be the only proof of what it means. That suite needs a container runtime, so on a machine without one the definition stood unguarded: replacing the default side of the comparison with the unnarrowed catalogue, the exact mistake the docblock warns about, left the whole unit suite green. The definition now has its own fast test, and that break takes two of its cases red. --- .../score/__tests__/configured-flag.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/lib/analytics/score/__tests__/configured-flag.test.ts diff --git a/src/lib/analytics/score/__tests__/configured-flag.test.ts b/src/lib/analytics/score/__tests__/configured-flag.test.ts new file mode 100644 index 000000000..df0dbc0be --- /dev/null +++ b/src/lib/analytics/score/__tests__/configured-flag.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_HEALTH_SCORE_CONFIG, resolveScoreConfigured } from "../config"; +import type { ScorePillarId } from "../types"; + +/** + * The "this score is configured" flag, at the level where its definition + * lives. + * + * The flag was proven end to end over the real snapshot, digest and derived + * wires, which is the right place to prove that it ARRIVES. It is the wrong + * place to be the only proof of what it MEANS: that suite needs Docker, so on + * a machine without a container runtime the definition is unguarded, and a + * change to it passes the whole unit suite in silence. Verified: replacing the + * default side of the comparison with the unnarrowed catalogue, which is the + * exact defect the docblock warns about, left 130 unit tests green. + * + * So the definition gets its own fast test. The rule it pins, in one line: + * configured means the composition the recipe resolves to differs from the one + * the account's defaults would resolve to today, with BOTH sides narrowed by + * the same modules. + */ + +const ALL = DEFAULT_HEALTH_SCORE_CONFIG.pillars; + +/** Every pillar the modules record, for an account with nothing switched off. */ +const RECORDS_EVERYTHING = [...ALL]; + +describe("resolveScoreConfigured", () => { + it("reads false when the person never chose", () => { + expect( + resolveScoreConfigured({ + config: { pillars: [...ALL] }, + recordedPillars: RECORDS_EVERYTHING, + }), + ).toBe(false); + }); + + it("reads true once the person's own recipe narrows the composition", () => { + const withoutFitness = ALL.filter((id) => id !== "FITNESS"); + expect( + resolveScoreConfigured({ + config: { pillars: withoutFitness }, + recordedPillars: RECORDS_EVERYTHING, + }), + ).toBe(true); + }); + + it("reads false when disabled modules alone narrow the set", () => { + // The person authored nothing. The modules withdrew SLEEP, and both sides + // of the comparison are narrowed by the same modules, so the difference + // cancels. Calling this "configured" would attribute a choice to someone + // who never made one — and it is the failure the docblock names, because + // an unnarrowed default side produces exactly this wrong answer. + const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[]; + expect( + resolveScoreConfigured({ + config: { pillars: [...ALL] }, + recordedPillars: recorded, + }), + ).toBe(false); + }); + + it("reads false when a taken-out pillar is one the modules had already withdrawn", () => { + // The recipe removes SLEEP; the sleep module is off anyway. Nothing about + // the composition changes, so nothing is claimed. The recipe is still + // stored and starts mattering the day the module comes back. + const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[]; + const withoutSleep = ALL.filter((id) => id !== "SLEEP"); + expect( + resolveScoreConfigured({ + config: { pillars: withoutSleep }, + recordedPillars: recorded, + }), + ).toBe(false); + }); + + it("reads true when the recipe narrows further than the modules already did", () => { + const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[]; + const alsoWithoutLipids = recorded.filter((id) => id !== "LIPIDS"); + expect( + resolveScoreConfigured({ + config: { pillars: alsoWithoutLipids }, + recordedPillars: recorded, + }), + ).toBe(true); + }); + + it("reads false again when the recipe is undone", () => { + const withoutFitness = ALL.filter((id) => id !== "FITNESS"); + expect( + resolveScoreConfigured({ + config: { pillars: withoutFitness }, + recordedPillars: RECORDS_EVERYTHING, + }), + ).toBe(true); + expect( + resolveScoreConfigured({ + config: { pillars: [...ALL] }, + recordedPillars: RECORDS_EVERYTHING, + }), + ).toBe(false); + }); +});