Merge work/score-settings-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:32:07 +02:00
commit 4d200ebd43
45 files changed
+833 -1019

No files matched your search

@@ -352,7 +352,6 @@ describe("i18n locale file integrity", () => {
["insights.healthScore.pillar.activity", "Aktivität"],
["insights.healthScore.pillar.sleep", "Schlaf"],
["insights.healthScore.pillar.wellbeing", "Wohlbefinden"],
["insights.healthScore.pillar.fitness", "Gemessene Fitness"],
["insights.healthScore.pillar.lipids", "Blutfette"],
];
for (const [key, expectedDe] of expected) {
@@ -8,7 +8,13 @@ import {
SCORE_ALGORITHM_CHANGED_AT,
} from "../composite";
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
import { SCORE_GREEN_FLOOR, scoreBand } from "../shared";
import {
SCORE_VERSION,
type PillarValue,
type ScorePillarId,
type ScorePillarResult,
} from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -19,7 +25,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -87,6 +92,111 @@ function evaluation(
});
}
/**
* Where the band changes, and what the band is NOT.
*
* v1.35.1 moved green from 75 to 70, so the Health Score says the same
* thing by a green dot that Readiness and the sleep score already did.
* Boundaries are pinned on both sides because an off-by-one here is
* invisible: a `>` where a `>=` belongs looks perfectly healthy at 80.
*
* The last case is the one that must survive every future threshold
* edit. The band is not a function of the number: `computeComposite`
* takes the worse of the mean's band and the worst counted pillar's, and
* `HealthScoreRecord.band` is stored rather than re-derived precisely
* because of it. A mean above the green floor with a red pillar under it
* is still red.
*/
describe("the band the mean alone gives", () => {
const CASES: Array<[number, string]> = [
[69, "yellow"],
[70, "green"],
[74, "green"],
[75, "green"],
[49, "red"],
[50, "yellow"],
];
for (const [score, expected] of CASES) {
it(`calls ${score} ${expected}`, () => {
expect(scoreBand(score)).toBe(expected);
});
}
it("says the same thing the app's other two scores say", () => {
// Readiness and the sleep score both call 70 green. Three scores in
// one app cannot mean three things by one colour, and the Health
// Score was the outlier.
expect(scoreBand(SCORE_GREEN_FLOOR)).toBe("green");
expect(scoreBand(SCORE_GREEN_FLOOR - 1)).toBe("yellow");
expect(SCORE_GREEN_FLOOR).toBe(70);
});
it("moved the method version with it", () => {
// A stored day keeps the band it was shown under, and the notice key
// carries this number, so each account hears once that the rules
// moved. A threshold edit without a version bump is a silent one.
expect(SCORE_VERSION).toBeGreaterThan(2);
});
});
describe("the worst pillar still holds the band down", () => {
it("keeps a composite red when a counted pillar is red, above the green floor", () => {
// Mean 73: green on the number alone, and green under the OLD floor
// it would have been yellow — so this case can only pass because the
// worst-pillar rule survived the threshold change.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 20),
pillar("ACTIVITY", 100),
pillar("SLEEP", 100),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.score).toBe(73);
expect(scoreBand(result.value.score)).toBe("green");
expect(result.value.band).toBe("red");
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
});
it("keeps a composite yellow when its worst pillar is yellow", () => {
// Mean 84, every pillar above the red floor, one of them yellow.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 52),
pillar("ACTIVITY", 100),
pillar("SLEEP", 100),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(scoreBand(result.value.score)).toBe("green");
expect(result.value.band).toBe("yellow");
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
});
it("names no setter when every pillar clears the mean's band", () => {
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 71),
pillar("ACTIVITY", 90),
pillar("SLEEP", 90),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.band).toBe("green");
expect(result.value.bandSetter).toBeNull();
});
});
describe("reference-score composite", () => {
it("requires three domains and one measured physiological domain", () => {
const tooNarrow = evaluation(
@@ -57,15 +57,27 @@ import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
const DAY_MS = 86_400_000;
/**
* Pillars the catalogue has retired since the swap.
*
* FITNESS went in v1.35.1. It had never produced a value on any account —
* the schema cannot prove a VO₂max reading came from a measured test, so
* every FITNESS row reached the scorer unproven and was refused — which
* is why retiring it moves no number. It is named here rather than edited
* out of the frozen expression below, so the "before" side stays a
* verbatim copy and the retirement stays a visible, deliberate subtraction
* instead of a silent one.
*/
const RETIRED_SINCE_THE_SWAP: readonly string[] = ["FITNESS"];
/**
* 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.
* reader is the day somebody's number moved. The one sanctioned
* subtraction is `RETIRED_SINCE_THE_SWAP`, applied below.
*/
function compositionBeforeTheSwap(
modules: ScoreReaderModules,
): ScorePillarId[] {
function compositionBeforeTheSwap(modules: ScoreReaderModules): string[] {
return [
"BLOOD_PRESSURE",
...(modules.glucose || modules.labs ? (["GLYCAEMIA"] as const) : []),
@@ -78,6 +90,13 @@ function compositionBeforeTheSwap(
];
}
/** The pre-swap composition minus the pillars the catalogue has retired. */
function compositionInheritedToday(modules: ScoreReaderModules): string[] {
return compositionBeforeTheSwap(modules).filter(
(id) => !RETIRED_SINCE_THE_SWAP.includes(id),
);
}
/** 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) =>
@@ -145,8 +164,8 @@ function measurement(
/**
* 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
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia and
* lipids stay honestly absent, which is what lets the same fixture
* show a selected-but-dataless pillar beside a module-withdrawn one.
*/
const MEASUREMENTS: MeasurementRow[] = [
@@ -213,7 +232,6 @@ function score(args: {
now: NOW,
profile: {
dateOfBirth: null,
gender: null,
heightCm: null,
timezone: "UTC",
sourcePriorityJson: null,
@@ -243,7 +261,7 @@ describe("the module-to-pillar map", () => {
expect(
pillarsWithModuleData(modules),
`availability for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
).toEqual(compositionInheritedToday(modules));
}
});
@@ -267,20 +285,15 @@ describe("the module-to-pillar map", () => {
).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.
it("leaves the three core-domain pillars ungated", () => {
// These read 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",
]);
expect(ungated).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "ADIPOSITY"]);
const allOff: ScoreReaderModules = {
glucose: false,
labs: false,
@@ -290,6 +303,19 @@ describe("the module-to-pillar map", () => {
expect(pillarsWithModuleData(allOff)).toEqual(ungated);
});
it("holds no retired pillar the catalogue still knows", () => {
// A retirement list that named a live pillar would quietly subtract
// it from the "before" side and hide a real composition change.
for (const id of RETIRED_SINCE_THE_SWAP) {
expect(SCORE_PILLAR_IDS as readonly string[]).not.toContain(id);
}
// And the list is not empty-by-accident: the subtraction it performs
// has to be visible in at least one module combination.
expect(compositionBeforeTheSwap(ALL_MODULES_ON)).not.toEqual(
compositionInheritedToday(ALL_MODULES_ON),
);
});
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);
@@ -307,7 +333,7 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
expect(
report.pillars.map((pillar) => pillar.id),
`composition for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
).toEqual(compositionInheritedToday(modules));
}
});
@@ -321,7 +347,6 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
"ACTIVITY",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]);
expect(report.composite.status).toBe("ok");
@@ -251,7 +251,7 @@ function recipe(changedAt: Date): unknown {
// 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"),
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
version: 3,
changedAt,
});
@@ -512,16 +512,16 @@ describe("a settings change is never narrated as a health event", () => {
const unconfigured = await computeUserHealthScore(scoreInput(null));
expect(configured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:3",
"health_score_algorithm:3:config:3",
);
expect(configured.algorithmNotice!.dismissed).toBe(false);
// An account that never chose keeps the key its acknowledgement is
// already filed under.
// An account that never chose keeps the bare method key, with no
// recipe suffix on it.
expect(unconfigured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2",
"health_score_algorithm:3",
);
dismissedKeys.add("health_score_algorithm:2:config:3");
dismissedKeys.add("health_score_algorithm:3:config:3");
expect(
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
.dismissed,
@@ -532,14 +532,14 @@ describe("a settings change is never narrated as a health event", () => {
const next = await computeUserHealthScore(
scoreInput(
healthScoreConfigFromSelection({
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
version: 4,
changedAt: new Date(NOW.getTime() - DAY_MS),
}),
),
);
expect(next.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:4",
"health_score_algorithm:3:config:4",
);
expect(next.algorithmNotice!.dismissed).toBe(false);
});
@@ -39,6 +39,7 @@ import type {
ScorePillarId,
ScorePillarResult,
} from "../types";
import { SCORE_VERSION } from "../types";
const DAY_MS = 86_400_000;
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -50,7 +51,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -116,7 +116,7 @@ function composite(score: number, asOf: Date): Derived<CompositeValue> {
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage,
confidence,
@@ -191,7 +191,7 @@ describe("the recipe boundary", () => {
composition: ["BLOOD_PRESSURE", "ACTIVITY"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage: {
requiredInputs: 3,
@@ -37,10 +37,10 @@ describe("resolveScoreConfigured", () => {
});
it("reads true once the person's own recipe narrows the composition", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
const narrowed = ALL.filter((id) => id !== "LIPIDS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
config: { pillars: narrowed },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
@@ -87,10 +87,10 @@ describe("resolveScoreConfigured", () => {
});
it("reads false again when the recipe is undone", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
const narrowed = ALL.filter((id) => id !== "LIPIDS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
config: { pillars: narrowed },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
@@ -4,7 +4,6 @@ import { gradeBpScore } from "@/lib/analytics/bp-grade";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
import { computeFitnessPillar } from "../fitness";
import { computeGlycaemiaPillar } from "../glycaemia";
import { computeLipidsPillar } from "../lipids";
import { computeSleepPillar } from "../sleep";
@@ -390,43 +389,6 @@ describe("reference-score pillars", () => {
if (who.status === "ok") expect(who.value.score).toBe(52);
});
it("admits fitness only when the source proves a measured test", () => {
const unverified = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "APPLE_HEALTH",
measured: false,
},
],
});
expect(unverified.status).toBe("insufficient");
const measured = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
measured: true,
},
],
});
expect(measured.status).toBe("ok");
if (measured.status === "ok") {
expect(measured.value.reference.source).toBe("FRIEND (Kaminsky 2015)");
}
});
it("requires a fresh lipid panel with lab-reported reference bounds", () => {
const result = computeLipidsPillar({
...live,
@@ -495,15 +457,6 @@ describe("reference-score pillars", () => {
fastingGlucose: [],
}).status,
).toBe("insufficient");
expect(
computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [{ value: 42, at: staleAt, source: "MANUAL", measured: true }],
}).status,
).toBe("insufficient");
expect(
computeLipidsPillar({
...live,
@@ -28,6 +28,7 @@ import type {
ScorePillarId,
ScorePillarResult,
} from "../types";
import { SCORE_VERSION } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -38,7 +39,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -104,7 +104,7 @@ function report(
pillars,
delta: null,
deltaReason: "no_previous_window",
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
algorithmNotice: null,
};
@@ -145,7 +145,7 @@ describe("stored bands", () => {
describe("input fingerprint", () => {
const base = {
scoreVersion: 2,
scoreVersion: 1,
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
pillars: HEALTHY,
};
@@ -157,7 +157,7 @@ describe("input fingerprint", () => {
});
it("moves when the algorithm version moves", () => {
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe(
healthScoreInputFingerprint(base),
);
});
-1
View File
@@ -28,7 +28,6 @@ export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"GLYCAEMIA",
"SLEEP",
"ADIPOSITY",
"FITNESS",
"LIPIDS",
]);
-2
View File
@@ -19,7 +19,6 @@ export async function computeHealthScoreDerived(
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
timezone: true,
thresholdsJson: true,
@@ -56,7 +55,6 @@ export async function computeHealthScoreDerived(
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone,
sourcePriorityJson,
-94
View File
@@ -1,94 +0,0 @@
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import type { Derived } from "@/lib/insights/derived/types";
import type { FitnessPillarInput, PillarValue } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
okPillar,
scoreReferenceBand,
withinWindow,
} from "./shared";
export const FITNESS_WINDOW_DAYS = 365;
export function computeFitnessPillar(
input: FitnessPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "FITNESS",
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
});
}
const norm = lookupNormalRange("VO2_MAX", input.ageYears, input.sex);
if (!norm) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["age and binary reference sex"],
reason: "missing_reference_profile",
});
}
const measured = input.rows
.filter(
(row) =>
row.measured && withinWindow(row.at, input.asOf, FITNESS_WINDOW_DAYS),
)
.sort((a, b) => b.at.getTime() - a.at.getTime());
const latest = measured[0];
if (!latest) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["measured VO₂max test"],
reason: input.rows.length > 0 ? "unverified_or_stale" : "not_tracked",
});
}
return okPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
coverage: coverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
}),
value: {
score: scoreReferenceBand(latest.value, norm.low, null),
observed: {
value: latest.value,
unit: "mL/(kg·min)",
label: `${latest.value} mL/(kg·min) measured`,
asOf: latest.at.toISOString(),
sources: [latest.source],
},
reference: {
kind: "population-percentile",
low: norm.low,
high: norm.high,
label: `${norm.low}–${norm.high} mL/(kg·min) age/sex band`,
source: "FRIEND (Kaminsky 2015)",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: "measured_vo2max",
},
});
}
-6
View File
@@ -4,7 +4,6 @@ import { computeActivityPillar } from "./activity";
import { computeAdiposityPillar } from "./adiposity";
import { computeBloodPressurePillar } from "./blood-pressure";
import { computeComposite } from "./composite";
import { computeFitnessPillar } from "./fitness";
import { computeGlycaemiaPillar } from "./glycaemia";
import { computeLipidsPillar } from "./lipids";
import { computeSleepPillar } from "./sleep";
@@ -70,11 +69,6 @@ export function computeHealthScore(
domain: SCORE_PILLAR_DOMAINS.WELLBEING,
result: computeWellbeingPillar(input.pillars.WELLBEING),
},
FITNESS: {
id: "FITNESS",
domain: SCORE_PILLAR_DOMAINS.FITNESS,
result: computeFitnessPillar(input.pillars.FITNESS),
},
LIPIDS: {
id: "LIPIDS",
domain: SCORE_PILLAR_DOMAINS.LIPIDS,
+6 -6
View File
@@ -20,12 +20,12 @@
* 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.
* `BLOOD_PRESSURE`, `ACTIVITY` and `ADIPOSITY` 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 three so a silent addition to the map cannot
* quietly start gating one of them.
*/
import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types";
-32
View File
@@ -20,7 +20,6 @@ import { reconstructNights } from "@/lib/insights/derived/sleep-score";
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 { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -44,7 +43,6 @@ const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
export interface ScoreReaderProfile {
dateOfBirth: Date | null;
gender: string | null;
heightCm: number | null;
timezone: string;
sourcePriorityJson: unknown;
@@ -424,7 +422,6 @@ function scoreInputsFor(args: {
activity: Settled<ActivityRead>;
sleep: Settled<MeasurementRow[]>;
adiposity: Settled<MeasurementRow[]>;
fitness: Settled<MeasurementRow[]>;
assessments: Settled<
Array<{
instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI";
@@ -463,7 +460,6 @@ function scoreInputsFor(args: {
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
);
const sex = toProfileSex(input.profile.gender);
const fastingCanonical = pickStableSourceRows(
args.glucose.value.filter((row) => row.glucoseContext === "FASTING"),
(row) => userDayKey(row.measuredAt, input.profile.timezone),
@@ -492,12 +488,6 @@ function scoreInputsFor(args: {
(row) =>
`${row.type}|${userDayKey(row.measuredAt, input.profile.timezone)}`,
);
const fitnessCanonical = pickCanonicalSourceRows(
args.fitness.value,
"vo2Max",
input.profile.sourcePriorityJson,
(date) => userDayKey(date, input.profile.timezone),
).canonicalRows;
return {
BLOOD_PRESSURE: {
@@ -599,21 +589,6 @@ function scoreInputsFor(args: {
at: row.takenAt,
})),
},
FITNESS: {
source: "live",
readFailed: !args.fitness.ok,
asOf,
ageYears,
sex,
// The schema records VO₂max but does not distinguish CPET measurements
// from device estimates. No current row can therefore prove `measured`.
rows: fitnessCanonical.map((row) => ({
value: row.value,
at: row.measuredAt,
source: row.source,
measured: false,
})),
},
LIPIDS: {
source: "live",
readFailed: !args.labs.ok,
@@ -653,7 +628,6 @@ export async function computeUserHealthScore(
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
weights,
@@ -695,11 +669,6 @@ export async function computeUserHealthScore(
[],
"ADIPOSITY",
),
capture(
readMeasurements(db, input.userId, ["VO2_MAX"], since),
[],
"FITNESS",
),
input.modules.mentalHealth
? capture(
db.mentalHealthAssessment.findMany({
@@ -827,7 +796,6 @@ export async function computeUserHealthScore(
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
};
+30 -2
View File
@@ -19,9 +19,37 @@ export function clampScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
/** Green from here up. Yellow from {@link SCORE_YELLOW_FLOOR} up. */
export const SCORE_GREEN_FLOOR = 70;
/** Below this is red. */
export const SCORE_YELLOW_FLOOR = 50;
/**
* The band a 0-100 score falls in, from the number alone.
*
* v1.35.1 — green starts at 70, not 75. The app carries three scores and
* had two conventions: Readiness and the sleep score both call 70 green
* (`bandForScore` in `src/lib/insights/derived/readiness.ts` and
* `src/lib/insights/derived/sleep-score.ts`), and the Health Score alone
* sat at 75. One app cannot mean two things by a green dot, so the
* outlier moved. The yellow floor stays at 50 deliberately: raising the
* bar for a reward and lowering the bar for a warning are different
* decisions, and only the first one was asked for.
*
* This is a METHOD change, so `SCORE_VERSION` moved with it. Stored days
* keep the band they were actually shown under, and the notice key
* carries the version, so each account is told once that the method
* moved.
*
* NOT the whole band rule. `computeComposite` takes the worse of this
* band and the worst counted pillar's band, so a composite carrying a red
* pillar stays red however high the mean sits. That rule is untouched;
* only the mean-side thresholds moved.
*/
export function scoreBand(score: number): ScoreBand {
if (score >= 75) return "green";
if (score >= 50) return "yellow";
if (score >= SCORE_GREEN_FLOOR) return "green";
if (score >= SCORE_YELLOW_FLOOR) return "yellow";
return "red";
}
+28 -28
View File
@@ -4,10 +4,33 @@ import type {
Derived,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
import type { ProfileSex } from "@/lib/profile/sex";
export const SCORE_VERSION = 2 as const;
/**
* The scoring method's identity.
*
* Moves whenever the method changes, which is what lets a stored day say
* which rules produced it and lets each account be told once that the
* rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version
* of the report.
*
* 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;
/**
* The pillars a score can be built from.
*
* v1.35.1 — FITNESS is gone. The schema records VO₂max but cannot tell a
* measured test apart from a device estimate, so every FITNESS row
* reached the scorer unproven and was refused: the pillar had never once
* produced a value on any account. A pillar that cannot score is not a
* choice, and listing it as one put chrome in front of a thing that
* recomputes nothing. Reading it back is still safe — a stored recipe
* naming it passes through `orderedUniquePillars`, which keeps only ids
* this catalogue knows.
*/
export const SCORE_PILLAR_IDS = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
@@ -15,7 +38,6 @@ export const SCORE_PILLAR_IDS = [
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
] as const;
@@ -34,18 +56,13 @@ export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] {
}
export type ScoreBand = "green" | "yellow" | "red";
export type ScoreDomain =
| "cardiometabolic"
| "activity"
| "sleep"
| "adiposity"
| "wellbeing"
| "fitness";
"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 eight pillars share the cardiometabolic
* domain, which is why a selection of five pillars can still fail.
* decoration: three of the seven pillars share the cardiometabolic
* domain, which is why a selection of four 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
@@ -58,7 +75,6 @@ export const SCORE_PILLAR_DOMAINS: Record<ScorePillarId, ScoreDomain> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -272,21 +288,6 @@ export interface WellbeingPillarInput extends DomainReadState {
assessments: WellbeingAssessment[];
}
export interface FitnessReading {
value: number;
at: Date;
source: string;
/** True only when persisted provenance proves a measured CPET/test. */
measured: boolean;
}
export interface FitnessPillarInput extends DomainReadState {
asOf: Date;
ageYears: number | null;
sex: ProfileSex;
rows: FitnessReading[];
}
export interface LipidReading {
marker: string;
value: number;
@@ -310,6 +311,5 @@ export interface PillarInputs {
SLEEP: SleepPillarInput;
ADIPOSITY: AdiposityPillarInput;
WELLBEING: WellbeingPillarInput;
FITNESS: FitnessPillarInput;
LIPIDS: LipidsPillarInput;
}
-1
View File
@@ -759,7 +759,6 @@ async function buildExtras(
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
@@ -129,7 +129,7 @@ function compositionFor(selection: readonly ScorePillarId[]): ScorePillarId[] {
}
describe("Health Score coverage contract", () => {
it("does not measure a four-pillar account against the eight-pillar catalogue", () => {
it("does not measure a four-pillar account against the whole catalogue", () => {
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
@@ -148,8 +148,9 @@ describe("Health Score coverage contract", () => {
});
expect(composite.status).toBe("ok");
// The catalogue is eight wide and must not be the yardstick.
expect(SCORE_PILLAR_IDS.length).toBe(8);
// The catalogue is wider than the selection and must not be the
// yardstick.
expect(SCORE_PILLAR_IDS.length).toBeGreaterThan(selection.length);
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.
@@ -162,7 +163,7 @@ describe("Health Score coverage contract", () => {
);
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.
// data, so nothing about the ones they left out may pull this down.
expect(composite.confidence.score).toBe(100);
expect(composite.value.composition).toEqual(selection);
});
@@ -187,7 +188,8 @@ describe("Health Score coverage contract", () => {
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.
// counts the whole catalogue, so the blanks are genuinely missing
// coverage.
const withData: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
@@ -202,8 +204,12 @@ describe("Health Score coverage contract", () => {
asOf: NOW,
});
expect(composite.coverage.missing).toContain("wellbeing");
expect(composite.coverage.missing).toContain("fitness");
// Named by DOMAIN, not by pillar: glycaemia and lipids are blank too,
// but blood pressure already covers the area they sit in, so wellbeing
// is the one area with nothing behind it. The previous case's `missing`
// was empty, which is the contrast that makes this assertion mean
// something.
expect(composite.coverage.missing).toEqual(["wellbeing"]);
});
it("keeps the registry's HEALTH_SCORE inputs equal to the score catalogue", () => {
+3 -4
View File
@@ -342,7 +342,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
id: "HEALTH_SCORE",
displayName: "Cardiometabolic reference score",
archetype: "composite",
// The eight ids below are the CATALOGUE of scorable pillars, and since
// The seven 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
@@ -356,7 +356,6 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
],
minHistoryDays: 1,
@@ -364,10 +363,10 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
// 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
// four pillars is not measured against a denominator of seven. 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
// of the seven 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.
@@ -1104,7 +1104,6 @@ export const dashboardSnapshotResponse = z
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]),
)
@@ -1136,7 +1135,6 @@ export const dashboardSnapshotResponse = z
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
])
.nullable()
+3 -60
View File
@@ -2,7 +2,7 @@
* 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
* Three 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:
*
@@ -16,17 +16,12 @@
* 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 {
@@ -81,9 +76,9 @@ describe("grouping", () => {
if (group.domain === "cardiometabolic") continue;
expect(group.rows).toHaveLength(1);
}
// Six domains across eight pillars is the whole reason the surface
// Five domains across seven pillars is the whole reason the surface
// groups: equal weight per pillar is not equal weight per domain.
expect(built.groups).toHaveLength(6);
expect(built.groups).toHaveLength(5);
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
});
@@ -158,7 +153,6 @@ describe("a module that is off", () => {
"BLOOD_PRESSURE",
"ACTIVITY",
"ADIPOSITY",
"FITNESS",
] as ScorePillarId[]) {
expect(row(built, id)).toBeDefined();
}
@@ -246,54 +240,3 @@ describe("states that are not absence", () => {
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",
]);
});
});
+1 -17
View File
@@ -15,30 +15,14 @@ export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
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.
*/
/** Domain headings, one per distinct area of health. */
export const SCORE_DOMAIN_LABEL_KEYS: Record<ScoreDomain, string> = {
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<Record<ScoreDomain, string>> = {
cardiometabolic: "settings.sections.score.domainNote.cardiometabolic",
};
+1 -43
View File
@@ -36,21 +36,6 @@ import {
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<ScorePillarId> = 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.
@@ -64,8 +49,6 @@ export type ScoreRowEligibility =
| "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";
@@ -74,8 +57,6 @@ export interface ScoreConfigRow {
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;
}
@@ -118,10 +99,8 @@ function resolveModules(
}
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";
@@ -169,8 +148,7 @@ export function buildScoreConfigRows(
id,
domain,
counts: selected.has(id),
selectable: !UNSCORABLE_PILLARS.has(id),
eligibility: eligibilityOf(id, input.verdicts?.[id]),
eligibility: eligibilityOf(input.verdicts?.[id]),
};
const existing = byDomain.get(domain);
if (existing) {
@@ -199,23 +177,3 @@ export function buildScoreConfigRows(
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),
);
}