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

@@ -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.