Pin the panel's rules with tests

Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.

The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.

The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:23:05 +02:00
1 parent 63c7f63f99
commit 06fdeb0336
6 files changed
+865 -103

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The Health Score panel at 360 px.
*
* The panel is the widest-content block in the hero band: a fixed label
* column, a fill bar, a value column and a popover trigger on one line, plus
* a coalesced failed-read line carrying an inline button, plus a disclosure
* whose open state adds a bordered tile and three prose paragraphs. 360 px is
* the narrowest common Android width and the one this overview has scrolled
* sideways at before.
*
* Two ways a guard like this proves nothing, both of which have bitten this
* repo: measuring only `document.scrollWidth` when `AuthShell` scrolls inside
* `<main>`, and measuring an empty page. The shared helper closes the first;
* the fixture and the fixture-honesty test below close the second by asserting
* the panel is actually on screen with rows in it before any width is read.
*
* Assertions are on `data-slot` attributes, never on rendered text, so a
* locale change or a copy edit cannot turn this red for the wrong reason.
*/
const PANEL = '[data-slot="health-score-card"]';
test.describe("the Health Score panel at phone width", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("is on screen with its rows before anything is measured", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 360, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const panel = page.locator(PANEL);
await expect(panel).toBeVisible();
await expect(
panel.locator('[data-slot="health-score-card-number"]'),
).toBeVisible();
// Four scored pillars in the fixture; a panel with no rows would fit any
// width and prove nothing about the row geometry.
await expect(panel.locator("li[data-status='ok']")).toHaveCount(4);
// The failed read and its retry are visible without opening anything.
await expect(
panel.locator('[data-slot="health-score-pillar-error"]'),
).toBeVisible();
await expect(
panel.locator('[data-slot="health-score-pillar-retry"]'),
).toBeVisible();
await expect(
panel.locator('[data-slot="health-score-crisis"]'),
).toBeVisible();
await expect(
panel.locator('[data-slot="health-score-not-scored-count"]'),
).toBeVisible();
});
test("does not scroll the page sideways, closed or open", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 360, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const panel = page.locator(PANEL);
await expect(panel).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, "/insights score panel @360px");
// Opened, the disclosure adds the bordered weight-goal tile and the method
// paragraphs — the widest content the panel can hold.
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
await expect(
panel.locator('[data-slot="health-score-weight-goal"]'),
).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, "/insights score panel open @360px");
});
test("keeps the panel inside the band, never wider than it", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 360, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const panel = page.locator(PANEL);
await expect(panel).toBeVisible();
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
await expect(
panel.locator('[data-slot="health-score-weight-goal"]'),
).toBeVisible();
const measured = await panel.evaluate((el) => {
const band = el.closest('[data-slot="insights-hero-strip"]');
// The widest descendant that is not contained by its own scroller.
let widest = 0;
for (const child of Array.from(el.querySelectorAll("*"))) {
const r = child.getBoundingClientRect();
if (r.width > widest) widest = r.width;
}
return {
panelScroll: el.scrollWidth,
panelClient: el.clientWidth,
panelRight: Math.round(el.getBoundingClientRect().right),
bandRight: band ? Math.round(band.getBoundingClientRect().right) : null,
widestChild: Math.round(widest),
};
});
expect(
measured.panelScroll,
`panel scrolls its own content: ${JSON.stringify(measured)}`,
).toBeLessThanOrEqual(measured.panelClient + 1);
expect(measured.bandRight).not.toBeNull();
expect(
measured.panelRight,
`panel extends past the band: ${JSON.stringify(measured)}`,
).toBeLessThanOrEqual(measured.bandRight! + 1);
expect(
measured.widestChild,
`a child is wider than the panel: ${JSON.stringify(measured)}`,
).toBeLessThanOrEqual(measured.panelClient + 1);
});
});
+131
View File
@@ -97,6 +97,115 @@ const COINCIDENT_FIRED = {
reason: null,
};
/**
* A populated Health Score report for the hero band's score column.
*
* The panel is the widest-content block in the band: a fixed-width label
* column, a fill bar, a value column and a popover trigger all on one 20 px
* line, plus a coalesced failed-read line that carries an inline button. An
* empty score leaves the column unmounted entirely, so without this the
* phone-width guard measured a band that had no panel in it.
*
* Every state the panel can paint is present at once: scored rows, a failed
* read with its retry, safety signposting, and counted absence.
*/
function scorePillar(id: string, domain: string, score: number) {
return {
id,
domain,
result: {
status: "ok" as const,
value: {
score,
observed: {
value: 126,
unit: "mmHg",
label: "126/78 mmHg",
asOf: NOW,
sources: ["MANUAL"],
},
reference: {
kind: "clinical-threshold" as const,
low: 120,
high: 129,
label: "120 to 129/70 to 79 mmHg",
source: "ESH 2023",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: id,
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance([id]),
},
};
}
function gatedPillar(id: string, domain: string, reason: string) {
return {
id,
domain,
result: {
status: "insufficient" as const,
coverage: {
requiredInputs: 4,
presentInputs: 0,
historyDays: 0,
missing: [] as string[],
},
provenance: provenance([id]),
reason,
},
};
}
const HEALTH_SCORE = {
composite: {
status: "ok" as const,
value: {
score: 74,
band: "green" as const,
bandSetter: "SLEEP",
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"],
noiseFloor: 3,
scoreVersion: 2,
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]),
},
pillars: [
scorePillar("BLOOD_PRESSURE", "cardiometabolic", 56),
scorePillar("ACTIVITY", "activity", 88),
scorePillar("SLEEP", "sleep", 41),
scorePillar("ADIPOSITY", "adiposity", 72),
gatedPillar("FITNESS", "fitness", "read_failed"),
gatedPillar("LIPIDS", "cardiometabolic", "read_failed"),
gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"),
gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"),
],
delta: 2,
deltaReason: null,
scoreVersion: 2,
weightGoal: {
status: "ok" as const,
value: {
currentKg: 81,
target: { min: 74, max: 78 },
distanceKg: 3,
deltaKg: 1,
asOf: NOW,
source: "MANUAL",
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance(["WEIGHT"]),
},
algorithmNotice: null,
restMode: null,
};
/** A deterministic sparkline series around `center`. */
function series(center: number, spread: number): number[] {
return Array.from({ length: 30 }, (_, i) =>
@@ -253,6 +362,28 @@ export async function mockPopulatedInsights(page: Page): Promise<void> {
}),
);
// The hero band's score column. The real response is fetched and the score
// injected into it, so every OTHER field the page reads off `/api/analytics`
// stays authentic and this fixture cannot silently blank a section it was
// never meant to touch.
await page.route(/\/api\/analytics(\?|$)/, async (route) => {
const response = await route.fetch();
let payload: { data?: Record<string, unknown> | null; error?: unknown };
try {
payload = await response.json();
} catch {
payload = { data: {}, error: null };
}
await route.fulfill({
response,
contentType: "application/json",
body: JSON.stringify({
...payload,
data: { ...(payload.data ?? {}), healthScore: HEALTH_SCORE },
}),
});
});
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
// it is the card whose heading carries the provenance sentence. Any other
// metric falls through to the real handler.
+29 -16
View File
@@ -268,42 +268,55 @@ describe("v1.16.8 — insights overview cold paint", () => {
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
});
it("reserves the health-score card's footprint while the payload is pending", () => {
it("reserves the score column's footprint while the payload is pending", () => {
const src = load(INSIGHTS_PATH);
// The pinned slot holds the collapsed card's footprint from first paint,
// so the resolved card is a swap rather than a push.
// The reserve holds the column from first paint, so the resolved panel is
// a swap rather than a push.
expect(src).toMatch(
/analyticsQuery\.isPending && !analytics \? \(\s*<HealthScoreCardSkeleton/,
/scorePending=\{analyticsQuery\.isPending && !analytics\}/,
);
});
it("pins the health score under the hero, outside the section registry", () => {
it("mounts the health score once, in the hero, outside the section registry", () => {
const src = load(INSIGHTS_PATH);
// An id a saved layout has never seen merges in as default-INVISIBLE, so
// registering the score would delete it for every customised page. It is
// also no longer inside the briefing branch, which used to take the whole
// also outside the briefing branch, which used to take the whole
// breakdown down with the flag.
const hero = src.indexOf("<HeroStrip");
const score = src.indexOf("<HealthScoreCard");
const region = src.indexOf("orderedSectionIds.map");
// The JSX mount, not the `<HeroStrip>` mentioned in the file's header
// comment — matching the comment would have made the slice below read a
// block of prose and pass on nothing.
const hero = src.search(/\n\s*<HeroStrip\n/);
expect(hero).toBeGreaterThan(-1);
expect(score).toBeGreaterThan(hero);
expect(score).toBeLessThan(region);
expect(src).not.toMatch(/"health-score":/);
// Exactly one score surface on the page, and it is the hero's prop.
expect(src.match(/healthScore=\{/g) ?? []).toHaveLength(1);
expect(src).not.toContain("<HealthScoreCard");
expect(src).not.toContain("<HealthScoreCardSkeleton");
const briefingBranch = src.slice(
src.indexOf('"daily-briefing": flags.briefing'),
src.indexOf("vitals: <VitalsDashboard"),
);
expect(briefingBranch).not.toContain("HealthScoreCard");
expect(briefingBranch).not.toContain("healthScore");
// The band itself is not gated on the briefing flag either.
const heroCall = src.slice(hero, src.indexOf("/>", hero));
expect(heroCall).toContain("healthScore={analytics?.healthScore ?? null}");
expect(heroCall).not.toContain("flags.briefing");
});
it("hero strip carries no score surface at all", () => {
it("hero strip owns the one score surface", () => {
const heroSrc = readFileSync(
join(ROOT, "src/components/insights/hero-strip.tsx"),
"utf8",
);
expect(heroSrc).not.toContain("HealthScoreCard");
expect(heroSrc).not.toContain("health-score-card-skeleton");
expect(heroSrc).not.toContain("healthScorePending");
expect(heroSrc).toContain("<HealthScoreCard");
expect(heroSrc).toContain("<HealthScoreCardSkeleton");
// The split appears only when the column has something in it.
expect(heroSrc).toContain("md:flex-row md:items-stretch");
expect(heroSrc).toMatch(
/\(healthScore \|\| scorePending\) &&\s*"md:flex-row/,
);
});
});
@@ -165,6 +165,20 @@ const FASTING_GLUCOSE: ScorePillarResult = {
},
};
/** A scored pillar at an arbitrary score, for the partition + tint tests. */
function scoredAt(
id: ScorePillarResult["id"],
score: number,
): ScorePillarResult {
const base = BLOOD_PRESSURE.result;
if (base.status !== "ok") throw new Error("fixture is not scored");
return {
...BLOOD_PRESSURE,
id,
result: { ...base, value: { ...base.value, score } },
};
}
function gatedPillar(
id: ScorePillarResult["id"],
reason: string,
@@ -253,6 +267,35 @@ function anatomyRegion(html: string): string {
return html.slice(start);
}
/** Everything visible without opening the disclosure. */
function atRest(html: string): string {
const end = html.indexOf('data-slot="health-score-anatomy-region"');
expect(end).toBeGreaterThan(-1);
return html.slice(0, end);
}
/** The named not-scored list itself, not merely the region containing it. */
function notScoredList(html: string): string {
const start = html.indexOf('data-slot="health-score-not-scored"');
expect(start).toBeGreaterThan(-1);
const end = html.indexOf("</ul>", start);
expect(end).toBeGreaterThan(start);
return html.slice(start, end);
}
/** Every `data-pillar="X"` in document order — the plural attribute never matches. */
function pillarAttrs(html: string): string[] {
return [...html.matchAll(/data-pillar="([^"]+)"/g)].map((m) => m[1]);
}
/** The ids a coalesced line declares it speaks for. */
function pillarsAttr(html: string, slot: string): string[] {
const m = html.match(
new RegExp(`data-slot="${slot}"\\s+data-pillars="([^"]*)"`),
);
return m && m[1] ? m[1].split(",") : [];
}
beforeEach(() => {
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" };
});
@@ -268,42 +311,107 @@ describe("<HealthScoreCard> disclosure", () => {
const controls = html.match(/aria-controls="([^"]+)"/);
expect(controls).not.toBeNull();
expect(html).toContain(`id="${controls![1]}" hidden=""`);
expect(anatomyRegion(html)).toContain('data-slot="health-score-pillars"');
expect(anatomyRegion(html)).toContain('data-slot="health-score-method"');
});
it("keeps the score face outside the disclosure", () => {
it("keeps the number and the contributor rows out of the disclosure", () => {
const html = render(<HealthScoreCard report={scoredReport()} />);
const face = html.slice(
0,
html.indexOf('data-slot="health-score-anatomy-toggle"'),
);
expect(face).toContain('data-slot="health-score-band"');
expect(face).toContain('data-slot="health-score-delta"');
expect(face).toContain(">86<");
// The pillar grid and the method footer belong to the opened region only.
expect(face).not.toContain('data-slot="health-score-pillars"');
expect(face).not.toContain('data-slot="health-score-method"');
const rest = atRest(html);
// The number, the bar, the band sentence and the delta are the panel's
// face; the rows sit directly under them, not behind a toggle.
expect(rest).toContain('data-slot="health-score-card-number"');
expect(rest).toContain('data-slot="health-score-card-progress"');
expect(rest).toContain('data-slot="health-score-band"');
expect(rest).toContain('data-slot="health-score-delta"');
expect(rest).toContain(">86<");
expect(rest).toContain('data-slot="health-score-pillars"');
// The named reasons, the weight goal and the method footer are what the
// disclosure is for.
expect(rest).not.toContain('data-slot="health-score-method"');
expect(rest).not.toContain('data-slot="health-score-weight-goal"');
expect(rest).not.toContain('data-slot="health-score-not-scored"');
});
it("wears the hero column's width and stretches to the band", () => {
const html = render(<HealthScoreCard report={scoredReport()} />);
// The panel owns its own column geometry, so the band's `items-stretch`
// can equalise it against the greeting.
expect(html).toContain("md:basis-[22rem]");
expect(html).toContain("xl:basis-[26rem]");
expect(html).toContain("md:shrink-0");
expect(html).toContain("flex h-full flex-col");
});
});
describe("<HealthScoreCard> pillars", () => {
it("grids the scored pillars only and never draws a gated one as a cell", () => {
it("gives a row to the scored pillars only, never to a gated one", () => {
const html = render(<HealthScoreCard report={scoredReport()} />);
const region = anatomyRegion(html);
expect(region).toContain("sm:grid-cols-2");
expect(region).toContain("lg:grid-cols-3");
expect(region).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
expect(region).not.toContain('data-pillar="ACTIVITY" data-status="ok"');
// Blood pressure scores 82, so its impact bar is band-green at 82%.
expect(region).toContain("width:82%");
expect(region).toContain("bg-success");
const rest = atRest(html);
expect(rest).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
expect(rest).not.toContain('data-pillar="ACTIVITY" data-status="ok"');
// One line per row: a truncating label column, a thin bar, the score.
expect(rest).toContain("grid-cols-[minmax(0,8rem)_1fr_2rem_auto]");
expect(rest).toContain("width:82%");
});
it("names every gated pillar with its reason instead of scoring it zero", () => {
it("tints each row with its OWN band, not the composite's", () => {
// The composite is red and the two pillars sit either side of the green
// threshold. The old rows took the composite's colour and painted the
// strong pillar red.
const html = render(
<HealthScoreCard
report={scoredReport({
composite: {
status: "ok",
value: {
score: 31,
band: "red",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
noiseFloor: 3,
scoreVersion: 2,
},
coverage: {
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
pillars: [
scoredAt("BLOOD_PRESSURE", 92),
scoredAt("GLYCAEMIA", 22),
scoredAt("SLEEP", 55),
],
})}
/>,
);
const rows = atRest(html).split('data-pillar="');
const barClass = (id: string) => {
const row = rows.find((r) => r.startsWith(id));
expect(row, `${id} has no row`).toBeDefined();
const m = row!.match(
/data-slot="health-score-pillar-bar" class="([^"]*)"/,
);
expect(m, `${id} has no bar`).not.toBeNull();
return m![1];
};
expect(barClass("BLOOD_PRESSURE")).toContain("bg-success");
expect(barClass("GLYCAEMIA")).toContain("bg-destructive");
expect(barClass("SLEEP")).toContain("bg-warning");
// The composite is red, so a composite-tinted set would be all-red.
expect(barClass("BLOOD_PRESSURE")).not.toContain("bg-destructive");
});
it("counts absence at rest and names it behind the disclosure", () => {
const html = render(<HealthScoreCard report={scoredReport()} />);
// At rest: a count, never a name drawn as a zero.
expect(atRest(html)).toContain("Not scored yet (1)");
expect(atRest(html)).toContain('data-count="1"');
const region = anatomyRegion(html);
expect(region).toContain('data-slot="health-score-not-scored"');
expect(region).toContain("Not scored yet (1)");
expect(region).toContain('data-reason="below_day_floor_or_stale"');
expect(region).toContain("Not enough recent eligible data");
expect(region).not.toContain("below_day_floor_or_stale<");
@@ -323,17 +431,52 @@ describe("<HealthScoreCard> pillars", () => {
onRetry={retry}
/>,
);
const region = anatomyRegion(html);
expect(region).toContain('data-slot="health-score-pillar-error"');
expect(region).toContain('role="alert"');
expect(region).toContain('data-slot="health-score-pillar-retry"');
expect(region).toContain("Data could not be loaded");
// A failure may be quiet, but it may not hide: the line and its retry are
// visible without opening anything.
const rest = atRest(html);
expect(rest).toContain('data-slot="health-score-pillar-error"');
expect(rest).toContain('role="alert"');
expect(rest).toContain('data-slot="health-score-pillar-retry"');
expect(rest).toContain("Data could not be loaded");
expect(rest).toContain("Not scored yet (1)");
// A failed read is NOT absence, so it stays out of the "not scored" list.
expect(region).toContain("Not scored yet (1)");
const region = anatomyRegion(html);
expect(region).not.toContain('data-reason="read_failed"');
expect(notScoredList(html)).not.toContain('data-reason="read_failed"');
expect(notScoredList(html)).not.toContain('data-pillar="SLEEP"');
});
it("leads the gated list with safety signposting", () => {
it("coalesces every failed read into one line with one retry", () => {
// Lipids and HbA1c come off one labs read, so a single failure gates two
// pillars. One cause, one line, one retry — and the retry refetches the
// whole payload anyway.
const html = render(
<HealthScoreCard
report={scoredReport({
pillars: [
BLOOD_PRESSURE,
gatedPillar("LIPIDS", "read_failed"),
gatedPillar("GLYCAEMIA", "read_failed"),
],
})}
onRetry={vi.fn()}
/>,
);
expect(
html.match(/data-slot="health-score-pillar-error"/g) ?? [],
).toHaveLength(1);
expect(
html.match(/data-slot="health-score-pillar-retry"/g) ?? [],
).toHaveLength(1);
expect(pillarsAttr(html, "health-score-pillar-error")).toEqual([
"LIPIDS",
"GLYCAEMIA",
]);
expect(atRest(html)).toContain("Lipids, Glycaemia: Data could not be");
});
it("names safety signposting at rest instead of counting it", () => {
const html = render(
<HealthScoreCard
report={scoredReport({
@@ -345,18 +488,108 @@ describe("<HealthScoreCard> pillars", () => {
})}
/>,
);
const region = anatomyRegion(html);
expect(region).toContain("Not scored yet (2)");
const crisis = region.indexOf('data-reason="crisis_signposting"');
const activity = region.indexOf('data-reason="below_day_floor_or_stale"');
expect(crisis).toBeGreaterThan(-1);
expect(crisis).toBeLessThan(activity);
expect(region).toContain("Safety guidance is shown instead of a score");
// Safety copy is content: it is named in full on the surface, not folded
// into a count and not hidden behind the toggle.
const rest = atRest(html);
expect(rest).toContain('data-slot="health-score-crisis"');
expect(rest).toContain("Wellbeing: Safety guidance is shown instead of");
// …and therefore it is not also one of the anonymous counted absences.
expect(rest).toContain("Not scored yet (1)");
expect(rest).toContain('data-count="1"');
expect(anatomyRegion(html)).not.toContain(
'data-reason="crisis_signposting"',
);
});
});
describe("<HealthScoreCard> one pillar, one home", () => {
/** Four scored, one failed read, one crisis, two plain absences. */
function mixedReport() {
return scoredReport({
pillars: [
scoredAt("BLOOD_PRESSURE", 82),
scoredAt("GLYCAEMIA", 74),
scoredAt("ACTIVITY", 61),
scoredAt("SLEEP", 45),
gatedPillar("FITNESS", "read_failed"),
gatedPillar("WELLBEING", "crisis_signposting"),
gatedPillar("ADIPOSITY", "missing_height"),
gatedPillar("LIPIDS", "not_tracked"),
],
});
}
it("puts every pillar in exactly one of the four homes", () => {
const html = render(<HealthScoreCard report={mixedReport()} />);
const rest = atRest(html);
const rowIds = [
...rest.matchAll(/data-pillar="([^"]+)" data-status="ok"/g),
].map((m) => m[1]);
const failedIds = pillarsAttr(html, "health-score-pillar-error");
const crisisIds = pillarsAttr(html, "health-score-crisis");
const counted = Number(rest.match(/data-count="(\d+)"/)?.[1] ?? 0);
expect(rowIds).toEqual([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
]);
expect(failedIds).toEqual(["FITNESS"]);
expect(crisisIds).toEqual(["WELLBEING"]);
expect(counted).toBe(2);
// The four homes partition the eight pillars: disjoint, and complete.
const named = [...rowIds, ...failedIds, ...crisisIds];
expect(new Set(named).size).toBe(named.length);
expect(named.length + counted).toBe(8);
});
it("never renders one pillar id twice anywhere in the panel", () => {
const html = render(<HealthScoreCard report={mixedReport()} />);
// The scored rows and the named not-scored list both carry `data-pillar`;
// the coalesced lines carry the plural `data-pillars` and are not matched
// here. Seeing an id twice is exactly the complaint this panel answers.
const ids = pillarAttrs(html);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.sort()).toEqual(
[
"ACTIVITY",
"ADIPOSITY",
"BLOOD_PRESSURE",
"GLYCAEMIA",
"LIPIDS",
"SLEEP",
].sort(),
);
});
it("never renders one pillar's name twice anywhere in the panel", () => {
const html = render(<HealthScoreCard report={mixedReport()} />);
// The method footer's "Overall band set by …" sentence names a pillar as
// prose rather than listing it, so it is not part of this count.
const methodAt = html.indexOf('data-slot="health-score-method"');
expect(methodAt).toBeGreaterThan(-1);
const listed = html.slice(0, methodAt);
for (const label of [
"Blood pressure",
"Glycaemia",
"Activity",
"Sleep",
"Adiposity",
"Wellbeing",
"Measured fitness",
"Lipids",
]) {
const hits = listed.split(label).length - 1;
expect(hits, `${label} appears ${hits} times`).toBeLessThanOrEqual(1);
}
});
});
describe("<HealthScoreCard> composite states", () => {
it("shows a provisional ring and the learning gate when the composite is gated", () => {
it("shows a dash and the learning line when the composite is gated", () => {
const html = render(<HealthScoreCard report={report()} />);
expect(html).toMatch(
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
@@ -365,8 +598,11 @@ describe("<HealthScoreCard> composite states", () => {
expect(html).toContain(
"The score needs at least three, including a measured physiological pillar.",
);
// No band sentence and no fabricated number in the face.
// 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"');
expect(html).toContain("—");
expect(html).not.toContain("/ 100");
expect(html).toContain("Method version 2");
});
@@ -389,17 +625,30 @@ describe("<HealthScoreCard> composite states", () => {
returnToBand={{ metricType: "RESTING_HEART_RATE", daysInside: 6 }}
/>,
);
const face = html.slice(
0,
html.indexOf('data-slot="health-score-anatomy-toggle"'),
);
expect(face).toContain("Rest Mode active since 2026-07-20");
expect(face).toContain('data-slot="health-score-tension"');
expect(face).toContain('data-slot="health-score-return-to-band"');
const rest = atRest(html);
expect(rest).toContain("Rest Mode active since 2026-07-20");
expect(rest).toContain('data-slot="health-score-tension"');
expect(rest).toContain('data-slot="health-score-return-to-band"');
// The contributor key and the metric type are localised, never raw.
expect(face).toContain("Resting heart rate");
expect(face).not.toContain("RESTING_HEART_RATE");
expect(face).not.toContain(">rhr<");
expect(rest).toContain("Resting heart rate");
expect(rest).not.toContain("RESTING_HEART_RATE");
expect(rest).not.toContain(">rhr<");
});
it("shows the weekly gain as a chip beside the label", () => {
const html = render(<HealthScoreCard report={scoredReport()} />);
expect(html).toContain('data-slot="health-score-card-label"');
expect(html).toContain('data-slot="health-score-card-delta-chip"');
expect(html).toMatch(/data-slot="health-score-card-delta-chip"[^>]*>\+2</);
});
it("keeps the chip away from a flat or falling week", () => {
const html = render(
<HealthScoreCard report={scoredReport({ delta: -3 })} />,
);
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
// The line still states it — only the green chip is reserved for a gain.
expect(html).toContain('data-slot="health-score-delta"');
});
it("keeps a one-sided tension quiet", () => {
@@ -497,9 +746,17 @@ describe("pillar detail formatting", () => {
const html = render(
<HealthScoreCard report={scoredReport({ pillars: [FASTING_GLUCOSE] })} />,
);
// The card mounts with mmol/L accounts too; the visible observed line is
// the one always on screen.
expect(html).toContain("Observed: Fasting glucose: 100 mg/dL");
// The observed value moved into the row's popover, which Radix mounts
// only once opened — so the panel shows the row, and the value itself is
// asserted through the same pure formatter the popover is fed.
expect(html).toContain('data-pillar="GLYCAEMIA" data-status="ok"');
expect(html).toContain('data-slot="info-popover-trigger"');
expect(
pillarObservedText(FASTING_GLUCOSE, {
t: translator(),
glucoseUnit: "mg/dL",
}),
).toBe("Fasting glucose: 100 mg/dL");
const mmol = pillarObservedText(FASTING_GLUCOSE, {
t: translator(),
@@ -575,12 +832,35 @@ describe("pillar detail formatting", () => {
});
describe("<HealthScoreCardSkeleton>", () => {
it("reserves the collapsed footprint and announces nothing", () => {
it("reserves the column's footprint and announces nothing", () => {
const html = render(<HealthScoreCardSkeleton />);
expect(html).toContain('data-slot="health-score-card-skeleton"');
expect(html).toContain('aria-hidden="true"');
// The `md` ring is a fixed 168 px box; the reserve matches it exactly.
expect(html).toContain("size-[168px]");
// A reserve that is not the panel's width is not a reserve.
expect(html).toContain("md:basis-[22rem]");
expect(html).toContain("xl:basis-[26rem]");
// 480 px: measured against the built panel, which resolves to 440 px at
// three rows and 524 px at six, so the reserve sits in the middle.
expect(html).toContain("min-h-[30rem]");
});
it("holds the same column geometry the resolved panel does", () => {
const skeleton = render(<HealthScoreCardSkeleton />);
const panel = render(<HealthScoreCard report={scoredReport()} />);
// Any drift between the two is a jump when the payload lands.
for (const cls of [
"w-full",
"md:shrink-0",
"md:grow-0",
"md:basis-[22rem]",
"xl:basis-[26rem]",
"flex h-full flex-col",
"rounded-xl border p-4",
"md:p-6",
]) {
expect(skeleton, cls).toContain(cls);
expect(panel, cls).toContain(cls);
}
});
});
@@ -3,6 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { HeroStrip } from "../hero-strip";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
import type {
HealthScoreReport,
ScorePillarId,
ScorePillarResult,
} 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.
@@ -37,6 +42,120 @@ const sampleBriefing: DailyBriefingPayload = {
// (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC
// instants and translate them to the intended Berlin wall-clock hour.
// May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h.
const provenance = {
inputs: ["BLOOD_PRESSURE"],
source: "live" as const,
windowDays: 90,
computedAt: "2026-07-28T12:00:00.000Z",
};
function scored(id: ScorePillarId, score: number): ScorePillarResult {
return {
id,
domain: "cardiometabolic",
result: {
status: "ok",
value: {
score,
observed: {
value: 126,
unit: "mmHg",
label: "126/78 mmHg",
asOf: "2026-07-27T08:00:00.000Z",
sources: ["MANUAL"],
},
reference: {
kind: "clinical-threshold",
low: 120,
high: 129,
label: "120 to 129 mmHg",
source: "ESH 2023",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: id,
},
coverage: {
requiredInputs: 12,
presentInputs: 12,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
};
}
function gated(id: ScorePillarId, reason: string): ScorePillarResult {
return {
id,
domain: "wellbeing",
result: {
status: "insufficient",
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [],
},
provenance: { ...provenance, inputs: [id] },
reason,
},
};
}
function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
return {
composite: {
status: "ok",
value: {
score: 74,
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE"],
noiseFloor: 3,
scoreVersion: 2,
},
coverage: {
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
pillars,
delta: 2,
deltaReason: null,
scoreVersion: 2,
weightGoal: {
status: "insufficient",
coverage: {
requiredInputs: 2,
presentInputs: 0,
historyDays: 0,
missing: [],
},
provenance: { ...provenance, inputs: ["WEIGHT"] },
reason: "no_personal_goal",
},
algorithmNotice: null,
};
}
const SCORE = scoreReport([
scored("BLOOD_PRESSURE", 82),
scored("ACTIVITY", 61),
gated("LIPIDS", "not_tracked"),
]);
const SCORE_WITH_FAILED_READ = scoreReport([
scored("BLOOD_PRESSURE", 82),
gated("LIPIDS", "read_failed"),
]);
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
@@ -238,27 +357,95 @@ describe("<HeroStrip>", () => {
expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"');
});
// ── B5 — the Health Score left the hero band ──────────────────────
// The score used to mount twice: a compact face in this band and the full
// breakdown below it. The breakdown is now one pinned card under the band
// (`page-client.tsx`), so the hero carries no score surface at all — no
// panel, no reserved column, no skeleton.
it("never renders a Health Score surface in the band", () => {
// ── The Health Score column ────────────────────────────────────────
// The score is back in the band's right column, and this band is its only
// mount on the overview.
it("keeps the greeting full width when there is no score", () => {
// An account without a score keeps the single-column band it always had;
// the split must not appear around an empty slot.
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain("md:flex-row");
expect(html).not.toContain("md:items-stretch");
expect(html).not.toContain('data-slot="health-score-card"');
expect(html).not.toContain('data-slot="health-score-card-compact"');
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
});
it("keeps the greeting on the full width with no reserved second column", () => {
// With the panel gone the band is single-column on every breakpoint; the
// `md:`/`lg:` split + stretch contract it carried for the score column
// must not come back, or the greeting reflows around an empty slot.
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain("md:flex-row");
expect(html).not.toContain("lg:flex-row");
expect(html).not.toContain("md:items-stretch");
expect(html).not.toContain("md:basis-[22rem]");
it("splits into two columns and paints the panel when a score is present", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
);
expect(html).toContain("md:flex-row");
expect(html).toContain("md:items-stretch");
expect(html).toContain('data-slot="health-score-card"');
// The greeting keeps its own column and can shrink inside it.
expect(html).toContain("min-w-0 flex-1");
expect(html).toContain('data-slot="insights-hero-strip-greeting"');
});
it("reserves the column while the payload is in flight", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} scorePending />,
);
// The split is already there, so the greeting does not re-wrap when the
// score lands — that reflow is why the reserve exists at all.
expect(html).toContain("md:flex-row");
expect(html).toContain('data-slot="health-score-card-skeleton"');
expect(html).toContain("md:basis-[22rem]");
expect(html).not.toContain('data-slot="health-score-card"');
});
it("prefers the resolved panel over the reserve", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={SCORE}
scorePending
/>,
);
expect(html).toContain('data-slot="health-score-card"');
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
});
it("renders the full panel with no briefing at all", () => {
// The score used to live inside the daily-briefing branch, so an instance
// with the briefing switched off lost the entire breakdown. The band
// renders unconditionally and carries the panel whole.
const html = render(
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
);
expect(html).toContain("A daily read of your trends"); // the no-briefing subtitle
for (const slot of [
"health-score-card",
"health-score-card-label",
"health-score-card-number",
"health-score-card-progress",
"health-score-band",
"health-score-pillars",
"health-score-not-scored-count",
"health-score-anatomy-toggle",
"health-score-anatomy-region",
"health-score-not-scored",
"health-score-weight-goal",
"health-score-method",
]) {
expect(html, slot).toContain(`data-slot="${slot}"`);
}
expect(html).toContain(">74<");
expect(html).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
});
it("forwards the retry so a failed read can be retried from the band", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={SCORE_WITH_FAILED_READ}
onScoreRetry={() => {}}
/>,
);
expect(html).toContain('data-slot="health-score-pillar-error"');
expect(html).toContain('data-slot="health-score-pillar-retry"');
});
it("does not render any Ask-the-Coach affordance in the hero band", () => {
+31 -16
View File
@@ -105,15 +105,17 @@ const PANEL_CHROME_CLASS =
"bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6";
/**
* Height the loading reserve holds so the greeting does not reflow when the
* analytics payload lands. Measured against the built panel at 352 px (the
* `md` column) with a five-row, one-failed-read report: 431 px including the
* card padding, of which the widest realistic case is what matters. Held a
* little under the measurement so a shorter report does not paint a gap:
* a three-row report measures 341 px, and 21rem/336px is the floor both sit
* near. See `health-score-card-geometry.test.ts` for the numbers.
* Height the reserve holds while the payload is in flight, so the band does
* not jump when the score lands. Not a guess: the panel was rendered with the
* app's own stylesheet and typeface and measured in a browser at the `md`
* column width of 352 px. A three-row account resolves to 440 px, four rows
* 468, five rows 496, six rows 524 — so 480 px sits in the middle of the 3-to-6
* row range the report actually produces, and the reserve above and below it
* is symmetric rather than always one-sided. The blocks in the reserve below
* mirror the panel's own, which brings its natural height to 484 px, within
* four pixels of the floor.
*/
const PANEL_RESERVE_CLASS = "min-h-[21rem]";
const PANEL_RESERVE_CLASS = "min-h-[30rem]";
/**
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces.
@@ -636,8 +638,14 @@ function PillarRow({
<li
data-pillar={pillar.id}
data-status="ok"
className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs"
className="grid grid-cols-[minmax(0,8rem)_1fr_2rem_auto] items-center gap-2 text-xs"
>
{/* 8rem, not 7: rendered in all six locales, 7rem clipped the longest
label in five of them — "Körperfettverteilung" and "Forma física
medida" by two pixels each, which reads as a bug rather than as a
truncation. 8rem clears every label except the French "Condition
physique mesurée", which needs 161 px and would not fit any column
width this panel can afford. */}
<span className="text-muted-foreground min-w-0 truncate">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
@@ -765,22 +773,29 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) {
className,
)}
>
{/* Block for block, the panel above: label, number, bar, two qualifier
lines, five contributor rows, one not-contributing line, and the
foot with its meter and toggle. */}
<Skeleton className="h-4 w-24" />
<Skeleton className="h-12 w-28" />
<Skeleton className="h-15 w-28" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-32" />
<div className="space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2 pt-3">
<div className="border-border/60 space-y-2 border-t pt-3">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
</div>
<Skeleton className="h-4 w-36" />
<div className="mt-auto space-y-3 pt-1">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-11 w-full rounded-sm" />
<Skeleton className="h-11 w-28 rounded-sm" />
<div className="border-border/60 border-t pt-1">
<Skeleton className="h-11 w-full rounded-sm" />
</div>
</div>
</div>
);