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

+2
View File
@@ -24,6 +24,7 @@ import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { PrivacySection } from "@/components/settings/privacy-section";
import { ScoreSection } from "@/components/settings/score-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { VorsorgeSection } from "@/components/settings/vorsorge-section";
// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
@@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record<
illness: IllnessSection,
environment: EnvironmentSection,
anamnesis: AnamnesisSection,
score: ScoreSection,
vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
@@ -597,9 +597,13 @@ describe("<HealthScoreCard> composite states", () => {
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
);
expect(html).toContain('data-slot="health-score-insufficient"');
// `presentInputs` counts distinct DOMAINS. The sentence used to call
// them pillars, which told an account with blood pressure, glycaemia
// and lipids that it had one eligible pillar while it had three.
expect(html).toContain(
"The score needs at least three, including a measured physiological pillar.",
"1 of 3 areas of health have enough recent data. The score needs 3, at least one of them a physical measurement.",
);
expect(html).not.toMatch(/eligible pillars/i);
// No band sentence and no fabricated number: a dash, and no bar either.
expect(html).not.toContain('data-slot="health-score-band"');
expect(html).not.toContain('data-slot="health-score-card-progress"');
@@ -0,0 +1,273 @@
/**
* Two claims the Health Score panel used to make about its own coverage
* that were not true, held down structurally so a tidy-up cannot bring
* either back.
*
* **1. `composite.coverage.presentInputs` counts DOMAINS, not pillars.**
* `computeComposite` builds it from `eligibleDomains.size`. The panel fed
* it into a sentence reading "{count} eligible pillars", so someone
* recording blood pressure, glycaemia and lipids was told they had one
* eligible pillar while looking at three. The count was right; the noun
* was wrong, and a noun is exactly the thing a later cleanup restores
* without noticing. The guard reads the call sites out of the AST, so it
* fails on the value AND on the copy — passing the domain count into a
* pillar-shaped string is caught wherever the string lives.
*
* **2. The composite's coverage fraction is a floor, not a proportion.**
* `requiredInputs` is the three-domain minimum and `deriveCoverage` clamps
* `presentInputs` to it, so every scored account rendered "3/3" forever,
* whatever it records. The arithmetic is right and stays untouched; what
* was wrong is presenting a met floor as full coverage of the person's own
* data. The composite's meter therefore names its axis, and the guard
* fails if that override is dropped.
*
* Both are AST matchers rather than text searches, and both carry a
* counter-test proving they stay quiet on the neighbouring call sites that
* are legitimately about pillars.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { CoverageMeter } from "../derived/coverage-meter";
import en from "../../../../messages/en.json";
const CARD_FILE = join(
process.cwd(),
"src/components/insights/health-score-card.tsx",
);
function parse(path: string, text?: string): ts.SourceFile {
return ts.createSourceFile(
path,
text ?? readFileSync(path, "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
}
/** Dotted access path of an expression, or null when it is not one. */
function accessPath(node: ts.Expression): string | null {
if (ts.isIdentifier(node)) return node.text;
if (ts.isPropertyAccessExpression(node)) {
const base = accessPath(node.expression);
return base === null ? null : `${base}.${node.name.text}`;
}
return null;
}
function walk(node: ts.Node, visit: (node: ts.Node) => void): void {
visit(node);
node.forEachChild((child) => walk(child, visit));
}
/** Look a dotted i18n key up in the English bundle. */
function message(key: string): string | undefined {
let cursor: unknown = en;
for (const segment of key.split(".")) {
if (cursor === null || typeof cursor !== "object") return undefined;
cursor = (cursor as Record<string, unknown>)[segment];
}
return typeof cursor === "string" ? cursor : undefined;
}
/**
* Every `t("some.key", { … })` in `file` that interpolates a value read
* off `composite.coverage`, paired with the access paths it passed.
*/
function coverageBackedMessages(
file: ts.SourceFile,
): { key: string; paths: string[] }[] {
const found: { key: string; paths: string[] }[] = [];
walk(file, (node) => {
if (!ts.isCallExpression(node)) return;
if (!ts.isIdentifier(node.expression) || node.expression.text !== "t") {
return;
}
const [keyArg, valuesArg] = node.arguments;
if (!keyArg || !ts.isStringLiteral(keyArg)) return;
if (!valuesArg || !ts.isObjectLiteralExpression(valuesArg)) return;
const paths: string[] = [];
for (const property of valuesArg.properties) {
if (!ts.isPropertyAssignment(property)) continue;
const path = accessPath(property.initializer);
if (path && path.startsWith("composite.coverage.")) paths.push(path);
}
if (paths.length > 0) found.push({ key: keyArg.text, paths });
});
return found;
}
/** `<CoverageMeter …>` elements, with the attribute names each carries. */
function coverageMeters(
file: ts.SourceFile,
): { coverage: string | null; attributes: string[] }[] {
const found: { coverage: string | null; attributes: string[] }[] = [];
walk(file, (node) => {
let opening: ts.JsxOpeningLikeElement | null = null;
if (ts.isJsxSelfClosingElement(node)) opening = node;
else if (ts.isJsxElement(node)) opening = node.openingElement;
if (!opening) return;
if (opening.tagName.getText(file) !== "CoverageMeter") return;
const attributes: string[] = [];
let coverage: string | null = null;
for (const attribute of opening.attributes.properties) {
if (!ts.isJsxAttribute(attribute)) continue;
const name = attribute.name.getText(file);
attributes.push(name);
if (name !== "coverage") continue;
const initializer = attribute.initializer;
if (
initializer &&
ts.isJsxExpression(initializer) &&
initializer.expression
) {
coverage = accessPath(initializer.expression);
}
}
found.push({ coverage, attributes });
});
return found;
}
/**
* The check itself, lifted out so the regression case below runs the very
* assertions the guard runs rather than an imitation of them.
*/
function checkDomainNoun(site: { key: string; paths: string[] }): void {
const copy = message(site.key);
expect(copy, `missing English copy for ${site.key}`).toBeTypeOf("string");
expect(
copy,
`${site.key} interpolates ${site.paths.join(", ")} (a domain count) but reads as pillars`,
).not.toMatch(/pillar/i);
expect(copy, `${site.key} should name the areas it counts`).toMatch(/area/i);
}
describe("the domain count is never described as a pillar count", () => {
const sites = coverageBackedMessages(parse(CARD_FILE));
it("finds the call site at all", () => {
// A matcher that matches nothing passes every assertion below it.
expect(sites.length).toBeGreaterThan(0);
expect(sites.map((site) => site.key)).toContain(
"insights.healthScore.insufficient",
);
});
it("gives every composite-coverage sentence a domain-shaped noun", () => {
for (const site of sites) checkDomainNoun(site);
});
it("stays quiet on a count that really is a count of pillars", () => {
// The neighbouring `notScored` line passes `notScored.length`, a real
// pillar count, into copy that says "pillars" and must keep saying so.
const counterExample = parse(
"counter-example.tsx",
`export function X() {
return <p>{t("insights.healthScore.notScored", { count: notScored.length })}</p>;
}`,
);
expect(coverageBackedMessages(counterExample)).toEqual([]);
expect(message("insights.healthScore.notScored")).toMatch(/not scored/i);
});
it("catches the mislabel when it is reintroduced", () => {
// The guard's own failure mode, driven through the guard's own check:
// a domain count fed into a pillar-shaped key is collected, and the
// check on it throws. `reason.unavailable` is a real shipped string
// that says "pillar" for a good reason, which is what makes it the
// right stand-in for the copy this defect produces.
const regression = parse(
"regression.tsx",
`export function X() {
return <p>{t("insights.healthScore.reason.unavailable", { count: composite.coverage.presentInputs })}</p>;
}`,
);
const collected = coverageBackedMessages(regression);
expect(collected).toHaveLength(1);
expect(() => checkDomainNoun(collected[0])).toThrow(/pillar/i);
});
});
describe("the composite's coverage meter names its own axis", () => {
const meters = coverageMeters(parse(CARD_FILE));
it("finds every meter on the panel", () => {
// Three: the composite's, each scored pillar's, and the weight goal's.
expect(meters).toHaveLength(3);
});
it("overrides the fraction on the composite's meter", () => {
const composite = meters.filter(
(meter) => meter.coverage === "composite.coverage",
);
expect(composite).toHaveLength(1);
expect(composite[0].attributes).toContain("axisLabel");
});
it("leaves the per-pillar and weight-goal meters alone", () => {
// Their fractions are real: a pillar's coverage moves with its own
// inputs, so the default `{present}/{required}` is honest there. The
// guard must not spread the override to them.
const others = meters.filter(
(meter) => meter.coverage !== "composite.coverage",
);
expect(others).toHaveLength(2);
for (const meter of others) {
expect(meter.attributes).not.toContain("axisLabel");
}
});
it("has copy for both axis labels", () => {
expect(message("insights.healthScore.coverage.minimumMet")).toMatch(
/area/i,
);
expect(message("insights.healthScore.coverage.areas")).toContain(
"{present}",
);
});
});
/**
* The attribute guard above proves the override is written down. This
* proves it does something: a prop that is declared and then ignored is
* exactly the shape of a check that cannot fail.
*/
describe("the axis override reaches the markup", () => {
const coverage = {
requiredInputs: 3,
presentInputs: 3,
historyDays: 20,
missing: [],
};
function render(axisLabel?: string): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CoverageMeter coverage={coverage} axisLabel={axisLabel} />
</I18nProvider>,
);
}
it("replaces the fraction in the label and the accessible name", () => {
const html = render("Covers the three areas the score needs");
expect(html).toContain("Covers the three areas the score needs");
expect(html).not.toContain(">3/3<");
expect(html).toMatch(/aria-label="Covers the three areas the score needs/);
});
it("keeps the fraction for every caller that does not override it", () => {
const html = render();
expect(html).toContain("3/3");
expect(html).toContain("3 of 3 inputs");
});
});
@@ -44,6 +44,20 @@ export interface CoverageMeterProps {
dots?: number;
/** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */
size?: "sm" | "md";
/**
* Replaces the bare `{present}/{required}` fraction, in the visible
* label, the `aria-label` and the tooltip heading alike.
*
* The default fraction reads as a proportion of the reader's own data,
* and for one caller it is not: the Health Score's composite counts
* DISTINCT DOMAINS against the three the score requires, and a scored
* account has met that floor by definition, so the fraction is pinned
* at "3/3" however much or little the person records. The dots and the
* percentage stay meaningful there because they track history depth;
* only the fraction lies. A caller in that position names its axis
* instead. Everyone else leaves this alone and keeps the fraction.
*/
axisLabel?: string;
/** Optional className applied to the outer wrapper. */
className?: string;
}
@@ -75,6 +89,7 @@ export function CoverageMeter({
confidence,
dots = 5,
size = "sm",
axisLabel,
className,
}: CoverageMeterProps) {
const { t } = useTranslations();
@@ -100,11 +115,16 @@ export function CoverageMeter({
const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2";
const summaryLabel = t("insights.derived.coverage.summary", {
present,
required,
percent,
});
const summaryLabel = axisLabel
? t("insights.derived.coverage.summaryLabelled", {
label: axisLabel,
percent,
})
: t("insights.derived.coverage.summary", {
present,
required,
percent,
});
return (
<TooltipProvider>
@@ -156,7 +176,11 @@ export function CoverageMeter({
size === "md" ? "text-xs" : "text-[11px]",
)}
>
{t("insights.derived.coverage.ratioLabel", { present, required })}
{axisLabel ??
t("insights.derived.coverage.ratioLabel", {
present,
required,
})}
</span>
</button>
</TooltipTrigger>
+46 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -27,11 +28,11 @@ import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import type {
HealthScoreReport,
ScorePillarId,
ScoreBand,
ScorePillarResult,
} from "@/lib/analytics/score/types";
import { useTranslations } from "@/lib/i18n/context";
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
import { cn } from "@/lib/utils";
/**
@@ -64,16 +65,12 @@ import { cn } from "@/lib/utils";
* composite's colour, which painted a pillar scoring 92 red on a red day.
*/
const PILLAR_LABEL_KEY: 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",
};
/**
* v1.35.0 — the map moved to `@/lib/score-config/labels` so the settings
* surface that decides what counts and this panel name the same pillar the
* same way. Aliased here because every reference below reads this name.
*/
const PILLAR_LABEL_KEY = SCORE_PILLAR_LABEL_KEYS;
const REASON_KEY: Record<string, string> = {
read_failed: "readFailed",
@@ -386,8 +383,16 @@ export function HealthScoreCard({
<LearningGate
compact
bodySlot="health-score-insufficient"
// `presentInputs` on the composite counts DISTINCT DOMAINS,
// not pillars. It said "pillars" for its whole life, which
// told someone recording blood pressure, glycaemia and lipids
// that they had one eligible pillar when they had three. The
// count is right; the noun was wrong. `required` rides along
// so the sentence tracks the rule's own constant instead of
// spelling "three" into six locales.
message={t("insights.healthScore.insufficient", {
count: composite.coverage.presentInputs,
required: composite.coverage.requiredInputs,
})}
/>
)}
@@ -515,12 +520,30 @@ export function HealthScoreCard({
{/* 7 — the foot. `mt-auto` collects the slack here rather than under the
number, so a short report and a long one both end on the same line. */}
<div className="mt-auto space-y-3 pt-1">
{/* The composite's meter names its own axis. Its fraction counts
distinct DOMAINS against the three the score requires, and a
scored account has cleared that floor by definition, so the
default "3/3" would read as full coverage of the person's data
for everyone who has a score at all. Below the floor the
fraction is a real, moving number and is shown as one; at or
above it, the floor is stated instead. The dots and percentage
are untouched either way: they track history depth, which does
vary, and they are the part of this meter that was always
telling the truth. */}
<CoverageMeter
coverage={composite.coverage}
confidence={
composite.status === "ok" ? composite.confidence : undefined
}
size="sm"
axisLabel={
composite.status === "ok"
? t("insights.healthScore.coverage.minimumMet")
: t("insights.healthScore.coverage.areas", {
present: composite.coverage.presentInputs,
required: composite.coverage.requiredInputs,
})
}
/>
{/* Progressive disclosure. No Collapsible primitive exists in the UI
@@ -624,6 +647,18 @@ export function HealthScoreCard({
standard={METRIC_PROVENANCE.HEALTH_SCORE.standard}
className="block"
/>
{/* v1.35.0 — the one way into the surface that decides what
counts. It sits at the foot of the disclosure that already
explains how the score comes about, which keeps the
configuration owned by the score rather than by the modules
screen, and leaves the panel itself otherwise untouched. */}
<Link
href="/settings/score"
data-slot="health-score-configure-link"
className="text-foreground/80 hover:text-foreground inline-block text-xs underline underline-offset-2"
>
{t("insights.healthScore.configureLink")}
</Link>
</div>
</div>
</div>
@@ -0,0 +1,147 @@
/**
* The Health Score notice, which had no renderer at all until this release.
*
* The machinery raised it, persisted the dismissal and evicted the cache for
* its whole life while nothing drew it, so the two things worth pinning are
* that each of its two shapes says the right thing and that it can be
* dismissed.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { ScoreChangeNotice } from "../score-change-notice";
function render(
props: {
configVersion: number;
changedAt: string | null;
},
locale: Locale = "en",
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ScoreChangeNotice
configVersion={props.configVersion}
changedAt={props.changedAt}
onDismiss={() => {}}
/>
</I18nProvider>,
);
}
describe("an account that has never chosen", () => {
it("explains what the upgrade did and that the number did not move", () => {
const html = render({ configVersion: 0, changedAt: null });
expect(html).toContain('data-kind="upgrade"');
expect(html).toContain("What counts is now yours to choose");
expect(html).toContain("used to follow the modules you had switched on");
expect(html).toContain("still counts exactly what it counted before");
});
it("never claims a change the account has not made", () => {
const html = render({ configVersion: 0, changedAt: null });
expect(html).not.toContain("You changed what counts");
});
});
describe("an account that changed its recipe", () => {
it("dates the change and says what it does to the history", () => {
const html = render({
configVersion: 3,
changedAt: "2026-07-31T09:00:00.000Z",
});
expect(html).toContain('data-kind="changed"');
expect(html).toContain("You changed what counts");
expect(html).toContain("2026");
expect(html).toContain("the weekly change stays paused");
expect(html).toContain("Each pillar&#x27;s own history runs straight");
});
it("keeps the note truthful when the date cannot be read", () => {
const html = render({ configVersion: 3, changedAt: "not-a-date" });
expect(html).toContain('data-kind="changed"');
expect(html).toContain("You changed what counts");
// No invented date, and no literal placeholder left standing.
expect(html).not.toContain("{date}");
expect(html).not.toContain("Invalid Date");
});
});
describe("dismissal", () => {
it("offers a dismiss control", () => {
const html = render({ configVersion: 0, changedAt: null });
expect(html).toContain('data-slot="score-change-notice-dismiss"');
expect(html).toContain("Got it");
});
it("calls back with no argument when pressed", () => {
const onDismiss = vi.fn();
// The button's own handler, exercised directly: `renderToStaticMarkup`
// has no events, and the parent's mutation is what carries the itemKey.
const node = (
<ScoreChangeNotice
configVersion={1}
changedAt={null}
onDismiss={onDismiss}
/>
);
expect(node.props.onDismiss).toBe(onDismiss);
});
});
describe("localisation", () => {
// Not an absence check: `t()` falls back to English, so a missing key
// renders English rather than a key path. These are each locale's own
// titles, so an English fallback, a copied value or a mis-wired bundle
// all fail here. Key parity across bundles is a repo-wide guard already.
const TITLES: Record<string, { upgrade: string; changed: string }> = {
de: {
upgrade: "Was zählt, entscheidest jetzt du",
changed: "Du hast geändert, was zählt",
},
fr: {
upgrade: "Ce qui compte, c'est vous qui le décidez",
changed: "Vous avez changé ce qui compte",
},
es: {
upgrade: "Ahora decides tú qué cuenta",
changed: "Has cambiado qué cuenta",
},
it: {
upgrade: "Ora sei tu a decidere cosa conta",
changed: "Hai cambiato cosa conta",
},
pl: {
upgrade: "Teraz to Ty decydujesz, co się liczy",
changed: "Zmieniłeś to, co się liczy",
},
};
it("renders both shapes in each locale's own words", () => {
for (const [locale, titles] of Object.entries(TITLES)) {
const upgrade = render(
{ configVersion: 0, changedAt: null },
locale as Locale,
);
expect(upgrade, `${locale} upgrade note`).toContain(
// The apostrophe in the French title is escaped in the markup.
titles.upgrade.replace(/'/g, "&#x27;"),
);
expect(upgrade).not.toContain("What counts is now yours to choose");
const changed = render(
{ configVersion: 2, changedAt: "2026-07-31T09:00:00.000Z" },
locale as Locale,
);
expect(changed, `${locale} changed note`).toContain(titles.changed);
expect(changed).not.toContain("You changed what counts");
}
});
});
@@ -0,0 +1,169 @@
/**
* The row states of the "what counts toward your score" surface, rendered.
*
* `rows.test.ts` pins the decisions; this pins that the decisions reach the
* markup, and that each row carries the stable attributes the e2e spec
* selects on rather than viewport-dependent text.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import type { ScoreConfigRow } from "@/lib/score-config/rows";
import { ScorePillarRow } from "../score-pillar-row";
function render(row: ScoreConfigRow, locale: Locale = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ScorePillarRow row={row} onToggle={() => {}} />
</I18nProvider>,
);
}
const base: ScoreConfigRow = {
id: "SLEEP",
domain: "sleep",
counts: true,
selectable: true,
eligibility: "counting",
};
describe("the three axes", () => {
it("names recording and showing beside the score switch", () => {
const html = render(base);
expect(html).toContain('data-slot="score-pillar-axis-recorded"');
expect(html).toContain('data-slot="score-pillar-axis-shown"');
expect(html).toContain('data-slot="score-pillar-switch"');
expect(html).toContain("Being recorded");
expect(html).toContain("Shown in the app");
});
it("offers exactly one switch, and it is the score one", () => {
const html = render(base);
expect(html.match(/role="switch"/g) ?? []).toHaveLength(1);
expect(html).toContain("Count Sleep toward the score");
});
});
describe("row attributes", () => {
it("carries the pillar, its domain and its state", () => {
const html = render({ ...base, counts: false, eligibility: "waiting" });
expect(html).toContain('data-pillar="SLEEP"');
expect(html).toContain('data-domain="sleep"');
expect(html).toContain('data-counts="false"');
expect(html).toContain('data-eligibility="waiting"');
});
});
describe("selected, waiting for data", () => {
it("renders the line for a counted pillar with nothing to score", () => {
const html = render({ ...base, counts: true, eligibility: "waiting" });
expect(html).toContain('data-state="waiting"');
expect(html).toContain("Selected, waiting for data");
});
it("does not render it once the person switches the pillar off", () => {
const html = render({ ...base, counts: false, eligibility: "waiting" });
expect(html).not.toContain("Selected, waiting for data");
});
it("does not render it while the server has said nothing", () => {
const html = render({ ...base, eligibility: "unknown" });
expect(html).not.toContain('data-slot="score-pillar-state"');
});
});
describe("wellbeing under crisis signposting", () => {
it("is named as safety guidance, never as a configuration problem", () => {
const html = render({
...base,
id: "WELLBEING",
domain: "wellbeing",
eligibility: "crisis",
});
expect(html).toContain('data-state="crisis"');
expect(html).toContain("Safety guidance is shown instead of a score");
expect(html).toContain("Your selection is fine");
expect(html).not.toContain("Selected, waiting for data");
});
});
describe("a failed read", () => {
it("is named as a failure, not as absence", () => {
const html = render({ ...base, eligibility: "read_failed" });
expect(html).toContain('data-state="read_failed"');
expect(html).toContain("could not be read");
expect(html).not.toContain("Selected, waiting for data");
});
});
describe("a pillar this build cannot score", () => {
it("is disabled, honest, and offers no switch at all", () => {
const html = render({
...base,
id: "FITNESS",
domain: "fitness",
selectable: false,
eligibility: "unavailable",
});
expect(html).toContain('data-selectable="false"');
expect(html).toContain('data-slot="score-pillar-unavailable"');
expect(html).toContain('aria-disabled="true"');
expect(html).toContain("Not available yet");
// Not a switch that no-ops behind a disabled attribute: no switch.
expect(html).not.toContain('data-slot="score-pillar-switch"');
expect(html).not.toContain('role="switch"');
});
it("says why, rather than leaving the row a dead end", () => {
const html = render({
...base,
id: "FITNESS",
domain: "fitness",
selectable: false,
eligibility: "unavailable",
});
expect(html).toContain("has never produced a score");
});
});
describe("localisation", () => {
// Asserting the absence of a key path would prove nothing: `t()` falls
// back to the English bundle, so a missing locale key renders English
// rather than the key. Locale PARITY is already a repo-wide guard
// (`i18n-locale-integrity`); what belongs here is that the row actually
// reaches each locale's own words, which fails on a missing key, on an
// English value copied into a bundle, and on a locale wired to the
// wrong strings.
const WAITING: Record<string, string> = {
de: "Ausgewählt, wartet auf Daten",
fr: "Sélectionné, en attente de données",
es: "Seleccionado, esperando datos",
it: "Selezionato, in attesa di dati",
pl: "Wybrany, czeka na dane",
};
it("renders each locale's own words, not the English fallback", () => {
for (const [locale, expected] of Object.entries(WAITING)) {
const html = render(
{ ...base, eligibility: "waiting" },
locale as Locale,
);
expect(html, `${locale} row`).toContain(expected);
expect(html, `${locale} row fell back to English`).not.toContain(
"Selected, waiting for data",
);
}
});
});
@@ -0,0 +1,138 @@
/**
* The write-time refusal has two ends, and this holds them together.
*
* The server owns the breadth rule and answers a too-narrow selection
* with a machine reason in `meta.reason`. The settings surface turns that
* reason into a sentence in the reader's own language. Nothing in the
* type system connects the two: rename a reason on the route, and the
* client silently falls through to "couldn't save, try again" while the
* person is left with no idea what is wrong with their selection. That is
* a refusal that stops refusing anything in particular, and it looks
* perfectly healthy in every unit test on either side.
*
* So: the reasons the rule can produce, the reasons the route has copy
* for, and the reasons the client has copy for must be the same set. The
* rule's own list is read out of `breadth.ts` rather than restated here,
* because a restated list is a fourth place to forget.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import en from "../../../../messages/en.json";
import { REFUSAL_KEYS } from "../score-section";
const ROOT = process.cwd();
const RULE_FILE = join(ROOT, "src/lib/analytics/score/breadth.ts");
const ROUTE_FILE = join(
ROOT,
"src/app/api/auth/me/health-score-config/route.ts",
);
function parse(path: string): ts.SourceFile {
return ts.createSourceFile(
path,
readFileSync(path, "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
}
function walk(node: ts.Node, visit: (node: ts.Node) => void): void {
visit(node);
node.forEachChild((child) => walk(child, visit));
}
/** The string members of an exported union type alias. */
function unionMembers(file: ts.SourceFile, name: string): string[] {
const members: string[] = [];
walk(file, (node) => {
if (!ts.isTypeAliasDeclaration(node)) return;
if (node.name.text !== name) return;
const type = node.type;
const parts = ts.isUnionTypeNode(type) ? type.types : [type];
for (const part of parts) {
if (
ts.isLiteralTypeNode(part) &&
ts.isStringLiteral(part.literal) &&
part.literal.text.length > 0
) {
members.push(part.literal.text);
}
}
});
return members;
}
/** The property names of a top-level object-literal constant. */
function objectKeys(file: ts.SourceFile, name: string): string[] {
const keys: string[] = [];
walk(file, (node) => {
if (!ts.isVariableDeclaration(node)) return;
if (!ts.isIdentifier(node.name) || node.name.text !== name) return;
let initializer = node.initializer;
while (
initializer &&
(ts.isAsExpression(initializer) ||
ts.isSatisfiesExpression(initializer) ||
ts.isParenthesizedExpression(initializer))
) {
initializer = initializer.expression;
}
if (!initializer || !ts.isObjectLiteralExpression(initializer)) return;
for (const property of initializer.properties) {
if (!ts.isPropertyAssignment(property)) continue;
const propertyName = property.name;
if (ts.isIdentifier(propertyName)) keys.push(propertyName.text);
else if (ts.isStringLiteral(propertyName)) keys.push(propertyName.text);
}
});
return keys;
}
function message(key: string): string | undefined {
let cursor: unknown = en;
for (const segment of key.split(".")) {
if (cursor === null || typeof cursor !== "object") return undefined;
cursor = (cursor as Record<string, unknown>)[segment];
}
return typeof cursor === "string" ? cursor : undefined;
}
const ruleReasons = unionMembers(parse(RULE_FILE), "ScoreBreadthFailure");
const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL");
describe("every refusal the rule can give reaches the reader", () => {
it("reads the rule's own list rather than a copy of it", () => {
// A matcher that finds nothing agrees with everything.
expect(ruleReasons.length).toBeGreaterThan(0);
expect(ruleReasons).toContain("three_domains_required");
});
it("gives the route a sentence for each of them", () => {
expect([...routeReasons].sort()).toEqual([...ruleReasons].sort());
});
it("gives the settings surface localised copy for each of them", () => {
expect(Object.keys(REFUSAL_KEYS).sort()).toEqual([...ruleReasons].sort());
});
it("resolves every one of those keys to real copy", () => {
for (const [reason, key] of Object.entries(REFUSAL_KEYS)) {
const copy = message(key);
expect(copy, `${reason} → ${key}`).toBeTypeOf("string");
expect(copy?.length ?? 0).toBeGreaterThan(20);
}
});
it("stays quiet on a constant that is not the refusal map", () => {
// The matcher is name-anchored, not shape-anchored: it must not pick
// up the neighbouring object literals on either file and quietly
// widen what the comparison above is comparing.
expect(objectKeys(parse(ROUTE_FILE), "NOT_A_REAL_CONSTANT")).toEqual([]);
expect(unionMembers(parse(RULE_FILE), "NotARealType")).toEqual([]);
});
});
@@ -121,6 +121,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"environment",
// v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown.
"anamnesis",
// v1.35.0 — the Health Score composition surface.
"score",
"vorsorge",
"thresholds",
"ai",
@@ -0,0 +1,108 @@
"use client";
/**
* The Health Score's "something about this number changed" note, rendered
* for the first time.
*
* The machinery behind it shipped a while ago and has been raising,
* persisting and evicting a notice that nothing ever drew: the report has
* carried `algorithmNotice` for its whole life and no surface read it.
* This is that renderer. It sits on the settings page rather than on the
* hero because the note's whole subject is what counts toward the score,
* and the person reading it is one tap from acting on it.
*
* Two notes ride one key, told apart by the account's own recipe version.
*
* - **Version 0** — nobody has chosen yet. The note explains what the
* upgrade did: what counts used to follow the module switches, it now
* follows this page, and the number itself did not move. It is the
* one-time note for accounts that existed before the choice did, and
* it is written to be true for a fresh account as well, because the
* stored recipe cannot tell the two apart and inventing a distinction
* from the account's age would be guessing.
* - **Version 1 and up** — the person changed what counts, on a date
* the recipe records. The note says what that does to the history.
*
* Dismissal is per key, so a dismissal of one version never silences the
* next, and it survives a reload because the server holds it.
*/
import { Info, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
export interface ScoreChangeNoticeProps {
/** The account's own recipe version. 0 while nobody has chosen. */
configVersion: number;
/** When the recipe last changed, ISO 8601, or null while it never has. */
changedAt: string | null;
/** Fired when the person acknowledges the note. */
onDismiss: () => void;
dismissing?: boolean;
}
export function ScoreChangeNotice({
configVersion,
changedAt,
onDismiss,
dismissing = false,
}: ScoreChangeNoticeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const kind = configVersion >= 1 ? "changed" : "upgrade";
const parsed = changedAt ? new Date(changedAt) : null;
const dateLabel =
parsed && !Number.isNaN(parsed.getTime()) ? fmt.date(parsed) : null;
// Written out rather than assembled from the discriminator so the i18n
// call-site guard can see every key this component can reach.
const title =
kind === "changed"
? t("settings.sections.score.notice.changed.title")
: t("settings.sections.score.notice.upgrade.title");
const body =
kind !== "changed"
? t("settings.sections.score.notice.upgrade.body")
: dateLabel
? t("settings.sections.score.notice.changed.body", { date: dateLabel })
: t("settings.sections.score.notice.changed.bodyUndated");
return (
<SettingsCard
as="section"
data-slot="score-change-notice"
data-kind={kind}
aria-labelledby="score-change-notice-title"
className="space-y-4"
>
<SettingsCardHeader
icon={Info}
titleId="score-change-notice-title"
title={title}
/>
<p data-slot="score-change-notice-body" className="text-sm">
{body}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDismiss}
disabled={dismissing}
data-slot="score-change-notice-dismiss"
className="min-h-11 sm:min-h-9"
>
{dismissing ? (
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.sections.score.notice.dismiss")}
</Button>
</SettingsCard>
);
}
@@ -0,0 +1,134 @@
"use client";
/**
* One pillar on the "what counts toward your Health Score" surface.
*
* The row's job is to make three things legible that nowhere else in the
* app shows together: whether the pillar is being recorded, whether it is
* shown, and whether it counts toward the score. Only the last is
* writable here, and the card above the rows says so in words.
*
* The first two are not switches on this surface and are not dressed to
* look like ones. They are read-outs, because the page that owns them is
* the modules screen and a second switch for the same fact is how two
* places end up disagreeing about one thing.
*
* Presentation only. The parent owns the draft, the mutation and the
* cache, so this stays a leaf a test can drive directly.
*/
import { Loader2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
import {
showsWaitingForData,
type ScoreConfigRow,
} from "@/lib/score-config/rows";
/** The eligibility values that earn a line of their own, and their copy. */
const ELIGIBILITY_LINE_KEYS: Partial<
Record<ScoreConfigRow["eligibility"], string>
> = {
counting: "settings.sections.score.state.counting",
crisis: "settings.sections.score.state.crisis",
read_failed: "settings.sections.score.state.readFailed",
unavailable: "settings.sections.score.state.unavailableDetail",
};
export interface ScorePillarRowProps {
row: ScoreConfigRow;
/** Disables the switch while a save is in flight. */
pending?: boolean;
onToggle: (next: boolean) => void;
}
export function ScorePillarRow({
row,
pending = false,
onToggle,
}: ScorePillarRowProps) {
const { t } = useTranslations();
const inputId = `score-pillar-${row.id}`;
// "Selected, waiting for data" is checked first because it is the one
// line that depends on the draft as well as on the server's verdict.
const stateKey = showsWaitingForData(row)
? "settings.sections.score.state.waiting"
: ELIGIBILITY_LINE_KEYS[row.eligibility];
return (
<div
data-slot="score-pillar-row"
data-pillar={row.id}
data-domain={row.domain}
data-counts={row.counts ? "true" : "false"}
data-eligibility={row.eligibility}
data-selectable={row.selectable ? "true" : "false"}
className="flex items-start justify-between gap-4 py-3"
>
<div className="min-w-0 space-y-1">
<Label htmlFor={inputId} className="text-sm font-medium">
{t(SCORE_PILLAR_LABEL_KEYS[row.id])}
</Label>
{/* The three axes, side by side. Recording and showing read as
settled facts; the third is the switch to the right. */}
<p
data-slot="score-pillar-axes"
className="text-muted-foreground text-xs"
>
<span data-slot="score-pillar-axis-recorded">
{t("settings.sections.score.axis.recorded")}
</span>
{" · "}
<span data-slot="score-pillar-axis-shown">
{t("settings.sections.score.axis.shown")}
</span>
</p>
{stateKey ? (
<p
data-slot="score-pillar-state"
data-state={showsWaitingForData(row) ? "waiting" : row.eligibility}
className="text-foreground text-xs"
>
{t(stateKey)}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{pending ? (
<Loader2
aria-hidden="true"
className="text-muted-foreground size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{row.selectable ? (
<Switch
id={inputId}
data-slot="score-pillar-switch"
checked={row.counts}
disabled={pending}
onCheckedChange={(next) => onToggle(next)}
aria-label={t("settings.sections.score.axis.countsFor", {
pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]),
})}
/>
) : (
// Not a disabled switch pretending to be a choice: a pillar this
// build can never score has no state to show, so it says so.
// `aria-disabled` + the opacity container is the standard
// disabled look (UI-STANDARDS §3).
<span
data-slot="score-pillar-unavailable"
aria-disabled="true"
className="text-muted-foreground text-xs opacity-50"
>
{t("settings.sections.score.state.unavailable")}
</span>
)}
</div>
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
"use client";
/**
* `<ScoreSection>` — Settings → Health Score. v1.35.0.
*
* The surface that decides which pillars count toward the Health Score.
* It is its own page rather than a corner of the modules screen, because
* the two answer different questions and conflating them is what made the
* old behaviour invisible: the module switches quietly decided the
* score's composition while claiming to decide what is recorded and shown.
*
* What the page holds to:
*
* - **One concept, one card.** The rows, the starting points and the
* save all sit in the same card. A top half and a bottom half of the
* same decision is the split the settings IA rules forbid.
* - **Grouped by domain, and the grouping is visible.** Blood pressure,
* glycaemia and lipids sit under one heading with a sentence saying
* they are one area of health carrying three pillars. Equal weight per
* pillar is not equal weight per area, and until this page nothing in
* the app said so.
* - **Only the score toggle is writable.** Recording and showing are
* read-outs with a line saying this page leaves them alone.
* - **A pillar whose module is off is not shown.** Turning a module off
* closes the data path deliberately; a pillar behind a closed path is
* not waiting for anything, and saying it waits nags a person in the
* voice of their own settings.
* - **The refusal comes from the server.** The breadth rule lives in
* `evaluateScoreBreadth` and is applied at write time; this page shows
* the refusal in the reader's language and never re-derives the rule.
* A rule copied here would drift the day the server's moved.
*
* Two reads feed it. The configuration (`/api/auth/me/health-score-config`)
* is the primary one and its failure takes the surface down honestly. The
* analytics payload is the secondary one: it carries the server's own
* verdict on each pillar, which is where "selected, waiting for data"
* comes from. When that read has not landed the rows simply make no claim
* about data; when it fails, the failure is named rather than dressed as
* absence.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Gauge, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ScoreChangeNotice } from "@/components/settings/score-change-notice";
import { ScorePillarRow } from "@/components/settings/score-pillar-row";
import { markAlgorithmNoticeDismissed } from "@/components/insights/health-score-card";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
isConflict,
readUpdatedAtToken,
withBaseToken,
} from "@/lib/api/optimistic-token";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import {
SCORE_DOMAIN_LABEL_KEYS,
SCORE_DOMAIN_NOTE_KEYS,
} from "@/lib/score-config/labels";
import {
buildScoreConfigRows,
selectionToSave,
type ScorePillarVerdict,
} from "@/lib/score-config/rows";
import {
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "@/lib/analytics/score/types";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
import type { ScoreModuleKey } from "@/lib/analytics/score/modules";
import type { ScoreBreadthFailure } from "@/lib/analytics/score/breadth";
/** The resolved configuration `GET /api/auth/me/health-score-config` returns. */
interface HealthScoreConfigPayload {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
/** Optimistic-concurrency token; absent until a selection exists. */
updatedAt?: string;
}
/**
* The two refusals the write can return, in the reader's language.
*
* Keyed on `meta.reason`, which is the machine-readable half of the
* server's answer. The server's own English sentence is not shown: it
* exists for callers without a locale, and rendering it would leave five
* of the six locales reading English at exactly the moment the person
* needs to understand something.
*
* Typed as a total map over `ScoreBreadthFailure` rather than over
* `string`, so a third refusal reason added to the rule cannot ship with
* this surface quietly falling through to a generic "couldn't save". The
* TYPE is imported, never the rule: the breadth question is answered
* server-side and answered once.
*/
export const REFUSAL_KEYS: Record<ScoreBreadthFailure, string> = {
measured_physiological_domain_required:
"settings.sections.score.refusal.physiological",
three_domains_required: "settings.sections.score.refusal.domains",
};
function verdictsFrom(
report: HealthScoreReport | null | undefined,
): Partial<Record<ScorePillarId, ScorePillarVerdict>> | undefined {
if (!report) return undefined;
const out: Partial<Record<ScorePillarId, ScorePillarVerdict>> = {};
for (const pillar of report.pillars) {
out[pillar.id] =
pillar.result.status === "ok"
? { status: "ok" }
: { status: "insufficient", reason: pillar.result.reason };
}
return out;
}
/** Stable fingerprint of the server's selection, for the draft re-seed. */
function selectionSignature(pillars: readonly ScorePillarId[]): string {
return SCORE_PILLAR_IDS.filter((id) => pillars.includes(id)).join(",");
}
export function ScoreSection() {
const { t } = useTranslations();
const { user, isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: queryKeys.healthScoreConfig(),
queryFn: () =>
apiGet<HealthScoreConfigPayload>("/api/auth/me/health-score-config"),
enabled: Boolean(isAuthenticated),
});
// The server's verdict on each pillar. Secondary: the switches work
// without it, so it never gates the surface.
const analyticsQuery = useAnalyticsQuery();
const report = analyticsQuery.data?.healthScore as
HealthScoreReport | null | undefined;
const verdicts = useMemo(() => verdictsFrom(report), [report]);
const serverSelection = useMemo(
() => configQuery.data?.pillars ?? [],
[configQuery.data?.pillars],
);
const serverSignature = selectionSignature(serverSelection);
// Re-seed the draft in render (the sanctioned "adjust state on prop
// change" shape, no effect) whenever the SERVER copy moves — a settled
// save, a refetch, or the first load. An in-flight edit is untouched
// until the server itself advances.
const [seededSignature, setSeededSignature] = useState<string | null>(null);
const [draft, setDraft] = useState<ScorePillarId[]>([]);
const [refusal, setRefusal] = useState<string | null>(null);
if (configQuery.isSuccess && serverSignature !== seededSignature) {
setSeededSignature(serverSignature);
setDraft([...serverSelection]);
setRefusal(null);
}
const modules = (user?.modules ?? {}) as Partial<
Record<ScoreModuleKey, boolean>
>;
const { groups, omitted } = useMemo(
() => buildScoreConfigRows({ selection: draft, modules, verdicts }),
// `modules` is a fresh object each render; its identity is not the
// dependency, its content is.
// eslint-disable-next-line react-hooks/exhaustive-deps
[draft, verdicts, user?.modules],
);
const dirty = selectionSignature(draft) !== serverSignature;
const saveMutation = useMutation({
mutationKey: queryKeys.healthScoreConfig(),
mutationFn: (selection: ScorePillarId[]) =>
apiPatch<HealthScoreConfigPayload>(
"/api/auth/me/health-score-config",
withBaseToken(
{ pillars: selectionToSave(selection) },
readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()),
),
),
onSuccess: (saved) => {
setRefusal(null);
queryClient.setQueryData(queryKeys.healthScoreConfig(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.healthScoreConfig(),
});
// The number now means something else. Every surface that holds a
// computed score has to go, or the change reads as broken.
void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
void queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
toast.success(t("settings.sections.score.saved"));
},
onError: (err) => {
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.healthScoreConfig(),
});
toast.message(t("common.conflictReloaded"));
return;
}
// The write-time breadth refusal. The rule is the server's; this
// only translates the reason it gave.
if (err instanceof ApiError && err.status === 422) {
const reason = err.meta?.reason;
// The wire carries an unknown string until it is checked against
// the map; an unrecognised one falls through to the generic
// failure rather than rendering an empty alert.
const key =
typeof reason === "string" && reason in REFUSAL_KEYS
? REFUSAL_KEYS[reason as ScoreBreadthFailure]
: undefined;
if (key) {
setRefusal(key);
return;
}
}
toast.error(t("settings.sections.score.error"));
},
});
const dismissMutation = useMutation({
mutationFn: async (itemKey: string) => {
await apiPost("/api/daily/digest/dismiss", { itemKey });
return itemKey;
},
onSuccess: (itemKey) => {
// Patch every cached copy of the notice, not just the one this page
// reads: the analytics payload carries the same raised notice and a
// stale `dismissed: false` there would raise it again on the next
// surface that learns to render it.
queryClient.setQueryData(queryKeys.analytics(), (previous: unknown) =>
markAlgorithmNoticeDismissed(previous, itemKey),
);
void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
},
onError: () => {
toast.error(t("settings.sections.score.notice.dismissError"));
},
});
function toggle(id: ScorePillarId, next: boolean) {
setRefusal(null);
setDraft((current) => {
const set = new Set(current);
if (next) set.add(id);
else set.delete(id);
return SCORE_PILLAR_IDS.filter((pillar) => set.has(pillar));
});
}
const notice = report?.algorithmNotice ?? null;
const showNotice =
notice != null && !notice.dismissed && configQuery.isSuccess;
const busy = saveMutation.isPending;
return (
<div className="space-y-6" data-slot="score-section">
{showNotice && notice ? (
<ScoreChangeNotice
configVersion={configQuery.data?.version ?? 0}
changedAt={configQuery.data?.changedAt ?? null}
dismissing={dismissMutation.isPending}
onDismiss={() => dismissMutation.mutate(notice.itemKey)}
/>
) : null}
{configQuery.isError ? (
<QueryErrorCard
title={t("settings.sections.score.loadFailed")}
onRetry={() => void configQuery.refetch()}
/>
) : (
<SettingsCard
as="section"
data-slot="score-config-card"
aria-labelledby="score-config-title"
className="space-y-4"
>
<SettingsCardHeader
icon={Gauge}
titleId="score-config-title"
title={t("settings.sections.score.card.title")}
description={t("settings.sections.score.card.description")}
status={
<Button
size="sm"
type="button"
onClick={() => saveMutation.mutate(draft)}
disabled={busy || !dirty || configQuery.isLoading}
data-slot="score-config-save"
className="min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.sections.score.save")}
</Button>
}
/>
{/* The sentence the whole feature exists for. Foreground, in the
body, because it is content and not a meta description. */}
<p data-slot="score-config-three-axes" className="text-sm">
{t("settings.sections.score.threeAxes")}
</p>
{/* The starting points. Both name data; neither names a person.
A template that supplies a rationale for dropping a pillar is
the one shape the evidence rules out, so there is none. */}
<div
data-slot="score-config-presets"
className="flex flex-wrap items-center gap-2"
>
<span className="text-muted-foreground text-xs">
{t("settings.sections.score.presets.label")}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
data-slot="score-config-preset-all"
className="min-h-11 sm:min-h-9"
onClick={() => {
setRefusal(null);
setDraft([...SCORE_PILLAR_IDS]);
}}
>
{t("settings.sections.score.presets.all")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy || !dirty}
data-slot="score-config-preset-current"
className="min-h-11 sm:min-h-9"
onClick={() => {
setRefusal(null);
setDraft([...serverSelection]);
}}
>
{t("settings.sections.score.presets.current")}
</Button>
</div>
{refusal ? (
<p
data-slot="score-config-refusal"
role="alert"
className="text-destructive text-sm"
>
{t(refusal)}
</p>
) : null}
{analyticsQuery.isError ? (
<QueryErrorRow
slot="score-config-eligibility-error"
message={t("settings.sections.score.eligibilityFailed")}
onRetry={() => void analyticsQuery.refetch()}
/>
) : null}
{configQuery.isLoading ? (
<div data-slot="score-config-loading" className="space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : (
<div className="space-y-6">
{groups.map((group) => (
<div
key={group.domain}
data-slot="score-config-group"
data-domain={group.domain}
>
<h3
data-slot="score-config-group-heading"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
</h3>
{SCORE_DOMAIN_NOTE_KEYS[group.domain] ? (
<p
data-slot="score-config-group-note"
className="text-muted-foreground mt-1 text-xs"
>
{t(SCORE_DOMAIN_NOTE_KEYS[group.domain] as string)}
</p>
) : null}
<div className="divide-border/60 mt-1 divide-y">
{group.rows.map((row) => (
<ScorePillarRow
key={row.id}
row={row}
pending={busy}
onToggle={(next) => toggle(row.id, next)}
/>
))}
</div>
</div>
))}
</div>
)}
{omitted.length > 0 ? (
<p
data-slot="score-config-omitted"
data-count={omitted.length}
className="text-muted-foreground text-xs"
>
{t("settings.sections.score.omitted", { count: omitted.length })}{" "}
<Link
href="/settings/modules"
data-slot="score-config-omitted-link"
className="text-foreground underline underline-offset-2"
>
{t("settings.sections.score.omittedLink")}
</Link>
</p>
) : null}
</SettingsCard>
)}
</div>
);
}
@@ -21,6 +21,7 @@ import {
ClipboardList,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
Plug,
@@ -61,6 +62,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
illness: Thermometer,
environment: CloudSun,
anamnesis: ClipboardList,
score: Gauge,
vorsorge: CalendarCheck,
thresholds: SlidersHorizontal,
ai: Sparkles,
+6
View File
@@ -78,6 +78,12 @@ export const SETTINGS_SECTION_SLUGS = [
// home for allergies + family history. Always shown (not module-gated, like
// `vorsorge`); the records are foundational health-profile data.
"anamnesis",
// v1.35.0 — "Health Score": which pillars count toward the composite.
// Its own surface rather than a corner of the modules hub, because the
// module switches answer a different question (what is recorded and
// shown) and folding the two together is what kept the score's
// composition invisible.
"score",
"vorsorge",
"thresholds",
"ai",
@@ -25,6 +25,7 @@ import {
CloudSun,
Download,
FileHeart,
Gauge,
Info,
KeyRound,
Plug,
@@ -234,6 +235,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "display",
},
// ── Health profile ──
// v1.35.0 — which pillars count toward the Health Score. Sits at the top
// of the health-profile cluster: it is a statement about the person's own
// health picture, not about how the app looks or what it records.
{
slug: "score",
titleKey: "settings.sections.score.title",
icon: Gauge,
group: "healthProfile",
},
// v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record
// home for allergies + family history. NOT module-gated (like Vorsorge) —
// these are foundational health-profile records, always available.
+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),
);
}