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

+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>
);