From 06fdeb0336d3ed687f4a475409995ed9fb2566a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:23:05 +0200 Subject: [PATCH] Pin the panel's rules with tests Four things a person notices if they break: a pillar never shows up twice anywhere in the panel, a failed read never lands in the not-scored list, the panel does not push the page sideways at 360 pixels, and the whole thing is there with the briefing switched off. Every one of them was broken on purpose first to check the test actually goes red. The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped the longest label in five of them, two of those by two pixels, which reads as a bug rather than a truncation. The reserve is 480 pixels, measured against the built panel rather than guessed: three rows come to 440 and six to 524. The overview fixture now carries a health score, so the phone-width guard measures a band with the panel in it instead of an empty column. --- e2e/insights-hero-score-mobile.spec.ts | 136 ++++++ e2e/utils/mock-populated-insights.ts | 131 ++++++ src/app/__tests__/insights-polish.test.ts | 45 +- .../__tests__/health-score-card.test.tsx | 390 +++++++++++++++--- .../insights/__tests__/hero-strip.test.tsx | 219 +++++++++- src/components/insights/health-score-card.tsx | 47 ++- 6 files changed, 865 insertions(+), 103 deletions(-) create mode 100644 e2e/insights-hero-score-mobile.spec.ts 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/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("", 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,8rem)_1fr_2rem_auto]"); + expect(rest).toContain("width:82%"); }); - it("names every gated pillar with its reason instead of scoring it zero", () => { + it("tints each row with its OWN band, not the composite's", () => { + // The composite is red and the two pillars sit either side of the green + // threshold. The old rows took the composite's colour and painted the + // strong pillar red. + const html = render( + , + ); + 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,35 @@ 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]"); + // 480 px: measured against the built panel, which resolves to 440 px at + // three rows and 524 px at six, so the reserve sits in the middle. + expect(html).toContain("min-h-[30rem]"); + }); + + it("holds the same column geometry the resolved panel does", () => { + const skeleton = render(); + 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 fa25c1587..f6076fb74 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -105,15 +105,17 @@ const PANEL_CHROME_CLASS = "bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6"; /** - * Height the loading reserve holds so the greeting does not reflow when the - * analytics payload lands. Measured against the built panel at 352 px (the - * `md` column) with a five-row, one-failed-read report: 431 px including the - * card padding, of which the widest realistic case is what matters. Held a - * little under the measurement so a shorter report does not paint a gap: - * a three-row report measures 341 px, and 21rem/336px is the floor both sit - * near. See `health-score-card-geometry.test.ts` for the numbers. + * Height the reserve holds while the payload is in flight, so the band does + * not jump when the score lands. Not a guess: the panel was rendered with the + * app's own stylesheet and typeface and measured in a browser at the `md` + * column width of 352 px. A three-row account resolves to 440 px, four rows + * 468, five rows 496, six rows 524 — so 480 px sits in the middle of the 3-to-6 + * row range the report actually produces, and the reserve above and below it + * is symmetric rather than always one-sided. The blocks in the reserve below + * mirror the panel's own, which brings its natural height to 484 px, within + * four pixels of the floor. */ -const PANEL_RESERVE_CLASS = "min-h-[21rem]"; +const PANEL_RESERVE_CLASS = "min-h-[30rem]"; /** * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. @@ -636,8 +638,14 @@ function PillarRow({
  • + {/* 8rem, not 7: rendered in all six locales, 7rem clipped the longest + label in five of them — "Körperfettverteilung" and "Forma física + medida" by two pixels each, which reads as a bug rather than as a + truncation. 8rem clears every label except the French "Condition + physique mesurée", which needs 161 px and would not fit any column + width this panel can afford. */} {t(PILLAR_LABEL_KEY[pillar.id])} @@ -765,22 +773,29 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) { className, )} > + {/* Block for block, the panel above: label, number, bar, two qualifier + lines, five contributor rows, one not-contributing line, and the + foot with its meter and toggle. */} - + -
    - - +
    + +
    -
    +
    +
    +
    - - + +
    + +
    );