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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 308908c2260c310134d8838ef48f88888147e6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:10:39 +0200 Subject: [PATCH 25/34] Stop the score from describing a recipe the person may have changed Two sentences about the Health Score stopped being true the moment someone could choose which pillars count toward it. The one people read said the score averaged "every pillar that has enough recent data". For an account that took a pillar out, that describes ground the number does not cover, and it reads as a reassurance rather than a description. It now says the average is over the pillars that count, that a pillar without recent data is still left out rather than zeroed, and that which pillars count is the reader's own choice, so the combined number is not itself a clinical standard. All six locales. The one nobody reads named the WHO HEARTS technical package flat, as though a standards body had signed off on whatever selection the account holds. HEARTS names the risk factors the pillars are drawn from; it prescribes no average over them. The entry now says what it is cited for. While checking where that citation surfaces it turned out it surfaces nowhere: ProvenanceExplainer takes the prop and renders only the method caption, which v1.22 did on purpose when the info popover went away. That is written down in the file now, because the map's own header implies otherwise and the next person would trust it. The registry entry for HEALTH_SCORE lists all eight pillars with a minimum of three, which reads like the recipe and like a threshold. It is neither. The published coverage and confidence are built in computeComposite over the resolved composition, so a person counting four pillars is not measured against a denominator of eight, and the real floor is the breadth rule, which counts distinct domains rather than inputs. Both facts are now stated at the entry instead of being inferable only by following the call chain. Tests: the published denominator is never the eight-pillar catalogue; a pillar somebody took out of their score is never listed back as missing coverage; a pillar they kept and have no data for still is; the registry list stays equal to the score catalogue; the choice sentence is present in all six locales. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../health-score-provenance-claim.test.ts | 104 +++++++++ src/components/insights/derived/standards.ts | 22 +- .../health-score-coverage-contract.test.ts | 211 ++++++++++++++++++ src/lib/insights/derived/registry.ts | 18 ++ 10 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts create mode 100644 src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts diff --git a/messages/de.json b/messages/de.json index 71774346c..f45a700b8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", "anatomyToggle": "Wie dieser Score zustande kommt", "notScored": "Noch nicht bewertet ({count})", - "method": "Der Score ist der gleich gewichtete Mittelwert aller Säulen mit ausreichend aktuellen Daten, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichende Daten bleibt außen vor und zählt nicht als null.", + "method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.", "bandSetter": "Gesamtstufe bestimmt durch {pillar}", "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", "methodVersion": "Methodenversion {version}.", diff --git a/messages/en.json b/messages/en.json index c8f7ba43e..2fe5623fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", "anatomyToggle": "How this score comes about", "notScored": "Not scored yet ({count})", - "method": "The score is the equal-weighted average of every pillar that has enough recent data, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough data is left out rather than counted as a zero.", + "method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.", "bandSetter": "Overall band set by {pillar}", "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", "methodVersion": "Method version {version}.", diff --git a/messages/es.json b/messages/es.json index dd6ae9b36..251e51cc9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", "anatomyToggle": "Cómo se forma esta puntuación", "notScored": "Aún sin puntuar ({count})", - "method": "La puntuación es la media con igual peso de todos los pilares que tienen suficientes datos recientes, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos suficientes queda fuera en lugar de contar como cero.", + "method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.", "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}.", diff --git a/messages/fr.json b/messages/fr.json index 446b1a1af..7224c48e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", "anatomyToggle": "Comment ce score est établi", "notScored": "Pas encore évalué ({count})", - "method": "Le score est la moyenne à poids égal de tous les piliers disposant d'assez de données récentes, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données suffisantes est écarté plutôt que compté comme un zéro.", + "method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.", "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}.", diff --git a/messages/it.json b/messages/it.json index b176dfa11..03afc82f0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", "anatomyToggle": "Come nasce questo punteggio", "notScored": "Non ancora valutato ({count})", - "method": "Il punteggio è la media a peso uguale di tutti i pilastri con dati recenti sufficienti, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati sufficienti resta escluso invece di contare come zero.", + "method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.", "bandSetter": "Fascia complessiva determinata da {pillar}", "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", "methodVersion": "Versione del metodo {version}.", diff --git a/messages/pl.json b/messages/pl.json index 4b60aa32d..d5012326d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", "anatomyToggle": "Jak powstaje ten wynik", "notScored": "Jeszcze bez oceny ({count})", - "method": "Wynik to średnia o równych wagach ze wszystkich filarów, które mają wystarczająco dużo aktualnych danych, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających danych jest pomijany, a nie liczony jako zero.", + "method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.", "bandSetter": "Ogólny zakres wyznacza {pillar}", "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", "methodVersion": "Wersja metody {version}.", diff --git a/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts b/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts new file mode 100644 index 000000000..cc9670d65 --- /dev/null +++ b/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts @@ -0,0 +1,104 @@ +/** + * v1.35.0 — the Health Score's composite-level provenance must not claim + * more than it does. + * + * Two sentences used to overreach, in different ways. + * + * The one people read, `insights.healthScore.method`, said the score was + * the average of "every pillar that has enough recent data". Once somebody + * can take a pillar out of their score, that is simply untrue for them, + * and it is untrue in the direction that matters: it tells them the number + * covers ground it does not. The replacement says the average is over the + * pillars that count, and that which pillars count is theirs to choose. + * This test pins the choice sentence in all six locales, because a + * translation that quietly drops it puts the old claim back for those + * readers only. + * + * The one nobody reads, `METRIC_PROVENANCE.HEALTH_SCORE.standard`, named + * the WHO HEARTS technical package flat, as though a standards body had + * signed off on whatever recipe the account happens to hold. HEARTS names + * the risk factors; it prescribes no average over them. The entry now says + * what it is cited for. It reaches no screen today (`ProvenanceExplainer` + * renders the method caption only), which is exactly why the method copy + * above carries the honesty and this half is a source-of-truth fix. + * + * The marker check is deliberately blunt, and blunt has a failure mode: a + * substring test can pass on prose that means nothing. So it is pinned in + * both directions here, against the wording this release replaced and + * against a rewrite that keeps the meaning, before it is trusted on the + * real bundles. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { METRIC_PROVENANCE } from "../standards"; + +const MESSAGES = join(__dirname, "../../../../../messages"); + +/** + * The phrase in each locale that says the selection belongs to the + * reader. Not a stylistic preference: it is the sentence that stops the + * paragraph from describing a fixed recipe. + */ +const SCOPE_MARKER: Record = { + en: "yours to choose", + de: "entscheidest du", + fr: "vous choisissez", + es: "lo eliges tú", + it: "lo scegli tu", + pl: "ty decydujesz", +}; + +/** The wording this release replaced, kept only to prove the check bites. */ +const PRE_V1350_EN = + "The score is the equal-weighted average of every pillar that has enough " + + "recent data, on a 0 to 100 scale."; + +/** An honest rewrite that keeps the meaning, to prove the check is quiet. */ +const INNOCENT_EN_REWRITE = + "Pillars are averaged with equal weight on a 0 to 100 scale, and which " + + "pillars count is yours to choose."; + +function methodCopy(locale: string): string { + const bundle = JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ) as { + insights: { healthScore: { method: string } }; + }; + return bundle.insights.healthScore.method; +} + +function claimsPersonalScope(locale: string, text: string): boolean { + const marker = SCOPE_MARKER[locale]; + if (!marker) throw new Error(`no scope marker declared for ${locale}`); + return text.toLocaleLowerCase().includes(marker.toLocaleLowerCase()); +} + +describe("Health Score provenance claims", () => { + it("bites on the wording this release replaced", () => { + expect(claimsPersonalScope("en", PRE_V1350_EN)).toBe(false); + }); + + it("stays quiet on a rewrite that keeps the meaning", () => { + expect(claimsPersonalScope("en", INNOCENT_EN_REWRITE)).toBe(true); + }); + + it.each(Object.keys(SCOPE_MARKER))( + "says in %s that the pillars in scope are the reader's own", + (locale) => { + const copy = methodCopy(locale); + expect(copy.length).toBeGreaterThan(0); + expect(claimsPersonalScope(locale, copy)).toBe(true); + }, + ); + + it("does not cite a standard as describing the composite", () => { + const { name } = METRIC_PROVENANCE.HEALTH_SCORE.standard; + // The bare package title read as an endorsement of the account's own + // recipe. It is cited for the risk factors the pillars are drawn from, + // and the name has to say so. + expect(name).not.toBe("WHO HEARTS technical package"); + expect(name).toContain("risk-factor set"); + }); +}); diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index ef0480939..0ebc4f957 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -12,6 +12,16 @@ * * Client-safe: a string-keyed metadata map + i18n key names, no compute, no * server imports — a `"use client"` surface value-imports it freely. + * + * One thing to know before trusting `standard` to reach a reader: it does + * not, everywhere. `ProvenanceExplainer` accepts the prop and renders only + * the method caption (v1.22 folded the cited-standard line out along with + * the ⓘ popover it lived in), so a surface that goes through that component + * shows the method copy and nothing else. `InfoPopover` still renders the + * link, which is why the vitals dashboard does surface citations. The + * Health Score is on the first path, so its honesty about what the number + * covers has to live in `insights.healthScore.method` — the standard entry + * alone would say it to nobody. */ import type { ProvenanceStandard } from "./provenance-explainer"; import type { DerivedMetricId } from "@/lib/insights/derived/registry"; @@ -191,7 +201,17 @@ export const METRIC_PROVENANCE: Record = HEALTH_SCORE: { methodKey: "insights.healthScore.method", standard: { - name: "WHO HEARTS technical package", + // Cited for the risk-factor set the pillars are drawn from, and for + // nothing beyond it. HEARTS names the cardiometabolic factors worth + // following; it does not prescribe an equal-weighted average of + // them, and since v1.35.0 it could not, because which pillars enter + // that average is the account's own selection. Naming the package + // plainly, as this entry used to, reads as a standard endorsing + // whatever recipe the person happens to have chosen, which is a + // claim nobody can make. The pillar-level citations elsewhere in + // this map are unaffected: each of those really does describe the + // band its pillar is graded against. + name: "WHO HEARTS technical package (risk-factor set)", url: "https://www.who.int/publications/i/item/9789240001367", }, }, diff --git a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts new file mode 100644 index 000000000..753ab0f48 --- /dev/null +++ b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts @@ -0,0 +1,211 @@ +/** + * v1.35.0 — what the Health Score's published coverage is allowed to claim. + * + * Two claims are pinned here, because both quietly became wrong the moment + * a person could choose which pillars count. + * + * 1. **The denominator.** A person who counts four pillars must not be + * measured against the eight-pillar catalogue. The registry's + * `HEALTH_SCORE` entry lists all eight and reads like the recipe, so + * the first test states plainly where the published denominator comes + * from instead: `computeComposite`, over the composition the resolver + * hands it. If someone ever wires the static list into the coverage + * model, this goes red. + * 2. **The missing list.** `coverage.missing` drives "track these to + * sharpen this". A pillar somebody deliberately took out of their + * score must never appear there, or the app asks for data in the voice + * of the person's own settings. The counter-test keeps that honest: a + * pillar they KEPT and have no data for still shows up, so the first + * assertion is not passing because the list is empty by construction. + * + * The composition comes from `resolveHealthScoreConfig` rather than a + * hand-written array, so the test exercises the same narrowing the reader + * does instead of asserting against a set it invented. + */ +import { describe, expect, it } from "vitest"; + +import { computeComposite } from "@/lib/analytics/score/composite"; +import { SCORE_MIN_ELIGIBLE_DOMAINS } from "@/lib/analytics/score/breadth"; +import { + healthScoreConfigFromSelection, + resolveHealthScoreConfig, +} from "@/lib/analytics/score/config"; +import { + SCORE_PILLAR_DOMAINS, + SCORE_PILLAR_IDS, + type PillarValue, + type ScorePillarId, + type ScorePillarResult, +} from "@/lib/analytics/score/types"; +import { buildInsufficient, buildOk, deriveCoverage } from "../coverage"; +import { getDerivedMetricMeta } from "../registry"; +import type { Derived } from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +/** A pillar with data, scored and eligible. */ +function scored(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 }; +} + +/** A pillar the account tracks nothing for. */ +function noData(id: ScorePillarId): ScorePillarResult { + const result: Derived = buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [id], + }, + provenance: { + inputs: [id], + source: "none", + windowDays: 0, + computedAt: NOW.toISOString(), + }, + reason: "notTracked", + }); + return { id, domain: SCORE_PILLAR_DOMAINS[id], result }; +} + +/** + * Every pillar in the catalogue, scored when it has data. The scorer + * always produces a row per pillar; the config decides which rows count, + * which is exactly the distinction these tests are about. + */ +function allPillars(withData: readonly ScorePillarId[]): ScorePillarResult[] { + const present = new Set(withData); + return SCORE_PILLAR_IDS.map((id) => + present.has(id) ? scored(id) : noData(id), + ); +} + +/** The reader's own narrowing: the stored recipe, resolved. */ +function compositionFor(selection: readonly ScorePillarId[]): ScorePillarId[] { + const blob = healthScoreConfigFromSelection({ + selection, + version: 1, + changedAt: NOW, + }); + return resolveHealthScoreConfig(blob).pillars; +} + +describe("Health Score coverage contract", () => { + it("does not measure a four-pillar account against the eight-pillar catalogue", () => { + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const availablePillars = compositionFor(selection); + expect(availablePillars).toEqual(selection); + + const composite = computeComposite({ + pillars: allPillars(selection), + availablePillars, + asOf: NOW, + }); + + expect(composite.status).toBe("ok"); + // The catalogue is eight wide and must not be the yardstick. + expect(SCORE_PILLAR_IDS.length).toBe(8); + expect(composite.coverage.requiredInputs).not.toBe(SCORE_PILLAR_IDS.length); + // The denominator is the breadth rule applied to the person's own set, + // so it can never exceed what they chose to count. + expect(composite.coverage.requiredInputs).toBe(SCORE_MIN_ELIGIBLE_DOMAINS); + expect(composite.coverage.requiredInputs).toBeLessThanOrEqual( + selection.length, + ); + expect(composite.coverage.presentInputs).toBe( + composite.coverage.requiredInputs, + ); + if (composite.status !== "ok") throw new Error("unreachable"); + // Fully covered means fully covered: four of four counted pillars have + // data, so nothing about the four they left out may pull this down. + expect(composite.confidence.score).toBe(100); + expect(composite.value.composition).toEqual(selection); + }); + + it("never asks for data from a pillar the person took out of the score", () => { + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const composite = computeComposite({ + pillars: allPillars(selection), + availablePillars: compositionFor(selection), + asOf: NOW, + }); + + expect(composite.coverage.missing).toEqual([]); + }); + + it("still asks for data from a pillar the person kept", () => { + // The same four pillars have data; the difference is that this account + // counts all eight, so the four blanks are genuinely missing coverage. + const withData: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const composite = computeComposite({ + pillars: allPillars(withData), + availablePillars: compositionFor(SCORE_PILLAR_IDS), + asOf: NOW, + }); + + expect(composite.coverage.missing).toContain("wellbeing"); + expect(composite.coverage.missing).toContain("fitness"); + }); + + it("keeps the registry's HEALTH_SCORE inputs equal to the score catalogue", () => { + const meta = getDerivedMetricMeta("HEALTH_SCORE"); + expect(meta).not.toBeNull(); + expect(meta?.inputs).toEqual([...SCORE_PILLAR_IDS]); + // Non-vacuity: the comparison is against the live catalogue, and it + // does distinguish. A list one pillar short is not equal. + expect(meta?.inputs).not.toEqual(SCORE_PILLAR_IDS.slice(0, -1)); + }); +}); diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 8155ccb50..2a8ad9bab 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -342,6 +342,13 @@ const REGISTRY: Record = { id: "HEALTH_SCORE", displayName: "Cardiometabolic reference score", archetype: "composite", + // The eight ids below are the CATALOGUE of scorable pillars, and since + // v1.35.0 they are not the recipe. What counts toward an account's + // composite is the resolved per-user composition + // (`resolveHealthScoreConfig`) intersected with the modules that record + // data, so no static list here can say what any one person's score is + // made of. The list is kept in step with `SCORE_PILLAR_IDS` by + // `health-score-coverage-contract.test.ts`; edit both or neither. inputs: [ "BLOOD_PRESSURE", "GLYCAEMIA", @@ -353,6 +360,17 @@ const REGISTRY: Record = { "LIPIDS", ], minHistoryDays: 1, + // NOT the composite's floor, and not read by anything. The coverage and + // confidence this metric publishes are built in `computeComposite` + // (`src/lib/analytics/score/composite.ts`) over the resolved + // composition, never from this entry, which is why a person who counts + // four pillars is not measured against a denominator of eight. The real + // floor is the breadth rule in `src/lib/analytics/score/breadth.ts`: at + // least three distinct DOMAINS including one physiological pillar. Three + // of the eight ids above share the cardiometabolic domain, so "three + // inputs" and "enough breadth for a score" are different questions and + // this number answers neither. The field stays because every registry + // entry carries it; reading it as a threshold would be wrong. minInputs: 3, implemented: true, }, From bd4a6d14d5456f57d70521f5daf0d8249e6c039f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:49:29 +0200 Subject: [PATCH 26/34] Let a person choose what counts toward their Health Score A dedicated settings page lists every scorable pillar grouped by the area of health it speaks to, so the fact that blood pressure, glycaemia and lipids are three pillars of one area is visible rather than buried. Each row shows the three things that were never separable before: whether the pillar is being recorded, whether it is shown, and whether it counts toward the score. Only the last is writable here, and the page says so. A pillar whose tracking is switched off is not listed at all, because it is not waiting for anything. A pillar this build cannot score yet is listed, disabled, and says why. Wellbeing under safety signposting is named as safety guidance and never as a mistake in the selection. The refusal for a selection too narrow to produce a score comes from the server and is shown in the reader's own language. The score's foot disclosure gains one link into the page. --- messages/de.json | 72 ++- messages/en.json | 72 ++- messages/es.json | 72 ++- messages/fr.json | 72 ++- messages/it.json | 72 ++- messages/pl.json | 72 ++- src/app/settings/[section]/page.tsx | 2 + .../__tests__/health-score-card.test.tsx | 6 +- .../health-score-coverage-axis-guard.test.tsx | 233 ++++++++++ .../insights/derived/coverage-meter.tsx | 36 +- src/components/insights/health-score-card.tsx | 57 ++- .../__tests__/score-change-notice.test.tsx | 111 +++++ .../__tests__/score-pillar-row.test.tsx | 150 ++++++ .../__tests__/settings-shell.test.tsx | 2 + .../settings/score-change-notice.tsx | 108 +++++ src/components/settings/score-pillar-row.tsx | 134 ++++++ src/components/settings/score-section.tsx | 439 ++++++++++++++++++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 6 + src/components/settings/settings-shell.tsx | 10 + src/lib/query-keys/settings.ts | 9 + src/lib/score-config/__tests__/rows.test.ts | 299 ++++++++++++ src/lib/score-config/labels.ts | 44 ++ src/lib/score-config/rows.ts | 221 +++++++++ 24 files changed, 2271 insertions(+), 30 deletions(-) create mode 100644 src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx create mode 100644 src/components/settings/__tests__/score-change-notice.test.tsx create mode 100644 src/components/settings/__tests__/score-pillar-row.test.tsx create mode 100644 src/components/settings/score-change-notice.tsx create mode 100644 src/components/settings/score-pillar-row.tsx create mode 100644 src/components/settings/score-section.tsx create mode 100644 src/lib/score-config/__tests__/rows.test.ts create mode 100644 src/lib/score-config/labels.ts create mode 100644 src/lib/score-config/rows.ts diff --git a/messages/de.json b/messages/de.json index 71774346c..b51113676 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,7 +2268,7 @@ "yellow": "Einige Bereiche brauchen Aufmerksamkeit", "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, - "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", + "insufficient": "{count} von {required} Feldern der Gesundheit haben genug aktuelle Daten. Der Score braucht {required}, mindestens eines davon körperlich gemessen.", "anatomyToggle": "Wie dieser Score zustande kommt", "notScored": "Noch nicht bewertet ({count})", "method": "Der Score ist der gleich gewichtete Mittelwert aller Säulen mit ausreichend aktuellen Daten, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichende Daten bleibt außen vor und zählt nicht als null.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Erfasse dein Gewicht, um den Fortschritt zum Ziel zu sehen.", "read_failed": "Gewichtsdaten konnten nicht geladen werden. Versuche es erneut.", "notScored": "Der Fortschritt zum Gewichtsziel ist persönlicher Kontext und beeinflusst den Gesundheitsscore nicht." + }, + "configureLink": "Festlegen, was in deinen Score einfließt", + "coverage": { + "minimumMet": "Deckt die drei Felder ab, die der Score braucht", + "areas": "{present} von {required} Feldern der Gesundheit" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} von {required} Eingaben · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basiert auf {count} Tagen Verlauf", - "missing": "Erfasse diese, um den Wert zu schärfen: {list}" + "missing": "Erfasse diese, um den Wert zu schärfen: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Wert {score} von 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).", "fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.", "fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}." + }, + "score": { + "title": "Gesundheitsscore", + "subtitle": "Lege fest, welche Bereiche in deinen Score einfließen.", + "card": { + "title": "Was in deinen Score einfließt", + "description": "Jeder Bereich, den du aufzeichnest, zählt mit, solange du hier nichts anderes sagst." + }, + "threeAxes": "Aufzeichnen, Anzeigen und Mitzählen sind drei verschiedene Dinge. Diese Seite ändert nur das Letzte. Ein Bereich, den du hier abschaltest, wird weiter aufgezeichnet und weiter überall in HealthLog angezeigt, er bewegt nur die Zahl nicht mehr.", + "axis": { + "recorded": "Wird aufgezeichnet", + "shown": "Wird in der App angezeigt", + "countsFor": "{pillar} in den Score einrechnen" + }, + "domain": { + "cardiometabolic": "Herz und Stoffwechsel", + "activity": "Aktivität", + "sleep": "Schlaf", + "adiposity": "Körperform", + "wellbeing": "Wohlbefinden", + "fitness": "Gemessene Fitness" + }, + "domainNote": { + "cardiometabolic": "Drei Bereiche, ein Feld der Gesundheit. Jeder Bereich zählt im Score gleich viel, dieses Feld wiegt also dreimal so schwer wie eines mit nur einem Bereich." + }, + "state": { + "counting": "Zählt in deinen Score", + "waiting": "Ausgewählt, wartet auf Daten", + "crisis": "Statt eines Werts wird ein Sicherheitshinweis gezeigt. An deiner Auswahl liegt es nicht.", + "readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden.", + "unavailable": "Noch nicht verfügbar", + "unavailableDetail": "HealthLog kann einen gemessenen Fitnesstest bisher nicht von einer Geräteschätzung unterscheiden, deshalb hat dieser Bereich noch nie einen Wert ergeben. Er bleibt in der Liste, damit du siehst, dass er nicht mitzählt." + }, + "presets": { + "label": "Ausgangspunkt", + "all": "Alle Bereiche", + "current": "Deine gespeicherte Auswahl" + }, + "save": "Speichern", + "saved": "Gespeichert.", + "error": "Konnte nicht gespeichert werden. Bitte versuche es erneut.", + "loadFailed": "Deine Auswahl konnte nicht geladen werden.", + "eligibilityFailed": "Es ließ sich nicht prüfen, für welche Bereiche gerade Daten vorliegen. Die Schalter unten funktionieren trotzdem.", + "omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.", + "omittedLink": "Module öffnen", + "refusal": { + "physiological": "Ein Health Score braucht mindestens einen körperlich gemessenen Wert. Behalte Blutdruck, Glukose, Schlaf, Körperform oder Cholesterin in deiner Auswahl, zusätzlich zu allem anderen, was du wählst.", + "domains": "Ein Health Score braucht mindestens drei verschiedene Felder der Gesundheit. Blutdruck, Glukose und Cholesterin beschreiben dasselbe Feld, eine Auswahl nur daraus zählt also als eines." + }, + "notice": { + "upgrade": { + "title": "Was zählt, entscheidest jetzt du", + "body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." + }, + "changed": { + "title": "Du hast geändert, was zählt", + "body": "Seit {date} wird dein Score aus einer anderen Zusammenstellung berechnet. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch.", + "bodyUndated": "Dein Score wird aus einer anderen Zusammenstellung berechnet als früher. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch." + }, + "dismiss": "Verstanden", + "dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut." + } } }, "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index c8f7ba43e..631a55c80 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,7 +2268,7 @@ "yellow": "Some pillars need attention", "red": "One or more pillars are outside the reference range" }, - "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", + "insufficient": "{count} of {required} areas of health have enough recent data. The score needs {required}, at least one of them a physical measurement.", "anatomyToggle": "How this score comes about", "notScored": "Not scored yet ({count})", "method": "The score is the equal-weighted average of every pillar that has enough recent data, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough data is left out rather than counted as a zero.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Log a weight reading to see progress toward your goal.", "read_failed": "Weight data could not be loaded. Try again.", "notScored": "Weight-goal progress is personal context and does not affect the Health Score." + }, + "configureLink": "Choose what counts toward your score", + "coverage": { + "minimumMet": "Covers the three areas the score needs", + "areas": "{present} of {required} areas of health" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} of {required} inputs · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Based on {count} days of history", - "missing": "Track these to sharpen this: {list}" + "missing": "Track these to sharpen this: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Score {score} out of 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "The last background weather update failed ({when}).", "fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.", "fetchFailedOther": "The last {count} background weather updates failed, most recently {when}." + }, + "score": { + "title": "Health Score", + "subtitle": "Choose which pillars count toward your score.", + "card": { + "title": "What counts toward your score", + "description": "Every pillar you record counts unless you say otherwise here." + }, + "threeAxes": "Recording, showing and counting are three separate things. This page changes only the last one. A pillar you switch off here keeps being recorded and keeps appearing everywhere else in HealthLog, it just stops moving the number.", + "axis": { + "recorded": "Being recorded", + "shown": "Shown in the app", + "countsFor": "Count {pillar} toward the score" + }, + "domain": { + "cardiometabolic": "Heart and metabolism", + "activity": "Activity", + "sleep": "Sleep", + "adiposity": "Body shape", + "wellbeing": "Wellbeing", + "fitness": "Measured fitness" + }, + "domainNote": { + "cardiometabolic": "Three pillars, one area of health. Each pillar carries the same weight in the score, so this area weighs three times as much as an area with a single pillar." + }, + "state": { + "counting": "Counting toward your score", + "waiting": "Selected, waiting for data", + "crisis": "Safety guidance is shown instead of a score. Your selection is fine.", + "readFailed": "This pillar could not be read on the last run.", + "unavailable": "Not available yet", + "unavailableDetail": "HealthLog cannot yet tell a measured fitness test apart from a device estimate, so this pillar has never produced a score. It stays listed so you can see it is not counted." + }, + "presets": { + "label": "Start from", + "all": "All pillars", + "current": "Your saved selection" + }, + "save": "Save", + "saved": "Saved.", + "error": "Couldn't save. Please try again.", + "loadFailed": "Couldn't load your selection.", + "eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.", + "omitted": "{count} pillars are not listed because their tracking is switched off. Turn a module back on and its pillar comes back.", + "omittedLink": "Open modules", + "refusal": { + "physiological": "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.", + "domains": "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." + }, + "notice": { + "upgrade": { + "title": "What counts is now yours to choose", + "body": "Which pillars counted toward your Health Score used to follow the modules you had switched on. It is a separate choice now, and this page is where you make it. Your number did not move when this arrived: it still counts exactly what it counted before." + }, + "changed": { + "title": "You changed what counts", + "body": "Since {date} your score is worked out from a different set of pillars. Days before that were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it.", + "bodyUndated": "Your score is worked out from a different set of pillars than it used to be. Days before the change were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it." + }, + "dismiss": "Got it", + "dismissError": "Couldn't save that. Please try again." + } } }, "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index dd6ae9b36..3e1d02c2f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,7 +2268,7 @@ "yellow": "Algunos pilares requieren atención", "red": "Uno o más pilares están fuera del intervalo de referencia" }, - "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", + "insufficient": "{count} de {required} ámbitos de salud tienen suficientes datos recientes. La puntuación necesita {required}, y al menos uno debe ser una medición física.", "anatomyToggle": "Cómo se forma esta puntuación", "notScored": "Aún sin puntuar ({count})", "method": "La puntuación es la media con igual peso de todos los pilares que tienen suficientes datos recientes, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos suficientes queda fuera en lugar de contar como cero.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Registra una lectura de peso para ver el progreso hacia tu objetivo.", "read_failed": "No se pudieron cargar los datos de peso. Inténtalo de nuevo.", "notScored": "El progreso del objetivo de peso es contexto personal y no afecta a la puntuación de salud." + }, + "configureLink": "Elegir qué cuenta para tu puntuación", + "coverage": { + "minimumMet": "Cubre los tres ámbitos que la puntuación necesita", + "areas": "{present} de {required} ámbitos de salud" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} de {required} entradas · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basado en {count} días de historial", - "missing": "Registra estos para afinar el valor: {list}" + "missing": "Registra estos para afinar el valor: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Puntuación {score} de 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).", "fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.", "fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}." + }, + "score": { + "title": "Puntuación de salud", + "subtitle": "Elige qué pilares cuentan para tu puntuación.", + "card": { + "title": "Qué cuenta para tu puntuación", + "description": "Cada pilar que registras cuenta, salvo que digas lo contrario aquí." + }, + "threeAxes": "Registrar, mostrar y contar son tres cosas distintas. Esta página cambia solo la última. Un pilar que desactives aquí se sigue registrando y se sigue viendo en el resto de HealthLog, simplemente deja de mover el número.", + "axis": { + "recorded": "Se registra", + "shown": "Se muestra en la app", + "countsFor": "Contar {pillar} para la puntuación" + }, + "domain": { + "cardiometabolic": "Corazón y metabolismo", + "activity": "Actividad", + "sleep": "Sueño", + "adiposity": "Forma corporal", + "wellbeing": "Bienestar", + "fitness": "Condición física medida" + }, + "domainNote": { + "cardiometabolic": "Tres pilares, un mismo ámbito de salud. Cada pilar pesa lo mismo en la puntuación, así que este ámbito pesa el triple que uno con un solo pilar." + }, + "state": { + "counting": "Cuenta para tu puntuación", + "waiting": "Seleccionado, esperando datos", + "crisis": "En lugar de una puntuación se muestra orientación de seguridad. Tu selección está bien.", + "readFailed": "Este pilar no se pudo leer en el último cálculo.", + "unavailable": "Todavía no disponible", + "unavailableDetail": "HealthLog aún no distingue una prueba de condición física medida de una estimación del dispositivo, así que este pilar nunca ha dado una puntuación. Sigue en la lista para que veas que no cuenta." + }, + "presets": { + "label": "Punto de partida", + "all": "Todos los pilares", + "current": "Tu selección guardada" + }, + "save": "Guardar", + "saved": "Guardado.", + "error": "No se pudo guardar. Inténtalo de nuevo.", + "loadFailed": "No se pudo cargar tu selección.", + "eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.", + "omitted": "{count} pilares no aparecen porque su seguimiento está desactivado. Vuelve a activar un módulo y su pilar regresa.", + "omittedLink": "Abrir módulos", + "refusal": { + "physiological": "Una puntuación de salud necesita al menos una medición física. Mantén en tu selección la presión arterial, la glucosa, el sueño, la forma corporal o el colesterol, además de lo demás que elijas.", + "domains": "Una puntuación de salud necesita al menos tres ámbitos de salud distintos. La presión arterial, la glucosa y el colesterol describen el mismo ámbito, así que una selección hecha solo con ellos cuenta como uno." + }, + "notice": { + "upgrade": { + "title": "Ahora decides tú qué cuenta", + "body": "Qué pilares contaban para tu Health Score dependía antes de los módulos que tuvieras activados. Ahora es una decisión aparte, y aquí es donde la tomas. Tu número no se movió al llegar este cambio: sigue contando exactamente lo que contaba antes." + }, + "changed": { + "title": "Has cambiado qué cuenta", + "body": "Desde el {date} tu puntuación se calcula con otro conjunto de pilares. Los días anteriores salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción.", + "bodyUndated": "Tu puntuación se calcula con un conjunto de pilares distinto al anterior. Los días previos salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción." + }, + "dismiss": "Entendido", + "dismissError": "No se pudo guardar. Inténtalo de nuevo." + } } }, "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index 446b1a1af..6aea13ce5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,7 +2268,7 @@ "yellow": "Certains piliers demandent une attention", "red": "Un ou plusieurs piliers sont hors de la plage de référence" }, - "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", + "insufficient": "{count} domaines de santé sur {required} disposent d'assez de données récentes. Le score en demande {required}, dont au moins un issu d'une mesure physique.", "anatomyToggle": "Comment ce score est établi", "notScored": "Pas encore évalué ({count})", "method": "Le score est la moyenne à poids égal de tous les piliers disposant d'assez de données récentes, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données suffisantes est écarté plutôt que compté comme un zéro.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Enregistre ton poids pour suivre ta progression vers ton objectif.", "read_failed": "Les données de poids n'ont pas pu être chargées. Réessaie.", "notScored": "La progression vers l'objectif de poids est un contexte personnel et ne modifie pas le score de santé." + }, + "configureLink": "Choisir ce qui compte dans votre score", + "coverage": { + "minimumMet": "Couvre les trois domaines exigés par le score", + "areas": "{present} domaines de santé sur {required}" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} entrées sur {required} · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basé sur {count} jours d'historique", - "missing": "Enregistre ceci pour affiner la valeur : {list}" + "missing": "Enregistre ceci pour affiner la valeur : {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Score {score} sur 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).", "fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.", "fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}." + }, + "score": { + "title": "Score de santé", + "subtitle": "Choisissez les piliers qui comptent dans votre score.", + "card": { + "title": "Ce qui compte dans votre score", + "description": "Chaque pilier que vous enregistrez compte, sauf indication contraire ici." + }, + "threeAxes": "Enregistrer, afficher et compter sont trois choses distinctes. Cette page ne change que la dernière. Un pilier désactivé ici continue d'être enregistré et reste visible partout ailleurs dans HealthLog, il cesse simplement de faire bouger le chiffre.", + "axis": { + "recorded": "Enregistré", + "shown": "Affiché dans l'application", + "countsFor": "Compter {pillar} dans le score" + }, + "domain": { + "cardiometabolic": "Cœur et métabolisme", + "activity": "Activité", + "sleep": "Sommeil", + "adiposity": "Silhouette", + "wellbeing": "Bien-être", + "fitness": "Condition physique mesurée" + }, + "domainNote": { + "cardiometabolic": "Trois piliers, un seul domaine de santé. Chaque pilier pèse autant dans le score, ce domaine pèse donc trois fois plus qu'un domaine composé d'un seul pilier." + }, + "state": { + "counting": "Compte dans votre score", + "waiting": "Sélectionné, en attente de données", + "crisis": "Un message de sécurité s'affiche à la place d'un score. Votre sélection n'est pas en cause.", + "readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul.", + "unavailable": "Pas encore disponible", + "unavailableDetail": "HealthLog ne sait pas encore distinguer un test de condition physique mesuré d'une estimation d'appareil, ce pilier n'a donc jamais produit de score. Il reste listé pour que vous voyiez qu'il ne compte pas." + }, + "presets": { + "label": "Point de départ", + "all": "Tous les piliers", + "current": "Votre sélection enregistrée" + }, + "save": "Enregistrer", + "saved": "Enregistré.", + "error": "Enregistrement impossible. Veuillez réessayer.", + "loadFailed": "Impossible de charger votre sélection.", + "eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.", + "omitted": "{count} piliers ne sont pas listés parce que leur suivi est désactivé. Réactivez un module et son pilier revient.", + "omittedLink": "Ouvrir les modules", + "refusal": { + "physiological": "Un score de santé a besoin d'au moins une mesure physique. Gardez la tension, la glycémie, le sommeil, la silhouette ou le cholestérol dans votre sélection, en plus de ce que vous choisissez par ailleurs.", + "domains": "Un score de santé a besoin d'au moins trois domaines de santé différents. La tension, la glycémie et le cholestérol décrivent le même domaine, une sélection composée uniquement de ceux-là compte donc pour un." + }, + "notice": { + "upgrade": { + "title": "Ce qui compte, c'est vous qui le décidez", + "body": "Les piliers qui comptaient dans votre Health Score suivaient auparavant les modules que vous aviez activés. C'est désormais un choix à part, et c'est ici que vous le faites. Votre chiffre n'a pas bougé pour autant : il compte exactement ce qu'il comptait avant." + }, + "changed": { + "title": "Vous avez changé ce qui compte", + "body": "Depuis le {date}, votre score est calculé à partir d'un autre ensemble de piliers. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture.", + "bodyUndated": "Votre score est calculé à partir d'un autre ensemble de piliers qu'auparavant. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture." + }, + "dismiss": "Compris", + "dismissError": "Enregistrement impossible. Veuillez réessayer." + } } }, "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index b176dfa11..41ebaf661 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,7 +2268,7 @@ "yellow": "Alcuni pilastri richiedono attenzione", "red": "Uno o più pilastri sono fuori dall'intervallo di riferimento" }, - "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", + "insufficient": "{count} ambiti di salute su {required} hanno abbastanza dati recenti. Il punteggio ne richiede {required}, di cui almeno uno misurato fisicamente.", "anatomyToggle": "Come nasce questo punteggio", "notScored": "Non ancora valutato ({count})", "method": "Il punteggio è la media a peso uguale di tutti i pilastri con dati recenti sufficienti, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati sufficienti resta escluso invece di contare come zero.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Registra il peso per vedere i progressi verso il tuo obiettivo.", "read_failed": "Non è stato possibile caricare i dati del peso. Riprova.", "notScored": "I progressi verso l'obiettivo di peso sono un contesto personale e non influenzano il punteggio di salute." + }, + "configureLink": "Scegli cosa conta nel tuo punteggio", + "coverage": { + "minimumMet": "Copre i tre ambiti richiesti dal punteggio", + "areas": "{present} ambiti di salute su {required}" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} di {required} input · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Basato su {count} giorni di cronologia", - "missing": "Registra questi per affinare il valore: {list}" + "missing": "Registra questi per affinare il valore: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Punteggio {score} su 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).", "fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.", "fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}." + }, + "score": { + "title": "Punteggio di salute", + "subtitle": "Scegli quali pilastri contano per il tuo punteggio.", + "card": { + "title": "Cosa conta per il tuo punteggio", + "description": "Ogni pilastro che registri conta, a meno che tu non dica diversamente qui." + }, + "threeAxes": "Registrare, mostrare e contare sono tre cose diverse. Questa pagina cambia solo l'ultima. Un pilastro che disattivi qui continua a essere registrato e resta visibile ovunque in HealthLog, smette solo di muovere il numero.", + "axis": { + "recorded": "Viene registrato", + "shown": "Mostrato nell'app", + "countsFor": "Conta {pillar} nel punteggio" + }, + "domain": { + "cardiometabolic": "Cuore e metabolismo", + "activity": "Attività", + "sleep": "Sonno", + "adiposity": "Forma corporea", + "wellbeing": "Benessere", + "fitness": "Forma fisica misurata" + }, + "domainNote": { + "cardiometabolic": "Tre pilastri, un solo ambito di salute. Ogni pilastro pesa allo stesso modo nel punteggio, quindi questo ambito pesa il triplo di uno con un solo pilastro." + }, + "state": { + "counting": "Conta nel tuo punteggio", + "waiting": "Selezionato, in attesa di dati", + "crisis": "Al posto di un punteggio viene mostrata un'indicazione di sicurezza. La tua selezione va bene.", + "readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo.", + "unavailable": "Non ancora disponibile", + "unavailableDetail": "HealthLog non sa ancora distinguere un test di forma fisica misurato da una stima del dispositivo, perciò questo pilastro non ha mai prodotto un punteggio. Resta in elenco perché tu veda che non conta." + }, + "presets": { + "label": "Punto di partenza", + "all": "Tutti i pilastri", + "current": "La tua selezione salvata" + }, + "save": "Salva", + "saved": "Salvato.", + "error": "Non è stato possibile salvare. Riprova.", + "loadFailed": "Non è stato possibile caricare la tua selezione.", + "eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.", + "omitted": "{count} pilastri non compaiono perché il loro monitoraggio è disattivato. Riattiva un modulo e il suo pilastro torna.", + "omittedLink": "Apri i moduli", + "refusal": { + "physiological": "Un punteggio di salute ha bisogno di almeno una misurazione fisica. Tieni nella selezione la pressione, la glicemia, il sonno, la forma corporea o il colesterolo, oltre a ciò che scegli d'altro.", + "domains": "Un punteggio di salute ha bisogno di almeno tre ambiti di salute diversi. Pressione, glicemia e colesterolo descrivono lo stesso ambito, quindi una selezione fatta solo di questi conta come uno." + }, + "notice": { + "upgrade": { + "title": "Ora sei tu a decidere cosa conta", + "body": "Quali pilastri contassero nel tuo Health Score dipendeva prima dai moduli che avevi attivi. Ora è una scelta a parte, e la fai qui. Il tuo numero non si è mosso con questo cambiamento: conta ancora esattamente quello che contava prima." + }, + "changed": { + "title": "Hai cambiato cosa conta", + "body": "Dal {date} il tuo punteggio è calcolato su un insieme di pilastri diverso. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni.", + "bodyUndated": "Il tuo punteggio è calcolato su un insieme di pilastri diverso da prima. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni." + }, + "dismiss": "Ho capito", + "dismissError": "Non è stato possibile salvare. Riprova." + } } }, "profile": "Profilo", diff --git a/messages/pl.json b/messages/pl.json index 4b60aa32d..e48b0c927 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,7 +2268,7 @@ "yellow": "Niektóre filary wymagają uwagi", "red": "Co najmniej jeden filar jest poza zakresem referencyjnym" }, - "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", + "insufficient": "{count} z {required} obszarów zdrowia ma wystarczająco świeżych danych. Wynik potrzebuje {required}, w tym co najmniej jednego pomiaru fizycznego.", "anatomyToggle": "Jak powstaje ten wynik", "notScored": "Jeszcze bez oceny ({count})", "method": "Wynik to średnia o równych wagach ze wszystkich filarów, które mają wystarczająco dużo aktualnych danych, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających danych jest pomijany, a nie liczony jako zero.", @@ -2347,6 +2347,11 @@ "weight_not_tracked": "Zapisz pomiar masy ciała, aby zobaczyć postęp w kierunku celu.", "read_failed": "Nie udało się wczytać danych masy ciała. Spróbuj ponownie.", "notScored": "Postęp celu wagowego jest osobistym kontekstem i nie wpływa na wynik zdrowia." + }, + "configureLink": "Wybierz, co liczy się do Twojego wyniku", + "coverage": { + "minimumMet": "Pokrywa trzy obszary wymagane przez wynik", + "areas": "{present} z {required} obszarów zdrowia" } }, "suggestedPrompts": { @@ -3608,7 +3613,8 @@ "summary": "{present} z {required} danych · {percent}%", "ratioLabel": "{present}/{required}", "historyDays": "Na podstawie {count} dni historii", - "missing": "Zapisuj te dane, aby uściślić wartość: {list}" + "missing": "Zapisuj te dane, aby uściślić wartość: {list}", + "summaryLabelled": "{label} · {percent}%" }, "scoreRing": { "aria": "Wynik {score} na 100, {band}", @@ -4897,6 +4903,68 @@ "fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).", "fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.", "fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}." + }, + "score": { + "title": "Wynik zdrowia", + "subtitle": "Wybierz, które filary liczą się do Twojego wyniku.", + "card": { + "title": "Co liczy się do Twojego wyniku", + "description": "Każdy filar, który zapisujesz, liczy się, dopóki nie powiesz tutaj inaczej." + }, + "threeAxes": "Zapisywanie, pokazywanie i liczenie to trzy różne rzeczy. Ta strona zmienia tylko ostatnią. Filar wyłączony tutaj nadal jest zapisywany i nadal widoczny w całym HealthLogu, przestaje jedynie ruszać liczbą.", + "axis": { + "recorded": "Jest zapisywany", + "shown": "Pokazywany w aplikacji", + "countsFor": "Licz {pillar} do wyniku" + }, + "domain": { + "cardiometabolic": "Serce i metabolizm", + "activity": "Aktywność", + "sleep": "Sen", + "adiposity": "Sylwetka", + "wellbeing": "Samopoczucie", + "fitness": "Zmierzona wydolność" + }, + "domainNote": { + "cardiometabolic": "Trzy filary, jeden obszar zdrowia. Każdy filar waży w wyniku tyle samo, więc ten obszar waży trzy razy tyle co obszar z jednym filarem." + }, + "state": { + "counting": "Liczy się do Twojego wyniku", + "waiting": "Wybrany, czeka na dane", + "crisis": "Zamiast wyniku pokazywana jest wskazówka bezpieczeństwa. Z Twoim wyborem wszystko w porządku.", + "readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu.", + "unavailable": "Jeszcze niedostępny", + "unavailableDetail": "HealthLog nie potrafi jeszcze odróżnić zmierzonego testu wydolności od szacunku z urządzenia, więc ten filar nigdy nie dał wyniku. Zostaje na liście, żebyś widział, że się nie liczy." + }, + "presets": { + "label": "Punkt wyjścia", + "all": "Wszystkie filary", + "current": "Twój zapisany wybór" + }, + "save": "Zapisz", + "saved": "Zapisano.", + "error": "Nie udało się zapisać. Spróbuj ponownie.", + "loadFailed": "Nie udało się wczytać Twojego wyboru.", + "eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.", + "omitted": "{count} filarów nie ma na liście, bo ich śledzenie jest wyłączone. Włącz moduł z powrotem, a jego filar wróci.", + "omittedLink": "Otwórz moduły", + "refusal": { + "physiological": "Wynik zdrowia potrzebuje co najmniej jednego pomiaru fizycznego. Zostaw w wyborze ciśnienie, glukozę, sen, sylwetkę albo cholesterol, obok wszystkiego innego, co wybierzesz.", + "domains": "Wynik zdrowia potrzebuje co najmniej trzech różnych obszarów zdrowia. Ciśnienie, glukoza i cholesterol opisują ten sam obszar, więc wybór złożony tylko z nich liczy się jako jeden." + }, + "notice": { + "upgrade": { + "title": "Teraz to Ty decydujesz, co się liczy", + "body": "To, które filary liczyły się do Twojego Health Score, szło wcześniej za modułami, które miałeś włączone. Teraz jest to osobna decyzja i podejmujesz ją tutaj. Twoja liczba się przy tym nie ruszyła: nadal liczy dokładnie to, co liczyła wcześniej." + }, + "changed": { + "title": "Zmieniłeś to, co się liczy", + "body": "Od {date} Twój wynik liczony jest z innego zestawu filarów. Dni wcześniejsze pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy.", + "bodyUndated": "Twój wynik liczony jest z innego zestawu filarów niż wcześniej. Dni sprzed zmiany pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy." + }, + "dismiss": "Rozumiem", + "dismissError": "Nie udało się zapisać. Spróbuj ponownie." + } } }, "profile": "Profil", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 9042a54b6..904500df1 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -24,6 +24,7 @@ import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { PrivacySection } from "@/components/settings/privacy-section"; +import { ScoreSection } from "@/components/settings/score-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; import { VorsorgeSection } from "@/components/settings/vorsorge-section"; // v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the @@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record< illness: IllnessSection, environment: EnvironmentSection, anamnesis: AnamnesisSection, + score: ScoreSection, vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index b879bdcf3..27fa9ec0c 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -595,9 +595,13 @@ describe(" composite states", () => { /data-slot="health-score-card"[^>]*data-status="insufficient"/, ); expect(html).toContain('data-slot="health-score-insufficient"'); + // `presentInputs` counts distinct DOMAINS. The sentence used to call + // them pillars, which told an account with blood pressure, glycaemia + // and lipids that it had one eligible pillar while it had three. expect(html).toContain( - "The score needs at least three, including a measured physiological pillar.", + "1 of 3 areas of health have enough recent data. The score needs 3, at least one of them a physical measurement.", ); + expect(html).not.toMatch(/eligible pillars/i); // No band sentence and no fabricated number: a dash, and no bar either. expect(html).not.toContain('data-slot="health-score-band"'); expect(html).not.toContain('data-slot="health-score-card-progress"'); diff --git a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx new file mode 100644 index 000000000..0107d54b7 --- /dev/null +++ b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx @@ -0,0 +1,233 @@ +/** + * Two claims the Health Score panel used to make about its own coverage + * that were not true, held down structurally so a tidy-up cannot bring + * either back. + * + * **1. `composite.coverage.presentInputs` counts DOMAINS, not pillars.** + * `computeComposite` builds it from `eligibleDomains.size`. The panel fed + * it into a sentence reading "{count} eligible pillars", so someone + * recording blood pressure, glycaemia and lipids was told they had one + * eligible pillar while looking at three. The count was right; the noun + * was wrong, and a noun is exactly the thing a later cleanup restores + * without noticing. The guard reads the call sites out of the AST, so it + * fails on the value AND on the copy — passing the domain count into a + * pillar-shaped string is caught wherever the string lives. + * + * **2. The composite's coverage fraction is a floor, not a proportion.** + * `requiredInputs` is the three-domain minimum and `deriveCoverage` clamps + * `presentInputs` to it, so every scored account rendered "3/3" forever, + * whatever it records. The arithmetic is right and stays untouched; what + * was wrong is presenting a met floor as full coverage of the person's own + * data. The composite's meter therefore names its axis, and the guard + * fails if that override is dropped. + * + * Both are AST matchers rather than text searches, and both carry a + * counter-test proving they stay quiet on the neighbouring call sites that + * are legitimately about pillars. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import en from "../../../../messages/en.json"; + +const CARD_FILE = join( + process.cwd(), + "src/components/insights/health-score-card.tsx", +); + +function parse(path: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + path, + text ?? readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); +} + +/** Dotted access path of an expression, or null when it is not one. */ +function accessPath(node: ts.Expression): string | null { + if (ts.isIdentifier(node)) return node.text; + if (ts.isPropertyAccessExpression(node)) { + const base = accessPath(node.expression); + return base === null ? null : `${base}.${node.name.text}`; + } + return null; +} + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + node.forEachChild((child) => walk(child, visit)); +} + +/** Look a dotted i18n key up in the English bundle. */ +function message(key: string): string | undefined { + let cursor: unknown = en; + for (const segment of key.split(".")) { + if (cursor === null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return typeof cursor === "string" ? cursor : undefined; +} + +/** + * Every `t("some.key", { … })` in `file` that interpolates a value read + * off `composite.coverage`, paired with the access paths it passed. + */ +function coverageBackedMessages( + file: ts.SourceFile, +): { key: string; paths: string[] }[] { + const found: { key: string; paths: string[] }[] = []; + walk(file, (node) => { + if (!ts.isCallExpression(node)) return; + if (!ts.isIdentifier(node.expression) || node.expression.text !== "t") { + return; + } + const [keyArg, valuesArg] = node.arguments; + if (!keyArg || !ts.isStringLiteral(keyArg)) return; + if (!valuesArg || !ts.isObjectLiteralExpression(valuesArg)) return; + const paths: string[] = []; + for (const property of valuesArg.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const path = accessPath(property.initializer); + if (path && path.startsWith("composite.coverage.")) paths.push(path); + } + if (paths.length > 0) found.push({ key: keyArg.text, paths }); + }); + return found; +} + +/** `` elements, with the attribute names each carries. */ +function coverageMeters( + file: ts.SourceFile, +): { coverage: string | null; attributes: string[] }[] { + const found: { coverage: string | null; attributes: string[] }[] = []; + walk(file, (node) => { + let opening: ts.JsxOpeningLikeElement | null = null; + if (ts.isJsxSelfClosingElement(node)) opening = node; + else if (ts.isJsxElement(node)) opening = node.openingElement; + if (!opening) return; + if (opening.tagName.getText(file) !== "CoverageMeter") return; + + const attributes: string[] = []; + let coverage: string | null = null; + for (const attribute of opening.attributes.properties) { + if (!ts.isJsxAttribute(attribute)) continue; + const name = attribute.name.getText(file); + attributes.push(name); + if (name !== "coverage") continue; + const initializer = attribute.initializer; + if ( + initializer && + ts.isJsxExpression(initializer) && + initializer.expression + ) { + coverage = accessPath(initializer.expression); + } + } + found.push({ coverage, attributes }); + }); + return found; +} + +/** + * The check itself, lifted out so the regression case below runs the very + * assertions the guard runs rather than an imitation of them. + */ +function checkDomainNoun(site: { key: string; paths: string[] }): void { + const copy = message(site.key); + expect(copy, `missing English copy for ${site.key}`).toBeTypeOf("string"); + expect( + copy, + `${site.key} interpolates ${site.paths.join(", ")} (a domain count) but reads as pillars`, + ).not.toMatch(/pillar/i); + expect(copy, `${site.key} should name the areas it counts`).toMatch(/area/i); +} + +describe("the domain count is never described as a pillar count", () => { + const sites = coverageBackedMessages(parse(CARD_FILE)); + + it("finds the call site at all", () => { + // A matcher that matches nothing passes every assertion below it. + expect(sites.length).toBeGreaterThan(0); + expect(sites.map((site) => site.key)).toContain( + "insights.healthScore.insufficient", + ); + }); + + it("gives every composite-coverage sentence a domain-shaped noun", () => { + for (const site of sites) checkDomainNoun(site); + }); + + it("stays quiet on a count that really is a count of pillars", () => { + // The neighbouring `notScored` line passes `notScored.length`, a real + // pillar count, into copy that says "pillars" and must keep saying so. + const counterExample = parse( + "counter-example.tsx", + `export function X() { + return

{t("insights.healthScore.notScored", { count: notScored.length })}

; + }`, + ); + expect(coverageBackedMessages(counterExample)).toEqual([]); + expect(message("insights.healthScore.notScored")).toMatch(/not scored/i); + }); + + it("catches the mislabel when it is reintroduced", () => { + // The guard's own failure mode, driven through the guard's own check: + // a domain count fed into a pillar-shaped key is collected, and the + // check on it throws. `reason.unavailable` is a real shipped string + // that says "pillar" for a good reason, which is what makes it the + // right stand-in for the copy this defect produces. + const regression = parse( + "regression.tsx", + `export function X() { + return

{t("insights.healthScore.reason.unavailable", { count: composite.coverage.presentInputs })}

; + }`, + ); + const collected = coverageBackedMessages(regression); + expect(collected).toHaveLength(1); + expect(() => checkDomainNoun(collected[0])).toThrow(/pillar/i); + }); +}); + +describe("the composite's coverage meter names its own axis", () => { + const meters = coverageMeters(parse(CARD_FILE)); + + it("finds every meter on the panel", () => { + // Three: the composite's, each scored pillar's, and the weight goal's. + expect(meters).toHaveLength(3); + }); + + it("overrides the fraction on the composite's meter", () => { + const composite = meters.filter( + (meter) => meter.coverage === "composite.coverage", + ); + expect(composite).toHaveLength(1); + expect(composite[0].attributes).toContain("axisLabel"); + }); + + it("leaves the per-pillar and weight-goal meters alone", () => { + // Their fractions are real: a pillar's coverage moves with its own + // inputs, so the default `{present}/{required}` is honest there. The + // guard must not spread the override to them. + const others = meters.filter( + (meter) => meter.coverage !== "composite.coverage", + ); + expect(others).toHaveLength(2); + for (const meter of others) { + expect(meter.attributes).not.toContain("axisLabel"); + } + }); + + it("has copy for both axis labels", () => { + expect(message("insights.healthScore.coverage.minimumMet")).toMatch( + /area/i, + ); + expect(message("insights.healthScore.coverage.areas")).toContain( + "{present}", + ); + }); +}); diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx index 872abb2e5..c3b20e97a 100644 --- a/src/components/insights/derived/coverage-meter.tsx +++ b/src/components/insights/derived/coverage-meter.tsx @@ -44,6 +44,20 @@ export interface CoverageMeterProps { dots?: number; /** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */ size?: "sm" | "md"; + /** + * Replaces the bare `{present}/{required}` fraction, in the visible + * label, the `aria-label` and the tooltip heading alike. + * + * The default fraction reads as a proportion of the reader's own data, + * and for one caller it is not: the Health Score's composite counts + * DISTINCT DOMAINS against the three the score requires, and a scored + * account has met that floor by definition, so the fraction is pinned + * at "3/3" however much or little the person records. The dots and the + * percentage stay meaningful there because they track history depth; + * only the fraction lies. A caller in that position names its axis + * instead. Everyone else leaves this alone and keeps the fraction. + */ + axisLabel?: string; /** Optional className applied to the outer wrapper. */ className?: string; } @@ -75,6 +89,7 @@ export function CoverageMeter({ confidence, dots = 5, size = "sm", + axisLabel, className, }: CoverageMeterProps) { const { t } = useTranslations(); @@ -100,11 +115,16 @@ export function CoverageMeter({ const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2"; - const summaryLabel = t("insights.derived.coverage.summary", { - present, - required, - percent, - }); + const summaryLabel = axisLabel + ? t("insights.derived.coverage.summaryLabelled", { + label: axisLabel, + percent, + }) + : t("insights.derived.coverage.summary", { + present, + required, + percent, + }); return ( @@ -156,7 +176,11 @@ export function CoverageMeter({ size === "md" ? "text-xs" : "text-[11px]", )} > - {t("insights.derived.coverage.ratioLabel", { present, required })} + {axisLabel ?? + t("insights.derived.coverage.ratioLabel", { + present, + required, + })} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 59be655b7..b49a49961 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useId, useState } from "react"; +import Link from "next/link"; import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -27,11 +28,11 @@ import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveGlucoseUnit } from "@/lib/glucose"; import type { HealthScoreReport, - ScorePillarId, ScoreBand, ScorePillarResult, } from "@/lib/analytics/score/types"; import { useTranslations } from "@/lib/i18n/context"; +import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels"; import { cn } from "@/lib/utils"; /** @@ -64,16 +65,12 @@ import { cn } from "@/lib/utils"; * composite's colour, which painted a pillar scoring 92 red on a red day. */ -const PILLAR_LABEL_KEY: Record = { - BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", - GLYCAEMIA: "insights.healthScore.pillar.glycaemia", - ACTIVITY: "insights.healthScore.pillar.activity", - SLEEP: "insights.healthScore.pillar.sleep", - ADIPOSITY: "insights.healthScore.pillar.adiposity", - WELLBEING: "insights.healthScore.pillar.wellbeing", - FITNESS: "insights.healthScore.pillar.fitness", - LIPIDS: "insights.healthScore.pillar.lipids", -}; +/** + * v1.35.0 — the map moved to `@/lib/score-config/labels` so the settings + * surface that decides what counts and this panel name the same pillar the + * same way. Aliased here because every reference below reads this name. + */ +const PILLAR_LABEL_KEY = SCORE_PILLAR_LABEL_KEYS; const REASON_KEY: Record = { read_failed: "readFailed", @@ -386,8 +383,16 @@ export function HealthScoreCard({ )} @@ -515,12 +520,30 @@ export function HealthScoreCard({ {/* 7 — the foot. `mt-auto` collects the slack here rather than under the number, so a short report and a long one both end on the same line. */}
+ {/* The composite's meter names its own axis. Its fraction counts + distinct DOMAINS against the three the score requires, and a + scored account has cleared that floor by definition, so the + default "3/3" would read as full coverage of the person's data + for everyone who has a score at all. Below the floor the + fraction is a real, moving number and is shown as one; at or + above it, the floor is stated instead. The dots and percentage + are untouched either way: they track history depth, which does + vary, and they are the part of this meter that was always + telling the truth. */} {/* Progressive disclosure. No Collapsible primitive exists in the UI @@ -608,6 +631,18 @@ export function HealthScoreCard({ standard={METRIC_PROVENANCE.HEALTH_SCORE.standard} className="block" /> + {/* v1.35.0 — the one way into the surface that decides what + counts. It sits at the foot of the disclosure that already + explains how the score comes about, which keeps the + configuration owned by the score rather than by the modules + screen, and leaves the panel itself otherwise untouched. */} + + {t("insights.healthScore.configureLink")} +
diff --git a/src/components/settings/__tests__/score-change-notice.test.tsx b/src/components/settings/__tests__/score-change-notice.test.tsx new file mode 100644 index 000000000..f78415f70 --- /dev/null +++ b/src/components/settings/__tests__/score-change-notice.test.tsx @@ -0,0 +1,111 @@ +/** + * The Health Score notice, which had no renderer at all until this release. + * + * The machinery raised it, persisted the dismissal and evicted the cache for + * its whole life while nothing drew it, so the two things worth pinning are + * that each of its two shapes says the right thing and that it can be + * dismissed. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import { ScoreChangeNotice } from "../score-change-notice"; + +function render( + props: { + configVersion: number; + changedAt: string | null; + }, + locale: Locale = "en", +): string { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +describe("an account that has never chosen", () => { + it("explains what the upgrade did and that the number did not move", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).toContain('data-kind="upgrade"'); + expect(html).toContain("What counts is now yours to choose"); + expect(html).toContain("used to follow the modules you had switched on"); + expect(html).toContain("still counts exactly what it counted before"); + }); + + it("never claims a change the account has not made", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).not.toContain("You changed what counts"); + }); +}); + +describe("an account that changed its recipe", () => { + it("dates the change and says what it does to the history", () => { + const html = render({ + configVersion: 3, + changedAt: "2026-07-31T09:00:00.000Z", + }); + + expect(html).toContain('data-kind="changed"'); + expect(html).toContain("You changed what counts"); + expect(html).toContain("2026"); + expect(html).toContain("the weekly change stays paused"); + expect(html).toContain("Each pillar's own history runs straight"); + }); + + it("keeps the note truthful when the date cannot be read", () => { + const html = render({ configVersion: 3, changedAt: "not-a-date" }); + + expect(html).toContain('data-kind="changed"'); + expect(html).toContain("You changed what counts"); + // No invented date, and no literal placeholder left standing. + expect(html).not.toContain("{date}"); + expect(html).not.toContain("Invalid Date"); + }); +}); + +describe("dismissal", () => { + it("offers a dismiss control", () => { + const html = render({ configVersion: 0, changedAt: null }); + + expect(html).toContain('data-slot="score-change-notice-dismiss"'); + expect(html).toContain("Got it"); + }); + + it("calls back with no argument when pressed", () => { + const onDismiss = vi.fn(); + // The button's own handler, exercised directly: `renderToStaticMarkup` + // has no events, and the parent's mutation is what carries the itemKey. + const node = ( + + ); + expect(node.props.onDismiss).toBe(onDismiss); + }); +}); + +describe("localisation", () => { + it("renders both shapes in each shipped locale", () => { + for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) { + for (const version of [0, 2]) { + const html = render( + { configVersion: version, changedAt: "2026-07-31T09:00:00.000Z" }, + locale, + ); + expect(html).not.toContain("settings.sections.score"); + } + } + }); +}); diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx new file mode 100644 index 000000000..2fb6846bf --- /dev/null +++ b/src/components/settings/__tests__/score-pillar-row.test.tsx @@ -0,0 +1,150 @@ +/** + * The row states of the "what counts toward your score" surface, rendered. + * + * `rows.test.ts` pins the decisions; this pins that the decisions reach the + * markup, and that each row carries the stable attributes the e2e spec + * selects on rather than viewport-dependent text. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import type { ScoreConfigRow } from "@/lib/score-config/rows"; +import { ScorePillarRow } from "../score-pillar-row"; + +function render(row: ScoreConfigRow, locale: Locale = "en"): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +const base: ScoreConfigRow = { + id: "SLEEP", + domain: "sleep", + counts: true, + selectable: true, + eligibility: "counting", +}; + +describe("the three axes", () => { + it("names recording and showing beside the score switch", () => { + const html = render(base); + + expect(html).toContain('data-slot="score-pillar-axis-recorded"'); + expect(html).toContain('data-slot="score-pillar-axis-shown"'); + expect(html).toContain('data-slot="score-pillar-switch"'); + expect(html).toContain("Being recorded"); + expect(html).toContain("Shown in the app"); + }); + + it("offers exactly one switch, and it is the score one", () => { + const html = render(base); + + expect(html.match(/role="switch"/g) ?? []).toHaveLength(1); + expect(html).toContain("Count Sleep toward the score"); + }); +}); + +describe("row attributes", () => { + it("carries the pillar, its domain and its state", () => { + const html = render({ ...base, counts: false, eligibility: "waiting" }); + + expect(html).toContain('data-pillar="SLEEP"'); + expect(html).toContain('data-domain="sleep"'); + expect(html).toContain('data-counts="false"'); + expect(html).toContain('data-eligibility="waiting"'); + }); +}); + +describe("selected, waiting for data", () => { + it("renders the line for a counted pillar with nothing to score", () => { + const html = render({ ...base, counts: true, eligibility: "waiting" }); + + expect(html).toContain('data-state="waiting"'); + expect(html).toContain("Selected, waiting for data"); + }); + + it("does not render it once the person switches the pillar off", () => { + const html = render({ ...base, counts: false, eligibility: "waiting" }); + + expect(html).not.toContain("Selected, waiting for data"); + }); + + it("does not render it while the server has said nothing", () => { + const html = render({ ...base, eligibility: "unknown" }); + + expect(html).not.toContain('data-slot="score-pillar-state"'); + }); +}); + +describe("wellbeing under crisis signposting", () => { + it("is named as safety guidance, never as a configuration problem", () => { + const html = render({ + ...base, + id: "WELLBEING", + domain: "wellbeing", + eligibility: "crisis", + }); + + expect(html).toContain('data-state="crisis"'); + expect(html).toContain("Safety guidance is shown instead of a score"); + expect(html).toContain("Your selection is fine"); + expect(html).not.toContain("Selected, waiting for data"); + }); +}); + +describe("a failed read", () => { + it("is named as a failure, not as absence", () => { + const html = render({ ...base, eligibility: "read_failed" }); + + expect(html).toContain('data-state="read_failed"'); + expect(html).toContain("could not be read"); + expect(html).not.toContain("Selected, waiting for data"); + }); +}); + +describe("a pillar this build cannot score", () => { + it("is disabled, honest, and offers no switch at all", () => { + const html = render({ + ...base, + id: "FITNESS", + domain: "fitness", + selectable: false, + eligibility: "unavailable", + }); + + expect(html).toContain('data-selectable="false"'); + expect(html).toContain('data-slot="score-pillar-unavailable"'); + expect(html).toContain('aria-disabled="true"'); + expect(html).toContain("Not available yet"); + // Not a switch that no-ops behind a disabled attribute: no switch. + expect(html).not.toContain('data-slot="score-pillar-switch"'); + expect(html).not.toContain('role="switch"'); + }); + + it("says why, rather than leaving the row a dead end", () => { + const html = render({ + ...base, + id: "FITNESS", + domain: "fitness", + selectable: false, + eligibility: "unavailable", + }); + + expect(html).toContain("has never produced a score"); + }); +}); + +describe("localisation", () => { + it("renders every state in each shipped locale", () => { + for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) { + const html = render({ ...base, eligibility: "waiting" }, locale); + // A missing key falls through to the key path; seeing one here means + // a locale bundle is short. + expect(html).not.toContain("settings.sections.score"); + } + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index b37d1a57a..8970f8ae0 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -121,6 +121,8 @@ describe("SETTINGS_SECTION_SLUGS", () => { "environment", // v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown. "anamnesis", + // v1.35.0 — the Health Score composition surface. + "score", "vorsorge", "thresholds", "ai", diff --git a/src/components/settings/score-change-notice.tsx b/src/components/settings/score-change-notice.tsx new file mode 100644 index 000000000..b7f995731 --- /dev/null +++ b/src/components/settings/score-change-notice.tsx @@ -0,0 +1,108 @@ +"use client"; + +/** + * The Health Score's "something about this number changed" note, rendered + * for the first time. + * + * The machinery behind it shipped a while ago and has been raising, + * persisting and evicting a notice that nothing ever drew: the report has + * carried `algorithmNotice` for its whole life and no surface read it. + * This is that renderer. It sits on the settings page rather than on the + * hero because the note's whole subject is what counts toward the score, + * and the person reading it is one tap from acting on it. + * + * Two notes ride one key, told apart by the account's own recipe version. + * + * - **Version 0** — nobody has chosen yet. The note explains what the + * upgrade did: what counts used to follow the module switches, it now + * follows this page, and the number itself did not move. It is the + * one-time note for accounts that existed before the choice did, and + * it is written to be true for a fresh account as well, because the + * stored recipe cannot tell the two apart and inventing a distinction + * from the account's age would be guessing. + * - **Version 1 and up** — the person changed what counts, on a date + * the recipe records. The note says what that does to the history. + * + * Dismissal is per key, so a dismissal of one version never silences the + * next, and it survives a reload because the server holds it. + */ + +import { Info, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +export interface ScoreChangeNoticeProps { + /** The account's own recipe version. 0 while nobody has chosen. */ + configVersion: number; + /** When the recipe last changed, ISO 8601, or null while it never has. */ + changedAt: string | null; + /** Fired when the person acknowledges the note. */ + onDismiss: () => void; + dismissing?: boolean; +} + +export function ScoreChangeNotice({ + configVersion, + changedAt, + onDismiss, + dismissing = false, +}: ScoreChangeNoticeProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const kind = configVersion >= 1 ? "changed" : "upgrade"; + const parsed = changedAt ? new Date(changedAt) : null; + const dateLabel = + parsed && !Number.isNaN(parsed.getTime()) ? fmt.date(parsed) : null; + + // Written out rather than assembled from the discriminator so the i18n + // call-site guard can see every key this component can reach. + const title = + kind === "changed" + ? t("settings.sections.score.notice.changed.title") + : t("settings.sections.score.notice.upgrade.title"); + const body = + kind !== "changed" + ? t("settings.sections.score.notice.upgrade.body") + : dateLabel + ? t("settings.sections.score.notice.changed.body", { date: dateLabel }) + : t("settings.sections.score.notice.changed.bodyUndated"); + + return ( + + +

+ {body} +

+ +
+ ); +} diff --git a/src/components/settings/score-pillar-row.tsx b/src/components/settings/score-pillar-row.tsx new file mode 100644 index 000000000..3dd1639b2 --- /dev/null +++ b/src/components/settings/score-pillar-row.tsx @@ -0,0 +1,134 @@ +"use client"; + +/** + * One pillar on the "what counts toward your Health Score" surface. + * + * The row's job is to make three things legible that nowhere else in the + * app shows together: whether the pillar is being recorded, whether it is + * shown, and whether it counts toward the score. Only the last is + * writable here, and the card above the rows says so in words. + * + * The first two are not switches on this surface and are not dressed to + * look like ones. They are read-outs, because the page that owns them is + * the modules screen and a second switch for the same fact is how two + * places end up disagreeing about one thing. + * + * Presentation only. The parent owns the draft, the mutation and the + * cache, so this stays a leaf a test can drive directly. + */ + +import { Loader2 } from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useTranslations } from "@/lib/i18n/context"; +import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels"; +import { + showsWaitingForData, + type ScoreConfigRow, +} from "@/lib/score-config/rows"; + +/** The eligibility values that earn a line of their own, and their copy. */ +const ELIGIBILITY_LINE_KEYS: Partial< + Record +> = { + counting: "settings.sections.score.state.counting", + crisis: "settings.sections.score.state.crisis", + read_failed: "settings.sections.score.state.readFailed", + unavailable: "settings.sections.score.state.unavailableDetail", +}; + +export interface ScorePillarRowProps { + row: ScoreConfigRow; + /** Disables the switch while a save is in flight. */ + pending?: boolean; + onToggle: (next: boolean) => void; +} + +export function ScorePillarRow({ + row, + pending = false, + onToggle, +}: ScorePillarRowProps) { + const { t } = useTranslations(); + const inputId = `score-pillar-${row.id}`; + // "Selected, waiting for data" is checked first because it is the one + // line that depends on the draft as well as on the server's verdict. + const stateKey = showsWaitingForData(row) + ? "settings.sections.score.state.waiting" + : ELIGIBILITY_LINE_KEYS[row.eligibility]; + + return ( +
+
+ + {/* The three axes, side by side. Recording and showing read as + settled facts; the third is the switch to the right. */} +

+ + {t("settings.sections.score.axis.recorded")} + + {" · "} + + {t("settings.sections.score.axis.shown")} + +

+ {stateKey ? ( +

+ {t(stateKey)} +

+ ) : null} +
+ +
+ {pending ? ( +
+
+ ); +} diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx new file mode 100644 index 000000000..29768ea0d --- /dev/null +++ b/src/components/settings/score-section.tsx @@ -0,0 +1,439 @@ +"use client"; + +/** + * `` — Settings → Health Score. v1.35.0. + * + * The surface that decides which pillars count toward the Health Score. + * It is its own page rather than a corner of the modules screen, because + * the two answer different questions and conflating them is what made the + * old behaviour invisible: the module switches quietly decided the + * score's composition while claiming to decide what is recorded and shown. + * + * What the page holds to: + * + * - **One concept, one card.** The rows, the starting points and the + * save all sit in the same card. A top half and a bottom half of the + * same decision is the split the settings IA rules forbid. + * - **Grouped by domain, and the grouping is visible.** Blood pressure, + * glycaemia and lipids sit under one heading with a sentence saying + * they are one area of health carrying three pillars. Equal weight per + * pillar is not equal weight per area, and until this page nothing in + * the app said so. + * - **Only the score toggle is writable.** Recording and showing are + * read-outs with a line saying this page leaves them alone. + * - **A pillar whose module is off is not shown.** Turning a module off + * closes the data path deliberately; a pillar behind a closed path is + * not waiting for anything, and saying it waits nags a person in the + * voice of their own settings. + * - **The refusal comes from the server.** The breadth rule lives in + * `evaluateScoreBreadth` and is applied at write time; this page shows + * the refusal in the reader's language and never re-derives the rule. + * A rule copied here would drift the day the server's moved. + * + * Two reads feed it. The configuration (`/api/auth/me/health-score-config`) + * is the primary one and its failure takes the surface down honestly. The + * analytics payload is the secondary one: it carries the server's own + * verdict on each pillar, which is where "selected, waiting for data" + * comes from. When that read has not landed the rows simply make no claim + * about data; when it fails, the failure is named rather than dressed as + * absence. + */ + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Gauge, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ScoreChangeNotice } from "@/components/settings/score-change-notice"; +import { ScorePillarRow } from "@/components/settings/score-pillar-row"; +import { markAlgorithmNoticeDismissed } from "@/components/insights/health-score-card"; +import { useAuth } from "@/hooks/use-auth"; +import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { + isConflict, + readUpdatedAtToken, + withBaseToken, +} from "@/lib/api/optimistic-token"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { + SCORE_DOMAIN_LABEL_KEYS, + SCORE_DOMAIN_NOTE_KEYS, +} from "@/lib/score-config/labels"; +import { + buildScoreConfigRows, + selectionToSave, + type ScorePillarVerdict, +} from "@/lib/score-config/rows"; +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; +import type { HealthScoreReport } from "@/lib/analytics/score/types"; +import type { ScoreModuleKey } from "@/lib/analytics/score/modules"; + +/** The resolved configuration `GET /api/auth/me/health-score-config` returns. */ +interface HealthScoreConfigPayload { + pillars: ScorePillarId[]; + excludedPillars: ScorePillarId[]; + hasSelection: boolean; + version: number; + changedAt: string | null; + /** Optimistic-concurrency token; absent until a selection exists. */ + updatedAt?: string; +} + +/** + * The two refusals the write can return, in the reader's language. + * + * Keyed on `meta.reason`, which is the machine-readable half of the + * server's answer. The server's own English sentence is not shown: it + * exists for callers without a locale, and rendering it would leave five + * of the six locales reading English at exactly the moment the person + * needs to understand something. + */ +const REFUSAL_KEYS: Record = { + measured_physiological_domain_required: + "settings.sections.score.refusal.physiological", + three_domains_required: "settings.sections.score.refusal.domains", +}; + +function verdictsFrom( + report: HealthScoreReport | null | undefined, +): Partial> | undefined { + if (!report) return undefined; + const out: Partial> = {}; + for (const pillar of report.pillars) { + out[pillar.id] = + pillar.result.status === "ok" + ? { status: "ok" } + : { status: "insufficient", reason: pillar.result.reason }; + } + return out; +} + +/** Stable fingerprint of the server's selection, for the draft re-seed. */ +function selectionSignature(pillars: readonly ScorePillarId[]): string { + return SCORE_PILLAR_IDS.filter((id) => pillars.includes(id)).join(","); +} + +export function ScoreSection() { + const { t } = useTranslations(); + const { user, isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const configQuery = useQuery({ + queryKey: queryKeys.healthScoreConfig(), + queryFn: () => + apiGet("/api/auth/me/health-score-config"), + enabled: Boolean(isAuthenticated), + }); + + // The server's verdict on each pillar. Secondary: the switches work + // without it, so it never gates the surface. + const analyticsQuery = useAnalyticsQuery(); + const report = analyticsQuery.data?.healthScore as + HealthScoreReport | null | undefined; + const verdicts = useMemo(() => verdictsFrom(report), [report]); + + const serverSelection = useMemo( + () => configQuery.data?.pillars ?? [], + [configQuery.data?.pillars], + ); + const serverSignature = selectionSignature(serverSelection); + + // Re-seed the draft in render (the sanctioned "adjust state on prop + // change" shape, no effect) whenever the SERVER copy moves — a settled + // save, a refetch, or the first load. An in-flight edit is untouched + // until the server itself advances. + const [seededSignature, setSeededSignature] = useState(null); + const [draft, setDraft] = useState([]); + const [refusal, setRefusal] = useState(null); + if (configQuery.isSuccess && serverSignature !== seededSignature) { + setSeededSignature(serverSignature); + setDraft([...serverSelection]); + setRefusal(null); + } + + const modules = (user?.modules ?? {}) as Partial< + Record + >; + const { groups, omitted } = useMemo( + () => buildScoreConfigRows({ selection: draft, modules, verdicts }), + // `modules` is a fresh object each render; its identity is not the + // dependency, its content is. + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft, verdicts, user?.modules], + ); + + const dirty = selectionSignature(draft) !== serverSignature; + + const saveMutation = useMutation({ + mutationKey: queryKeys.healthScoreConfig(), + mutationFn: (selection: ScorePillarId[]) => + apiPatch( + "/api/auth/me/health-score-config", + withBaseToken( + { pillars: selectionToSave(selection) }, + readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()), + ), + ), + onSuccess: (saved) => { + setRefusal(null); + queryClient.setQueryData(queryKeys.healthScoreConfig(), saved); + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthScoreConfig(), + }); + // The number now means something else. Every surface that holds a + // computed score has to go, or the change reads as broken. + void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + toast.success(t("settings.sections.score.saved")); + }, + onError: (err) => { + if (isConflict(err)) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthScoreConfig(), + }); + toast.message(t("common.conflictReloaded")); + return; + } + // The write-time breadth refusal. The rule is the server's; this + // only translates the reason it gave. + if (err instanceof ApiError && err.status === 422) { + const reason = err.meta?.reason; + const key = + typeof reason === "string" ? REFUSAL_KEYS[reason] : undefined; + if (key) { + setRefusal(key); + return; + } + } + toast.error(t("settings.sections.score.error")); + }, + }); + + const dismissMutation = useMutation({ + mutationFn: async (itemKey: string) => { + await apiPost("/api/daily/digest/dismiss", { itemKey }); + return itemKey; + }, + onSuccess: (itemKey) => { + // Patch every cached copy of the notice, not just the one this page + // reads: the analytics payload carries the same raised notice and a + // stale `dismissed: false` there would raise it again on the next + // surface that learns to render it. + queryClient.setQueryData(queryKeys.analytics(), (previous: unknown) => + markAlgorithmNoticeDismissed(previous, itemKey), + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + }, + onError: () => { + toast.error(t("settings.sections.score.notice.dismissError")); + }, + }); + + function toggle(id: ScorePillarId, next: boolean) { + setRefusal(null); + setDraft((current) => { + const set = new Set(current); + if (next) set.add(id); + else set.delete(id); + return SCORE_PILLAR_IDS.filter((pillar) => set.has(pillar)); + }); + } + + const notice = report?.algorithmNotice ?? null; + const showNotice = + notice != null && !notice.dismissed && configQuery.isSuccess; + + const busy = saveMutation.isPending; + + return ( +
+ {showNotice && notice ? ( + dismissMutation.mutate(notice.itemKey)} + /> + ) : null} + + {configQuery.isError ? ( + void configQuery.refetch()} + /> + ) : ( + + saveMutation.mutate(draft)} + disabled={busy || !dirty || configQuery.isLoading} + data-slot="score-config-save" + className="min-h-11 sm:min-h-9" + > + {busy ? ( + + )} +
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index ccac49cd3..ae891f2ec 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -21,6 +21,7 @@ import { ClipboardList, FileHeart, FlaskConical, + Gauge, Info, KeyRound, Plug, @@ -61,6 +62,7 @@ const SLUG_ICON: Record = { illness: Thermometer, environment: CloudSun, anamnesis: ClipboardList, + score: Gauge, vorsorge: CalendarCheck, thresholds: SlidersHorizontal, ai: Sparkles, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 576964410..0f43f0b8a 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -78,6 +78,12 @@ export const SETTINGS_SECTION_SLUGS = [ // home for allergies + family history. Always shown (not module-gated, like // `vorsorge`); the records are foundational health-profile data. "anamnesis", + // v1.35.0 — "Health Score": which pillars count toward the composite. + // Its own surface rather than a corner of the modules hub, because the + // module switches answer a different question (what is recorded and + // shown) and folding the two together is what kept the score's + // composition invisible. + "score", "vorsorge", "thresholds", "ai", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 2c5cafc26..7bbe37097 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -25,6 +25,7 @@ import { CloudSun, Download, FileHeart, + Gauge, Info, KeyRound, Plug, @@ -234,6 +235,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ group: "display", }, // ── Health profile ── + // v1.35.0 — which pillars count toward the Health Score. Sits at the top + // of the health-profile cluster: it is a statement about the person's own + // health picture, not about how the app looks or what it records. + { + slug: "score", + titleKey: "settings.sections.score.title", + icon: Gauge, + group: "healthProfile", + }, // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record // home for allergies + family history. NOT module-gated (like Vorsorge) — // these are foundational health-profile records, always available. diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index f5975ae0f..e923c2656 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -38,6 +38,15 @@ export const settingsKeys = { */ modulesPrefs: () => ["settings", "modules"] as const, + /** + * v1.35.0 — which pillars count toward the Health Score, behind + * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than + * a slice of `authMe()`: the payload carries the optimistic-concurrency + * token the save echoes, and folding it into the account payload would + * make every unrelated profile write advance the token. + */ + healthScoreConfig: () => ["settings", "health-score-config"] as const, + apiVersion: () => ["api", "version"] as const, publicVersion: () => ["public", "version"] as const, /** v1.15.12 H1 — admin overview "update available" check against the diff --git a/src/lib/score-config/__tests__/rows.test.ts b/src/lib/score-config/__tests__/rows.test.ts new file mode 100644 index 000000000..0c2e9df96 --- /dev/null +++ b/src/lib/score-config/__tests__/rows.test.ts @@ -0,0 +1,299 @@ +/** + * What the "what counts toward your score" surface shows, and what it + * refuses to show. + * + * Four decisions are pinned here because each of them is a sentence a + * person reads about their own health, and each has a plausible wrong + * version that would look fine in a screenshot: + * + * 1. A pillar every feeding module has switched off gets NO row. Not a + * greyed row, not a "waiting for data" line. It is not waiting for + * anything, and under a never-written recipe the word "selected" + * would describe a default nobody picked. + * 2. "Selected, waiting for data" belongs to a pillar the person + * counts, whose module is on, and which the SERVER said has nothing + * to score. Not to a pillar the person just switched off, and not to + * one the server never mentioned. + * 3. Wellbeing under crisis signposting is its own state and never a + * configuration problem. + * 4. A pillar this build can never score is never sent as part of a + * selection, because the write-time breadth rule would otherwise + * accept a recipe on its behalf that the scorer then refuses. + */ +import { describe, expect, it } from "vitest"; + +import { + buildScoreConfigRows, + selectionToSave, + showsWaitingForData, + UNSCORABLE_PILLARS, + type ScoreConfigRow, +} from "@/lib/score-config/rows"; +import { + SCORE_PILLAR_IDS, + type ScorePillarId, +} from "@/lib/analytics/score/types"; + +const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS]; +const ALL_MODULES_ON = { + glucose: true, + labs: true, + sleep: true, + mentalHealth: true, +}; + +function rowsOf(result: ReturnType) { + return result.groups.flatMap((group) => group.rows); +} + +function row( + result: ReturnType, + id: ScorePillarId, +): ScoreConfigRow | undefined { + return rowsOf(result).find((candidate) => candidate.id === id); +} + +describe("grouping", () => { + it("puts blood pressure, glycaemia and lipids under one domain", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + const cardio = built.groups.find( + (group) => group.domain === "cardiometabolic", + ); + expect(cardio).toBeDefined(); + expect(cardio?.rows.map((r) => r.id)).toEqual([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "LIPIDS", + ]); + }); + + it("gives every other pillar a domain of its own", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + for (const group of built.groups) { + if (group.domain === "cardiometabolic") continue; + expect(group.rows).toHaveLength(1); + } + // Six domains across eight pillars is the whole reason the surface + // groups: equal weight per pillar is not equal weight per domain. + expect(built.groups).toHaveLength(6); + expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length); + }); + + it("emits no group for a domain whose only pillar is hidden", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, sleep: false }, + }); + + expect(built.groups.some((group) => group.domain === "sleep")).toBe(false); + }); +}); + +describe("a module that is off", () => { + it("removes the pillar entirely rather than showing it as waiting", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, sleep: false }, + verdicts: { SLEEP: { status: "insufficient", reason: "stale" } }, + }); + + expect(row(built, "SLEEP")).toBeUndefined(); + expect(built.omitted).toContain("SLEEP"); + }); + + it("keeps glycaemia while either of its two modules is on", () => { + const glucoseOnly = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, labs: false }, + }); + const labsOnly = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, glucose: false }, + }); + + expect(row(glucoseOnly, "GLYCAEMIA")).toBeDefined(); + expect(row(labsOnly, "GLYCAEMIA")).toBeDefined(); + // Lipids ride on labs alone, so they leave with it. + expect(row(glucoseOnly, "LIPIDS")).toBeUndefined(); + expect(row(labsOnly, "LIPIDS")).toBeDefined(); + }); + + it("hides glycaemia only when both of its modules are off", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { ...ALL_MODULES_ON, glucose: false, labs: false }, + }); + + expect(row(built, "GLYCAEMIA")).toBeUndefined(); + expect(built.omitted).toEqual(["GLYCAEMIA", "LIPIDS"]); + }); + + it("reads a missing module key as on", () => { + const built = buildScoreConfigRows({ selection: ALL, modules: {} }); + + expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length); + expect(built.omitted).toEqual([]); + }); + + it("never omits a pillar no module feeds", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: { + glucose: false, + labs: false, + sleep: false, + mentalHealth: false, + }, + }); + + for (const id of [ + "BLOOD_PRESSURE", + "ACTIVITY", + "ADIPOSITY", + "FITNESS", + ] as ScorePillarId[]) { + expect(row(built, id)).toBeDefined(); + } + }); +}); + +describe("selected, waiting for data", () => { + it("is shown for a counted pillar the server had nothing to score", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } }, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.eligibility).toBe("waiting"); + expect(showsWaitingForData(lipids!)).toBe(true); + }); + + it("is not shown for a pillar the draft has switched off", () => { + const built = buildScoreConfigRows({ + selection: ALL.filter((id) => id !== "LIPIDS"), + modules: ALL_MODULES_ON, + verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } }, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.counts).toBe(false); + expect(showsWaitingForData(lipids!)).toBe(false); + }); + + it("is not shown while the server has said nothing", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + const lipids = row(built, "LIPIDS"); + expect(lipids?.eligibility).toBe("unknown"); + expect(showsWaitingForData(lipids!)).toBe(false); + }); + + it("is not shown for a pillar that scored", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { SLEEP: { status: "ok" } }, + }); + + const sleep = row(built, "SLEEP"); + expect(sleep?.eligibility).toBe("counting"); + expect(showsWaitingForData(sleep!)).toBe(false); + }); +}); + +describe("states that are not absence", () => { + it("keeps crisis signposting out of the waiting state", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { + WELLBEING: { + status: "insufficient", + reason: "crisis_signposting", + }, + }, + }); + + const wellbeing = row(built, "WELLBEING"); + expect(wellbeing?.eligibility).toBe("crisis"); + expect(showsWaitingForData(wellbeing!)).toBe(false); + }); + + it("keeps a failed read out of the waiting state", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + verdicts: { + BLOOD_PRESSURE: { status: "insufficient", reason: "read_failed" }, + }, + }); + + const bp = row(built, "BLOOD_PRESSURE"); + expect(bp?.eligibility).toBe("read_failed"); + expect(showsWaitingForData(bp!)).toBe(false); + }); +}); + +describe("a pillar this build cannot score", () => { + it("is present, honest and never selectable", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + // Even a verdict claiming it scored cannot make it selectable. + verdicts: { FITNESS: { status: "ok" } }, + }); + + const fitness = row(built, "FITNESS"); + expect(fitness).toBeDefined(); + expect(fitness?.selectable).toBe(false); + expect(fitness?.eligibility).toBe("unavailable"); + }); + + it("leaves every other pillar selectable", () => { + const built = buildScoreConfigRows({ + selection: ALL, + modules: ALL_MODULES_ON, + }); + + for (const candidate of rowsOf(built)) { + expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id)); + } + }); + + it("is never sent as part of a saved selection", () => { + expect(selectionToSave(ALL)).not.toContain("FITNESS"); + expect(selectionToSave(["FITNESS"])).toEqual([]); + }); + + it("cannot carry a selection over the breadth floor on its own", () => { + // The failure this guards: FITNESS is a domain of its own AND counts + // as physiological, so echoing it back would let the write-time rule + // accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of + // them physiological — for a score the reader then refuses, because + // FITNESS never reaches the composition. + expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([ + "ACTIVITY", + "WELLBEING", + ]); + }); + + it("keeps registry order and drops duplicates", () => { + expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([ + "ACTIVITY", + "LIPIDS", + ]); + }); +}); diff --git a/src/lib/score-config/labels.ts b/src/lib/score-config/labels.ts new file mode 100644 index 000000000..31580daff --- /dev/null +++ b/src/lib/score-config/labels.ts @@ -0,0 +1,44 @@ +/** + * The localised names of the score's pillars and the domains they sit in. + * + * One map per concept, read by the hero panel and by the settings surface + * that decides what counts. Two copies of a name map drift the moment one + * surface renames a pillar, and the two surfaces would then be talking + * about different things while showing the same list. + */ +import type { ScoreDomain, ScorePillarId } from "@/lib/analytics/score/types"; + +export const SCORE_PILLAR_LABEL_KEYS: Record = { + BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", + GLYCAEMIA: "insights.healthScore.pillar.glycaemia", + ACTIVITY: "insights.healthScore.pillar.activity", + SLEEP: "insights.healthScore.pillar.sleep", + ADIPOSITY: "insights.healthScore.pillar.adiposity", + WELLBEING: "insights.healthScore.pillar.wellbeing", + FITNESS: "insights.healthScore.pillar.fitness", + LIPIDS: "insights.healthScore.pillar.lipids", +}; + +/** + * Domain headings. The cardiometabolic one carries three pillars, which + * is the whole reason the surface groups at all: equal weight per pillar + * is not equal weight per area, and nothing in the app said so before. + */ +export const SCORE_DOMAIN_LABEL_KEYS: Record = { + cardiometabolic: "settings.sections.score.domain.cardiometabolic", + activity: "settings.sections.score.domain.activity", + sleep: "settings.sections.score.domain.sleep", + adiposity: "settings.sections.score.domain.adiposity", + wellbeing: "settings.sections.score.domain.wellbeing", + fitness: "settings.sections.score.domain.fitness", +}; + +/** + * Domains that need a sentence under the heading. Only the one that + * carries more than one pillar does, and it is not decoration: without it + * the triple count stays invisible and deselecting one of the three + * reshapes the balance with no cue. + */ +export const SCORE_DOMAIN_NOTE_KEYS: Partial> = { + cardiometabolic: "settings.sections.score.domainNote.cardiometabolic", +}; diff --git a/src/lib/score-config/rows.ts b/src/lib/score-config/rows.ts new file mode 100644 index 000000000..45f0d8250 --- /dev/null +++ b/src/lib/score-config/rows.ts @@ -0,0 +1,221 @@ +/** + * v1.35.0 — what the "what counts toward your score" surface shows, as a + * pure function over three inputs the page already holds. + * + * Kept out of the component so the row states can be driven directly by a + * test instead of through a rendered tree, and so the page has exactly one + * place that decides what a row says. + * + * The three inputs, and why each is needed: + * + * - **the draft selection** — what the person currently has switched on + * in front of them, which is not yet what the server holds; + * - **the modules map** — whether the pillar's data is being recorded at + * all. A pillar every feeding module has switched off is not shown, + * full stop (see `omitted`, and `composition-source.test.ts`, which + * pins the same decision one layer down); + * - **the server's own pillar verdicts** — whether a pillar currently + * scores, is waiting for data, is suppressed for safety, or could not + * be read. These are never re-derived here. A pillar the server said + * nothing about reads as `unknown`, and `unknown` renders no claim. + * + * The eligibility a row shows describes the recipe the SERVER scored, not + * the draft in front of the person. That is deliberate: the draft has not + * been computed against anything, so a line derived from it would be a + * guess wearing the voice of a fact. + */ +import { + SCORE_PILLAR_DOMAINS, + SCORE_PILLAR_IDS, + type ScoreDomain, + type ScorePillarId, +} from "@/lib/analytics/score/types"; +import { + pillarsWithModuleData, + type ScoreModuleKey, + type ScoreReaderModules, +} from "@/lib/analytics/score/modules"; + +/** + * Pillars this build can never score, whatever anyone selects. + * + * FITNESS is the only entry and it is not a placeholder for a future + * list. The schema records VO₂max but cannot distinguish a measured test + * from a device estimate, so every FITNESS row reaches the scorer with + * `measured: false` and is refused (`src/lib/analytics/score/reader.ts`, + * the FITNESS input block). Offering it as a switch would be chrome over + * a thing that recomputes nothing — the exact defect this release exists + * to remove — so the row is present, honest, and inert. + */ +export const UNSCORABLE_PILLARS: ReadonlySet = new Set([ + "FITNESS", +]); + +/** + * What the server's last scoring run said about one pillar. Every value + * comes from a `ScorePillarResult`; nothing here is computed from data. + */ +export type ScoreRowEligibility = + /** It scored. */ + | "counting" + /** Selected, its data path is open, and there is nothing to score yet. */ + | "waiting" + /** Wellbeing under crisis signposting. Never a configuration problem. */ + | "crisis" + /** The read broke. Absence and failure are different states. */ + | "read_failed" + /** This build cannot score it at all. */ + | "unavailable" + /** The server said nothing about it. No claim is made either way. */ + | "unknown"; + +export interface ScoreConfigRow { + id: ScorePillarId; + domain: ScoreDomain; + /** True when the draft counts this pillar toward the score. */ + counts: boolean; + /** False when the switch is inert — today only the unscorable pillars. */ + selectable: boolean; + eligibility: ScoreRowEligibility; +} + +export interface ScoreConfigGroup { + domain: ScoreDomain; + rows: ScoreConfigRow[]; +} + +/** + * One pillar's verdict as the page reads it off the report: the status, + * plus the reason when the status is `insufficient`. + */ +export interface ScorePillarVerdict { + status: "ok" | "insufficient"; + reason?: string | null; +} + +export interface BuildScoreConfigRowsInput { + /** The draft selection the switches currently show. */ + selection: readonly ScorePillarId[]; + /** Resolved module map. A missing key reads as ON (fail open). */ + modules: Partial>; + /** + * Per-pillar verdicts from the last scoring run, keyed by pillar id. + * A pillar the server did not report on is simply absent. + */ + verdicts?: Partial>; +} + +/** Fail-open module resolution, matching every other gate in the app. */ +function resolveModules( + modules: Partial>, +): ScoreReaderModules { + return { + glucose: modules.glucose !== false, + labs: modules.labs !== false, + sleep: modules.sleep !== false, + mentalHealth: modules.mentalHealth !== false, + }; +} + +function eligibilityOf( + id: ScorePillarId, + verdict: ScorePillarVerdict | undefined, +): ScoreRowEligibility { + if (UNSCORABLE_PILLARS.has(id)) return "unavailable"; + if (!verdict) return "unknown"; + if (verdict.status === "ok") return "counting"; + if (verdict.reason === "crisis_signposting") return "crisis"; + if (verdict.reason === "read_failed") return "read_failed"; + return "waiting"; +} + +/** + * The pillars whose data path is closed, so the surface omits them. + * + * Returned beside the groups rather than silently dropped, because a + * caller that wants to say where those pillars went (the modules screen) + * needs to know there were any. + */ +export interface ScoreConfigRowsResult { + groups: ScoreConfigGroup[]; + /** Registry-ordered pillars hidden because every feeding module is off. */ + omitted: ScorePillarId[]; +} + +/** + * Build the surface's rows, grouped by domain in registry order. + * + * A domain with no visible rows produces no group, so a heading can never + * stand above an empty cluster. + */ +export function buildScoreConfigRows( + input: BuildScoreConfigRowsInput, +): ScoreConfigRowsResult { + const recorded = new Set( + pillarsWithModuleData(resolveModules(input.modules)), + ); + const selected = new Set(input.selection); + const groups: ScoreConfigGroup[] = []; + const byDomain = new Map(); + const omitted: ScorePillarId[] = []; + + for (const id of SCORE_PILLAR_IDS) { + if (!recorded.has(id)) { + omitted.push(id); + continue; + } + const domain = SCORE_PILLAR_DOMAINS[id]; + const row: ScoreConfigRow = { + id, + domain, + counts: selected.has(id), + selectable: !UNSCORABLE_PILLARS.has(id), + eligibility: eligibilityOf(id, input.verdicts?.[id]), + }; + const existing = byDomain.get(domain); + if (existing) { + existing.push(row); + } else { + const rows = [row]; + byDomain.set(domain, rows); + groups.push({ domain, rows }); + } + } + + return { groups, omitted }; +} + +/** + * Whether a row earns the "selected, waiting for data" line. + * + * Narrow on purpose, and the narrowing is the decision: the line belongs + * to a pillar the person counts, whose module is on (which is what makes + * the row visible at all), and which the server reported as having + * nothing to score yet. A pillar whose module is off never reaches here + * because it has no row; a pillar the draft excludes is not waiting for + * anything on the person's behalf; and an `unknown` verdict is not + * evidence of absence. + */ +export function showsWaitingForData(row: ScoreConfigRow): boolean { + return row.counts && row.eligibility === "waiting"; +} + +/** + * The selection a save should send. + * + * Every pillar the person can actually choose, and nothing else. A pillar + * this build cannot score is left out rather than echoed back: echoing it + * would let it satisfy the write-time breadth rule (it is a domain of its + * own AND a physiological pillar) on behalf of a score it can never + * reach, so the write would be accepted and the score would still + * disappear at read time. The refusal is only worth having if it judges + * the recipe that will actually be scored. + */ +export function selectionToSave( + selection: readonly ScorePillarId[], +): ScorePillarId[] { + const selected = new Set(selection); + return SCORE_PILLAR_IDS.filter( + (id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id), + ); +} 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 27/34] 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 28/34] 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); + }); +}); From bf0a637bab86b116ba091d37a42a82b6cb50d58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 19:05:20 +0200 Subject: [PATCH 29/34] Keep the score's own coverage honest about what it counts The composite's coverage counts distinct areas of health, not pillars. The learning line called them pillars, so an account recording blood pressure, glycaemia and cholesterol read that it had one eligible pillar while three sat in front of it. The count was right and stays; the noun was wrong. The meter beneath the score carried the same confusion further. Its fraction is the three-area minimum the score requires, and a score only exists once that floor is met, so it rendered as full coverage for every account that has one. The arithmetic is untouched. Below the floor the fraction is a real number and is shown as one; at or above it, the floor is stated. The dots and the percentage were always honest, they track how much history is behind the number, and they stay. Both are held down by an AST guard rather than a text search, with a counter-test for the neighbouring lines that are legitimately about pillars and legitimately about a real fraction. --- e2e/settings-score.spec.ts | 293 ++++++++++++++++++ .../health-score-coverage-axis-guard.test.tsx | 40 +++ .../__tests__/score-change-notice.test.tsx | 54 +++- .../__tests__/score-pillar-row.test.tsx | 31 +- .../score-refusal-consumer-guard.test.ts | 138 +++++++++ src/components/settings/score-section.tsx | 16 +- 6 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 e2e/settings-score.spec.ts create mode 100644 src/components/settings/__tests__/score-refusal-consumer-guard.test.ts diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts new file mode 100644 index 000000000..2f8f3a24a --- /dev/null +++ b/e2e/settings-score.spec.ts @@ -0,0 +1,293 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { settleBeforeMeasure } from "./utils/settle"; + +/** + * Settings → Health Score: the surface that decides which pillars count. + * + * Everything asserted here goes through stable `data-slot` / `data-*` + * attributes rather than viewport-dependent text, so the spec keeps working + * at any width and in any locale (only the copy assertions read English, and + * the suite pins the English locale through the storage state). + * + * The refusal case is driven through the REAL write. Deselecting down to + * activity and wellbeing genuinely fails the server's breadth rule, and a + * refused write persists nothing, so the case leaves the shared account + * exactly as it found it. Mocking the 422 would have proved that the page + * can render a message, not that the rule is enforced or that the page reads + * the reason the server actually sends. + * + * The analytics read IS mocked, because it is the page's secondary read and + * the states it drives — waiting for data, safety signposting — depend on + * what the seeded account happens to hold. Fixing it makes those two rows + * deterministic without touching the write path under test. + */ + +/** A slim Health Score report: only the fields the settings page reads. */ +function scoreReport() { + const coverage = { + requiredInputs: 3, + presentInputs: 2, + historyDays: 14, + missing: [], + }; + const provenance = { + inputs: [], + source: "live", + windowDays: 90, + computedAt: new Date().toISOString(), + }; + const insufficient = (reason: string) => ({ + status: "insufficient", + reason, + coverage, + provenance, + }); + return { + composite: insufficient("three_domains_required"), + pillars: [ + { + id: "LIPIDS", + domain: "cardiometabolic", + result: insufficient("not_tracked"), + }, + { + id: "WELLBEING", + domain: "wellbeing", + result: insufficient("crisis_signposting"), + }, + { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: insufficient("read_failed"), + }, + ], + delta: null, + deltaReason: "no_current_score", + scoreVersion: 2, + weightGoal: insufficient("no_goal"), + // Raised and never dismissed, which is the state every account is in: + // nothing has ever rendered this notice. + algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false }, + }; +} + +async function mockAnalytics(page: import("@playwright/test").Page) { + await page.route(/\/api\/analytics(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, healthScore: scoreReport() }, + error: null, + }), + }); + }); +} + +test.describe("Settings → Health Score", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await mockAnalytics(page); + }); + + test("groups the pillars by area and makes the three axes legible", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + // The heading + subheading pair the settings IA requires, owned by the + // shell and not re-declared inside the card. + await expect(page.locator("#settings-section-score-title")).toBeVisible(); + await expect( + page.locator("#settings-section-score-title + p"), + ).toBeVisible(); + + // One concept, one card. A second card beside it would be the + // top/bottom split of one decision the rules forbid; the notice is a + // different concept and is allowed its own. + await expect(page.locator('[data-slot="score-config-card"]')).toHaveCount( + 1, + ); + + // The cardiometabolic triple, visibly under one heading. + const cardio = page.locator( + '[data-slot="score-config-group"][data-domain="cardiometabolic"]', + ); + await expect(cardio).toHaveCount(1); + await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount( + 3, + ); + await expect( + cardio.locator('[data-slot="score-config-group-note"]'), + ).toBeVisible(); + + // Every visible row names recording and showing beside its switch, and + // the card says in words that this page leaves those two alone. + const rows = page.locator('[data-slot="score-pillar-row"]'); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + await expect( + page.locator('[data-slot="score-pillar-axis-recorded"]'), + ).toHaveCount(rowCount); + await expect( + page.locator('[data-slot="score-pillar-axis-shown"]'), + ).toHaveCount(rowCount); + await expect( + page.locator('[data-slot="score-config-three-axes"]'), + ).toContainText("keeps being recorded"); + }); + + test("shows the pillar it cannot score as present, disabled and honest", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const fitness = page.locator( + '[data-slot="score-pillar-row"][data-pillar="FITNESS"]', + ); + await settleBeforeMeasure(page, fitness); + + await expect(fitness).toHaveAttribute("data-selectable", "false"); + await expect(fitness).toHaveAttribute("data-eligibility", "unavailable"); + // No switch at all, rather than one that no-ops behind `disabled`. + await expect( + fitness.locator('[data-slot="score-pillar-switch"]'), + ).toHaveCount(0); + await expect( + fitness.locator('[data-slot="score-pillar-unavailable"]'), + ).toHaveAttribute("aria-disabled", "true"); + }); + + test("tells waiting for data apart from safety guidance and a failed read", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + const state = (pillar: string) => + page.locator( + `[data-slot="score-pillar-row"][data-pillar="${pillar}"] [data-slot="score-pillar-state"]`, + ); + + await expect(state("LIPIDS")).toHaveAttribute("data-state", "waiting"); + // Safety signposting is its own state and never a configuration error. + await expect(state("WELLBEING")).toHaveAttribute("data-state", "crisis"); + // A failed read is a failure, not absence. + await expect(state("BLOOD_PRESSURE")).toHaveAttribute( + "data-state", + "read_failed", + ); + }); + + test("renders the score notice and lets it be dismissed", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const notice = page.locator('[data-slot="score-change-notice"]'); + await settleBeforeMeasure(page, notice); + + await expect(notice).toBeVisible(); + + let dismissed: string | null = null; + await page.route(/\/api\/daily\/digest\/dismiss$/, async (route) => { + dismissed = String(route.request().postDataJSON()?.itemKey ?? ""); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { dismissed: true }, error: null }), + }); + }); + // The refetch after the dismissal has to come back already dismissed, + // or the notice would simply be raised again and the test would be + // asserting a race rather than the behaviour. + await page.route(/\/api\/analytics(\?|$)/, async (route) => { + const report = scoreReport(); + report.algorithmNotice.dismissed = true; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, healthScore: report }, + error: null, + }), + }); + }); + + await notice.locator('[data-slot="score-change-notice-dismiss"]').click(); + + await expect(notice).toHaveCount(0); + expect(dismissed).toBe("health_score_algorithm:2"); + }); + + test("refuses a selection too narrow to produce a score, in plain language", async ({ + page, + }) => { + await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); + const card = page.locator('[data-slot="score-config-card"]'); + await settleBeforeMeasure(page, card); + + // Start from everything, then take out every pillar except activity and + // wellbeing: two areas of health and no physical measurement, which the + // server refuses. + await page.locator('[data-slot="score-config-preset-all"]').click(); + const keep = new Set(["ACTIVITY", "WELLBEING"]); + const rows = page.locator( + '[data-slot="score-pillar-row"][data-selectable="true"]', + ); + for (let index = 0; index < (await rows.count()); index += 1) { + const row = rows.nth(index); + const pillar = await row.getAttribute("data-pillar"); + if (!pillar || keep.has(pillar)) continue; + if ((await row.getAttribute("data-counts")) === "false") continue; + await row.locator('[data-slot="score-pillar-switch"]').click(); + } + + await page.locator('[data-slot="score-config-save"]').click(); + + const refusal = page.locator('[data-slot="score-config-refusal"]'); + await expect(refusal).toBeVisible(); + await expect(refusal).toHaveAttribute("role", "alert"); + // The server's machine reason, rendered as a sentence a person can act + // on, not as an error code and not as the server's English fallback. + await expect(refusal).toContainText("physical measurement"); + await expect(refusal).not.toContainText("_required"); + }); + + test("keeps the content column the same width as its neighbours", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven assertion; desktop project only", + ); + await page.setViewportSize({ width: 1440, height: 900 }); + + async function columnWidth(route: string, sentinel: string) { + await page.goto(route, { waitUntil: "domcontentloaded" }); + await settleBeforeMeasure(page, page.locator(sentinel)); + return page.evaluate(() => { + const column = document.querySelector("section[aria-labelledby]"); + const grid = column?.closest("div.grid") ?? null; + const cell = grid?.lastElementChild ?? null; + return cell ? Math.round(cell.getBoundingClientRect().width) : -1; + }); + } + + const modules = await columnWidth( + "/settings/modules", + '[data-slot="card"]', + ); + const score = await columnWidth( + "/settings/score", + '[data-slot="score-config-card"]', + ); + + expect(modules).toBeGreaterThan(0); + expect(Math.abs(score - modules)).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx index 0107d54b7..085688c61 100644 --- a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx +++ b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx @@ -29,8 +29,11 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { I18nProvider } from "@/lib/i18n/context"; +import { CoverageMeter } from "../derived/coverage-meter"; import en from "../../../../messages/en.json"; const CARD_FILE = join( @@ -231,3 +234,40 @@ describe("the composite's coverage meter names its own axis", () => { ); }); }); + +/** + * The attribute guard above proves the override is written down. This + * proves it does something: a prop that is declared and then ignored is + * exactly the shape of a check that cannot fail. + */ +describe("the axis override reaches the markup", () => { + const coverage = { + requiredInputs: 3, + presentInputs: 3, + historyDays: 20, + missing: [], + }; + + function render(axisLabel?: string): string { + return renderToStaticMarkup( + + + , + ); + } + + it("replaces the fraction in the label and the accessible name", () => { + const html = render("Covers the three areas the score needs"); + + expect(html).toContain("Covers the three areas the score needs"); + expect(html).not.toContain(">3/3<"); + expect(html).toMatch(/aria-label="Covers the three areas the score needs/); + }); + + it("keeps the fraction for every caller that does not override it", () => { + const html = render(); + + expect(html).toContain("3/3"); + expect(html).toContain("3 of 3 inputs"); + }); +}); diff --git a/src/components/settings/__tests__/score-change-notice.test.tsx b/src/components/settings/__tests__/score-change-notice.test.tsx index f78415f70..4c50ba780 100644 --- a/src/components/settings/__tests__/score-change-notice.test.tsx +++ b/src/components/settings/__tests__/score-change-notice.test.tsx @@ -97,15 +97,51 @@ describe("dismissal", () => { }); describe("localisation", () => { - it("renders both shapes in each shipped locale", () => { - for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) { - for (const version of [0, 2]) { - const html = render( - { configVersion: version, changedAt: "2026-07-31T09:00:00.000Z" }, - locale, - ); - expect(html).not.toContain("settings.sections.score"); - } + // Not an absence check: `t()` falls back to English, so a missing key + // renders English rather than a key path. These are each locale's own + // titles, so an English fallback, a copied value or a mis-wired bundle + // all fail here. Key parity across bundles is a repo-wide guard already. + const TITLES: Record = { + de: { + upgrade: "Was zählt, entscheidest jetzt du", + changed: "Du hast geändert, was zählt", + }, + fr: { + upgrade: "Ce qui compte, c'est vous qui le décidez", + changed: "Vous avez changé ce qui compte", + }, + es: { + upgrade: "Ahora decides tú qué cuenta", + changed: "Has cambiado qué cuenta", + }, + it: { + upgrade: "Ora sei tu a decidere cosa conta", + changed: "Hai cambiato cosa conta", + }, + pl: { + upgrade: "Teraz to Ty decydujesz, co się liczy", + changed: "Zmieniłeś to, co się liczy", + }, + }; + + it("renders both shapes in each locale's own words", () => { + for (const [locale, titles] of Object.entries(TITLES)) { + const upgrade = render( + { configVersion: 0, changedAt: null }, + locale as Locale, + ); + expect(upgrade, `${locale} upgrade note`).toContain( + // The apostrophe in the French title is escaped in the markup. + titles.upgrade.replace(/'/g, "'"), + ); + expect(upgrade).not.toContain("What counts is now yours to choose"); + + const changed = render( + { configVersion: 2, changedAt: "2026-07-31T09:00:00.000Z" }, + locale as Locale, + ); + expect(changed, `${locale} changed note`).toContain(titles.changed); + expect(changed).not.toContain("You changed what counts"); } }); }); diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx index 2fb6846bf..85101c7ee 100644 --- a/src/components/settings/__tests__/score-pillar-row.test.tsx +++ b/src/components/settings/__tests__/score-pillar-row.test.tsx @@ -139,12 +139,31 @@ describe("a pillar this build cannot score", () => { }); describe("localisation", () => { - it("renders every state in each shipped locale", () => { - for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) { - const html = render({ ...base, eligibility: "waiting" }, locale); - // A missing key falls through to the key path; seeing one here means - // a locale bundle is short. - expect(html).not.toContain("settings.sections.score"); + // Asserting the absence of a key path would prove nothing: `t()` falls + // back to the English bundle, so a missing locale key renders English + // rather than the key. Locale PARITY is already a repo-wide guard + // (`i18n-locale-integrity`); what belongs here is that the row actually + // reaches each locale's own words, which fails on a missing key, on an + // English value copied into a bundle, and on a locale wired to the + // wrong strings. + const WAITING: Record = { + de: "Ausgewählt, wartet auf Daten", + fr: "Sélectionné, en attente de données", + es: "Seleccionado, esperando datos", + it: "Selezionato, in attesa di dati", + pl: "Wybrany, czeka na dane", + }; + + it("renders each locale's own words, not the English fallback", () => { + for (const [locale, expected] of Object.entries(WAITING)) { + const html = render( + { ...base, eligibility: "waiting" }, + locale as Locale, + ); + expect(html, `${locale} row`).toContain(expected); + expect(html, `${locale} row fell back to English`).not.toContain( + "Selected, waiting for data", + ); } }); }); diff --git a/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts new file mode 100644 index 000000000..2a864de77 --- /dev/null +++ b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts @@ -0,0 +1,138 @@ +/** + * The write-time refusal has two ends, and this holds them together. + * + * The server owns the breadth rule and answers a too-narrow selection + * with a machine reason in `meta.reason`. The settings surface turns that + * reason into a sentence in the reader's own language. Nothing in the + * type system connects the two: rename a reason on the route, and the + * client silently falls through to "couldn't save, try again" while the + * person is left with no idea what is wrong with their selection. That is + * a refusal that stops refusing anything in particular, and it looks + * perfectly healthy in every unit test on either side. + * + * So: the reasons the rule can produce, the reasons the route has copy + * for, and the reasons the client has copy for must be the same set. The + * rule's own list is read out of `breadth.ts` rather than restated here, + * because a restated list is a fourth place to forget. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import en from "../../../../messages/en.json"; +import { REFUSAL_KEYS } from "../score-section"; + +const ROOT = process.cwd(); +const RULE_FILE = join(ROOT, "src/lib/analytics/score/breadth.ts"); +const ROUTE_FILE = join( + ROOT, + "src/app/api/auth/me/health-score-config/route.ts", +); + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); +} + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + node.forEachChild((child) => walk(child, visit)); +} + +/** The string members of an exported union type alias. */ +function unionMembers(file: ts.SourceFile, name: string): string[] { + const members: string[] = []; + walk(file, (node) => { + if (!ts.isTypeAliasDeclaration(node)) return; + if (node.name.text !== name) return; + const type = node.type; + const parts = ts.isUnionTypeNode(type) ? type.types : [type]; + for (const part of parts) { + if ( + ts.isLiteralTypeNode(part) && + ts.isStringLiteral(part.literal) && + part.literal.text.length > 0 + ) { + members.push(part.literal.text); + } + } + }); + return members; +} + +/** The property names of a top-level object-literal constant. */ +function objectKeys(file: ts.SourceFile, name: string): string[] { + const keys: string[] = []; + walk(file, (node) => { + if (!ts.isVariableDeclaration(node)) return; + if (!ts.isIdentifier(node.name) || node.name.text !== name) return; + let initializer = node.initializer; + while ( + initializer && + (ts.isAsExpression(initializer) || + ts.isSatisfiesExpression(initializer) || + ts.isParenthesizedExpression(initializer)) + ) { + initializer = initializer.expression; + } + if (!initializer || !ts.isObjectLiteralExpression(initializer)) return; + for (const property of initializer.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const propertyName = property.name; + if (ts.isIdentifier(propertyName)) keys.push(propertyName.text); + else if (ts.isStringLiteral(propertyName)) keys.push(propertyName.text); + } + }); + return keys; +} + +function message(key: string): string | undefined { + let cursor: unknown = en; + for (const segment of key.split(".")) { + if (cursor === null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return typeof cursor === "string" ? cursor : undefined; +} + +const ruleReasons = unionMembers(parse(RULE_FILE), "ScoreBreadthFailure"); +const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL"); + +describe("every refusal the rule can give reaches the reader", () => { + it("reads the rule's own list rather than a copy of it", () => { + // A matcher that finds nothing agrees with everything. + expect(ruleReasons.length).toBeGreaterThan(0); + expect(ruleReasons).toContain("three_domains_required"); + }); + + it("gives the route a sentence for each of them", () => { + expect([...routeReasons].sort()).toEqual([...ruleReasons].sort()); + }); + + it("gives the settings surface localised copy for each of them", () => { + expect(Object.keys(REFUSAL_KEYS).sort()).toEqual([...ruleReasons].sort()); + }); + + it("resolves every one of those keys to real copy", () => { + for (const [reason, key] of Object.entries(REFUSAL_KEYS)) { + const copy = message(key); + expect(copy, `${reason} → ${key}`).toBeTypeOf("string"); + expect(copy?.length ?? 0).toBeGreaterThan(20); + } + }); + + it("stays quiet on a constant that is not the refusal map", () => { + // The matcher is name-anchored, not shape-anchored: it must not pick + // up the neighbouring object literals on either file and quietly + // widen what the comparison above is comparing. + expect(objectKeys(parse(ROUTE_FILE), "NOT_A_REAL_CONSTANT")).toEqual([]); + expect(unionMembers(parse(RULE_FILE), "NotARealType")).toEqual([]); + }); +}); diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 29768ea0d..f89c37ea2 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -79,6 +79,7 @@ import { } from "@/lib/analytics/score/types"; import type { HealthScoreReport } from "@/lib/analytics/score/types"; import type { ScoreModuleKey } from "@/lib/analytics/score/modules"; +import type { ScoreBreadthFailure } from "@/lib/analytics/score/breadth"; /** The resolved configuration `GET /api/auth/me/health-score-config` returns. */ interface HealthScoreConfigPayload { @@ -99,8 +100,14 @@ interface HealthScoreConfigPayload { * exists for callers without a locale, and rendering it would leave five * of the six locales reading English at exactly the moment the person * needs to understand something. + * + * Typed as a total map over `ScoreBreadthFailure` rather than over + * `string`, so a third refusal reason added to the rule cannot ship with + * this surface quietly falling through to a generic "couldn't save". The + * TYPE is imported, never the rule: the breadth question is answered + * server-side and answered once. */ -const REFUSAL_KEYS: Record = { +export const REFUSAL_KEYS: Record = { measured_physiological_domain_required: "settings.sections.score.refusal.physiological", three_domains_required: "settings.sections.score.refusal.domains", @@ -213,8 +220,13 @@ export function ScoreSection() { // only translates the reason it gave. if (err instanceof ApiError && err.status === 422) { const reason = err.meta?.reason; + // The wire carries an unknown string until it is checked against + // the map; an unrecognised one falls through to the generic + // failure rather than rendering an empty alert. const key = - typeof reason === "string" ? REFUSAL_KEYS[reason] : undefined; + typeof reason === "string" && reason in REFUSAL_KEYS + ? REFUSAL_KEYS[reason as ScoreBreadthFailure] + : undefined; if (key) { setRefusal(key); return; From 778725834f4ec4b502188bb0ce6c045baf364195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 20:32:25 +0200 Subject: [PATCH 30/34] =?UTF-8?q?chore(release):=20v1.35.0=20=E2=80=94=20t?= =?UTF-8?q?he=20score=20you=20decide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Health Score stops being a by-product of switches meant for something else. It gets its own page, its own recorded history with the recipe that made each day, and a comparison that refuses to draw a line across two different definitions. An account that upgrades keeps the composition it already had, so the number does not move on its own. What changes is that the composition is now visible and yours. --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47baaf25d..ac579edee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ ## [Unreleased] +## [1.35.0] — 2026-07-31 + +The Health Score becomes something you decide rather than something that happens +to you. + +### Added + +- You choose what counts toward your Health Score. A settings page of its own + lists the pillars, each one on or off. It starts with everything that can be + scored, so a narrower score is a decision you make rather than a default you + inherit. +- Each day's score is now recorded with the recipe that produced it: the value, + the method, which pillars took part, what each of them scored, and a + fingerprint of the inputs it depended on. Until now nothing was kept, and every + comparison was recomputed under today's rules. +- The score says when its composition was authored, so a number built from a + chosen set never looks like one built from everything available. + +### Changed + +- The module switches no longer decide what the score is made of. Turning off + Sleep or Labs used to remove that pillar from the score with nothing on screen + saying so. Those switches now govern what you see, and the score's own page + governs what it counts. An existing account keeps exactly the composition it + has today, so no number moves on upgrade. +- Changing what counts no longer reads as a change in your health. The comparison + stops at the point the recipe changed and says so, rather than drawing a line + across two different definitions. The per-pillar figures continue, because those + stay comparable. + +### Fixed + +- A settings change can no longer produce a message telling you your score + dropped. +- The score's own coverage figure now describes the pillars it actually counts + rather than every pillar that exists. + +### Removed + +- Two refusals that could not refuse anything, and a boundary condition no input + could ever reach. + +### Operations + +- Two migrations: one for the per-account composition, one for the recorded + scores. The recorded days travel through backup, restore and delete-all-data + like every other table. + ## [1.34.5] — 2026-07-31 A release about the score explaining itself, and about sentences arriving in the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e779b136..7a36fc111 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.34.5 + version: 1.35.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 483df3683..6c736290e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.34.5", + "version": "1.35.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index cb99cf294..43d40a7be 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.34.5"; + /* @sw-version-fallback */ "v1.35.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 88162eda0724d323e1b56b645d44eb7c3e1c9568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 20:34:29 +0200 Subject: [PATCH 31/34] Say whether each coverage case is an authored recipe or the default Two branches met here. One made `configured` a required field so a caller cannot stay silent and publish "not authored" for an account that chose its own composition. The other wrote these cases before that existed, so they compiled against the old shape and stopped compiling against the new one. Filling the field in to satisfy the compiler would have thrown away the point of making it required, so each case now states what it actually is: the two with a hand-picked selection are authored, and the one whose own comment says the account counts all eight is the default. --- .../__tests__/health-score-coverage-contract.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts index 753ab0f48..b25492211 100644 --- a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts +++ b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts @@ -142,6 +142,8 @@ describe("Health Score coverage contract", () => { const composite = computeComposite({ pillars: allPillars(selection), availablePillars, + // An authored recipe: the person took four pillars out. + configured: true, asOf: NOW, }); @@ -175,6 +177,8 @@ describe("Health Score coverage contract", () => { const composite = computeComposite({ pillars: allPillars(selection), availablePillars: compositionFor(selection), + // An authored recipe. + configured: true, asOf: NOW, }); @@ -193,6 +197,8 @@ describe("Health Score coverage contract", () => { const composite = computeComposite({ pillars: allPillars(withData), availablePillars: compositionFor(SCORE_PILLAR_IDS), + // The full catalogue is this account's default, not a choice. + configured: false, asOf: NOW, }); From 84706196ddff91968e2a1bcfb65e294ef95e7ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 21:17:01 +0200 Subject: [PATCH 32/34] Let the score settings announce a save the way every other surface does The new page wrote its own success toast. The outcome module owns that affordance so a saved thing looks the same everywhere, and a structural check catches anyone reaching around it. That check offers an allowlist entry as a way out; taking it would have left two places deciding what a success looks like, which is the thing the module exists to prevent. --- src/components/settings/score-section.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index f89c37ea2..99334a8f5 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -43,6 +43,8 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Gauge, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -206,7 +208,7 @@ export function ScoreSection() { queryKey: queryKeys.dashboardSnapshot(), }); void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); - toast.success(t("settings.sections.score.saved")); + toastWrittenOutcome("success", t("settings.sections.score.saved")); }, onError: (err) => { if (isConflict(err)) { From b85a348891858b393e87f126927c5fe9f88893c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 22:08:05 +0200 Subject: [PATCH 33/34] Give the type check the same memory ceiling the build already has CI died at the 4 GB default heap while checking types. The build was given its own ceiling earlier this month so a local run, CI and the published image all invoke it the same way; the type check was left on the default and has now grown past it. Same treatment, same reason. Also raises the aggregate bundle cap from 3260 to 3310 KB, and only that one. Every per-route ceiling is untouched and every measured route sits within a kilobyte of its previous value, so nothing a person downloads grew. The rise is the new score-settings route, a mutually exclusive chunk that the whole-build sum counts even though no single visit pays for it, plus its strings across six locales. The reasoning is recorded in the budget file beside the number. --- bundle-budget.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index 95a5fb7eb..b0fe17b50 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.34.1: / 431, /insights 420, /measurements 417, /insights/mood 438, total 3238, baseline 130, lazy catalogs 792. The 108 KB aggregate rise from v1.34.0's 3130 is not one user load: the explicit assessment error/timeout/poll-exhaustion contract adds 2.7-4.4 KB to each of roughly forty mutually exclusive metric route chunks, which the whole-build sum counts independently. Catalogs account for only about 2 KB of the delta, no catalog is statically route-referenced, and Recharts remains exactly one fingerprinted chunk. A measured single next/dynamic assessment boundary was reverted because it raised the artifact further to 3243 KB while saving only about 1 KB per route. The aggregate cap therefore moves narrowly from 3140 to 3260 (22 KB headroom); per-route ceilings, the static-catalog guard, and the one-Recharts-chunk guard remain unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.35.0: / 431, /insights 421, /measurements 417, /insights/mood 439, total 3286, baseline 130, lazy catalogs 804. Every per-route ceiling is unchanged and every measured route is within a kilobyte of its v1.34.1 value, so nothing a person actually downloads grew. The 48 KB aggregate rise from 3238 is the score-configuration settings route, which is a new mutually exclusive chunk the whole-build sum counts independently, plus about 12 KB of its strings across the six locale catalogs. No catalog is statically route-referenced and Recharts remains exactly one fingerprinted chunk. The aggregate cap therefore moves from 3260 to 3310 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 3260, + "totalClientKbGz": 3310, "maxRechartsChunks": 1 } diff --git a/package.json b/package.json index 6c736290e..8fca01907 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "db:studio": "prisma studio", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "tsc --noEmit", + "typecheck": "NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:integration": "vitest run --config vitest.integration.config.mts", From 5424ab0b60f36f42ac48e3320686ffa61a607c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 23:31:00 +0200 Subject: [PATCH 34/34] Assert the settings heading that the width actually shows The shell renders the heading pair twice under distinct ids, one column hidden below the medium breakpoint and one hidden above it, so exactly one is ever on screen. The spec asserted the desktop id alone: green on the wide project, red on the narrow one against a heading that is hidden by design. It now follows whichever of the two the viewport reveals, and asserts that exactly one is visible, so a real duplicate would still fail. --- e2e/settings-score.spec.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 2f8f3a24a..9d77e0bd3 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -101,10 +101,21 @@ test.describe("Settings → Health Score", () => { await settleBeforeMeasure(page, card); // The heading + subheading pair the settings IA requires, owned by the - // shell and not re-declared inside the card. - await expect(page.locator("#settings-section-score-title")).toBeVisible(); + // shell and not re-declared inside the card. The shell renders the pair + // twice under distinct ids — `…-title` inside a `hidden md:block` column + // and `…-title-mobile` inside an `md:hidden` one — so exactly one of them + // is on screen at any width. Asserting the desktop id alone passed on the + // wide project and failed on the narrow one against a heading that is + // hidden by design. + const heading = page + .locator( + "#settings-section-score-title, #settings-section-score-title-mobile", + ) + .filter({ has: page.locator(":scope:visible") }); + await expect(heading).toHaveCount(1); + await expect(heading.first()).toBeVisible(); await expect( - page.locator("#settings-section-score-title + p"), + heading.first().locator("xpath=following-sibling::p[1]"), ).toBeVisible(); // One concept, one card. A second card beside it would be the