Merge work/hero-score-panel

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:48:22 +02:00
commit f8074e1b07
10 files changed
+1811 -367

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.
@@ -174,6 +174,11 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
// The green `+N` chip beside the Health Score label. It reports a week that
// went up, not an action that succeeded, and it wears the same band token
// the score's own number does — there is no outcome here to route through
// the outcome module.
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
+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/,
);
});
});
+19 -29
View File
@@ -26,10 +26,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import {
HealthScoreCard,
HealthScoreCardSkeleton,
} from "@/components/insights/health-score-card";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
@@ -561,33 +557,27 @@ export default function InsightsPageClient() {
// <relative>" line with a discreet connect-provider hint, matching
// the briefing card footer. Only when a briefing is actually shown.
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// The Health Score lives in the band's right column and NOWHERE else
// on this page. It is deliberately not a registry section: an id a
// saved layout has never seen merges in as default-INVISIBLE
// (`orderedVisibleSectionIds`), so registering it would silently
// delete the score for every account that has ever customised this
// page. It is also outside the daily-briefing branch, so an instance
// with the briefing switched off keeps the whole breakdown.
healthScore={analytics?.healthScore ?? null}
// `isPending` settles false on success AND error, so a no-score
// account drops the reserve exactly once.
scorePending={analyticsQuery.isPending && !analytics}
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
// baseline off the dashboard snapshot. The panel localises the keys;
// null leaves it quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
// A pillar whose read FAILED is a named line inside the panel, and the
// retry there refetches the payload that carries every pillar.
onScoreRetry={() => void analyticsQuery.refetch()}
/>
{/* The Health Score is PINNED here, between the greeting and the
customizable region, and is deliberately NOT a registry section: an
id a saved layout has never seen merges in as default-INVISIBLE
(`orderedVisibleSectionIds`), so registering it would silently delete
the score for every account that has ever customised this page. It
also used to render inside the daily-briefing branch, which meant an
instance with the briefing switched off lost the whole breakdown.
`isPending` settles false on success AND error, so a no-score account
drops the reserve exactly once. */}
{analyticsQuery.isPending && !analytics ? (
<HealthScoreCardSkeleton />
) : analytics?.healthScore ? (
<HealthScoreCard
report={analytics.healthScore}
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
// baseline off the dashboard snapshot. The card localises the keys;
// null leaves it quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
// A pillar whose read FAILED is an error row inside the card, and a
// retry there refetches the payload that carries every pillar.
onRetry={() => void analyticsQuery.refetch()}
/>
) : null}
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
overview (section show/hide + order) and sorting the nav pills now
live ONLY in Settings → Insights, reached via the top-right cog on the
@@ -0,0 +1,488 @@
import { createRequire } from "node:module";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { Browser, Page } from "@playwright/test";
import { I18nProvider } from "@/lib/i18n/context";
import { locales, type Locale } from "@/lib/i18n/config";
import type {
HealthScoreReport,
ScorePillarResult,
} from "@/lib/analytics/score/types";
import { HeroStrip } from "../hero-strip";
/**
* The hero band's geometry, measured rather than asserted from source.
*
* Two numbers in `health-score-card.tsx` cannot be checked by reading the
* file: the height the loading reserve holds, and the width of the row's
* label column. Both were carried for a while as docblock claims with no way
* to reproduce them. This file is that way: it renders the REAL band with the
* REAL compiled stylesheet, lays it out in a browser at the real column
* width, and reads the boxes back.
*
* What it does NOT do is pretend to know the shipped typeface. The app loads
* Inter through `next/font/google` at build time; no font file is committed,
* so a headless browser here resolves the `font-sans` fallback chain instead.
* Every assertion below is therefore written to be font-independent — a
* relative comparison inside one run, or a structural property (does the row
* push, does the label ellipsise, is the bar still there) that holds in any
* typeface. The absolute pixel numbers this file prints are true of the run
* that printed them and are reported as such, never baked into a comment as
* if they were a property of the design.
*
* It needs a Chromium build. `pnpm test` on a machine that has one (any
* machine that has run `pnpm e2e`) measures for real; a machine without one
* skips loudly rather than passing quietly. The unit CI job installs no
* browsers, so treat this as a check for the person editing the panel and for
* the release audit, not as the gate.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }),
}));
/** The `md` column: `md:basis-[22rem]`, i.e. 352 CSS px. */
const COLUMN_PX = 352;
/** Wide enough for the two-column split, narrow enough to stay below `xl`. */
const VIEWPORT_PX = 1024;
/** One `text-xs` row is a 16 px line; two lines would exceed this. */
const ONE_LINE_PX = 20;
/**
* The row's info trigger carries `-mx-2` so its 44 px tap target does not
* inflate a 20 px row: it reaches 8 px into the card's own padding on each
* side, which is deliberate and stays inside the panel. Anything past that is
* a real push, so the row check allows exactly this much and no more.
*/
const TAP_TARGET_OVERHANG_PX = 8;
const require = createRequire(import.meta.url);
/**
* Compile `globals.css` the way the app does.
*
* `postcss` is not a direct dependency — it arrives under the Tailwind
* PostCSS plugin, which is. Resolving it from that plugin's own location is
* what keeps this working under pnpm's strict layout without adding a
* dependency for a check.
*/
async function compileStylesheet(): Promise<string> {
const pluginEntry = require.resolve("@tailwindcss/postcss");
const postcss = require(require.resolve("postcss", { paths: [pluginEntry] }));
const tailwind = require(pluginEntry);
const fs = await import("node:fs/promises");
const source = await fs.readFile("src/app/globals.css", "utf8");
const plugin = tailwind.default ? tailwind.default() : tailwind();
const result = await postcss([plugin]).process(source, {
from: "src/app/globals.css",
});
return result.css;
}
const provenance = {
inputs: ["BLOOD_PRESSURE"],
source: "live" as const,
windowDays: 90,
computedAt: "2026-07-28T12:00:00.000Z",
};
const coverage = {
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [] as string[],
};
function scored(id: string, 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/70 to 79 mmHg",
source: "ESH 2023",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: `pillar_${id}`,
},
coverage,
confidence: { score: 100, band: "high" },
provenance,
},
} as ScorePillarResult;
}
/** Every pillar id, so each locale is measured on its own longest label. */
const ALL_PILLARS = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
] as const;
function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
return {
composite: {
status: "ok",
value: {
score: 74,
band: "green",
bandSetter: null,
composition: pillars.map((p) => p.id),
noiseFloor: 3,
scoreVersion: 2,
},
coverage,
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,
} as HealthScoreReport;
}
/** N scored rows, the shape the report actually produces (3 to 6, per §5). */
function reportWithRows(rows: number): HealthScoreReport {
return reportWith(
ALL_PILLARS.slice(0, rows).map((id, i) => scored(id, 80 - i * 4)),
);
}
function band(node: React.ReactNode, locale: Locale = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
let browser: Browser | null = null;
let page: Page | null = null;
let stylesheet = "";
let skipReason: string | null = null;
beforeAll(async () => {
try {
const { chromium } = await import("@playwright/test");
browser = await chromium.launch();
} catch (error) {
skipReason = `no Chromium build available (${String(error).slice(0, 120)})`;
// Loud on purpose: a geometry check that quietly skips is the same thing
// as no geometry check, and this repo has shipped that before.
console.warn(`[hero geometry] SKIPPED — ${skipReason}`);
return;
}
stylesheet = await compileStylesheet();
page = await browser.newPage({
viewport: { width: VIEWPORT_PX, height: 1200 },
});
}, 60_000);
afterAll(async () => {
await browser?.close();
});
/** Lay the rendered band out and hand the page back for measuring. */
async function layout(html: string, width = VIEWPORT_PX): Promise<Page> {
const target = page;
if (!target) throw new Error("browser page was never created");
await target.setContent(
`<!doctype html><html class="dark"><body class="font-sans bg-background" style="margin:0">
<div style="width:${width}px">${html}</div>
</body></html>`,
);
await target.addStyleTag({ content: stylesheet });
return target;
}
const PANEL = '[data-slot="health-score-card"]';
const RESERVE = '[data-slot="health-score-card-skeleton"]';
describe("hero band geometry", () => {
it("holds a reserve that sits inside the range the real report produces", async () => {
if (skipReason) return expect(skipReason).toBeTruthy();
const heights: Record<string, number> = {};
for (const rows of [3, 4, 5, 6]) {
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWithRows(rows)}
/>,
),
);
heights[`rows${rows}`] = await view
.locator(PANEL)
.evaluate((el) => Math.round(el.getBoundingClientRect().height));
const width = await view
.locator(PANEL)
.evaluate((el) => Math.round(el.getBoundingClientRect().width));
expect(width, "the panel is not on the md column width").toBe(COLUMN_PX);
}
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
scorePending
/>,
),
);
// Measured twice: as it renders, and with the declared minimum removed.
// If the two agree, the constant is decoration and the reserve is really
// whatever its blocks happen to add up to — which is how a reserve drifts
// without anyone touching the number that claims to own it.
const { reserve, natural } = await view.locator(RESERVE).evaluate((el) => {
const rendered = Math.round(el.getBoundingClientRect().height);
const declared = el.style.minHeight;
(el as HTMLElement).style.minHeight = "0px";
const content = Math.round(el.getBoundingClientRect().height);
(el as HTMLElement).style.minHeight = declared;
return { reserve: rendered, natural: content };
});
console.info(
`[hero geometry] column ${COLUMN_PX}px · panel ${JSON.stringify(heights)} · reserve ${reserve}px (blocks alone ${natural}px)`,
);
expect(
natural,
`the reserve's own blocks are ${natural}px, so the declared minimum never binds and the reserve is whatever the blocks happen to be`,
).toBeLessThan(reserve);
// The reserve cannot match every report, so it must not be one-sided: a
// three-row account may not be pushed down, a six-row account may not be
// left with a gap that collapses. Between the two is the whole rule.
expect(
reserve,
`reserve ${reserve}px is below a three-row panel (${heights.rows3}px): a short report gets pushed`,
).toBeGreaterThanOrEqual(heights.rows3);
expect(
reserve,
`reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`,
).toBeLessThanOrEqual(heights.rows6);
}, 60_000);
it("keeps every row on one line in all six locales", async () => {
if (skipReason) return expect(skipReason).toBeTruthy();
const widest: Record<string, { label: string; natural: number }> = {};
for (const locale of locales) {
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWith(
ALL_PILLARS.map((id, i) => scored(id, 80 - i * 4)),
)}
/>,
locale,
),
);
const rows = await view.locator(`${PANEL} li[data-status="ok"]`).all();
expect(rows.length, `${locale}: no rows to measure`).toBe(
ALL_PILLARS.length,
);
let localeWidest = { label: "", natural: 0 };
for (const row of rows) {
const m = await row.evaluate((el) => {
const label = el.querySelector("span");
const bar = el.querySelector(
'[data-slot="health-score-pillar-bar"]',
)?.parentElement;
const value = el.querySelectorAll("span")[1];
// The natural width the label would take if nothing constrained it.
const probe = document.createElement("span");
probe.textContent = label?.textContent ?? "";
probe.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;width:max-content";
probe.className = label?.className ?? "";
document.body.appendChild(probe);
const natural = Math.ceil(probe.getBoundingClientRect().width);
probe.remove();
return {
id: el.getAttribute("data-pillar") ?? "",
text: label?.textContent ?? "",
natural,
labelWidth: Math.round(label?.getBoundingClientRect().width ?? 0),
labelHeight: Math.round(label?.getBoundingClientRect().height ?? 0),
labelOverflow: label
? getComputedStyle(label).textOverflow
: "none",
barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0),
valueScroll: value?.scrollWidth ?? 0,
valueClient: value?.clientWidth ?? 0,
rowScroll: el.scrollWidth,
rowClient: el.clientWidth,
};
});
// The row is the invariant, not the label: a long name may ellipsise,
// but it may never widen the row or wrap it onto a second line. Drop
// `min-w-0` or `truncate` from the label and the first grid track
// grows to the longest word instead, which is exactly this.
expect(
m.rowScroll,
`${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`,
).toBeLessThanOrEqual(m.rowClient + TAP_TARGET_OVERHANG_PX);
expect(
m.labelWidth,
`${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`,
).toBeLessThanOrEqual(112 + 1);
expect(
m.labelHeight,
`${locale}/${m.id}: "${m.text}" wrapped onto a second line`,
).toBeLessThanOrEqual(ONE_LINE_PX);
expect(
m.labelOverflow,
`${locale}/${m.id}: "${m.text}" is cut without an ellipsis`,
).toBe("ellipsis");
// The label column may not eat the two things the row exists to show.
expect(
m.barWidth,
`${locale}/${m.id}: the fill bar was squeezed out by the label column`,
).toBeGreaterThan(24);
expect(
m.valueScroll,
`${locale}/${m.id}: the score itself is truncated`,
).toBeLessThanOrEqual(m.valueClient + 1);
if (m.natural > localeWidest.natural) {
localeWidest = { label: m.text, natural: m.natural };
}
}
widest[locale] = localeWidest;
}
// Reported, never asserted: these widths are the measuring browser's
// fallback typeface, not the shipped Inter, so they say how much room the
// longest name wants in THIS run — not a pixel budget the design owns.
console.info(
`[hero geometry] longest label per locale against a 112px column: ${Object.entries(
widest,
)
.map(([l, w]) => `${l} "${w.label}" ${w.natural}px`)
.join(" · ")}`,
);
}, 60_000);
it.each([
{ width: VIEWPORT_PX, where: "the md column" },
{ width: 360, where: "a 360px phone, stacked under the greeting" },
])(
"never scrolls sideways in $where",
async ({ width }) => {
if (skipReason) return expect(skipReason).toBeTruthy();
const target = page!;
await target.setViewportSize({ width, height: 1200 });
// French carries the longest pillar names of the six, so it is the
// width-worst case rather than an arbitrary pick.
const view = await layout(
band(
<HeroStrip
briefing={null}
now={new Date("2026-05-10T07:00:00Z")}
healthScore={reportWith(
ALL_PILLARS.map((id, i) => scored(id, 80 - i * 4)),
)}
/>,
"fr",
),
width,
);
const m = await view.locator(PANEL).evaluate((el) => {
let widest = 0;
let culprit = "";
for (const child of Array.from(el.querySelectorAll("*"))) {
const w = child.getBoundingClientRect().width;
if (w > widest) {
widest = w;
culprit = child.getAttribute("data-slot") ?? child.className;
}
}
const strip = el.closest('[data-slot="insights-hero-strip"]')!;
const stripBox = getComputedStyle(strip);
return {
scroll: el.scrollWidth,
client: el.clientWidth,
width: Math.round(el.getBoundingClientRect().width),
widest: Math.round(widest),
culprit: String(culprit).slice(0, 60),
// The width the band actually offers, padding taken off.
available: Math.round(
strip.getBoundingClientRect().width -
parseFloat(stripBox.paddingLeft) -
parseFloat(stripBox.paddingRight),
),
docScroll: document.documentElement.scrollWidth,
docClient: document.documentElement.clientWidth,
};
});
expect(
m.scroll,
`the panel scrolls its own content: ${JSON.stringify(m)}`,
).toBeLessThanOrEqual(m.client + 1);
expect(
m.widest,
`a child is wider than the panel: ${JSON.stringify(m)}`,
).toBeLessThanOrEqual(m.client + 1);
// The panel against the room the band gives it. This is the assertion
// that has to carry the phone case: the band wears `overflow-hidden`
// for its glow, so a panel wider than the band is silently CLIPPED
// rather than scrolled, and the document check below stays green while
// a person loses the right-hand end of every row.
expect(
m.width,
`the panel is wider than the band gives it: ${JSON.stringify(m)}`,
).toBeLessThanOrEqual(m.available + 1);
// Kept anyway, for anything that escapes the band's clip.
expect(
m.docScroll,
`the page scrolls sideways: ${JSON.stringify(m)}`,
).toBeLessThanOrEqual(m.docClient + 1);
await target.setViewportSize({ width: VIEWPORT_PX, height: 1200 });
},
60_000,
);
});
@@ -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,7rem)_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,38 @@ 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]");
// That it declares a floor at all is what this file can honestly check;
// whether the floor is the RIGHT height is a browser question, and
// `health-score-card-geometry.test.tsx` answers it by measuring the panel
// and the reserve side by side. Pinning the literal here would only mean
// the two places have to be edited together.
expect(html).toMatch(/min-h-\[[\d.]+rem\]/);
});
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", () => {
+401 -238
View File
@@ -1,22 +1,21 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { InfoPopover } from "@/components/ui/info-popover";
import { LearningGate } from "@/components/ui/learning-gate";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import {
BAND_BORDER_CLASS,
BAND_NUMBER_CLASS,
BAND_PROGRESS_CLASS,
bandForScore,
clampScore,
} from "@/components/insights/derived/band-tokens";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
import {
pillarDetailLines,
@@ -36,23 +35,33 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* The Health Score surface: one card, one score, one place.
* The Health Score panel: the hero band's right-hand column.
*
* Pre-rework the score had two mounts — a compact face in the hero band and a
* nine-block stack further down the overview that only rendered while the
* daily-briefing flag was on. The stack is now a single progressive-disclosure
* card pinned directly under the hero, and the hero band is the full-width
* greeting again.
* The score belongs beside the greeting, not under it. It sat there for
* eleven weeks — big number at the top of a narrow column, thin contributor
* rows directly beneath it inside the same card — and three arrangements in
* four days moved it away, ending in a full-width card pinned below the
* greeting that spent most of its height on things that do not count toward
* the number. This is the original arrangement, restored, with the eight
* pillars the composite now carries instead of the four it had then.
*
* Collapsed reads as the score face: the ring, the band sentence, the weekly
* delta (or the honest reason there is none), Rest Mode, the two ambient
* context lines, and the coverage meter. Expanded adds the pillar grid, the
* named list of pillars that are not scored yet, the personal weight goal, and
* the method footer with its cited standard.
* The rules the intervening work established are all kept:
*
* Absence stays absence: a pillar the server gated as `insufficient` is listed
* by name with its reason instead of being drawn as a zero, and a pillar whose
* read FAILED is a visible error row with a retry, never silence.
* - One mount. This panel is the only score surface on the overview.
* - It renders whether or not the daily briefing is switched on.
* - It is not a customisable section, so a saved layout cannot hide it.
* - The column reserves its own height while the payload is in flight,
* so the band does not reflow when the score lands.
*
* Only a pillar that carries a score gets a row. A pillar that contributes
* nothing is one quiet line: a failed read is named with a retry, safety
* signposting is named in full, and everything else that is simply not
* recorded yet is counted, with the names and reasons one tap away in the
* foot disclosure. Each pillar has exactly one home — a row, the failed-read
* line, the safety line, or the count — and never two.
*
* Each contributor row is tinted by ITS OWN band. The old rows took the
* composite's colour, which painted a pillar scoring 92 red on a red day.
*/
const PILLAR_LABEL_KEY: Record<ScorePillarId, string> = {
@@ -82,6 +91,39 @@ const REASON_KEY: Record<string, string> = {
stale: "insufficientHistory",
};
/**
* The column geometry, shared by the panel and by its loading reserve so the
* two can never drift apart. `basis` rather than a width so the column flexes
* inside the hero band's `md:flex-row` split (~36 % at md, ~40 % at xl), and
* `w-full` below `md` where the column stacks under the greeting.
*/
const PANEL_COLUMN_CLASS =
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]";
/** The card chrome the panel wears inside the gradient band. */
const PANEL_CHROME_CLASS =
"bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6";
/**
* Height the reserve holds while the payload is in flight, so the band does
* not jump when the score lands.
*
* The rule, not a remembered number: the reserve sits between the shortest
* and the tallest panel the report actually produces (three to six scored
* rows), so a short report is not pushed down and a long one does not leave a
* gap that collapses. It is deliberately near the middle of that range, which
* is the best a single number can do when the panel's height depends on how
* many pillars an account scores.
*
* `health-score-card-geometry.test.tsx` measures all of it in a browser
* against the compiled stylesheet and fails if this value leaves that range —
* and also if the value stops binding at all. It did stop: the blocks below
* added up to more than the minimum declared here, so the reserve was
* whatever the blocks happened to be and this constant was decoration. The
* blocks were trimmed to sit under it again.
*/
const PANEL_RESERVE_CLASS = "min-h-[28rem]";
/**
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces.
* Kept as a local string union so the client never imports the server
@@ -202,24 +244,34 @@ export function HealthScoreCard({
return t(`insights.healthScore.reason.${key}`);
}
function labelsOf(pillars: ScorePillarResult[]): string {
return pillars.map((p) => t(PILLAR_LABEL_KEY[p.id])).join(", ");
}
const composite = report.composite;
// Four homes, one pillar each. A pillar that scores gets a row; a pillar
// whose read failed joins the one coalesced failure line; a pillar gated
// for safety signposting is named in full at rest; everything else gated
// is counted, and named a tap away. The sets are disjoint by construction
// and each pillar falls into exactly one of them.
const scored = report.pillars.filter((p) => p.result.status === "ok");
const readFailed = report.pillars.filter(
(p) => gateReason(p) === "read_failed",
);
const crisis = report.pillars.filter(
(p) => gateReason(p) === "crisis_signposting",
);
// A failed read is not absence, so it never joins the "not scored yet" list;
// it gets its own error row above. Safety copy leads the list — signposting
// is content, never buried meta.
const notScored = report.pillars
.filter((p) => {
const reason = gateReason(p);
return reason !== null && reason !== "read_failed";
})
.sort(
(a, b) =>
Number(gateReason(b) === "crisis_signposting") -
Number(gateReason(a) === "crisis_signposting"),
// it gets its own line above. Safety copy is content, so it is named at rest
// rather than counted — which is also why it is not in this list twice.
const notScored = report.pillars.filter((p) => {
const reason = gateReason(p);
return (
reason !== null &&
reason !== "read_failed" &&
reason !== "crisis_signposting"
);
});
// Only forwarded when a real disagreement is present on BOTH sides; the
// server resolver already enforces that, but the guard keeps the card from
@@ -245,122 +297,252 @@ export function HealthScoreCard({
})
: null;
const compositeScore =
composite.status === "ok" ? clampScore(composite.value.score) : null;
const compositeBand = composite.status === "ok" ? composite.value.band : null;
return (
<Card
<div
data-slot="health-score-card"
data-status={composite.status}
className={cn("animate-insight-in", className)}
data-band={compositeBand ?? "none"}
className={cn(
"animate-insight-in",
PANEL_CHROME_CLASS,
compositeBand ? BAND_BORDER_CLASS[compositeBand] : null,
PANEL_COLUMN_CLASS,
// `h-full` lets the hero band's `items-stretch` equalise the column
// against the greeting; `mt-auto` on the foot collects the slack.
"flex h-full flex-col gap-3.5",
className,
)}
>
<CardHeader>
<TileHeader title={t("insights.healthScore.label")} titleAs="h2" />
</CardHeader>
<CardContent className="space-y-4">
{/* The score face. The ring keeps its own identity block; everything
that qualifies the number sits beside it, so a person reads the
score and the reason for it in one pass. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="flex justify-center sm:justify-start">
<ScoreRing
score={composite.status === "ok" ? composite.value.score : null}
band={
composite.status === "ok" ? composite.value.band : undefined
}
size="md"
label={t("insights.derived.anatomy.outOf")}
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
{composite.status === "ok" ? (
<p
data-slot="health-score-band"
className="text-foreground text-sm"
>
{t(`insights.healthScore.band.${composite.value.band}`)}
</p>
) : null}
{report.delta != null ? (
<p
data-slot="health-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{t("insights.healthScore.delta", {
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
})}
</p>
) : report.deltaReason ? (
<p
data-slot="health-score-delta-reason"
className="text-muted-foreground text-xs"
>
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
</p>
) : null}
{report.restMode?.active ? (
<p
data-slot="health-score-rest-mode"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.restMode", {
since:
report.restMode.since ?? t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
<p
data-slot="health-score-tension"
className="text-muted-foreground text-xs"
>
{tensionLine}
</p>
) : null}
{returnToBandLine ? (
<p
data-slot="health-score-return-to-band"
className="text-muted-foreground text-xs"
>
{returnToBandLine}
</p>
) : null}
</div>
</div>
{/* 1 — the label, and the weekly gain when there is one. */}
<div className="flex items-center justify-between gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-xs font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
{report.delta != null && report.delta > 0 ? (
<span
data-slot="health-score-card-delta-chip"
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
>
+{report.delta}
</span>
) : null}
</div>
{composite.status !== "ok" ? (
{/* 2 — the number. The reason the panel exists. */}
<div className="flex items-baseline gap-1">
<span
data-slot="health-score-card-number"
className={cn(
"text-5xl leading-none font-semibold tabular-nums sm:text-6xl",
compositeBand
? BAND_NUMBER_CLASS[compositeBand]
: "text-muted-foreground",
)}
>
{compositeScore == null ? "—" : Math.round(compositeScore)}
</span>
{compositeScore == null ? null : (
<span
aria-hidden="true"
className="text-muted-foreground text-sm tabular-nums"
>
/ 100
</span>
)}
</div>
{/* 3 — the composite bar. */}
{compositeScore != null && compositeBand ? (
<div
data-slot="health-score-card-progress"
role="progressbar"
aria-label={t("insights.healthScore.label")}
aria-valuenow={Math.round(compositeScore)}
aria-valuemin={0}
aria-valuemax={100}
className="bg-muted/50 h-2 w-full overflow-hidden rounded-full"
>
<div
className={cn("h-full", BAND_PROGRESS_CLASS[compositeBand])}
style={{ width: `${compositeScore}%` }}
/>
</div>
) : null}
{/* 4 — the lines that qualify the number, one each, only when true. */}
<div className="space-y-1">
{compositeBand ? (
<p data-slot="health-score-band" className="text-foreground text-xs">
{t(`insights.healthScore.band.${compositeBand}`)}
</p>
) : (
<LearningGate
variant="bordered"
compact
bodySlot="health-score-insufficient"
message={t("insights.healthScore.insufficient", {
count: composite.coverage.presentInputs,
})}
/>
)}
{report.delta != null ? (
<p
data-slot="health-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{t("insights.healthScore.delta", {
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
})}
</p>
) : report.deltaReason ? (
<p
data-slot="health-score-delta-reason"
className="text-muted-foreground text-xs"
>
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
</p>
) : null}
{report.restMode?.active ? (
<p
data-slot="health-score-rest-mode"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.restMode", {
since: report.restMode.since ?? t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
<p
data-slot="health-score-tension"
className="text-muted-foreground text-xs"
>
{tensionLine}
</p>
) : null}
{returnToBandLine ? (
<p
data-slot="health-score-return-to-band"
className="text-muted-foreground text-xs"
>
{returnToBandLine}
</p>
) : null}
</div>
{/* 5 — the contributing factors, always visible, directly under the
number. Server registry order; no re-ranking. */}
{scored.length > 0 ? (
<ul
data-slot="health-score-pillars"
className="border-border/60 space-y-2 border-t pt-3"
>
{scored.map((pillar) => (
<PillarRow key={pillar.id} pillar={pillar} detail={detail} />
))}
</ul>
) : null}
{/* 6 — what is not contributing. One weight class, two faces: the
failure carries the glyph and the only retry on the surface, the
absence is muted and actionless. */}
{readFailed.length > 0 || crisis.length > 0 || notScored.length > 0 ? (
<div className="space-y-1.5">
{readFailed.length > 0 ? (
<div
data-slot="health-score-pillar-error"
data-pillars={readFailed.map((p) => p.id).join(",")}
role="alert"
className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
>
<AlertTriangle
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
<span className="min-w-0">
{labelsOf(readFailed)}
{": "}
{reasonText("read_failed")}
</span>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
data-slot="health-score-pillar-retry"
className="h-7 gap-1.5 px-2 text-xs"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
) : null}
</div>
) : null}
{crisis.length > 0 ? (
<p
data-slot="health-score-crisis"
data-pillars={crisis.map((p) => p.id).join(",")}
className="text-foreground text-xs"
>
{labelsOf(crisis)}
{": "}
{reasonText("crisis_signposting")}
</p>
) : null}
{notScored.length > 0 ? (
<p
data-slot="health-score-not-scored-count"
data-count={notScored.length}
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.notScored", {
count: notScored.length,
})}
</p>
) : null}
</div>
) : null}
{/* 7 — the foot. `mt-auto` collects the slack here rather than under the
number, so a short report and a long one both end on the same line. */}
<div className="mt-auto space-y-3 pt-1">
<CoverageMeter
coverage={composite.coverage}
confidence={
composite.status === "ok" ? composite.confidence : undefined
}
size="md"
size="sm"
/>
{/* Progressive disclosure. No Collapsible primitive exists in the UI
kit, so this is the same accessible button + region shape the
glucose panel ships: `aria-expanded` on the trigger,
`aria-controls` + `hidden` on the region. */}
<div className="border-border border-t pt-3">
<div className="border-border/60 border-t pt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="health-score-anatomy-toggle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-left text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("insights.healthScore.anatomyToggle")}</span>
<span className="min-w-0">
{t("insights.healthScore.anatomyToggle")}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
"size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
@@ -370,71 +552,28 @@ export function HealthScoreCard({
id={regionId}
hidden={!open}
data-slot="health-score-anatomy-region"
className="space-y-4 pt-3"
className="space-y-3 pt-1 pb-1"
>
{scored.length > 0 ? (
<ul
data-slot="health-score-pillars"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
{scored.map((pillar) => (
<PillarCell key={pillar.id} pillar={pillar} detail={detail} />
))}
</ul>
) : null}
{readFailed.map((pillar) => (
<QueryErrorRow
key={pillar.id}
slot="health-score-pillar-error"
retrySlot="health-score-pillar-retry"
message={
<>
{t(PILLAR_LABEL_KEY[pillar.id])}
{": "}
{reasonText("read_failed")}
</>
}
onRetry={onRetry}
/>
))}
{notScored.length > 0 ? (
<div data-slot="health-score-not-scored" className="space-y-2">
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t("insights.healthScore.notScored", {
count: notScored.length,
})}
</p>
<ul className="space-y-1">
{notScored.map((pillar) => {
const reason = gateReason(pillar) ?? "";
const crisis = reason === "crisis_signposting";
return (
<li
key={pillar.id}
data-pillar={pillar.id}
data-reason={reason}
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2"
>
<span className="text-foreground text-xs font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<span
className={cn(
"text-xs",
crisis
? "text-foreground"
: "text-muted-foreground",
)}
>
{reasonText(reason)}
</span>
</li>
);
})}
</ul>
</div>
<ul data-slot="health-score-not-scored" className="space-y-1">
{notScored.map((pillar) => {
const reason = gateReason(pillar) ?? "";
return (
<li
key={pillar.id}
data-pillar={pillar.id}
data-reason={reason}
className="text-muted-foreground text-xs"
>
<span className="text-foreground font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
{": "}
{reasonText(reason)}
</li>
);
})}
</ul>
) : null}
<WeightGoalRow report={report} />
@@ -472,18 +611,23 @@ export function HealthScoreCard({
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
/**
* One scored pillar: name + score, a band-tinted impact bar, and the observed
* line with its detail popover and coverage meter. The reference band, the
* personal target and the source live in the popover — the same strings as
* before, one tap away instead of four permanent lines per pillar.
* One scored pillar, one line: a truncating name, a thin fill bar, the score,
* and the (i) that opens everything else. The bar takes the pillar's OWN band
* colour — a pillar at 92 reads green even when the composite is red, which
* is the point of showing the contributors at all.
*
* The observed value, the reference band, the personal target, the source and
* the per-pillar confidence all live in the popover. That is one tap more
* than the pinned card charged for the observed line, and it is what buys the
* column back.
*/
function PillarCell({
function PillarRow({
pillar,
detail,
}: {
@@ -494,25 +638,28 @@ function PillarCell({
if (pillar.result.status !== "ok") return null;
const score = clampScore(pillar.result.value.score);
const band = bandForScore(score);
const observed = pillarObservedText(pillar, detail);
const lines = pillarDetailLines(pillar, detail);
return (
<li
data-pillar={pillar.id}
data-status="ok"
className="border-border/70 space-y-2 rounded-lg border p-3"
className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-foreground min-w-0 text-sm font-medium">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<span className="text-foreground text-lg font-semibold tabular-nums">
{Math.round(score)}
</span>
</div>
{/* The label truncates; the row never grows. `minmax(0,…)` plus
`min-w-0 truncate` is what makes that structural rather than a
promise — drop either and the track grows to the longest word and
pushes the row. Widening the column does not rescue the long names:
the longest label in five of the six locales overruns any column
this panel can afford, so the extra width would come off the fill
bar and buy a few more characters of one language. */}
<span className="text-muted-foreground min-w-0 truncate">
{t(PILLAR_LABEL_KEY[pillar.id])}
</span>
<div
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
role="presentation"
className="bg-muted/50 h-1.5 min-w-0 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
data-slot="health-score-pillar-bar"
@@ -520,36 +667,41 @@ function PillarCell({
style={{ width: `${score}%` }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground min-w-0 text-xs">
{t("insights.healthScore.observed", {
value: pillarObservedText(pillar, detail),
})}
</span>
<span className="flex shrink-0 items-center gap-2">
<InfoPopover
content={lines.map((line) => (
<span className="text-foreground text-right tabular-nums">
{Math.round(score)}
</span>
<InfoPopover
content={
<>
{observed ? (
<span className="block">
{t("insights.healthScore.observed", { value: observed })}
</span>
) : null}
{lines.map((line) => (
<span key={line} className="block">
{line}
</span>
))}
bodyDataSlot="health-score-pillar-detail"
/>
<CoverageMeter
coverage={pillar.result.coverage}
confidence={pillar.result.confidence}
size="sm"
/>
</span>
</div>
<span className="mt-1.5 block">
<CoverageMeter
coverage={pillar.result.coverage}
confidence={pillar.result.confidence}
size="sm"
/>
</span>
</>
}
bodyDataSlot="health-score-pillar-detail"
/>
</li>
);
}
/**
* The personal weight goal: an inner tile inside the disclosure, deliberately
* NOT a pillar cell, because it is context the user set for themselves and it
* never moves the score.
* NOT a contributor row, because it is context the user set for themselves and
* it never moves the score. That is also why it left the at-rest column.
*/
function WeightGoalRow({ report }: { report: HealthScoreReport }) {
const { t } = useTranslations();
@@ -610,38 +762,49 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) {
}
/**
* The pinned slot's reserve while the analytics payload is in flight. Mirrors
* the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger
* row — so first paint lands at the card's real height and the resolved card
* is a swap rather than a push. Decorative: the card announces nothing while
* it loads.
* The hero column's reserve while the analytics payload is in flight. Wears
* the panel's own column classes and chrome and holds a measured minimum
* height, so the greeting beside it does not reflow when the score lands —
* the reason this reserve was added in the first place.
*/
export function HealthScoreCardSkeleton({ className }: { className?: string }) {
return (
<Card
<div
data-slot="health-score-card-skeleton"
aria-hidden="true"
className={className}
className={cn(
PANEL_CHROME_CLASS,
"border-border/60",
PANEL_COLUMN_CLASS,
"flex h-full flex-col gap-3.5",
PANEL_RESERVE_CLASS,
className,
)}
>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* The `md` ring paints at a fixed 168 px box; the reserve matches it
exactly so the swap moves nothing. */}
<Skeleton className="size-[168px] shrink-0 self-center rounded-full sm:self-auto" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<Skeleton className="h-3 w-28" />
<div className="border-border border-t pt-3">
{/* Block for block, the panel above: label, number, bar, two qualifier
lines, three contributor rows, one not-contributing line, and the
foot with its meter and toggle. Three rows rather than five is what
keeps these blocks under the minimum above, so the minimum is the
thing that decides the height. */}
<Skeleton className="h-4 w-24" />
<Skeleton className="h-15 w-28" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
</div>
<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" />
</div>
<Skeleton className="h-4 w-36" />
<div className="mt-auto space-y-3 pt-1">
<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>
</CardContent>
</Card>
</div>
</div>
);
}
+61 -13
View File
@@ -11,8 +11,14 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { useAuth } from "@/hooks/use-auth";
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import {
HealthScoreCard,
HealthScoreCardSkeleton,
type ReadinessContributorKey,
} from "@/components/insights/health-score-card";
import { cn } from "@/lib/utils";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
/**
* Insights redesign hero strip.
@@ -33,14 +39,14 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
* overview now leads with the present-focused daily briefing, not a
* coach prompt.
*
* The right-side Health Score panel is gone too. The score had two mounts,
* a compact face here and the full breakdown below; the breakdown is now one
* progressive-disclosure card pinned directly under this band, which needs
* the full page width when it opens. The hero is back to the full-width
* greeting + subtitle + baseline meta — the shape it already had for an
* account without a score.
* The Health Score is back in the band's right column, where it lived for
* eleven weeks. It is the score's ONLY mount on the overview — there is no
* second card below the greeting any more. The band splits into two columns
* from `md` up, and only when there is a score (or one on its way): an
* account without a score keeps the full-width greeting exactly as it is.
* Below `md` the panel stacks under the greeting at full width.
*
* Pure presentational — the page owns the briefing data.
* Pure presentational — the page owns the briefing and the score data.
*/
interface HeroStripProps {
@@ -68,6 +74,24 @@ interface HeroStripProps {
* footer hint below.
*/
noProviderStale?: boolean;
/**
* The Health Score report. Non-null paints the right column; null with
* `scorePending` paints the reserve; null without it keeps the band
* full-width, which is the shape an account without a score already had.
*/
healthScore?: HealthScoreReport | null;
/** True while the analytics payload is in flight and nothing is cached. */
scorePending?: boolean;
/** Server-resolved Tension Verdict, forwarded to the panel. */
tension?: {
band: ScoreBand;
positive: ReadinessContributorKey[];
negative: ReadinessContributorKey[];
} | null;
/** Server-resolved return-to-baseline, forwarded to the panel. */
returnToBand?: { metricType: string; daysInside: number } | null;
/** Retry for a pillar whose read failed — refetches the whole payload. */
onScoreRetry?: () => void;
}
/**
@@ -94,6 +118,11 @@ export function HeroStrip({
userName,
now,
noProviderStale = false,
healthScore = null,
scorePending = false,
tension = null,
returnToBand = null,
onScoreRetry,
}: HeroStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -134,12 +163,17 @@ export function HeroStrip({
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
)}
>
{/* One column. The band carried a right-hand score panel from v1.4.20
until the score moved to its own pinned card below; with the panel
gone the greeting owns the full width on every breakpoint, which is
the shape this band already had for an account without a score. */}
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-3">
{/* Two columns from `md` up, and only when the score column has
something to show. Without a score the band stays one column, which
is the shape it already had for an account that has none. */}
<div
className={cn(
"flex min-w-0 flex-col gap-4",
(healthScore || scorePending) &&
"md:flex-row md:items-stretch md:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
reserves primary/accent for actions, not decorative header
@@ -214,6 +248,20 @@ export function HeroStrip({
* untouched — only the hero entry points are gone, so the band
* is now greeting + subtitle + baseline meta.
*/}
{/* The score column. The reserve holds the column's footprint while
the payload is in flight so the greeting beside it does not shift
when the score lands. Both wear the same width classes. */}
{healthScore ? (
<HealthScoreCard
report={healthScore}
tension={tension}
returnToBand={returnToBand}
onRetry={onScoreRetry}
/>
) : scorePending ? (
<HealthScoreCardSkeleton />
) : null}
</div>
</div>
);