feat(score): compute a health score from the data a person actually has

Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.

The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.

The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.

`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.

`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.

The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.

Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
This commit is contained in:
Marc-André Bombeck committed 2026-08-30 14:15:47 +02:00
1 parent f0ccd9db38
commit 4ad00406d3
13 files changed
+185 -82

No files matched your search

+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count}개 항목은 기록이 꺼져 있어서 목록에 없어요. 모듈을 다시 켜면 해당 항목도 돌아와요.",
"omittedLink": "모듈 열기",
"refusal": {
"physiological": "건강 점수에는 신체 측정값이 최소 하나 필요해요. 다른 항목을 어떻게 고르든 혈압, 혈당, 수면, 체형, 콜레스테롤 중 하나는 선택에 남겨 두세요.",
"domains": "건강 점수에는 서로 다른 건강 영역이 최소 세 개 필요해요. 혈압, 혈당, 콜레스테롤은 모두 같은 영역이라, 이 셋만 고르면 하나로 쳐요."
"empty": "건강 점수에는 점수를 매길 건강 영역이 최소 하나 필요해요. 하나는 선택해 두세요 — 점수가 어떤 영역을 바탕으로 하는지 알려 줘요."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -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": {
@@ -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 () => {
+11 -10
View File
@@ -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<ScoreBreadthFailure, string> = {
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(
+82 -29
View File
@@ -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<ScorePillarId> = new Set([
"BLOOD_PRESSURE",
@@ -31,40 +56,68 @@ const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = 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 };
}
+16 -5
View File
@@ -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<ScoreBand, number> = {
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,
},
+7 -4
View File
@@ -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.
+48 -5
View File
@@ -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<PillarValue>;
}
/**
* 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;