diff --git a/e2e/insights-hero-score-mobile.spec.ts b/e2e/insights-hero-score-mobile.spec.ts new file mode 100644 index 000000000..726024268 --- /dev/null +++ b/e2e/insights-hero-score-mobile.spec.ts @@ -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 + * `
`, 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); + }); +}); diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 0968f2e9b..378c60208 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -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 { }), ); + // 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 | 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. diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index dc7f3586b..d200f6a9a 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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 }, diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index b225b623d..df6ed66b6 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -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* { + 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("` 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*", 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("" 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 ? ( - - ) : analytics?.healthScore ? ( - 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 diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx new file mode 100644 index 000000000..9912a2c8c --- /dev/null +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -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 { + 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( + {node}, + ); +} + +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 { + const target = page; + if (!target) throw new Error("browser page was never created"); + await target.setContent( + ` +
${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 = {}; + for (const rows of [3, 4, 5, 6]) { + const view = await layout( + band( + , + ), + ); + 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( + , + ), + ); + // 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 = {}; + + for (const locale of locales) { + const view = await layout( + band( + 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( + 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, + ); +}); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 0316245bd..b879bdcf3 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -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("", 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(" 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(); - 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(); + // 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(" 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(); - 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( + , + ); + 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(); + // 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(" 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( + , + ); + 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( 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(" 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(); + 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(); + // 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(); + // 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(" 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(); expect(html).toMatch( /data-slot="health-score-card"[^>]*data-status="insufficient"/, @@ -365,8 +598,11 @@ describe(" 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(" 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(); + 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 { + const html = render( + , + ); + 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( , ); - // 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("", () => { - it("reserves the collapsed footprint and announces nothing", () => { + it("reserves the column's footprint and announces nothing", () => { const html = render(); 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(); + const panel = render(); + // 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); + } }); }); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 65e3dc1e8..1785b8175 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -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("", () => { 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(); + 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(); - 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + {}} + />, + ); + 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", () => { diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 587056d8a..59be655b7 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -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 = { @@ -82,6 +91,39 @@ const REASON_KEY: Record = { 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 ( - - - - - - {/* 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. */} -
-
- -
-
- {composite.status === "ok" ? ( -

- {t(`insights.healthScore.band.${composite.value.band}`)} -

- ) : null} - {report.delta != null ? ( -

- {t("insights.healthScore.delta", { - delta: report.delta > 0 ? `+${report.delta}` : report.delta, - })} -

- ) : report.deltaReason ? ( -

- {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} -

- ) : null} - {report.restMode?.active ? ( -

- {t("insights.healthScore.restMode", { - since: - report.restMode.since ?? t("insights.healthScore.none"), - })} -

- ) : null} - {tensionLine ? ( -

- {tensionLine} -

- ) : null} - {returnToBandLine ? ( -

- {returnToBandLine} -

- ) : null} -
-
+ {/* 1 — the label, and the weekly gain when there is one. */} +
+

+ {t("insights.healthScore.label")} +

+ {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} +
- {composite.status !== "ok" ? ( + {/* 2 — the number. The reason the panel exists. */} +
+ + {compositeScore == null ? "—" : Math.round(compositeScore)} + + {compositeScore == null ? null : ( + + )} +
+ + {/* 3 — the composite bar. */} + {compositeScore != null && compositeBand ? ( +
+
+
+ ) : null} + + {/* 4 — the lines that qualify the number, one each, only when true. */} +
+ {compositeBand ? ( +

+ {t(`insights.healthScore.band.${compositeBand}`)} +

+ ) : ( + )} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} + {tensionLine ? ( +

+ {tensionLine} +

+ ) : null} + {returnToBandLine ? ( +

+ {returnToBandLine} +

+ ) : null} +
+ {/* 5 — the contributing factors, always visible, directly under the + number. Server registry order; no re-ranking. */} + {scored.length > 0 ? ( +
    + {scored.map((pillar) => ( + + ))} +
+ ) : 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 ? ( +
+ {readFailed.length > 0 ? ( +
p.id).join(",")} + role="alert" + className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" + > +
+ ) : null} + + {crisis.length > 0 ? ( +

p.id).join(",")} + className="text-foreground text-xs" + > + {labelsOf(crisis)} + {": "} + {reasonText("crisis_signposting")} +

+ ) : null} + + {notScored.length > 0 ? ( +

+ {t("insights.healthScore.notScored", { + count: notScored.length, + })} +

+ ) : null} +
+ ) : 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. */} +
{/* 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. */} -
+
- - +
+ ); } /** - * 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 (
  • -
    - - {t(PILLAR_LABEL_KEY[pillar.id])} - - - {Math.round(score)} - -
    + {/* 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. */} + + {t(PILLAR_LABEL_KEY[pillar.id])} +
  • ); } /** * 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 ( -