mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat: say how much of the record the score rests on
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.
The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.
The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.
The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.
Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.
Copy lands in all seven catalogues.
Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
This commit is contained in:
1 parent
320788a984
commit
59ccf5be2a
17 files changed
+578
-60
No files matched your search
@@ -385,3 +385,62 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38 — the hero's ring shows the number and nothing else, so a score
|
||||
* resting on one area of health would look here exactly like one resting
|
||||
* on five. The basis line is the whole difference; it is stated only
|
||||
* below the recommended breadth, in the hero's quiet tier, and it never
|
||||
* touches the ring itself.
|
||||
*/
|
||||
describe("<TodayHero> score basis", () => {
|
||||
function withBasis(
|
||||
domains: number,
|
||||
tier: "full" | "partial" | "minimal",
|
||||
): DailyDigest {
|
||||
return digest({
|
||||
score: {
|
||||
value: 82,
|
||||
band: "green",
|
||||
delta: 3,
|
||||
deltaReason: null,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
scoreBasis: { domains, recommended: 3, tier, physiological: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("says what a two-area score rests on", () => {
|
||||
const html = render(<TodayHero digest={withBasis(2, "partial")} />);
|
||||
expect(html).toContain('data-slot="today-hero-score-basis"');
|
||||
expect(visibleText(html)).toContain("Based on 2 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("leaves the ring's own face untouched", () => {
|
||||
const html = render(<TodayHero digest={withBasis(1, "minimal")} />);
|
||||
// Same band, same populated face, same delta chip as a full-breadth
|
||||
// day: the line is scope, not a downgrade.
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).not.toContain('data-provisional="true"');
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
expect(visibleText(html)).toContain("82");
|
||||
});
|
||||
|
||||
it("says nothing once the recommended breadth is met", () => {
|
||||
const html = render(<TodayHero digest={withBasis(3, "full")} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
|
||||
it("says nothing for a digest carrying no basis at all", () => {
|
||||
// An older cached digest. The hero never counts areas out of
|
||||
// `composition` — three pillars can be one area.
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
|
||||
it("says nothing when there is no score to rest on anything", () => {
|
||||
const html = render(<TodayHero digest={digest({ score: null })} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,24 @@ export function TodayHero({
|
||||
// server-delivered digest, so SSR and hydration choose it identically.
|
||||
const compactAllClear = !lead && !topSignal && !hasItems;
|
||||
|
||||
// v1.38 — what the ring's number rests on, when that is less than the
|
||||
// breadth the score recommends. The hero renders the number and nothing
|
||||
// else, so without this line a one-area score is indistinguishable here
|
||||
// from a full one. Said only below the recommendation: at `full` the
|
||||
// fraction would read "3 of 3" (or worse, "4 of 3" — the block counts
|
||||
// domains, the recommendation caps at three), and a line every account
|
||||
// carries stops being a label. The areas themselves are named on the
|
||||
// insights card, one tap away through the ring; this is scope, not a
|
||||
// warning, and the ring keeps its size and band colour at every tier.
|
||||
const scoreBasis = digest.score?.scoreBasis ?? null;
|
||||
const basisLine =
|
||||
scoreBasis && scoreBasis.tier !== "full"
|
||||
? t("insights.healthScore.basis", {
|
||||
count: scoreBasis.domains,
|
||||
recommended: scoreBasis.recommended,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, no cached briefing lead and no reaction line — surfaces nothing
|
||||
// here. The tile strip below carries its own "add your first reading" empty
|
||||
@@ -371,6 +389,14 @@ export function TodayHero({
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
className="text-muted-foreground max-w-[11rem] text-center text-xs text-balance md:text-right"
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -181,6 +181,28 @@ function reportWithRows(rows: number): HealthScoreReport {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.38 — the same panel, plus the basis line a narrow score carries.
|
||||
*
|
||||
* It is the one block the milestone adds to the at-rest column, and it
|
||||
* lands on the shortest panels: an account with one or two areas of
|
||||
* health has few rows, so this is the shape that decides whether the
|
||||
* reserve still brackets what the report produces.
|
||||
*/
|
||||
function reportWithBasis(rows: number): HealthScoreReport {
|
||||
const report = reportWithRows(rows);
|
||||
if (report.composite.status !== "ok") {
|
||||
throw new Error("fixture is not scored");
|
||||
}
|
||||
report.composite.value.scoreBasis = {
|
||||
domains: 1,
|
||||
recommended: 3,
|
||||
tier: "minimal",
|
||||
physiological: true,
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
function band(node: React.ReactNode, locale: Locale = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
@@ -234,17 +256,28 @@ describe("hero band geometry", () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const heights: Record<string, number> = {};
|
||||
for (const rows of [3, 4, 5, 6]) {
|
||||
const shapes: { key: string; report: HealthScoreReport }[] = [
|
||||
...[3, 4, 5, 6].map((rows) => ({
|
||||
key: `rows${rows}`,
|
||||
report: reportWithRows(rows),
|
||||
})),
|
||||
// The narrow account: three rows AND the basis line. Measured
|
||||
// rather than reasoned about, because whether one extra `text-xs`
|
||||
// line pushes a three-row panel past the reserve is a fact about
|
||||
// the compiled stylesheet, not about the diff.
|
||||
{ key: "rows3basis", report: reportWithBasis(3) },
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWithRows(rows)}
|
||||
healthScore={shape.report}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
heights[`rows${rows}`] = await view
|
||||
heights[shape.key] = await view
|
||||
.locator(PANEL)
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height));
|
||||
const width = await view
|
||||
@@ -295,9 +328,16 @@ describe("hero band geometry", () => {
|
||||
reserve,
|
||||
`reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`,
|
||||
).toBeLessThanOrEqual(heights.rows6);
|
||||
// v1.38 — the narrow account has to sit inside the same range. The
|
||||
// basis line only ever renders on a panel below the recommended
|
||||
// breadth, so this is the tallest the shortest report can get.
|
||||
expect(
|
||||
heights.rows3basis,
|
||||
`a three-row panel with the basis line is ${heights.rows3basis}px, past the tallest report the reserve brackets (${heights.rows6}px)`,
|
||||
).toBeLessThanOrEqual(heights.rows6);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps every row on one line in all six locales", async () => {
|
||||
it("keeps every row on one line in every locale", async () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const widest: Record<string, { label: string; natural: number }> = {};
|
||||
|
||||
@@ -589,13 +589,19 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-insufficient"');
|
||||
// `presentInputs` counts distinct DOMAINS. The sentence used to call
|
||||
// them pillars, which told an account with blood pressure, glycaemia
|
||||
// and lipids that it had one eligible pillar while it had three.
|
||||
// The gate survives exactly one case since v1.38: nothing readable at
|
||||
// all. It no longer names a breadth the score "needs" — there is none,
|
||||
// one area is enough — and it no longer counts areas, because above
|
||||
// zero there is a score instead of a gate.
|
||||
expect(html).toContain(
|
||||
"1 of 3 areas of health have enough recent data. The score needs 3, at least one of them a physical measurement.",
|
||||
"Nothing has enough recent data to score yet. One area of health is enough to start, and the score will say which areas it rests on.",
|
||||
);
|
||||
expect(html).not.toMatch(/eligible pillars/i);
|
||||
expect(html).not.toMatch(/the score needs/i);
|
||||
// The gate is the refusal's own face and nothing else wears it: a
|
||||
// narrow score gets the basis line, a different sentence in a
|
||||
// different slot.
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
// No band sentence and no fabricated number: a dash, and no bar either.
|
||||
expect(html).not.toContain('data-slot="health-score-band"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
@@ -929,3 +935,132 @@ describe("markAlgorithmNoticeDismissed", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38 — the basis line, and the promise it carries.
|
||||
*
|
||||
* Two domains score now, and one does. The panel's whole job at those
|
||||
* breadths is to say so without saying it apologetically: the number keeps
|
||||
* its size and its band colour, the bar stays, and a single sentence under
|
||||
* the band states the scope. What the panel must NOT grow is a second
|
||||
* visual language for narrow scores — a smaller number, a drained colour,
|
||||
* a warning glyph, a gate. The number's own markup is compared against the
|
||||
* full-breadth render rather than pattern-matched, because "renders the
|
||||
* same" is the actual claim and a class list is the only place it can
|
||||
* break.
|
||||
*/
|
||||
describe("<HealthScoreCard> basis line", () => {
|
||||
function withBasis(
|
||||
domains: number,
|
||||
tier: "full" | "partial" | "minimal",
|
||||
band: "green" | "yellow" | "red" = "green",
|
||||
): HealthScoreReport {
|
||||
return scoredReport({
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 86,
|
||||
band,
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
scoreBasis: { domains, recommended: 3, tier, physiological: true },
|
||||
noiseFloor: 3,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: domains,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 70, band: "medium" },
|
||||
provenance,
|
||||
},
|
||||
} as Partial<HealthScoreReport>);
|
||||
}
|
||||
|
||||
/** The opening tag of the headline number, classes and all. */
|
||||
function numberTag(html: string): string {
|
||||
const match = html.match(
|
||||
/<span data-slot="health-score-card-number"[^>]*>/,
|
||||
);
|
||||
expect(match, "the headline number is missing").not.toBeNull();
|
||||
return match![0];
|
||||
}
|
||||
|
||||
it("states the scope of a two-area score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(2, "partial")} />);
|
||||
expect(atRest(html)).toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("Based on 2 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("states the scope of a one-area score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(1, "minimal")} />);
|
||||
expect(html).toContain("Based on 1 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("renders a partial score's number exactly as a full one's", () => {
|
||||
const partial = render(
|
||||
<HealthScoreCard report={withBasis(1, "minimal")} />,
|
||||
);
|
||||
const full = render(<HealthScoreCard report={withBasis(3, "full")} />);
|
||||
|
||||
// Byte-for-byte: same size classes, same band colour, same slot.
|
||||
expect(numberTag(partial)).toBe(numberTag(full));
|
||||
expect(numberTag(partial)).toContain("text-5xl");
|
||||
expect(numberTag(partial)).toContain("sm:text-6xl");
|
||||
expect(numberTag(partial)).toContain("text-success");
|
||||
// And the number itself is there, at full value, with its bar.
|
||||
expect(partial).toContain(">86<");
|
||||
expect(partial).toContain("/ 100");
|
||||
expect(partial).toContain('data-slot="health-score-card-progress"');
|
||||
expect(partial).toMatch(
|
||||
/data-slot="health-score-card"[^>]*data-status="ok"/,
|
||||
);
|
||||
expect(partial).toMatch(
|
||||
/data-slot="health-score-card"[^>]*data-band="green"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a red partial score in its own band colour", () => {
|
||||
// The band drag is arithmetic and the tier is labelling; a narrow set
|
||||
// may not recolour the number either way.
|
||||
const html = render(
|
||||
<HealthScoreCard report={withBasis(1, "minimal", "red")} />,
|
||||
);
|
||||
expect(numberTag(html)).toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("adds no gate, no alert and no glyph beside a narrow score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(1, "minimal")} />);
|
||||
const rest = atRest(html);
|
||||
expect(rest).not.toContain('data-slot="health-score-insufficient"');
|
||||
expect(rest).not.toMatch(/enough recent data to score yet/i);
|
||||
// The only `role="alert"` this panel has ever carried is the failed
|
||||
// read; a narrow score is not a failure and must not borrow it.
|
||||
expect(rest).not.toContain('role="alert"');
|
||||
expect(rest).toContain('data-slot="health-score-band"');
|
||||
});
|
||||
|
||||
it("says nothing extra once the recommended breadth is met", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(3, "full")} />);
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
expect(html).not.toMatch(/Based on \d+ of 3 areas/);
|
||||
});
|
||||
|
||||
it("says nothing when the composite carries no basis at all", () => {
|
||||
// An older cached payload. The line is a server-resolved fact; the
|
||||
// panel never counts domains out of `composition` to invent one.
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
});
|
||||
|
||||
it("still shows the gate, and only the gate, on a refusal", () => {
|
||||
const html = render(<HealthScoreCard report={report()} />);
|
||||
expect(html).toContain('data-slot="health-score-insufficient"');
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("—");
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
});
|
||||
});
|
||||
@@ -13,29 +13,49 @@
|
||||
* fails on the value AND on the copy — passing the domain count into a
|
||||
* pillar-shaped string is caught wherever the string lives.
|
||||
*
|
||||
* **2. The composite's coverage fraction is a floor, not a proportion.**
|
||||
* `requiredInputs` is the three-domain minimum and `deriveCoverage` clamps
|
||||
* `presentInputs` to it, so every scored account rendered "3/3" forever,
|
||||
* whatever it records. The arithmetic is right and stays untouched; what
|
||||
* was wrong is presenting a met floor as full coverage of the person's own
|
||||
* data. The composite's meter therefore names its axis, and the guard
|
||||
* fails if that override is dropped.
|
||||
* **2. The composite's coverage fraction was a floor, not a proportion.**
|
||||
* `requiredInputs` is the three-domain recommendation and `deriveCoverage`
|
||||
* clamps `presentInputs` to it. Until v1.38 three domains were also the
|
||||
* price of a score, so every scored account rendered "3/3" forever,
|
||||
* whatever it recorded: a met floor presented as full coverage of the
|
||||
* person's own data. The composite's meter therefore names its axis, and
|
||||
* the guard fails if that override is dropped.
|
||||
*
|
||||
* Both are AST matchers rather than text searches, and both carry a
|
||||
* counter-test proving they stay quiet on the neighbouring call sites that
|
||||
* are legitimately about pillars.
|
||||
* **3. Since v1.38 the fraction is a real moving number, and the axis has
|
||||
* to move with it.** Two domains now score, so a scored account no longer
|
||||
* clears the recommendation by definition and the override cannot branch
|
||||
* on "is there a score" any more — that would tell a two-domain account it
|
||||
* covers the three areas the score recommends, which is claim 2 rebuilt one
|
||||
* floor higher. The branch is the breadth TIER: `full` states the
|
||||
* recommendation, everything narrower shows the fraction it can actually
|
||||
* count. The last block below renders the real panel at each tier rather
|
||||
* than reading the branch out of the source, because a branch that reads
|
||||
* correctly and renders the other arm is the failure this pair of claims
|
||||
* keeps producing.
|
||||
*
|
||||
* The first two are AST matchers rather than text searches, and both carry
|
||||
* a counter-test proving they stay quiet on the neighbouring call sites
|
||||
* that are legitimately about pillars.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { ScoreBreadthTier } from "@/lib/analytics/score/breadth";
|
||||
import type { HealthScoreReport } from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { CoverageMeter } from "../derived/coverage-meter";
|
||||
import { HealthScoreCard } from "../health-score-card";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }),
|
||||
}));
|
||||
|
||||
const CARD_FILE = join(
|
||||
process.cwd(),
|
||||
"src/components/insights/health-score-card.tsx",
|
||||
@@ -155,9 +175,13 @@ describe("the domain count is never described as a pillar count", () => {
|
||||
|
||||
it("finds the call site at all", () => {
|
||||
// A matcher that matches nothing passes every assertion below it.
|
||||
// The named example is the axis fraction since v1.38: the refusal
|
||||
// sentence stopped counting areas when it stopped being reachable
|
||||
// above zero, and the meter a few lines below renders the same
|
||||
// fraction anyway.
|
||||
expect(sites.length).toBeGreaterThan(0);
|
||||
expect(sites.map((site) => site.key)).toContain(
|
||||
"insights.healthScore.insufficient",
|
||||
"insights.healthScore.coverage.areas",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,9 +250,12 @@ describe("the composite's coverage meter names its own axis", () => {
|
||||
});
|
||||
|
||||
it("has copy for both axis labels", () => {
|
||||
expect(message("insights.healthScore.coverage.minimumMet")).toMatch(
|
||||
/area/i,
|
||||
);
|
||||
const met = message("insights.healthScore.coverage.recommendedMet");
|
||||
expect(met).toMatch(/area/i);
|
||||
// The recommendation arm may not describe the breadth as something
|
||||
// the score needs: needing three is the rule v1.38 removed, and this
|
||||
// sentence is the last place in the panel that still spelled it.
|
||||
expect(met).not.toMatch(/\bneeds?\b|\brequire/i);
|
||||
expect(message("insights.healthScore.coverage.areas")).toContain(
|
||||
"{present}",
|
||||
);
|
||||
@@ -271,3 +298,120 @@ describe("the axis override reaches the markup", () => {
|
||||
expect(html).toContain("3 of 3 inputs");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claim 3, rendered. The three tiers the composite can carry, each put
|
||||
* through the real panel, so the axis is judged on what a person reads
|
||||
* rather than on which branch the source appears to take.
|
||||
*/
|
||||
describe("the axis states the recommendation only where it is met", () => {
|
||||
const provenance = {
|
||||
inputs: ["BLOOD_PRESSURE"],
|
||||
source: "live" as const,
|
||||
windowDays: 90,
|
||||
computedAt: "2026-07-28T12:00:00.000Z",
|
||||
};
|
||||
|
||||
/** An ok composite spanning `domains` of the three recommended. */
|
||||
function scoredReport(
|
||||
domains: number,
|
||||
tier: ScoreBreadthTier,
|
||||
): HealthScoreReport {
|
||||
return {
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 78,
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
scoreBasis: {
|
||||
domains,
|
||||
recommended: 3,
|
||||
tier,
|
||||
physiological: true,
|
||||
},
|
||||
noiseFloor: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: domains,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 70, band: "medium" },
|
||||
provenance,
|
||||
},
|
||||
pillars: [],
|
||||
delta: null,
|
||||
deltaReason: null,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["WEIGHT"] },
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
} as HealthScoreReport;
|
||||
}
|
||||
|
||||
/** The refusal: not one pillar has usable data. */
|
||||
function refusedReport(): HealthScoreReport {
|
||||
return {
|
||||
...scoredReport(0, "minimal"),
|
||||
composite: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance,
|
||||
reason: "no_usable_data",
|
||||
},
|
||||
} as HealthScoreReport;
|
||||
}
|
||||
|
||||
function axisOf(report: HealthScoreReport): string {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthScoreCard report={report} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The composite's meter is the panel's first; the pillar meters live
|
||||
// behind popovers and the weight-goal meter comes after it.
|
||||
const match = html.match(/data-slot="coverage-meter-label"[^>]*>([^<]*)</);
|
||||
expect(match, "the composite meter rendered no axis label").not.toBeNull();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
it("states the recommendation for a full-breadth score", () => {
|
||||
expect(axisOf(scoredReport(3, "full"))).toBe(
|
||||
"Covers the three areas the score recommends",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the moving fraction for a two-area score", () => {
|
||||
// The arm that used to be unreachable for a scored account: before
|
||||
// v1.38 two domains had no score at all, so this sentence could only
|
||||
// ever appear beside a dash.
|
||||
expect(axisOf(scoredReport(2, "partial"))).toBe("2 of 3 areas of health");
|
||||
});
|
||||
|
||||
it("shows the moving fraction for a one-area score", () => {
|
||||
expect(axisOf(scoredReport(1, "minimal"))).toBe("1 of 3 areas of health");
|
||||
});
|
||||
|
||||
it("shows the moving fraction when there is no score at all", () => {
|
||||
expect(axisOf(refusedReport())).toBe("0 of 3 areas of health");
|
||||
});
|
||||
});
|
||||
@@ -158,6 +158,21 @@ const SCORE_WITH_FAILED_READ = scoreReport([
|
||||
gated("LIPIDS", "read_failed"),
|
||||
]);
|
||||
|
||||
/** v1.38 — a score resting on one area of health, basis block and all. */
|
||||
function narrowScore(): HealthScoreReport {
|
||||
const report = scoreReport([scored("BLOOD_PRESSURE", 74)]);
|
||||
if (report.composite.status !== "ok") {
|
||||
throw new Error("fixture is not scored");
|
||||
}
|
||||
report.composite.value.scoreBasis = {
|
||||
domains: 1,
|
||||
recommended: 3,
|
||||
tier: "minimal",
|
||||
physiological: true,
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
|
||||
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
|
||||
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
|
||||
@@ -460,6 +475,26 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
|
||||
});
|
||||
|
||||
it("carries a narrow score's basis line through to the band", () => {
|
||||
// v1.38 — the panel owns the sentence; the band has to actually mount
|
||||
// the panel with the report it was handed for the sentence to arrive.
|
||||
// Two ends and the pipe: the card tests prove the line exists, this
|
||||
// proves it reaches the surface a person looks at.
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={narrowScore()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("Based on 1 of 3 areas of health.");
|
||||
// Still the panel's face: full number, its own band colour, its bar.
|
||||
expect(html).toContain(">74<");
|
||||
expect(html).toContain('data-slot="health-score-card-progress"');
|
||||
expect(html).not.toContain('data-slot="health-score-insufficient"');
|
||||
});
|
||||
|
||||
it("forwards the retry so a failed read can be retried from the band", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
|
||||
@@ -298,6 +298,25 @@ export function HealthScoreCard({
|
||||
composite.status === "ok" ? clampScore(composite.value.score) : null;
|
||||
const compositeBand = composite.status === "ok" ? composite.value.band : null;
|
||||
|
||||
// v1.38 — what the number rests on, resolved on the server. A set that
|
||||
// spans the recommended breadth says nothing extra: three of three is
|
||||
// the shape the meter's axis already states, and a four-domain account
|
||||
// would read "4 of 3". Below the recommendation the line is the only
|
||||
// thing on the panel that tells a narrow score from a broad one, so it
|
||||
// is stated in the same weight as the band sentence it follows —
|
||||
// scope, not caveat. The number keeps its size and its band colour at
|
||||
// every tier; the coverage meter and the confidence band below carry
|
||||
// the quantitative caveat they were built for.
|
||||
const basis =
|
||||
composite.status === "ok" ? (composite.value.scoreBasis ?? null) : null;
|
||||
const basisLine =
|
||||
basis && basis.tier !== "full"
|
||||
? t("insights.healthScore.basis", {
|
||||
count: basis.domains,
|
||||
recommended: basis.recommended,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card"
|
||||
@@ -380,22 +399,22 @@ export function HealthScoreCard({
|
||||
{t(`insights.healthScore.band.${compositeBand}`)}
|
||||
</p>
|
||||
) : (
|
||||
// The gate survives for the one case that is still a refusal:
|
||||
// not a single pillar has usable data. It no longer counts
|
||||
// areas, because the count is always nothing and the meter in
|
||||
// the foot renders the same fraction honestly a few lines
|
||||
// below.
|
||||
<LearningGate
|
||||
compact
|
||||
bodySlot="health-score-insufficient"
|
||||
// `presentInputs` on the composite counts DISTINCT DOMAINS,
|
||||
// not pillars. It said "pillars" for its whole life, which
|
||||
// told someone recording blood pressure, glycaemia and lipids
|
||||
// that they had one eligible pillar when they had three. The
|
||||
// count is right; the noun was wrong. `required` rides along
|
||||
// so the sentence tracks the rule's own constant instead of
|
||||
// spelling "three" into six locales.
|
||||
message={t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
required: composite.coverage.requiredInputs,
|
||||
})}
|
||||
message={t("insights.healthScore.insufficient")}
|
||||
/>
|
||||
)}
|
||||
{basisLine ? (
|
||||
<p data-slot="health-score-basis" className="text-foreground text-xs">
|
||||
{basisLine}
|
||||
</p>
|
||||
) : null}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
@@ -521,15 +540,24 @@ export function HealthScoreCard({
|
||||
number, so a short report and a long one both end on the same line. */}
|
||||
<div className="mt-auto space-y-3 pt-1">
|
||||
{/* The composite's meter names its own axis. Its fraction counts
|
||||
distinct DOMAINS against the three the score requires, and a
|
||||
scored account has cleared that floor by definition, so the
|
||||
default "3/3" would read as full coverage of the person's data
|
||||
for everyone who has a score at all. Below the floor the
|
||||
fraction is a real, moving number and is shown as one; at or
|
||||
above it, the floor is stated instead. The dots and percentage
|
||||
are untouched either way: they track history depth, which does
|
||||
vary, and they are the part of this meter that was always
|
||||
telling the truth. */}
|
||||
distinct DOMAINS against the three the score recommends. Until
|
||||
v1.38 those three were a floor every scored account had
|
||||
cleared by definition, so the default "3/3" read as full
|
||||
coverage of the person's data for everyone who had a score at
|
||||
all, and the axis stated the floor instead.
|
||||
|
||||
The floor is gone, so the branch cannot be "does a score
|
||||
exist" any more: a two-domain account has a score AND a real
|
||||
fraction, and telling it that it covers the three areas the
|
||||
score recommends would be the old lie in a new place. The
|
||||
branch is the TIER — the recommendation is stated only where
|
||||
it is actually met, and everywhere else the moving number is
|
||||
shown as one. The dots and percentage are untouched either
|
||||
way: they track history depth, which does vary.
|
||||
|
||||
A composite carrying no basis block at all — an older cached
|
||||
payload — takes the fraction arm, which states what it can
|
||||
count rather than a recommendation it cannot check. */}
|
||||
<CoverageMeter
|
||||
coverage={composite.coverage}
|
||||
confidence={
|
||||
@@ -537,8 +565,8 @@ export function HealthScoreCard({
|
||||
}
|
||||
size="sm"
|
||||
axisLabel={
|
||||
composite.status === "ok"
|
||||
? t("insights.healthScore.coverage.minimumMet")
|
||||
basis?.tier === "full"
|
||||
? t("insights.healthScore.coverage.recommendedMet")
|
||||
: t("insights.healthScore.coverage.areas", {
|
||||
present: composite.coverage.presentInputs,
|
||||
required: composite.coverage.requiredInputs,
|
||||
|
||||
@@ -320,6 +320,19 @@ export function ScoreSection() {
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
|
||||
{/* v1.38 — the breadth sentence. Three areas of health used to
|
||||
be the price of a score, and the write refused anything
|
||||
narrower; it is a recommendation now and one pillar saves.
|
||||
The recommendation stays on the surface anyway, because a
|
||||
one-area score is honest but thin and a person choosing
|
||||
what counts should know that before they save, not after
|
||||
the card tells them what it rests on. Content, so
|
||||
foreground and in the body beside the sentence it
|
||||
qualifies — not a muted footnote under the rows. */}
|
||||
<p data-slot="score-config-recommendation" className="text-sm">
|
||||
{t("settings.sections.score.recommendation")}
|
||||
</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
|
||||
|
||||
Reference in new issue
Block a user