Merge work/score-settings-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:32:07 +02:00
commit 4d200ebd43
45 files changed
+833 -1019

No files matched your search

-1
View File
@@ -559,7 +559,6 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
now: scoredAt,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
@@ -69,14 +69,13 @@ const SESSION_OK = {
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const ALL_EIGHT: ScorePillarId[] = [
const ALL_PILLARS: ScorePillarId[] = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
];
@@ -154,7 +153,7 @@ describe("GET /api/auth/me/health-score-config", () => {
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(ALL_EIGHT);
expect(env.data.pillars).toEqual(ALL_PILLARS);
expect(env.data.hasSelection).toBe(false);
expect(env.data.version).toBe(0);
expect(env.data.updatedAt).toBeUndefined();
@@ -172,7 +171,7 @@ describe("GET /api/auth/me/health-score-config", () => {
);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(
ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(env.data.hasSelection).toBe(true);
@@ -185,7 +184,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
@@ -211,7 +210,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
const stored = writtenConfig();
expect(stored.excludedPillars).toEqual(
ALL_EIGHT.filter((id) => !selection.includes(id)),
ALL_PILLARS.filter((id) => !selection.includes(id)),
);
expect(stored.version).toBe(1);
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
@@ -230,7 +229,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
});
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
mkPatch({ pillars: ALL_PILLARS }),
);
expect(writtenConfig().version).toBe(8);
});
@@ -240,7 +239,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
mkPatch({ pillars: ALL_PILLARS }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(writtenConfig().excludedPillars).toEqual([]);
@@ -252,7 +251,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
primeUser(null);
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
mkPatch({ pillars: ALL_PILLARS }),
);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
@@ -334,7 +333,7 @@ describe("PATCH — envelope and transport", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }),
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
@@ -344,7 +343,7 @@ describe("PATCH — envelope and transport", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }),
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
@@ -373,7 +372,7 @@ describe("PATCH — envelope and transport", () => {
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
@@ -386,7 +385,7 @@ describe("PATCH — envelope and transport", () => {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({
pillars: ALL_EIGHT,
pillars: ALL_PILLARS,
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
@@ -1,22 +1,17 @@
/**
* The Today hero's "just in" chip and the reaction line's lead replacement.
* The Today hero's arrival handling and the reaction line's lead
* replacement.
*
* Two invariants, both of which have a specific failure this project has
* already paid for:
* The hero used to carry a "just in" chip under the score, with the
* arrival's local time on it. It is gone: under a number, "just in" did
* not say what had arrived — the score? a reading? — and the clock face
* changed nothing a person would then do. What is left is the invariant
* that an arrival alone is not a reason to paint anything, and the one
* the reaction line has always had.
*
* 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the
* server-dehydrated digest on the SSR pass because it is the LCP element
* (v1.30.9). So anything inside it that differs between the server and the
* browser is a React #418 mismatch. A wall-clock time is exactly that: the
* server's locale and timezone are not the reader's. The chip therefore
* renders its SLOT on both passes and fills the time in only after mount.
* These tests render through `renderToStaticMarkup`, which takes the SERVER
* snapshot of `useSyncExternalStore` — i.e. precisely the pass that must
* carry no formatted time.
*
* 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second
* paragraph. The hero stays one lead line tall, so nothing below it moves
* when the line arrives on a poll.
* NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a
* second paragraph. The hero stays one lead line tall, so nothing below
* it moves when the line arrives on a poll.
*/
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -53,30 +48,56 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
};
}
/** Any hh:mm clock face — the thing the server must never emit. */
/** Any hh:mm clock face — the thing the hero must never print here. */
const CLOCK_FACE = /\d{1,2}:\d{2}/;
describe("TodayHero — the just-in chip", () => {
it("renders no chip when nothing landed", () => {
const html = render(<TodayHero digest={digest()} />);
expect(html).not.toContain('data-slot="today-hero-just-in"');
});
it("renders the chip slot on the server pass, carrying the kind", () => {
describe("TodayHero — an arrival is not a chip", () => {
it("paints no arrival marker when something has landed", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
);
// The slot is present on the SSR pass, so the hydration render — which
// takes the same server snapshot — produces identical HTML.
expect(html).toContain('data-slot="today-hero-just-in"');
expect(html).toContain('data-just-in-kind="sleep_night"');
expect(html).toContain("Just in");
expect(html).not.toContain('data-slot="today-hero-just-in"');
expect(html).not.toContain("Just in");
expect(html).not.toContain(ARRIVED_AT);
});
it("renders for a bare account when the arrival has no generated line", () => {
it("prints no clock face beside the score", () => {
// The removed chip was the only wall clock on this surface, and a
// wall clock rendered on the server pass is a hydration mismatch as
// well as noise. Neither is possible once nothing prints one.
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
expect(html).not.toMatch(CLOCK_FACE);
});
it("still shows a pending night, which is a different sentence", () => {
// The freshness note says the day is not complete yet, which changes
// how the number should be read. That one stays.
const html = render(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
expect(html).toContain('data-slot="today-hero-sleep-pending"');
expect(html).not.toContain('data-slot="today-hero-just-in"');
});
it("renders nothing at all for an account whose only news was the arrival", () => {
// With no chip there is nothing left for such a digest to say, and a
// bordered empty hero above the tile strip would be worse than
// absence.
const html = render(
<TodayHero
digest={digest({
@@ -89,68 +110,7 @@ describe("TodayHero — the just-in chip", () => {
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain('data-slot="today-hero-just-in"');
});
it("emits NO formatted time and no raw ISO instant on the server pass", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"'));
// The mismatch itself: a wall clock the browser would render differently.
expect(chip).not.toMatch(CLOCK_FACE);
// And the raw instant must not leak either — it is machine data, not copy.
expect(html).not.toContain(ARRIVED_AT);
});
it("keeps the chip muted meta — never an accent, never an opacity modifier", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
/>,
);
const row = html.slice(
0,
html.indexOf('data-slot="today-hero-just-in"') + 400,
);
expect(row).toContain("text-muted-foreground");
expect(row).toContain("text-xs");
// UI-STANDARDS §text: alpha on muted text drops below AA contrast.
expect(html).not.toContain("text-muted-foreground/");
expect(html).not.toContain("text-primary");
});
it("shows the chip alongside a still-pending night", () => {
// Legitimately co-occurring: a weight landed this morning while last
// night's sleep has not arrived yet.
const html = render(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
expect(html).toContain('data-slot="today-hero-sleep-pending"');
expect(html).toContain('data-slot="today-hero-just-in"');
});
it("localises the chip", () => {
const html = render(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
"de",
);
expect(html).toContain("Gerade eingetroffen");
expect(html).not.toContain("Just in");
expect(html).not.toContain('data-slot="today-hero"');
});
});
+20 -89
View File
@@ -37,8 +37,6 @@
import Link from "next/link";
import { Moon } from "lucide-react";
import { useMounted } from "@/hooks/use-mounted";
import { formatTime } from "@/lib/format";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
@@ -86,32 +84,6 @@ function withoutRepeatedScore(
return result.length > 0 ? result : null;
}
/**
* The "just in" chip's clock face.
*
* Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as
* an ISO instant precisely so this stays a client concern: the server has no
* business rendering a local wall-clock time it would format under its OWN
* locale and timezone, and any such string would differ from the browser's on
* the hydration render — React #418, a class of bug this project has already
* paid for once.
*
* Goes through the shared `formatTime`, which reads the profile's timezone and
* clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces
* asked the BROWSER instead, which is a different question: someone whose
* profile says Europe/Berlin while their laptop sits in Manila saw a Manila
* time here and a Berlin time on every other surface, for the same instant.
*/
function formatJustInTime(iso: string): string | null {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return null;
try {
return formatTime(at);
} catch {
return null;
}
}
export function TodayHero({
digest,
renderFilteredAllClear = false,
@@ -125,7 +97,6 @@ export function TodayHero({
// paints from the server-dehydrated digest on both the SSR and the hydration
// render). So the slot renders on both passes and the formatted time fills in
// on the first client re-render, keeping those two passes byte-identical.
const mounted = useMounted();
const { keep, letGo } = useCoachCheckinAction();
const dismissItem = usePriorityItemDismiss();
@@ -166,7 +137,6 @@ export function TodayHero({
const signalFallbackLead =
hasScore && !lead ? topSignal?.headline?.trim() || null : null;
const displayLead = lead ?? signalFallbackLead;
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
// A score-only all-clear digest has no narrative content for the leading
// column. Keeping the full md ring in that two-column shell left a blank
// column beside a 168 px dial and pushed the actual all-clear read below it.
@@ -191,8 +161,7 @@ export function TodayHero({
!hasScore &&
!hasItems &&
!digest.briefingLead &&
!digest.reactionLine &&
!digest.justIn
!digest.reactionLine
) {
return null;
}
@@ -267,28 +236,15 @@ export function TodayHero({
{/* In the compact fallback the quiet freshness tier belongs next
to the smaller ring. That uses the otherwise-empty leading
space and avoids adding a second row below the dial. */}
{compactAllClear && (digest.sleepPending || digest.justIn) ? (
{compactAllClear && digest.sleepPending ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{digest.sleepPending ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : null}
{digest.justIn ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
</div>
@@ -334,43 +290,18 @@ export function TodayHero({
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
text-muted-foreground` is the meta floor; never an accent, never an
opacity modifier). Holds the freshness note and the "just in" chip;
they can legitimately co-occur (a weight landed while last night's
sleep is still pending), so they share one wrapping row rather than
competing for the same slot. */}
{!compactAllClear && (digest.sleepPending || digest.justIn) ? (
opacity modifier). Freshness note (plan §2.4) — provisional day,
last night's sleep not yet folded in. Muted, non-blocking,
refreshes in place when the morning job lands. */}
{!compactAllClear && digest.sleepPending ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{/* Freshness note (plan §2.4) — provisional day, last night's
sleep not yet folded in. Muted, non-blocking, refreshes in
place when the morning job lands. */}
{digest.sleepPending ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : null}
{/* The "just in" chip — a state change, not a notification. No
badge count, no accent, no animation: the record simply reads
differently on the next refresh, and this names why. It is
present for as long as the arrival is news (the server applies
the window, so the chip expires on a poll rather than on a
client timer) and carries the arrival's local time once the
client has mounted and can format it honestly. */}
{digest.justIn ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
@@ -9,6 +9,7 @@ import type {
HealthScoreReport,
ScorePillarResult,
} from "@/lib/analytics/score/types";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import { HeroStrip } from "../hero-strip";
/**
@@ -134,7 +135,6 @@ const ALL_PILLARS = [
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
] as const;
@@ -149,7 +149,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
composition: pillars.map((p) => p.id),
configured: false,
noiseFloor: 3,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage,
confidence: { score: 100, band: "high" },
@@ -158,7 +158,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
pillars,
delta: 2,
deltaReason: null,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
weightGoal: {
status: "insufficient",
coverage: {
@@ -7,6 +7,7 @@ import type {
HealthScoreReport,
ScorePillarResult,
} from "@/lib/analytics/score/types";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import {
HealthScoreCard,
HealthScoreCardSkeleton,
@@ -211,7 +212,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR],
delta: null,
deltaReason: "no_current_score",
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
weightGoal: {
status: "insufficient",
coverage: {
@@ -241,7 +242,7 @@ function scoredReport(
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage: {
requiredInputs: 3,
@@ -371,7 +372,7 @@ describe("<HealthScoreCard> pillars", () => {
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage: {
requiredInputs: 3,
@@ -505,7 +506,7 @@ describe("<HealthScoreCard> pillars", () => {
});
describe("<HealthScoreCard> one pillar, one home", () => {
/** Four scored, one failed read, one crisis, two plain absences. */
/** Four scored, one failed read, one crisis, one plain absence. */
function mixedReport() {
return scoredReport({
pillars: [
@@ -513,10 +514,9 @@ describe("<HealthScoreCard> one pillar, one home", () => {
scoredAt("GLYCAEMIA", 74),
scoredAt("ACTIVITY", 61),
scoredAt("SLEEP", 45),
gatedPillar("FITNESS", "read_failed"),
gatedPillar("LIPIDS", "read_failed"),
gatedPillar("WELLBEING", "crisis_signposting"),
gatedPillar("ADIPOSITY", "missing_height"),
gatedPillar("LIPIDS", "not_tracked"),
],
});
}
@@ -538,14 +538,14 @@ describe("<HealthScoreCard> one pillar, one home", () => {
"ACTIVITY",
"SLEEP",
]);
expect(failedIds).toEqual(["FITNESS"]);
expect(failedIds).toEqual(["LIPIDS"]);
expect(crisisIds).toEqual(["WELLBEING"]);
expect(counted).toBe(2);
expect(counted).toBe(1);
// The four homes partition the eight pillars: disjoint, and complete.
// The four homes partition the seven pillars: disjoint, and complete.
const named = [...rowIds, ...failedIds, ...crisisIds];
expect(new Set(named).size).toBe(named.length);
expect(named.length + counted).toBe(8);
expect(named.length + counted).toBe(7);
});
it("never renders one pillar id twice anywhere in the panel", () => {
@@ -556,14 +556,7 @@ describe("<HealthScoreCard> one pillar, one home", () => {
const ids = pillarAttrs(html);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.sort()).toEqual(
[
"ACTIVITY",
"ADIPOSITY",
"BLOOD_PRESSURE",
"GLYCAEMIA",
"LIPIDS",
"SLEEP",
].sort(),
["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE", "GLYCAEMIA", "SLEEP"].sort(),
);
});
@@ -581,7 +574,6 @@ describe("<HealthScoreCard> one pillar, one home", () => {
"Sleep",
"Adiposity",
"Wellbeing",
"Measured fitness",
"Lipids",
]) {
const hits = listed.split(label).length - 1;
@@ -609,7 +601,7 @@ describe("<HealthScoreCard> composite states", () => {
expect(html).not.toContain('data-slot="health-score-card-progress"');
expect(html).toContain("—");
expect(html).not.toContain("/ 100");
expect(html).toContain("Method version 2");
expect(html).toContain(`Method version ${SCORE_VERSION}`);
});
it("explains a missing comparison where the trend would be", () => {
@@ -674,7 +666,9 @@ describe("<HealthScoreCard> footer", () => {
const region = anatomyRegion(html);
expect(region).toContain('data-slot="health-score-method"');
expect(region).toContain("Overall band set by none");
expect(region).toContain("Method version 2. Weekly comparison floor");
expect(region).toContain(
`Method version ${SCORE_VERSION}. Weekly comparison floor`,
);
expect(region).toContain('data-slot="provenance-explainer-method"');
expect(region).toContain("equal-weighted average");
});
@@ -8,6 +8,7 @@ import type {
ScorePillarId,
ScorePillarResult,
} from "@/lib/analytics/score/types";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
// it so the SSR render does not reach for a QueryClient the test omits.
@@ -116,7 +117,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
composition: ["BLOOD_PRESSURE"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage: {
requiredInputs: 3,
@@ -130,7 +131,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
pillars,
delta: 2,
deltaReason: null,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
weightGoal: {
status: "insufficient",
coverage: {
@@ -82,12 +82,6 @@ export function pillarObservedText(
value: observed.value,
});
}
if (pillar.id === "FITNESS") {
return t("insights.healthScore.observedFitness", {
value: observed.value,
unit: observed.unit,
});
}
return observed.label;
}
@@ -129,18 +123,12 @@ function pillarReferenceText(
? t("insights.healthScore.referenceAdiposity", {
high: reference.high ?? "",
})
: pillar.id === "FITNESS"
? t("insights.healthScore.referenceFitness", {
: pillar.id === "GLYCAEMIA"
? t("insights.healthScore.referenceHba1c", {
low: reference.low ?? "",
high: reference.high ?? "",
unit: value.observed.unit,
})
: pillar.id === "GLYCAEMIA"
? t("insights.healthScore.referenceHba1c", {
low: reference.low ?? "",
high: reference.high ?? "",
})
: reference.label;
: reference.label;
return formatReference(
reference,
label,
@@ -25,27 +25,28 @@ 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");
});
describe("the row", () => {
it("offers exactly one switch, and it is the score one", () => {
const html = render(base);
expect(html).toContain('data-slot="score-pillar-switch"');
expect(html.match(/role="switch"/g) ?? []).toHaveLength(1);
expect(html).toContain("Count Sleep toward the score");
});
it("says nothing about recording or showing", () => {
// Those two belong to the modules screen. Restating them under every
// pillar put the same two words on every row of a page about a third
// question, and the sentence above the rows already says so once.
const html = render(base);
expect(html).not.toContain('data-slot="score-pillar-axes"');
expect(html).not.toContain("Being recorded");
expect(html).not.toContain("Shown in the app");
});
});
describe("row attributes", () => {
@@ -106,38 +107,6 @@ describe("a failed read", () => {
});
});
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
@@ -0,0 +1,213 @@
/**
* What the score settings page does with a selection that is already
* stored, and what it puts on the wire when you press Save.
*
* The v1.35.0 defect this pins the fix for: the page filtered the
* selection on its way to the server, so the answer that came back could
* never match the form that sent it. The switches then re-seeded from the
* server's narrower list — a pillar the person had never touched read as
* switched off — and the draft could never equal the server copy again,
* which left Save lit and the card claiming unsaved changes forever.
*
* Two ends and the pipe between them:
*
* - the rows a stored selection renders (`data-counts` per pillar),
* - the state of the Save button, which is the whole "did this settle"
* question in one attribute,
* - the body the mutation actually hands to `apiPatch`, driven through
* the component's own `mutationFn` rather than restated here.
*
* SSR markup, matching the rest of the settings suite. The draft re-seed
* happens in render, so a static render is enough to see it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "@/lib/analytics/score/types";
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
/** The resolved config the page's primary read returns. */
const configState: { pillars: ScorePillarId[] } = { pillars: [...ALL] };
/** Every `mutationFn` the component registered, newest last. */
const mutationFns: Array<(input: never) => unknown> = [];
const apiPatch = vi.fn(async () => ({ pillars: configState.pillars }));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
// The factory's key, not a guess: `queryKeys.healthScoreConfig()`.
if (
Array.isArray(queryKey) &&
queryKey[0] === "settings" &&
queryKey[1] === "health-score-config"
) {
return {
data: {
pillars: configState.pillars,
excludedPillars: ALL.filter(
(id) => !configState.pillars.includes(id),
),
hasSelection: true,
version: 1,
changedAt: null,
updatedAt: "2026-08-01T00:00:00.000Z",
},
isSuccess: true,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: undefined,
isSuccess: false,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useMutation: (options: { mutationFn?: (input: never) => unknown }) => {
if (options.mutationFn) mutationFns.push(options.mutationFn);
return { mutate: vi.fn(), isPending: false };
},
useQueryClient: () => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(),
invalidateQueries: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { message: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPatch: (...args: unknown[]) =>
(apiPatch as unknown as (...a: unknown[]) => unknown)(...args),
ApiError: class ApiError extends Error {},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, isLoading: false, user: {} }),
}));
vi.mock("@/lib/queries/use-analytics-query", () => ({
useAnalyticsQuery: () => ({
data: undefined,
isError: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ScoreSection } from "../score-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ScoreSection />
</I18nProvider>,
);
}
/** `data-counts` per pillar, read off the rendered rows. */
function countsByPillar(html: string): Record<string, string> {
const out: Record<string, string> = {};
for (const match of html.matchAll(
/data-pillar="([A-Z_]+)"[^>]*data-counts="(true|false)"/g,
)) {
out[match[1]] = match[2];
}
return out;
}
function saveIsDisabled(html: string): boolean {
const at = html.indexOf('data-slot="score-config-save"');
expect(at, "save button is rendered").toBeGreaterThan(-1);
const open = html.lastIndexOf("<button", at);
return html.slice(open, html.indexOf(">", at)).includes("disabled=");
}
beforeEach(() => {
configState.pillars = [...ALL];
mutationFns.length = 0;
apiPatch.mockClear();
});
describe("a selection that is already stored", () => {
it("shows every pillar the person kept switched on, and only those", () => {
configState.pillars = ALL.filter(
(id) => id !== "SLEEP" && id !== "WELLBEING",
);
const counts = countsByPillar(render());
expect(Object.keys(counts).sort()).toEqual([...ALL].sort());
for (const id of ALL) {
expect(counts[id], `${id} switch`).toBe(
configState.pillars.includes(id) ? "true" : "false",
);
}
});
it("has nothing to save the moment it loads", () => {
configState.pillars = ALL.filter((id) => id !== "SLEEP");
expect(saveIsDisabled(render())).toBe(true);
});
it("settles on the every-pillar starting point too", () => {
// "All pillars" has to be a selection the page can hold: if the save
// silently narrowed it, the form would come back different from the
// list it sent and never read as saved again.
configState.pillars = [...ALL];
const html = render();
expect(Object.values(countsByPillar(html))).toEqual(ALL.map(() => "true"));
expect(saveIsDisabled(html)).toBe(true);
});
});
describe("what the save puts on the wire", () => {
it("sends the selection unchanged, pillar for pillar", async () => {
render();
const save = mutationFns[0];
expect(save, "the save mutation registered a mutationFn").toBeTypeOf(
"function",
);
await (save as (input: ScorePillarId[]) => unknown)([...ALL]);
expect(apiPatch).toHaveBeenCalledTimes(1);
const [path, body] = apiPatch.mock.calls[0] as unknown as [
string,
{ pillars: ScorePillarId[] },
];
expect(path).toBe("/api/auth/me/health-score-config");
expect(body.pillars).toEqual(ALL);
});
it("puts the selection in registry order whatever order it arrives in", async () => {
render();
const save = mutationFns[0] as (input: ScorePillarId[]) => unknown;
await save(["LIPIDS", "ACTIVITY", "BLOOD_PRESSURE"]);
const [, body] = apiPatch.mock.calls[0] as unknown as [
string,
{ pillars: ScorePillarId[] },
];
expect(body.pillars).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "LIPIDS"]);
});
});
+18 -52
View File
@@ -3,15 +3,11 @@
/**
* 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.
* The row carries the pillar's name, one line about where it currently
* stands with the server, and the switch that decides whether it counts.
* Nothing else: recording and showing are the modules screen's business,
* and repeating them on every row was noise on a page about a third
* question.
*
* Presentation only. The parent owns the draft, the mutation and the
* cache, so this stays a leaf a test can drive directly.
@@ -35,7 +31,6 @@ const ELIGIBILITY_LINE_KEYS: Partial<
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 {
@@ -65,32 +60,17 @@ export function ScorePillarRow({
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"
className="flex min-h-11 items-center justify-between gap-4"
>
<div className="min-w-0 space-y-1">
<div className="min-w-0 space-y-0.5">
<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"
className="text-muted-foreground text-xs"
>
{t(stateKey)}
</p>
@@ -104,30 +84,16 @@ export function ScorePillarRow({
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>
)}
<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]),
})}
/>
</div>
</div>
);
+169 -151
View File
@@ -14,13 +14,18 @@
* - **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.
* - **The settings card anatomy, unmodified.** Header through
* `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on
* the title's reading edge, save bottom-right in its own row. The
* v1.35.0 cut hung the save in the header's status slot and let the
* body start at the card edge, which read as a different kind of card
* from the ones above and below it.
* - **Grouped by domain, and the grouping is spacing.** Blood pressure,
* glycaemia and lipids sit under one heading; the gap between groups
* does the separating. Rules between every row made a short list look
* like a table of unrelated things.
* - **Only the score toggle is writable.** Recording and showing belong
* to the modules screen and are not restated per row.
* - **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
@@ -66,16 +71,13 @@ import {
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 { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels";
import {
buildScoreConfigRows,
selectionToSave,
type ScorePillarVerdict,
} from "@/lib/score-config/rows";
import {
orderedUniquePillars,
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "@/lib/analytics/score/types";
@@ -191,7 +193,13 @@ export function ScoreSection() {
apiPatch<HealthScoreConfigPayload>(
"/api/auth/me/health-score-config",
withBaseToken(
{ pillars: selectionToSave(selection) },
// Registry order, no duplicates. What the person sees is what
// the server stores: every listed pillar is a real choice, so
// nothing is filtered out of the selection on the way to the
// write. A selection that came back different from the one that
// was sent is what left the page permanently claiming unsaved
// changes in v1.35.0.
{ pillars: orderedUniquePillars(selection) },
readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()),
),
),
@@ -295,157 +303,167 @@ export function ScoreSection() {
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 body starts on the title's reading edge — icon box plus
gap is 28 px, which is the `pl-7` every other settings card
uses. */}
<div className="mt-4 space-y-6 pl-7">
{/* 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]);
}}
{/* 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"
>
{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>
<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}
className="space-y-2"
>
{/* Names an area of health, so it sits in the content
tier. The muted section-label treatment it shipped
with read as switched-off beside the rows it
heads. */}
<h3
data-slot="score-config-group-heading"
className="text-foreground text-xs font-medium tracking-wide uppercase"
>
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
</h3>
{/* No rules between the rows: the gap between groups
is the grouping. */}
<div className="space-y-3">
{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}
</div>
{refusal ? (
<p
data-slot="score-config-refusal"
role="alert"
className="text-destructive text-sm"
{/* Bottom-right, in its own row, like every other settings card
that saves a form. */}
<div className="mt-4 flex justify-end pl-7">
<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"
>
{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}
{busy ? (
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.sections.score.save")}
</Button>
</div>
</SettingsCard>
)}
</div>
@@ -352,7 +352,6 @@ describe("i18n locale file integrity", () => {
["insights.healthScore.pillar.activity", "Aktivität"],
["insights.healthScore.pillar.sleep", "Schlaf"],
["insights.healthScore.pillar.wellbeing", "Wohlbefinden"],
["insights.healthScore.pillar.fitness", "Gemessene Fitness"],
["insights.healthScore.pillar.lipids", "Blutfette"],
];
for (const [key, expectedDe] of expected) {
@@ -8,7 +8,13 @@ import {
SCORE_ALGORITHM_CHANGED_AT,
} from "../composite";
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
import { SCORE_GREEN_FLOOR, scoreBand } from "../shared";
import {
SCORE_VERSION,
type PillarValue,
type ScorePillarId,
type ScorePillarResult,
} from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -19,7 +25,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -87,6 +92,111 @@ function evaluation(
});
}
/**
* Where the band changes, and what the band is NOT.
*
* v1.35.1 moved green from 75 to 70, so the Health Score says the same
* thing by a green dot that Readiness and the sleep score already did.
* Boundaries are pinned on both sides because an off-by-one here is
* invisible: a `>` where a `>=` belongs looks perfectly healthy at 80.
*
* The last case is the one that must survive every future threshold
* edit. The band is not a function of the number: `computeComposite`
* takes the worse of the mean's band and the worst counted pillar's, and
* `HealthScoreRecord.band` is stored rather than re-derived precisely
* because of it. A mean above the green floor with a red pillar under it
* is still red.
*/
describe("the band the mean alone gives", () => {
const CASES: Array<[number, string]> = [
[69, "yellow"],
[70, "green"],
[74, "green"],
[75, "green"],
[49, "red"],
[50, "yellow"],
];
for (const [score, expected] of CASES) {
it(`calls ${score} ${expected}`, () => {
expect(scoreBand(score)).toBe(expected);
});
}
it("says the same thing the app's other two scores say", () => {
// Readiness and the sleep score both call 70 green. Three scores in
// one app cannot mean three things by one colour, and the Health
// Score was the outlier.
expect(scoreBand(SCORE_GREEN_FLOOR)).toBe("green");
expect(scoreBand(SCORE_GREEN_FLOOR - 1)).toBe("yellow");
expect(SCORE_GREEN_FLOOR).toBe(70);
});
it("moved the method version with it", () => {
// A stored day keeps the band it was shown under, and the notice key
// carries this number, so each account hears once that the rules
// moved. A threshold edit without a version bump is a silent one.
expect(SCORE_VERSION).toBeGreaterThan(2);
});
});
describe("the worst pillar still holds the band down", () => {
it("keeps a composite red when a counted pillar is red, above the green floor", () => {
// Mean 73: green on the number alone, and green under the OLD floor
// it would have been yellow — so this case can only pass because the
// worst-pillar rule survived the threshold change.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 20),
pillar("ACTIVITY", 100),
pillar("SLEEP", 100),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.score).toBe(73);
expect(scoreBand(result.value.score)).toBe("green");
expect(result.value.band).toBe("red");
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
});
it("keeps a composite yellow when its worst pillar is yellow", () => {
// Mean 84, every pillar above the red floor, one of them yellow.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 52),
pillar("ACTIVITY", 100),
pillar("SLEEP", 100),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(scoreBand(result.value.score)).toBe("green");
expect(result.value.band).toBe("yellow");
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
});
it("names no setter when every pillar clears the mean's band", () => {
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 71),
pillar("ACTIVITY", 90),
pillar("SLEEP", 90),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.band).toBe("green");
expect(result.value.bandSetter).toBeNull();
});
});
describe("reference-score composite", () => {
it("requires three domains and one measured physiological domain", () => {
const tooNarrow = evaluation(
@@ -57,15 +57,27 @@ import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
const DAY_MS = 86_400_000;
/**
* Pillars the catalogue has retired since the swap.
*
* FITNESS went in v1.35.1. It had never produced a value on any account —
* the schema cannot prove a VO₂max reading came from a measured test, so
* every FITNESS row reached the scorer unproven and was refused — which
* is why retiring it moves no number. It is named here rather than edited
* out of the frozen expression below, so the "before" side stays a
* verbatim copy and the retirement stays a visible, deliberate subtraction
* instead of a silent one.
*/
const RETIRED_SINCE_THE_SWAP: readonly string[] = ["FITNESS"];
/**
* The `availablePillars` expression exactly as it stood before the swap,
* kept here as the "before" side of the inheritance proof. It must never
* be edited to follow the implementation: the day it disagrees with the
* reader is the day somebody's number moved.
* reader is the day somebody's number moved. The one sanctioned
* subtraction is `RETIRED_SINCE_THE_SWAP`, applied below.
*/
function compositionBeforeTheSwap(
modules: ScoreReaderModules,
): ScorePillarId[] {
function compositionBeforeTheSwap(modules: ScoreReaderModules): string[] {
return [
"BLOOD_PRESSURE",
...(modules.glucose || modules.labs ? (["GLYCAEMIA"] as const) : []),
@@ -78,6 +90,13 @@ function compositionBeforeTheSwap(
];
}
/** The pre-swap composition minus the pillars the catalogue has retired. */
function compositionInheritedToday(modules: ScoreReaderModules): string[] {
return compositionBeforeTheSwap(modules).filter(
(id) => !RETIRED_SINCE_THE_SWAP.includes(id),
);
}
/** All sixteen on/off states of the four modules the reader is handed. */
const MODULE_COMBINATIONS: ScoreReaderModules[] = [0, 1].flatMap((glucose) =>
[0, 1].flatMap((labs) =>
@@ -145,8 +164,8 @@ function measurement(
/**
* An account with enough data for four pillars to score: steps, sleep,
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia, lipids
* and fitness stay honestly absent, which is what lets the same fixture
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia and
* lipids stay honestly absent, which is what lets the same fixture
* show a selected-but-dataless pillar beside a module-withdrawn one.
*/
const MEASUREMENTS: MeasurementRow[] = [
@@ -213,7 +232,6 @@ function score(args: {
now: NOW,
profile: {
dateOfBirth: null,
gender: null,
heightCm: null,
timezone: "UTC",
sourcePriorityJson: null,
@@ -243,7 +261,7 @@ describe("the module-to-pillar map", () => {
expect(
pillarsWithModuleData(modules),
`availability for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
).toEqual(compositionInheritedToday(modules));
}
});
@@ -267,20 +285,15 @@ describe("the module-to-pillar map", () => {
).toContain("LIPIDS");
});
it("leaves the four core-domain pillars ungated", () => {
// These read weight, blood pressure, steps and waist — domains with
// no module switch at all, so no module can withdraw their data.
// Pinned rather than derived so an addition to the map that started
// gating one of them has to be a deliberate edit here too.
it("leaves the three core-domain pillars ungated", () => {
// These read blood pressure, steps and waist — domains with no module
// switch at all, so no module can withdraw their data. Pinned rather
// than derived so an addition to the map that started gating one of
// them has to be a deliberate edit here too.
const ungated = SCORE_PILLAR_IDS.filter(
(id) => !MODULE_GATED_PILLARS.has(id),
);
expect(ungated).toEqual([
"BLOOD_PRESSURE",
"ACTIVITY",
"ADIPOSITY",
"FITNESS",
]);
expect(ungated).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "ADIPOSITY"]);
const allOff: ScoreReaderModules = {
glucose: false,
labs: false,
@@ -290,6 +303,19 @@ describe("the module-to-pillar map", () => {
expect(pillarsWithModuleData(allOff)).toEqual(ungated);
});
it("holds no retired pillar the catalogue still knows", () => {
// A retirement list that named a live pillar would quietly subtract
// it from the "before" side and hide a real composition change.
for (const id of RETIRED_SINCE_THE_SWAP) {
expect(SCORE_PILLAR_IDS as readonly string[]).not.toContain(id);
}
// And the list is not empty-by-accident: the subtraction it performs
// has to be visible in at least one module combination.
expect(compositionBeforeTheSwap(ALL_MODULES_ON)).not.toEqual(
compositionInheritedToday(ALL_MODULES_ON),
);
});
it("names only real modules and real pillars", () => {
for (const key of Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]) {
expect(MODULE_KEYS as readonly string[]).toContain(key);
@@ -307,7 +333,7 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
expect(
report.pillars.map((pillar) => pillar.id),
`composition for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
).toEqual(compositionInheritedToday(modules));
}
});
@@ -321,7 +347,6 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
"ACTIVITY",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]);
expect(report.composite.status).toBe("ok");
@@ -251,7 +251,7 @@ function recipe(changedAt: Date): unknown {
// rest. The composition this resolves to is the same either way in
// this fixture, which is exactly the case a composition comparison
// cannot see.
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
version: 3,
changedAt,
});
@@ -512,16 +512,16 @@ describe("a settings change is never narrated as a health event", () => {
const unconfigured = await computeUserHealthScore(scoreInput(null));
expect(configured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:3",
"health_score_algorithm:3:config:3",
);
expect(configured.algorithmNotice!.dismissed).toBe(false);
// An account that never chose keeps the key its acknowledgement is
// already filed under.
// An account that never chose keeps the bare method key, with no
// recipe suffix on it.
expect(unconfigured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2",
"health_score_algorithm:3",
);
dismissedKeys.add("health_score_algorithm:2:config:3");
dismissedKeys.add("health_score_algorithm:3:config:3");
expect(
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
.dismissed,
@@ -532,14 +532,14 @@ describe("a settings change is never narrated as a health event", () => {
const next = await computeUserHealthScore(
scoreInput(
healthScoreConfigFromSelection({
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
version: 4,
changedAt: new Date(NOW.getTime() - DAY_MS),
}),
),
);
expect(next.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:4",
"health_score_algorithm:3:config:4",
);
expect(next.algorithmNotice!.dismissed).toBe(false);
});
@@ -39,6 +39,7 @@ import type {
ScorePillarId,
ScorePillarResult,
} from "../types";
import { SCORE_VERSION } from "../types";
const DAY_MS = 86_400_000;
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -50,7 +51,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -116,7 +116,7 @@ function composite(score: number, asOf: Date): Derived<CompositeValue> {
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage,
confidence,
@@ -191,7 +191,7 @@ describe("the recipe boundary", () => {
composition: ["BLOOD_PRESSURE", "ACTIVITY"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
},
coverage: {
requiredInputs: 3,
@@ -37,10 +37,10 @@ describe("resolveScoreConfigured", () => {
});
it("reads true once the person's own recipe narrows the composition", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
const narrowed = ALL.filter((id) => id !== "LIPIDS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
config: { pillars: narrowed },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
@@ -87,10 +87,10 @@ describe("resolveScoreConfigured", () => {
});
it("reads false again when the recipe is undone", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
const narrowed = ALL.filter((id) => id !== "LIPIDS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
config: { pillars: narrowed },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
@@ -4,7 +4,6 @@ import { gradeBpScore } from "@/lib/analytics/bp-grade";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
import { computeFitnessPillar } from "../fitness";
import { computeGlycaemiaPillar } from "../glycaemia";
import { computeLipidsPillar } from "../lipids";
import { computeSleepPillar } from "../sleep";
@@ -390,43 +389,6 @@ describe("reference-score pillars", () => {
if (who.status === "ok") expect(who.value.score).toBe(52);
});
it("admits fitness only when the source proves a measured test", () => {
const unverified = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "APPLE_HEALTH",
measured: false,
},
],
});
expect(unverified.status).toBe("insufficient");
const measured = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
measured: true,
},
],
});
expect(measured.status).toBe("ok");
if (measured.status === "ok") {
expect(measured.value.reference.source).toBe("FRIEND (Kaminsky 2015)");
}
});
it("requires a fresh lipid panel with lab-reported reference bounds", () => {
const result = computeLipidsPillar({
...live,
@@ -495,15 +457,6 @@ describe("reference-score pillars", () => {
fastingGlucose: [],
}).status,
).toBe("insufficient");
expect(
computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [{ value: 42, at: staleAt, source: "MANUAL", measured: true }],
}).status,
).toBe("insufficient");
expect(
computeLipidsPillar({
...live,
@@ -28,6 +28,7 @@ import type {
ScorePillarId,
ScorePillarResult,
} from "../types";
import { SCORE_VERSION } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -38,7 +39,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -104,7 +104,7 @@ function report(
pillars,
delta: null,
deltaReason: "no_previous_window",
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
algorithmNotice: null,
};
@@ -145,7 +145,7 @@ describe("stored bands", () => {
describe("input fingerprint", () => {
const base = {
scoreVersion: 2,
scoreVersion: 1,
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
pillars: HEALTHY,
};
@@ -157,7 +157,7 @@ describe("input fingerprint", () => {
});
it("moves when the algorithm version moves", () => {
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe(
healthScoreInputFingerprint(base),
);
});
-1
View File
@@ -28,7 +28,6 @@ export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"GLYCAEMIA",
"SLEEP",
"ADIPOSITY",
"FITNESS",
"LIPIDS",
]);
-2
View File
@@ -19,7 +19,6 @@ export async function computeHealthScoreDerived(
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
timezone: true,
thresholdsJson: true,
@@ -56,7 +55,6 @@ export async function computeHealthScoreDerived(
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone,
sourcePriorityJson,
-94
View File
@@ -1,94 +0,0 @@
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import type { Derived } from "@/lib/insights/derived/types";
import type { FitnessPillarInput, PillarValue } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
okPillar,
scoreReferenceBand,
withinWindow,
} from "./shared";
export const FITNESS_WINDOW_DAYS = 365;
export function computeFitnessPillar(
input: FitnessPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "FITNESS",
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
});
}
const norm = lookupNormalRange("VO2_MAX", input.ageYears, input.sex);
if (!norm) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["age and binary reference sex"],
reason: "missing_reference_profile",
});
}
const measured = input.rows
.filter(
(row) =>
row.measured && withinWindow(row.at, input.asOf, FITNESS_WINDOW_DAYS),
)
.sort((a, b) => b.at.getTime() - a.at.getTime());
const latest = measured[0];
if (!latest) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["measured VO₂max test"],
reason: input.rows.length > 0 ? "unverified_or_stale" : "not_tracked",
});
}
return okPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
coverage: coverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
}),
value: {
score: scoreReferenceBand(latest.value, norm.low, null),
observed: {
value: latest.value,
unit: "mL/(kg·min)",
label: `${latest.value} mL/(kg·min) measured`,
asOf: latest.at.toISOString(),
sources: [latest.source],
},
reference: {
kind: "population-percentile",
low: norm.low,
high: norm.high,
label: `${norm.low}–${norm.high} mL/(kg·min) age/sex band`,
source: "FRIEND (Kaminsky 2015)",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: "measured_vo2max",
},
});
}
-6
View File
@@ -4,7 +4,6 @@ import { computeActivityPillar } from "./activity";
import { computeAdiposityPillar } from "./adiposity";
import { computeBloodPressurePillar } from "./blood-pressure";
import { computeComposite } from "./composite";
import { computeFitnessPillar } from "./fitness";
import { computeGlycaemiaPillar } from "./glycaemia";
import { computeLipidsPillar } from "./lipids";
import { computeSleepPillar } from "./sleep";
@@ -70,11 +69,6 @@ export function computeHealthScore(
domain: SCORE_PILLAR_DOMAINS.WELLBEING,
result: computeWellbeingPillar(input.pillars.WELLBEING),
},
FITNESS: {
id: "FITNESS",
domain: SCORE_PILLAR_DOMAINS.FITNESS,
result: computeFitnessPillar(input.pillars.FITNESS),
},
LIPIDS: {
id: "LIPIDS",
domain: SCORE_PILLAR_DOMAINS.LIPIDS,
+6 -6
View File
@@ -20,12 +20,12 @@
* was the `glucose || labs` ternary, and it is the entry that would be
* lost by assuming one module per pillar.
*
* `BLOOD_PRESSURE`, `ACTIVITY`, `ADIPOSITY` and `FITNESS` are absent
* from the map, which is the same thing the reader has always done:
* they read core measurement domains that carry no module switch, so no
* module can withdraw their data. Absent means ungated, not forgotten —
* the test beside this file pins the four so a silent addition to the
* map cannot quietly start gating one of them.
* `BLOOD_PRESSURE`, `ACTIVITY` and `ADIPOSITY` are absent from the map,
* which is the same thing the reader has always done: they read core
* measurement domains that carry no module switch, so no module can
* withdraw their data. Absent means ungated, not forgotten — the test
* beside this file pins the three so a silent addition to the map cannot
* quietly start gating one of them.
*/
import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types";
-32
View File
@@ -20,7 +20,6 @@ import { reconstructNights } from "@/lib/insights/derived/sleep-score";
import { pickCumulativeDaySum } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/format";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -44,7 +43,6 @@ const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
export interface ScoreReaderProfile {
dateOfBirth: Date | null;
gender: string | null;
heightCm: number | null;
timezone: string;
sourcePriorityJson: unknown;
@@ -424,7 +422,6 @@ function scoreInputsFor(args: {
activity: Settled<ActivityRead>;
sleep: Settled<MeasurementRow[]>;
adiposity: Settled<MeasurementRow[]>;
fitness: Settled<MeasurementRow[]>;
assessments: Settled<
Array<{
instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI";
@@ -463,7 +460,6 @@ function scoreInputsFor(args: {
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
);
const sex = toProfileSex(input.profile.gender);
const fastingCanonical = pickStableSourceRows(
args.glucose.value.filter((row) => row.glucoseContext === "FASTING"),
(row) => userDayKey(row.measuredAt, input.profile.timezone),
@@ -492,12 +488,6 @@ function scoreInputsFor(args: {
(row) =>
`${row.type}|${userDayKey(row.measuredAt, input.profile.timezone)}`,
);
const fitnessCanonical = pickCanonicalSourceRows(
args.fitness.value,
"vo2Max",
input.profile.sourcePriorityJson,
(date) => userDayKey(date, input.profile.timezone),
).canonicalRows;
return {
BLOOD_PRESSURE: {
@@ -599,21 +589,6 @@ function scoreInputsFor(args: {
at: row.takenAt,
})),
},
FITNESS: {
source: "live",
readFailed: !args.fitness.ok,
asOf,
ageYears,
sex,
// The schema records VO₂max but does not distinguish CPET measurements
// from device estimates. No current row can therefore prove `measured`.
rows: fitnessCanonical.map((row) => ({
value: row.value,
at: row.measuredAt,
source: row.source,
measured: false,
})),
},
LIPIDS: {
source: "live",
readFailed: !args.labs.ok,
@@ -653,7 +628,6 @@ export async function computeUserHealthScore(
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
weights,
@@ -695,11 +669,6 @@ export async function computeUserHealthScore(
[],
"ADIPOSITY",
),
capture(
readMeasurements(db, input.userId, ["VO2_MAX"], since),
[],
"FITNESS",
),
input.modules.mentalHealth
? capture(
db.mentalHealthAssessment.findMany({
@@ -827,7 +796,6 @@ export async function computeUserHealthScore(
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
};
+30 -2
View File
@@ -19,9 +19,37 @@ export function clampScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
/** Green from here up. Yellow from {@link SCORE_YELLOW_FLOOR} up. */
export const SCORE_GREEN_FLOOR = 70;
/** Below this is red. */
export const SCORE_YELLOW_FLOOR = 50;
/**
* The band a 0-100 score falls in, from the number alone.
*
* v1.35.1 — green starts at 70, not 75. The app carries three scores and
* had two conventions: Readiness and the sleep score both call 70 green
* (`bandForScore` in `src/lib/insights/derived/readiness.ts` and
* `src/lib/insights/derived/sleep-score.ts`), and the Health Score alone
* sat at 75. One app cannot mean two things by a green dot, so the
* outlier moved. The yellow floor stays at 50 deliberately: raising the
* bar for a reward and lowering the bar for a warning are different
* decisions, and only the first one was asked for.
*
* This is a METHOD change, so `SCORE_VERSION` moved with it. Stored days
* keep the band they were actually shown under, and the notice key
* carries the version, so each account is told once that the method
* moved.
*
* NOT the whole band rule. `computeComposite` takes the worse of this
* band and the worst counted pillar's band, so a composite carrying a red
* pillar stays red however high the mean sits. That rule is untouched;
* only the mean-side thresholds moved.
*/
export function scoreBand(score: number): ScoreBand {
if (score >= 75) return "green";
if (score >= 50) return "yellow";
if (score >= SCORE_GREEN_FLOOR) return "green";
if (score >= SCORE_YELLOW_FLOOR) return "yellow";
return "red";
}
+28 -28
View File
@@ -4,10 +4,33 @@ import type {
Derived,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
import type { ProfileSex } from "@/lib/profile/sex";
export const SCORE_VERSION = 2 as const;
/**
* The scoring method's identity.
*
* Moves whenever the method changes, which is what lets a stored day say
* which rules produced it and lets each account be told once that the
* rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version
* of the report.
*
* 3 — v1.35.1: green starts at 70 rather than 75, matching the two other
* scores in the app, and the fitness pillar left the catalogue.
* 2 — the composite the previous line shipped.
*/
export const SCORE_VERSION = 3 as const;
/**
* The pillars a score can be built from.
*
* v1.35.1 — FITNESS is gone. The schema records VO₂max but cannot tell a
* measured test apart from a device estimate, so every FITNESS row
* reached the scorer unproven and was refused: the pillar had never once
* produced a value on any account. A pillar that cannot score is not a
* choice, and listing it as one put chrome in front of a thing that
* recomputes nothing. Reading it back is still safe — a stored recipe
* naming it passes through `orderedUniquePillars`, which keeps only ids
* this catalogue knows.
*/
export const SCORE_PILLAR_IDS = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
@@ -15,7 +38,6 @@ export const SCORE_PILLAR_IDS = [
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
] as const;
@@ -34,18 +56,13 @@ export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] {
}
export type ScoreBand = "green" | "yellow" | "red";
export type ScoreDomain =
| "cardiometabolic"
| "activity"
| "sleep"
| "adiposity"
| "wellbeing"
| "fitness";
"cardiometabolic" | "activity" | "sleep" | "adiposity" | "wellbeing";
/**
* Which domain each pillar speaks to. The composite needs three
* distinct domains, so this map is part of the breadth rule and not
* decoration: three of the eight pillars share the cardiometabolic
* domain, which is why a selection of five pillars can still fail.
* decoration: three of the seven pillars share the cardiometabolic
* domain, which is why a selection of four pillars can still fail.
*
* One map, read by the scorer when it builds a pillar result and by the
* breadth rule when it judges a selection, so the two can never drift
@@ -58,7 +75,6 @@ export const SCORE_PILLAR_DOMAINS: Record<ScorePillarId, ScoreDomain> = {
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
@@ -272,21 +288,6 @@ export interface WellbeingPillarInput extends DomainReadState {
assessments: WellbeingAssessment[];
}
export interface FitnessReading {
value: number;
at: Date;
source: string;
/** True only when persisted provenance proves a measured CPET/test. */
measured: boolean;
}
export interface FitnessPillarInput extends DomainReadState {
asOf: Date;
ageYears: number | null;
sex: ProfileSex;
rows: FitnessReading[];
}
export interface LipidReading {
marker: string;
value: number;
@@ -310,6 +311,5 @@ export interface PillarInputs {
SLEEP: SleepPillarInput;
ADIPOSITY: AdiposityPillarInput;
WELLBEING: WellbeingPillarInput;
FITNESS: FitnessPillarInput;
LIPIDS: LipidsPillarInput;
}
-1
View File
@@ -759,7 +759,6 @@ async function buildExtras(
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
@@ -129,7 +129,7 @@ function compositionFor(selection: readonly ScorePillarId[]): ScorePillarId[] {
}
describe("Health Score coverage contract", () => {
it("does not measure a four-pillar account against the eight-pillar catalogue", () => {
it("does not measure a four-pillar account against the whole catalogue", () => {
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
@@ -148,8 +148,9 @@ describe("Health Score coverage contract", () => {
});
expect(composite.status).toBe("ok");
// The catalogue is eight wide and must not be the yardstick.
expect(SCORE_PILLAR_IDS.length).toBe(8);
// The catalogue is wider than the selection and must not be the
// yardstick.
expect(SCORE_PILLAR_IDS.length).toBeGreaterThan(selection.length);
expect(composite.coverage.requiredInputs).not.toBe(SCORE_PILLAR_IDS.length);
// The denominator is the breadth rule applied to the person's own set,
// so it can never exceed what they chose to count.
@@ -162,7 +163,7 @@ describe("Health Score coverage contract", () => {
);
if (composite.status !== "ok") throw new Error("unreachable");
// Fully covered means fully covered: four of four counted pillars have
// data, so nothing about the four they left out may pull this down.
// data, so nothing about the ones they left out may pull this down.
expect(composite.confidence.score).toBe(100);
expect(composite.value.composition).toEqual(selection);
});
@@ -187,7 +188,8 @@ describe("Health Score coverage contract", () => {
it("still asks for data from a pillar the person kept", () => {
// The same four pillars have data; the difference is that this account
// counts all eight, so the four blanks are genuinely missing coverage.
// counts the whole catalogue, so the blanks are genuinely missing
// coverage.
const withData: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
@@ -202,8 +204,12 @@ describe("Health Score coverage contract", () => {
asOf: NOW,
});
expect(composite.coverage.missing).toContain("wellbeing");
expect(composite.coverage.missing).toContain("fitness");
// Named by DOMAIN, not by pillar: glycaemia and lipids are blank too,
// but blood pressure already covers the area they sit in, so wellbeing
// is the one area with nothing behind it. The previous case's `missing`
// was empty, which is the contrast that makes this assertion mean
// something.
expect(composite.coverage.missing).toEqual(["wellbeing"]);
});
it("keeps the registry's HEALTH_SCORE inputs equal to the score catalogue", () => {
+3 -4
View File
@@ -342,7 +342,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
id: "HEALTH_SCORE",
displayName: "Cardiometabolic reference score",
archetype: "composite",
// The eight ids below are the CATALOGUE of scorable pillars, and since
// The seven ids below are the CATALOGUE of scorable pillars, and since
// v1.35.0 they are not the recipe. What counts toward an account's
// composite is the resolved per-user composition
// (`resolveHealthScoreConfig`) intersected with the modules that record
@@ -356,7 +356,6 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
],
minHistoryDays: 1,
@@ -364,10 +363,10 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
// confidence this metric publishes are built in `computeComposite`
// (`src/lib/analytics/score/composite.ts`) over the resolved
// composition, never from this entry, which is why a person who counts
// four pillars is not measured against a denominator of eight. The real
// four pillars is not measured against a denominator of seven. The real
// floor is the breadth rule in `src/lib/analytics/score/breadth.ts`: at
// least three distinct DOMAINS including one physiological pillar. Three
// of the eight ids above share the cardiometabolic domain, so "three
// of the seven ids above share the cardiometabolic domain, so "three
// inputs" and "enough breadth for a score" are different questions and
// this number answers neither. The field stays because every registry
// entry carries it; reading it as a threshold would be wrong.
@@ -1104,7 +1104,6 @@ export const dashboardSnapshotResponse = z
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]),
)
@@ -1136,7 +1135,6 @@ export const dashboardSnapshotResponse = z
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
])
.nullable()
+3 -60
View File
@@ -2,7 +2,7 @@
* What the "what counts toward your score" surface shows, and what it
* refuses to show.
*
* Four decisions are pinned here because each of them is a sentence a
* Three decisions are pinned here because each of them is a sentence a
* person reads about their own health, and each has a plausible wrong
* version that would look fine in a screenshot:
*
@@ -16,17 +16,12 @@
* one the server never mentioned.
* 3. Wellbeing under crisis signposting is its own state and never a
* configuration problem.
* 4. A pillar this build can never score is never sent as part of a
* selection, because the write-time breadth rule would otherwise
* accept a recipe on its behalf that the scorer then refuses.
*/
import { describe, expect, it } from "vitest";
import {
buildScoreConfigRows,
selectionToSave,
showsWaitingForData,
UNSCORABLE_PILLARS,
type ScoreConfigRow,
} from "@/lib/score-config/rows";
import {
@@ -81,9 +76,9 @@ describe("grouping", () => {
if (group.domain === "cardiometabolic") continue;
expect(group.rows).toHaveLength(1);
}
// Six domains across eight pillars is the whole reason the surface
// Five domains across seven pillars is the whole reason the surface
// groups: equal weight per pillar is not equal weight per domain.
expect(built.groups).toHaveLength(6);
expect(built.groups).toHaveLength(5);
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
});
@@ -158,7 +153,6 @@ describe("a module that is off", () => {
"BLOOD_PRESSURE",
"ACTIVITY",
"ADIPOSITY",
"FITNESS",
] as ScorePillarId[]) {
expect(row(built, id)).toBeDefined();
}
@@ -246,54 +240,3 @@ describe("states that are not absence", () => {
expect(showsWaitingForData(bp!)).toBe(false);
});
});
describe("a pillar this build cannot score", () => {
it("is present, honest and never selectable", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
// Even a verdict claiming it scored cannot make it selectable.
verdicts: { FITNESS: { status: "ok" } },
});
const fitness = row(built, "FITNESS");
expect(fitness).toBeDefined();
expect(fitness?.selectable).toBe(false);
expect(fitness?.eligibility).toBe("unavailable");
});
it("leaves every other pillar selectable", () => {
const built = buildScoreConfigRows({
selection: ALL,
modules: ALL_MODULES_ON,
});
for (const candidate of rowsOf(built)) {
expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id));
}
});
it("is never sent as part of a saved selection", () => {
expect(selectionToSave(ALL)).not.toContain("FITNESS");
expect(selectionToSave(["FITNESS"])).toEqual([]);
});
it("cannot carry a selection over the breadth floor on its own", () => {
// The failure this guards: FITNESS is a domain of its own AND counts
// as physiological, so echoing it back would let the write-time rule
// accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of
// them physiological — for a score the reader then refuses, because
// FITNESS never reaches the composition.
expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([
"ACTIVITY",
"WELLBEING",
]);
});
it("keeps registry order and drops duplicates", () => {
expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([
"ACTIVITY",
"LIPIDS",
]);
});
});
+1 -17
View File
@@ -15,30 +15,14 @@ export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
SLEEP: "insights.healthScore.pillar.sleep",
ADIPOSITY: "insights.healthScore.pillar.adiposity",
WELLBEING: "insights.healthScore.pillar.wellbeing",
FITNESS: "insights.healthScore.pillar.fitness",
LIPIDS: "insights.healthScore.pillar.lipids",
};
/**
* Domain headings. The cardiometabolic one carries three pillars, which
* is the whole reason the surface groups at all: equal weight per pillar
* is not equal weight per area, and nothing in the app said so before.
*/
/** Domain headings, one per distinct area of health. */
export const SCORE_DOMAIN_LABEL_KEYS: Record<ScoreDomain, string> = {
cardiometabolic: "settings.sections.score.domain.cardiometabolic",
activity: "settings.sections.score.domain.activity",
sleep: "settings.sections.score.domain.sleep",
adiposity: "settings.sections.score.domain.adiposity",
wellbeing: "settings.sections.score.domain.wellbeing",
fitness: "settings.sections.score.domain.fitness",
};
/**
* Domains that need a sentence under the heading. Only the one that
* carries more than one pillar does, and it is not decoration: without it
* the triple count stays invisible and deselecting one of the three
* reshapes the balance with no cue.
*/
export const SCORE_DOMAIN_NOTE_KEYS: Partial<Record<ScoreDomain, string>> = {
cardiometabolic: "settings.sections.score.domainNote.cardiometabolic",
};
+1 -43
View File
@@ -36,21 +36,6 @@ import {
type ScoreReaderModules,
} from "@/lib/analytics/score/modules";
/**
* Pillars this build can never score, whatever anyone selects.
*
* FITNESS is the only entry and it is not a placeholder for a future
* list. The schema records VO₂max but cannot distinguish a measured test
* from a device estimate, so every FITNESS row reaches the scorer with
* `measured: false` and is refused (`src/lib/analytics/score/reader.ts`,
* the FITNESS input block). Offering it as a switch would be chrome over
* a thing that recomputes nothing — the exact defect this release exists
* to remove — so the row is present, honest, and inert.
*/
export const UNSCORABLE_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"FITNESS",
]);
/**
* What the server's last scoring run said about one pillar. Every value
* comes from a `ScorePillarResult`; nothing here is computed from data.
@@ -64,8 +49,6 @@ export type ScoreRowEligibility =
| "crisis"
/** The read broke. Absence and failure are different states. */
| "read_failed"
/** This build cannot score it at all. */
| "unavailable"
/** The server said nothing about it. No claim is made either way. */
| "unknown";
@@ -74,8 +57,6 @@ export interface ScoreConfigRow {
domain: ScoreDomain;
/** True when the draft counts this pillar toward the score. */
counts: boolean;
/** False when the switch is inert — today only the unscorable pillars. */
selectable: boolean;
eligibility: ScoreRowEligibility;
}
@@ -118,10 +99,8 @@ function resolveModules(
}
function eligibilityOf(
id: ScorePillarId,
verdict: ScorePillarVerdict | undefined,
): ScoreRowEligibility {
if (UNSCORABLE_PILLARS.has(id)) return "unavailable";
if (!verdict) return "unknown";
if (verdict.status === "ok") return "counting";
if (verdict.reason === "crisis_signposting") return "crisis";
@@ -169,8 +148,7 @@ export function buildScoreConfigRows(
id,
domain,
counts: selected.has(id),
selectable: !UNSCORABLE_PILLARS.has(id),
eligibility: eligibilityOf(id, input.verdicts?.[id]),
eligibility: eligibilityOf(input.verdicts?.[id]),
};
const existing = byDomain.get(domain);
if (existing) {
@@ -199,23 +177,3 @@ export function buildScoreConfigRows(
export function showsWaitingForData(row: ScoreConfigRow): boolean {
return row.counts && row.eligibility === "waiting";
}
/**
* The selection a save should send.
*
* Every pillar the person can actually choose, and nothing else. A pillar
* this build cannot score is left out rather than echoed back: echoing it
* would let it satisfy the write-time breadth rule (it is a domain of its
* own AND a physiological pillar) on behalf of a score it can never
* reach, so the write would be accepted and the score would still
* disappear at read time. The refusal is only worth having if it judges
* the recipe that will actually be scored.
*/
export function selectionToSave(
selection: readonly ScorePillarId[],
): ScorePillarId[] {
const selected = new Set(selection);
return SCORE_PILLAR_IDS.filter(
(id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id),
);
}