mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge work/score-settings-ui
This commit is contained in:
commit
cdfe4ec114
26 files changed
+2809
-30
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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'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, "'"),
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user