diff --git a/messages/de.json b/messages/de.json index b02ed86da..8ae897ed8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5263,8 +5263,7 @@ "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." + "empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt — der Score nennt selbst, worauf er beruht." }, "notice": { "upgrade": { diff --git a/messages/en.json b/messages/en.json index a7472a8de..e371cc6b0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5263,8 +5263,7 @@ "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." + "empty": "A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from." }, "notice": { "upgrade": { diff --git a/messages/es.json b/messages/es.json index 5cbaeb9f8..f825c4601 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5263,8 +5263,7 @@ "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." + "empty": "Una puntuación de salud necesita al menos un ámbito de salud que puntuar. Deja al menos uno seleccionado: la puntuación dirá en qué ámbitos se basa." }, "notice": { "upgrade": { diff --git a/messages/fr.json b/messages/fr.json index c05cc7694..3b2126c15 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5263,8 +5263,7 @@ "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." + "empty": "Un score de santé a besoin d'au moins un domaine de santé à évaluer. Gardez-en au moins un sélectionné : le score indiquera sur quels domaines il repose." }, "notice": { "upgrade": { diff --git a/messages/it.json b/messages/it.json index 3238984e3..d7805f777 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5263,8 +5263,7 @@ "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." + "empty": "Un punteggio di salute ha bisogno di almeno un ambito di salute da valutare. Tienine almeno uno selezionato: il punteggio dirà su quali ambiti si basa." }, "notice": { "upgrade": { diff --git a/messages/ko.json b/messages/ko.json index 11d30c9fb..ddf30002e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5263,8 +5263,7 @@ "omitted": "{count}개 항목은 기록이 꺼져 있어서 목록에 없어요. 모듈을 다시 켜면 해당 항목도 돌아와요.", "omittedLink": "모듈 열기", "refusal": { - "physiological": "건강 점수에는 신체 측정값이 최소 하나 필요해요. 다른 항목을 어떻게 고르든 혈압, 혈당, 수면, 체형, 콜레스테롤 중 하나는 선택에 남겨 두세요.", - "domains": "건강 점수에는 서로 다른 건강 영역이 최소 세 개 필요해요. 혈압, 혈당, 콜레스테롤은 모두 같은 영역이라, 이 셋만 고르면 하나로 쳐요." + "empty": "건강 점수에는 점수를 매길 건강 영역이 최소 하나 필요해요. 하나는 선택해 두세요 — 점수가 어떤 영역을 바탕으로 하는지 알려 줘요." }, "notice": { "upgrade": { diff --git a/messages/pl.json b/messages/pl.json index 444d9899d..9bde7250f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5263,8 +5263,7 @@ "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." + "empty": "Wynik zdrowia potrzebuje co najmniej jednego obszaru zdrowia do oceny. Zostaw wybrany przynajmniej jeden — wynik sam powie, na których obszarach się opiera." }, "notice": { "upgrade": { diff --git a/src/app/api/auth/me/health-score-config/route.ts b/src/app/api/auth/me/health-score-config/route.ts index 4020933dc..b07c53c07 100644 --- a/src/app/api/auth/me/health-score-config/route.ts +++ b/src/app/api/auth/me/health-score-config/route.ts @@ -19,15 +19,17 @@ * * 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 refuses a selection that cannot produce a score.** As of v1.38 + * that is one selection: the empty one. Three areas of health is what + * the score recommends, not what it demands — a two-area or one-area + * recipe now saves and produces a score that says on its face how broad + * it is, so refusing the write would refuse something the scorer is + * perfectly willing to compute. Taking every pillar out is different in + * kind: there is nothing left to average, and storing that recipe would + * leave the person with a settings page that says one thing and a score + * that has silently vanished. The scorer applies the same rule again at + * read time through the same function, deliberately, so a config edited + * outside the app 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 @@ -82,14 +84,11 @@ const healthScoreConfigPatchSchema = z /** * 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. + * say it. One entry, because one selection cannot: the empty one. */ 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.", + no_pillars_selected: + "A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.", } as const; export const GET = apiHandler(async () => { diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index d7d0f8c8a..0468e7963 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -97,24 +97,25 @@ interface HealthScoreConfigPayload { } /** - * The two refusals the write can return, in the reader's language. + * The refusals the write can return, in the reader's language. + * + * One, since v1.38: a selection with nothing in it. Two areas or one now + * save and score, so there is no longer anything to explain about them. * * 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 + * exists for callers without a locale, and rendering it would leave six + * of the seven 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. + * `string`, so a second 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. */ export const REFUSAL_KEYS: Record = { - measured_physiological_domain_required: - "settings.sections.score.refusal.physiological", - three_domains_required: "settings.sections.score.refusal.domains", + no_pillars_selected: "settings.sections.score.refusal.empty", }; function verdictsFrom( diff --git a/src/lib/analytics/score/breadth.ts b/src/lib/analytics/score/breadth.ts index 302c27514..0b1055751 100644 --- a/src/lib/analytics/score/breadth.ts +++ b/src/lib/analytics/score/breadth.ts @@ -1,14 +1,31 @@ /** - * v1.35.0 — the breadth rule, in one place. + * v1.38 — the breadth rule, in one place, and no longer a floor. * - * 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. + * Until now a composite Health Score existed only when the pillars behind + * it spanned at least three distinct domains and included at least one + * physiological measure. Everything below that line got "not enough data + * yet" — including accounts that had a perfectly readable standing in one + * or two areas. The refusal told those people nothing they could not + * already see, and it hid a number they had earned. + * + * So three domains stops being the price of admission and becomes the + * recommendation it always described: the score is computed the same way + * at every breadth, and the verdict says how broad it is. `full` is the + * old rule, `partial` is two domains, `minimal` is one. Refusal survives + * for the one case where there is genuinely nothing to average — no ok + * pillar at all. + * + * The physiological question moves with it. It is now a labelling fact + * rather than a veto: an account whose only ok pillar is activity or + * wellbeing gets a `minimal` score that says on its face what it rests + * on, which is more than the old refusal ever said. The arithmetic does + * not change with the tier — inventing a discount for the pillars + * somebody does not track would be exactly the made-up target this score + * spent two releases removing. + * + * Both layers still call this function. The settings write refuses an + * empty selection, the scorer refuses an empty ok set, and neither + * restates the rule. */ import { SCORE_PILLAR_DOMAINS, @@ -16,12 +33,20 @@ import { type ScorePillarId, } from "./types"; -export const SCORE_MIN_ELIGIBLE_DOMAINS = 3; +/** + * The breadth the score recommends, not the breadth it requires. + * + * Still the denominator `deriveCoverage` counts distinct domains + * against, which is what makes a partial score read as partial without + * any new machinery: one domain of three lands at low confidence through + * the blend that was already there. + */ +export const SCORE_RECOMMENDED_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. + * `WELLBEING` are the only two that do not. This no longer decides + * whether a score exists; it decides what the score says about itself. */ const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ "BLOOD_PRESSURE", @@ -31,40 +56,68 @@ const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ "LIPIDS", ]); -export type ScoreBreadthFailure = - "three_domains_required" | "measured_physiological_domain_required"; +/** + * How broad the set behind a score is. Ordered by breadth, and named + * rather than numbered so a surface can speak about it without + * re-deriving the thresholds. + */ +export type ScoreBreadthTier = "full" | "partial" | "minimal"; + +/** + * The one thing that still refuses. A set with no ok pillar has nothing + * to average, and a mean over nothing is not a low score, it is an + * absent one. + */ +export type ScoreBreadthFailure = "no_pillars_selected"; /** * 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. + * reason that is not there, and cannot skip the check to get a tier. + * `domains` carries the distinct domains the set spans; `physiological` + * carries whether any of them rests on a measurement, for the label. */ export type ScoreBreadthVerdict = - | { ok: true; reason: null; domains: ScoreDomain[] } - | { ok: false; reason: ScoreBreadthFailure; domains: ScoreDomain[] }; + | { + ok: true; + reason: null; + tier: ScoreBreadthTier; + domains: ScoreDomain[]; + physiological: boolean; + } + | { + ok: false; + reason: ScoreBreadthFailure; + tier: null; + domains: ScoreDomain[]; + physiological: boolean; + }; /** * 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. + * The tier is a function of DOMAINS, not pillars, which is why blood + * pressure, glucose and cholesterol together are `minimal`: three + * pillars, one area of health. */ 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) { + const physiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id)); + if (domains.length === 0) { return { ok: false, - reason: "measured_physiological_domain_required", + reason: "no_pillars_selected", + tier: null, domains, + physiological, }; } - if (domains.length < SCORE_MIN_ELIGIBLE_DOMAINS) { - return { ok: false, reason: "three_domains_required", domains }; - } - return { ok: true, reason: null, domains }; + const tier: ScoreBreadthTier = + domains.length >= SCORE_RECOMMENDED_DOMAINS + ? "full" + : domains.length === 2 + ? "partial" + : "minimal"; + return { ok: true, reason: null, tier, domains, physiological }; } diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 5df5488b1..36e81b9ee 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -15,15 +15,15 @@ import { type ScorePillarResult, } from "./types"; import { mean, provenance, scoreBand } from "./shared"; -import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth"; +import { evaluateScoreBreadth, SCORE_RECOMMENDED_DOMAINS } from "./breadth"; import type { ScoreConfigBoundary } from "./config"; -export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z"; +export const SCORE_ALGORITHM_CHANGED_AT = "2026-08-30T00:00:00.000Z"; // 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 }; +export { SCORE_RECOMMENDED_DOMAINS }; const BAND_RANK: Record = { green: 2, @@ -74,7 +74,7 @@ export function computeComposite( Number.POSITIVE_INFINITY, ); const { coverage, confidence } = deriveCoverage({ - requiredInputs: SCORE_MIN_ELIGIBLE_DOMAINS, + requiredInputs: SCORE_RECOMMENDED_DOMAINS, presentInputs: eligibleDomains.size, historyDays: Number.isFinite(historyDays) ? historyDays : 0, missing, @@ -100,11 +100,13 @@ export function computeComposite( asOf: input.asOf, }); + // The only refusal left. Not "too narrow" — nothing readable at all, + // and a mean over nothing is an absent score rather than a low one. if (!breadth.ok) { return buildInsufficient({ coverage, provenance: compositeProvenance, - reason: breadth.reason, + reason: "no_usable_data", }); } @@ -148,6 +150,15 @@ export function computeComposite( : null, composition, configured: input.configured, + // Resolved here for the same reason `configured` is: the count is + // over DOMAINS, and a client counting `composition` would get it + // wrong every time a cardiometabolic pair is in play. + scoreBasis: { + domains: breadth.domains.length, + recommended: SCORE_RECOMMENDED_DOMAINS, + tier: breadth.tier, + physiological: breadth.physiological, + }, noiseFloor, scoreVersion: SCORE_VERSION, }, diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index 32242119f..1e0238eed 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -52,10 +52,13 @@ * 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 next stored day carries the new recipe version and its change + * date (`./record.ts`), so a reader of the stored series can draw + * the composite line in two segments rather than one line through + * the change, while the per-pillar rows run straight through it. + * This used to point at `./series.ts`, which was deleted as dead + * because nothing rendered it; the columns it described are still + * written, which is what the sentence was ever about; * * 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. diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index e9ce9f3b7..52e5e1643 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -4,6 +4,10 @@ import type { Derived, DerivedProvenanceSource, } from "@/lib/insights/derived/types"; +// Type-only, so the rule module can keep importing this one without a +// runtime cycle. The tier's definition belongs beside the rule that +// produces it; its NAME belongs here, where the wire shape is declared. +import type { ScoreBreadthTier } from "./breadth"; /** * The scoring method's identity. @@ -13,11 +17,17 @@ import type { * rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version * of the report. * + * 4 — v1.38: three domains stopped being the price of admission. A score + * is computed from whatever is readable and says how broad that is + * (`scoreBasis`); only an account with nothing readable at all is + * refused. Accounts that were below the old floor start their series + * here, and no v3 number changes meaning — the arithmetic is the + * same, the set it runs over is what widened. * 3 — v1.35.1: green starts at 70 rather than 75, matching the two other * scores in the app, and the fitness pillar left the catalogue. * 2 — the composite the previous line shipped. */ -export const SCORE_VERSION = 3 as const; +export const SCORE_VERSION = 4 as const; /** * The pillars a score can be built from. @@ -59,10 +69,11 @@ export type ScoreDomain = "cardiometabolic" | "activity" | "sleep" | "adiposity" | "wellbeing"; /** - * 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 seven pillars share the cardiometabolic - * domain, which is why a selection of four pillars can still fail. + * Which domain each pillar speaks to. The composite recommends three + * distinct domains and reports how many it had, so this map is part of + * the breadth rule and not decoration: three of the seven pillars share + * the cardiometabolic domain, which is why a selection of four pillars + * can still be a one-area score. * * 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 @@ -128,6 +139,30 @@ export interface ScorePillarResult { result: Derived; } +/** + * v1.38 — what the number rests on, resolved on the server. + * + * The score is computed the same way at every breadth, so this block is + * the only thing that tells a partial score apart from a full one. It is + * resolved beside `configured` and for the same reason: no client + * re-derives it, and no client counts domains out of `composition` + * (three of the seven pillars share one area, so that count would be + * wrong more often than right). + * + * `physiological` is a fact about the set, not a warning. An account + * scoring on activity alone reads false and still gets its number; the + * old rule refused that account outright and told it nothing. + */ +export interface ScoreBasis { + /** Distinct domains counted. */ + domains: number; + /** The breadth the method recommends — `SCORE_RECOMMENDED_DOMAINS`. */ + recommended: number; + tier: ScoreBreadthTier; + /** Whether any counted pillar rests on a physiological measurement. */ + physiological: boolean; +} + export interface CompositeValue { score: number; band: ScoreBand; @@ -143,6 +178,14 @@ export interface CompositeValue { * owns the definition and the reasons behind it. */ configured: boolean; + /** + * v1.38 — how broad the set behind the number is. Optional on the type + * so an older cached composite or a fixture written before the field + * existed stays valid (the additive-contract pattern the snapshot and + * digest blocks have followed six times); `computeComposite` always + * sets it on the ok arm. + */ + scoreBasis?: ScoreBasis; /** Equal-weighted floor across delta-eligible pillars. */ noiseFloor: number; scoreVersion: typeof SCORE_VERSION;