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. */} - + -
    - - +
    + +
    -
    +
    +
    +
    - - + +
    + +
    );