mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge work/score-settings-polish
This commit is contained in:
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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
||||
"GLYCAEMIA",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user