mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge work/score-settings-polish
This commit is contained in:
commit
4d200ebd43
45 files changed
+833
-1019
No files matched your search
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
||||
"GLYCAEMIA",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user