Merge work/score-settings-ui

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 20:31:02 +02:00
commit cdfe4ec114
26 files changed
+2809 -30

No files matched your search

+9
View File
@@ -38,6 +38,15 @@ export const settingsKeys = {
*/
modulesPrefs: () => ["settings", "modules"] as const,
/**
* v1.35.0 — which pillars count toward the Health Score, behind
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
* a slice of `authMe()`: the payload carries the optimistic-concurrency
* token the save echoes, and folding it into the account payload would
* make every unrelated profile write advance the token.
*/
healthScoreConfig: () => ["settings", "health-score-config"] as const,
apiVersion: () => ["api", "version"] as const,
publicVersion: () => ["public", "version"] as const,
/** v1.15.12 H1 — admin overview "update available" check against the
+299
View File
@@ -0,0 +1,299 @@
/**
* What the "what counts toward your score" surface shows, and what it
* refuses to show.
*
* Four decisions are pinned here because each of them is a sentence a
* person reads about their own health, and each has a plausible wrong
* version that would look fine in a screenshot:
*
* 1. A pillar every feeding module has switched off gets NO row. Not a
* greyed row, not a "waiting for data" line. It is not waiting for
* anything, and under a never-written recipe the word "selected"
* would describe a default nobody picked.
* 2. "Selected, waiting for data" belongs to a pillar the person
* counts, whose module is on, and which the SERVER said has nothing
* to score. Not to a pillar the person just switched off, and not to
* one the server never mentioned.
* 3. Wellbeing under crisis signposting is its own state and never a
* configuration problem.
* 4. A pillar this build can never score is never sent as part of a
* selection, because the write-time breadth rule would otherwise
* accept a recipe on its behalf that the scorer then refuses.
*/
import { describe, expect, it } from "vitest";
import {
buildScoreConfigRows,
selectionToSave,
showsWaitingForData,
UNSCORABLE_PILLARS,
type ScoreConfigRow,
} from "@/lib/score-config/rows";
import {
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "@/lib/analytics/score/types";
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
const ALL_MODULES_ON = {
glucose: true,
labs: true,
sleep: true,
mentalHealth: true,
};
function rowsOf(result: ReturnType<typeof buildScoreConfigRows>) {
return result.groups.flatMap((group) => group.rows);
}
function row(
result: ReturnType<typeof buildScoreConfigRows>,
id: ScorePillarId,
): ScoreConfigRow | undefined {
return rowsOf(result).find((candidate) => candidate.id === id);
}
describe("grouping", () => {
it("puts blood pressure, glycaemia and lipids under one domain", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
});
const cardio = built.groups.find(
(group) => group.domain === "cardiometabolic",
);
expect(cardio).toBeDefined();
expect(cardio?.rows.map((r) => r.id)).toEqual([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"LIPIDS",
]);
});
it("gives every other pillar a domain of its own", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
});
for (const group of built.groups) {
if (group.domain === "cardiometabolic") continue;
expect(group.rows).toHaveLength(1);
}
// Six domains across eight pillars is the whole reason the surface
// groups: equal weight per pillar is not equal weight per domain.
expect(built.groups).toHaveLength(6);
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
});
it("emits no group for a domain whose only pillar is hidden", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: { ...ALL_MODULES_ON, sleep: false },
});
expect(built.groups.some((group) => group.domain === "sleep")).toBe(false);
});
});
describe("a module that is off", () => {
it("removes the pillar entirely rather than showing it as waiting", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: { ...ALL_MODULES_ON, sleep: false },
verdicts: { SLEEP: { status: "insufficient", reason: "stale" } },
});
expect(row(built, "SLEEP")).toBeUndefined();
expect(built.omitted).toContain("SLEEP");
});
it("keeps glycaemia while either of its two modules is on", () => {
const glucoseOnly = buildScoreConfigRows({
selection: ALL,
modules: { ...ALL_MODULES_ON, labs: false },
});
const labsOnly = buildScoreConfigRows({
selection: ALL,
modules: { ...ALL_MODULES_ON, glucose: false },
});
expect(row(glucoseOnly, "GLYCAEMIA")).toBeDefined();
expect(row(labsOnly, "GLYCAEMIA")).toBeDefined();
// Lipids ride on labs alone, so they leave with it.
expect(row(glucoseOnly, "LIPIDS")).toBeUndefined();
expect(row(labsOnly, "LIPIDS")).toBeDefined();
});
it("hides glycaemia only when both of its modules are off", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: { ...ALL_MODULES_ON, glucose: false, labs: false },
});
expect(row(built, "GLYCAEMIA")).toBeUndefined();
expect(built.omitted).toEqual(["GLYCAEMIA", "LIPIDS"]);
});
it("reads a missing module key as on", () => {
const built = buildScoreConfigRows({ selection: ALL, modules: {} });
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
expect(built.omitted).toEqual([]);
});
it("never omits a pillar no module feeds", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: {
glucose: false,
labs: false,
sleep: false,
mentalHealth: false,
},
});
for (const id of [
"BLOOD_PRESSURE",
"ACTIVITY",
"ADIPOSITY",
"FITNESS",
] as ScorePillarId[]) {
expect(row(built, id)).toBeDefined();
}
});
});
describe("selected, waiting for data", () => {
it("is shown for a counted pillar the server had nothing to score", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } },
});
const lipids = row(built, "LIPIDS");
expect(lipids?.eligibility).toBe("waiting");
expect(showsWaitingForData(lipids!)).toBe(true);
});
it("is not shown for a pillar the draft has switched off", () => {
const built = buildScoreConfigRows({
selection: ALL.filter((id) => id !== "LIPIDS"),
modules: ALL_MODULES_ON,
verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } },
});
const lipids = row(built, "LIPIDS");
expect(lipids?.counts).toBe(false);
expect(showsWaitingForData(lipids!)).toBe(false);
});
it("is not shown while the server has said nothing", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
});
const lipids = row(built, "LIPIDS");
expect(lipids?.eligibility).toBe("unknown");
expect(showsWaitingForData(lipids!)).toBe(false);
});
it("is not shown for a pillar that scored", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
verdicts: { SLEEP: { status: "ok" } },
});
const sleep = row(built, "SLEEP");
expect(sleep?.eligibility).toBe("counting");
expect(showsWaitingForData(sleep!)).toBe(false);
});
});
describe("states that are not absence", () => {
it("keeps crisis signposting out of the waiting state", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
verdicts: {
WELLBEING: {
status: "insufficient",
reason: "crisis_signposting",
},
},
});
const wellbeing = row(built, "WELLBEING");
expect(wellbeing?.eligibility).toBe("crisis");
expect(showsWaitingForData(wellbeing!)).toBe(false);
});
it("keeps a failed read out of the waiting state", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
verdicts: {
BLOOD_PRESSURE: { status: "insufficient", reason: "read_failed" },
},
});
const bp = row(built, "BLOOD_PRESSURE");
expect(bp?.eligibility).toBe("read_failed");
expect(showsWaitingForData(bp!)).toBe(false);
});
});
describe("a pillar this build cannot score", () => {
it("is present, honest and never selectable", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
// Even a verdict claiming it scored cannot make it selectable.
verdicts: { FITNESS: { status: "ok" } },
});
const fitness = row(built, "FITNESS");
expect(fitness).toBeDefined();
expect(fitness?.selectable).toBe(false);
expect(fitness?.eligibility).toBe("unavailable");
});
it("leaves every other pillar selectable", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
});
for (const candidate of rowsOf(built)) {
expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id));
}
});
it("is never sent as part of a saved selection", () => {
expect(selectionToSave(ALL)).not.toContain("FITNESS");
expect(selectionToSave(["FITNESS"])).toEqual([]);
});
it("cannot carry a selection over the breadth floor on its own", () => {
// The failure this guards: FITNESS is a domain of its own AND counts
// as physiological, so echoing it back would let the write-time rule
// accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of
// them physiological — for a score the reader then refuses, because
// FITNESS never reaches the composition.
expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([
"ACTIVITY",
"WELLBEING",
]);
});
it("keeps registry order and drops duplicates", () => {
expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([
"ACTIVITY",
"LIPIDS",
]);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* The localised names of the score's pillars and the domains they sit in.
*
* One map per concept, read by the hero panel and by the settings surface
* that decides what counts. Two copies of a name map drift the moment one
* surface renames a pillar, and the two surfaces would then be talking
* about different things while showing the same list.
*/
import type { ScoreDomain, ScorePillarId } from "@/lib/analytics/score/types";
export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
GLYCAEMIA: "insights.healthScore.pillar.glycaemia",
ACTIVITY: "insights.healthScore.pillar.activity",
SLEEP: "insights.healthScore.pillar.sleep",
ADIPOSITY: "insights.healthScore.pillar.adiposity",
WELLBEING: "insights.healthScore.pillar.wellbeing",
FITNESS: "insights.healthScore.pillar.fitness",
LIPIDS: "insights.healthScore.pillar.lipids",
};
/**
* Domain headings. The cardiometabolic one carries three pillars, which
* is the whole reason the surface groups at all: equal weight per pillar
* is not equal weight per area, and nothing in the app said so before.
*/
export const SCORE_DOMAIN_LABEL_KEYS: Record<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",
};
+221
View File
@@ -0,0 +1,221 @@
/**
* v1.35.0 — what the "what counts toward your score" surface shows, as a
* pure function over three inputs the page already holds.
*
* Kept out of the component so the row states can be driven directly by a
* test instead of through a rendered tree, and so the page has exactly one
* place that decides what a row says.
*
* The three inputs, and why each is needed:
*
* - **the draft selection** — what the person currently has switched on
* in front of them, which is not yet what the server holds;
* - **the modules map** — whether the pillar's data is being recorded at
* all. A pillar every feeding module has switched off is not shown,
* full stop (see `omitted`, and `composition-source.test.ts`, which
* pins the same decision one layer down);
* - **the server's own pillar verdicts** — whether a pillar currently
* scores, is waiting for data, is suppressed for safety, or could not
* be read. These are never re-derived here. A pillar the server said
* nothing about reads as `unknown`, and `unknown` renders no claim.
*
* The eligibility a row shows describes the recipe the SERVER scored, not
* the draft in front of the person. That is deliberate: the draft has not
* been computed against anything, so a line derived from it would be a
* guess wearing the voice of a fact.
*/
import {
SCORE_PILLAR_DOMAINS,
SCORE_PILLAR_IDS,
type ScoreDomain,
type ScorePillarId,
} from "@/lib/analytics/score/types";
import {
pillarsWithModuleData,
type ScoreModuleKey,
type ScoreReaderModules,
} from "@/lib/analytics/score/modules";
/**
* Pillars this build can never score, whatever anyone selects.
*
* FITNESS is the only entry and it is not a placeholder for a future
* list. The schema records VO₂max but cannot distinguish a measured test
* from a device estimate, so every FITNESS row reaches the scorer with
* `measured: false` and is refused (`src/lib/analytics/score/reader.ts`,
* the FITNESS input block). Offering it as a switch would be chrome over
* a thing that recomputes nothing — the exact defect this release exists
* to remove — so the row is present, honest, and inert.
*/
export const UNSCORABLE_PILLARS: ReadonlySet<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.
*/
export type ScoreRowEligibility =
/** It scored. */
| "counting"
/** Selected, its data path is open, and there is nothing to score yet. */
| "waiting"
/** Wellbeing under crisis signposting. Never a configuration problem. */
| "crisis"
/** The read broke. Absence and failure are different states. */
| "read_failed"
/** This build cannot score it at all. */
| "unavailable"
/** The server said nothing about it. No claim is made either way. */
| "unknown";
export interface ScoreConfigRow {
id: ScorePillarId;
domain: ScoreDomain;
/** True when the draft counts this pillar toward the score. */
counts: boolean;
/** False when the switch is inert — today only the unscorable pillars. */
selectable: boolean;
eligibility: ScoreRowEligibility;
}
export interface ScoreConfigGroup {
domain: ScoreDomain;
rows: ScoreConfigRow[];
}
/**
* One pillar's verdict as the page reads it off the report: the status,
* plus the reason when the status is `insufficient`.
*/
export interface ScorePillarVerdict {
status: "ok" | "insufficient";
reason?: string | null;
}
export interface BuildScoreConfigRowsInput {
/** The draft selection the switches currently show. */
selection: readonly ScorePillarId[];
/** Resolved module map. A missing key reads as ON (fail open). */
modules: Partial<Record<ScoreModuleKey, boolean>>;
/**
* Per-pillar verdicts from the last scoring run, keyed by pillar id.
* A pillar the server did not report on is simply absent.
*/
verdicts?: Partial<Record<ScorePillarId, ScorePillarVerdict>>;
}
/** Fail-open module resolution, matching every other gate in the app. */
function resolveModules(
modules: Partial<Record<ScoreModuleKey, boolean>>,
): ScoreReaderModules {
return {
glucose: modules.glucose !== false,
labs: modules.labs !== false,
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
};
}
function eligibilityOf(
id: ScorePillarId,
verdict: ScorePillarVerdict | undefined,
): ScoreRowEligibility {
if (UNSCORABLE_PILLARS.has(id)) return "unavailable";
if (!verdict) return "unknown";
if (verdict.status === "ok") return "counting";
if (verdict.reason === "crisis_signposting") return "crisis";
if (verdict.reason === "read_failed") return "read_failed";
return "waiting";
}
/**
* The pillars whose data path is closed, so the surface omits them.
*
* Returned beside the groups rather than silently dropped, because a
* caller that wants to say where those pillars went (the modules screen)
* needs to know there were any.
*/
export interface ScoreConfigRowsResult {
groups: ScoreConfigGroup[];
/** Registry-ordered pillars hidden because every feeding module is off. */
omitted: ScorePillarId[];
}
/**
* Build the surface's rows, grouped by domain in registry order.
*
* A domain with no visible rows produces no group, so a heading can never
* stand above an empty cluster.
*/
export function buildScoreConfigRows(
input: BuildScoreConfigRowsInput,
): ScoreConfigRowsResult {
const recorded = new Set(
pillarsWithModuleData(resolveModules(input.modules)),
);
const selected = new Set(input.selection);
const groups: ScoreConfigGroup[] = [];
const byDomain = new Map<ScoreDomain, ScoreConfigRow[]>();
const omitted: ScorePillarId[] = [];
for (const id of SCORE_PILLAR_IDS) {
if (!recorded.has(id)) {
omitted.push(id);
continue;
}
const domain = SCORE_PILLAR_DOMAINS[id];
const row: ScoreConfigRow = {
id,
domain,
counts: selected.has(id),
selectable: !UNSCORABLE_PILLARS.has(id),
eligibility: eligibilityOf(id, input.verdicts?.[id]),
};
const existing = byDomain.get(domain);
if (existing) {
existing.push(row);
} else {
const rows = [row];
byDomain.set(domain, rows);
groups.push({ domain, rows });
}
}
return { groups, omitted };
}
/**
* Whether a row earns the "selected, waiting for data" line.
*
* Narrow on purpose, and the narrowing is the decision: the line belongs
* to a pillar the person counts, whose module is on (which is what makes
* the row visible at all), and which the server reported as having
* nothing to score yet. A pillar whose module is off never reaches here
* because it has no row; a pillar the draft excludes is not waiting for
* anything on the person's behalf; and an `unknown` verdict is not
* evidence of absence.
*/
export function showsWaitingForData(row: ScoreConfigRow): boolean {
return row.counts && row.eligibility === "waiting";
}
/**
* The selection a save should send.
*
* Every pillar the person can actually choose, and nothing else. A pillar
* this build cannot score is left out rather than echoed back: echoing it
* would let it satisfy the write-time breadth rule (it is a domain of its
* own AND a physiological pillar) on behalf of a score it can never
* reach, so the write would be accepted and the score would still
* disappear at read time. The refusal is only worth having if it judges
* the recipe that will actually be scored.
*/
export function selectionToSave(
selection: readonly ScorePillarId[],
): ScorePillarId[] {
const selected = new Set(selection);
return SCORE_PILLAR_IDS.filter(
(id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id),
);
}