From 29188b2262be7179d6a7118c521806c6a863cefe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 00:22:31 +0200 Subject: [PATCH] fix(insights): let a section heading's trailing caption wrap instead of widening the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights overview panned sideways at every phone width. SectionHeading pinned its trailing action slot with shrink-0, which sizes a flex item to its max-content width. That slot was designed for a compact control, but every caller passes prose today — the provenance method caption, the device-score subtitle, the sleep-quality subtitle. A sentence at max-content measures 1229 px in English and 1464 px in German, and since the row sits inside the AuthShell
— whose overflow-y:auto makes overflow-x compute to auto — that width turned the page into a horizontal scroller. min-w-0 lifts the flex min-width:auto floor so the caption wraps to the row instead; the parent's flex-wrap still moves the slot to its own line before anything is squeezed. The existing width guards could not see this. Both measured documentElement.scrollWidth, which never budges when
absorbs the overflow into its own scroll area, and the insights case ran against an empty mocked payload that fits any viewport by construction. - Add e2e/utils/horizontal-overflow.ts: asserts the document AND the
scroll container, and names the widest offending element on failure. - Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements and /medications at 360/390/393/402/430 px against populated fixtures, plus a canary proving the measurement can see through
, and a check that the insights fixture really populates the page. - Rewrite the cycle spec's empty payload as a populated one and sweep every phone width; its /insights case moves to the new spec. - Point mobile-density.spec.ts at the shared assertion — it carried the same document-only blind spot. --- e2e/cycle-insights-mobile.spec.ts | 101 +++++--- e2e/mobile-density.spec.ts | 38 ++- e2e/mobile-horizontal-overflow.spec.ts | 202 +++++++++++++++ e2e/utils/horizontal-overflow.ts | 205 +++++++++++++++ e2e/utils/mock-populated-insights.ts | 268 ++++++++++++++++++++ src/components/insights/section-heading.tsx | 15 +- 6 files changed, 782 insertions(+), 47 deletions(-) create mode 100644 e2e/mobile-horizontal-overflow.spec.ts create mode 100644 e2e/utils/horizontal-overflow.ts create mode 100644 e2e/utils/mock-populated-insights.ts diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts index d45dfbbf2..a33ea0ad4 100644 --- a/e2e/cycle-insights-mobile.spec.ts +++ b/e2e/cycle-insights-mobile.spec.ts @@ -1,20 +1,29 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + expectNoHorizontalOverflow, + PHONE_WIDTHS, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; /** - * Pixel-5 mobile smoke for the Insights overview + the cycle vertical - * (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts: + * Mobile smoke for the cycle vertical (v1.15.10). Asserts: * - * 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the - * core guarantee behind the cycle-ring-width fix (the 120 px ring tile - * used to overflow narrow grid cells). - * 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers + * 1. `/cycle` has no horizontal page scroll at any phone width — covers * the tab-strip-overflow fix (the four long German labels used to force * a horizontal scroll at 375 px) and the responsive cycle ring. - * 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target + * 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target * height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump. * + * The `/insights` width case that used to live here has moved to + * `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights + * payload and against `documentElement.scrollWidth`, so it was green through a + * populated page that really did pan sideways inside the `AuthShell` `
` + * scroll container. The replacement runs populated fixtures at several phone + * widths and measures the scroll container the app actually uses. The cycle + * fixture below is populated for the same reason. + * * The cycle feature gate is enabled by rewriting the real `/api/auth/me` * response with `cycleTrackingEnabled: true` (every other field stays * authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip, @@ -108,22 +117,48 @@ const HISTORY_BODY = { }; const INSIGHTS_BODY = { - data: { rows: [], headline: null, symptomPatterns: [] }, + data: { + rows: buildInsightRows(), + headline: { + symptom: "HEADACHE", + phase: "LUTEAL", + rate: 0.3, + confidence: "high", + }, + symptomPatterns: buildInsightRows(), + }, error: null, }; -async function assertNoHorizontalScroll(page: import("@playwright/test").Page) { - const dims = await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - innerWidth: window.innerWidth, +/** + * A populated cycle-insights payload. The previous fixture was + * `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which + * fits any viewport by construction and proved nothing about the width + * guarantee it was written for. Long labels are deliberate: the tab strip and + * the summary rows are where narrow-width text pressure shows up. + */ +function buildInsightRows(): Array> { + const symptoms = [ + "HEADACHE", + "CRAMPS", + "BLOATING", + "FATIGUE", + "MOOD_SWINGS", + "BREAST_TENDERNESS", + "BACK_PAIN", + "ACNE", + ]; + return symptoms.map((symptom, i) => ({ + symptom, + phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4], + occurrences: 12 - i, + totalDays: 40, + rate: (12 - i) / 40, + confidence: i < 4 ? "high" : "low", })); - expect( - dims.scrollWidth, - `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, - ).toBeLessThanOrEqual(dims.innerWidth + 1); } -test.describe("cycle + insights mobile smoke", () => { +test.describe("cycle mobile smoke", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }, testInfo) => { @@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => { ); }); - test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => { - await page.goto("/insights", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); - await assertNoHorizontalScroll(page); - }); - - test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({ + test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({ page, }) => { + for (const width of PHONE_WIDTHS) { + await page.setViewportSize({ width, height: 851 }); + await page.goto("/cycle", { waitUntil: "domcontentloaded" }); + + // The cycle vertical mounted (the page redirects home if the gate is off). + const wheel = page.locator('[data-slot="cycle-wheel-tile"]'); + await expect(wheel).toBeVisible(); + + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, `/cycle @${width}px`); + } + + // Back to the Pixel-5 width for the tap-target measurement. + await page.setViewportSize({ width: 393, height: 851 }); await page.goto("/cycle", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); - - // The cycle vertical mounted (the page redirects home if the gate is off). - const wheel = page.locator('[data-slot="cycle-wheel-tile"]'); - await expect(wheel).toBeVisible(); - - // 1) No horizontal page scroll at the Pixel-5 width. - await assertNoHorizontalScroll(page); + await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible(); + await page.waitForLoadState("networkidle").catch(() => {}); // 2) Calendar day cells clear the 44 px tap-target height floor. const cells = await page.locator('[role="gridcell"]').all(); diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index ebe54672a..af0e0836d 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -8,6 +8,7 @@ import { POPULATED_SUMMARIES, } from "./utils/mock-dashboard-snapshot"; import { mockMoodInsights } from "./utils/mock-mood-insights"; +import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow"; /** * Phone-width density guard (390×844 iPhone class, 360×780 small @@ -17,8 +18,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights"; * squeezed into a one-word-per-line column beside a wide delta, the * delta spilling past the tile edge at 360 px): * - * 1. No horizontal page overflow — `scrollWidth` never exceeds the - * viewport width (1 px sub-pixel tolerance). + * 1. No horizontal page overflow — neither the document nor the `AuthShell` + * `
` scroll container exceeds the viewport width (1 px sub-pixel + * tolerance). The `
` half is load-bearing: this guard measured only + * the document until a report of sideways scroll on a populated Insights + * page showed the document never widens, because `
` absorbs the + * overflow into its own scrollable area. * 2. Nothing escapes its tile — for every card / Today hero / list * row, no descendant's visible box crosses the tile's left or * right edge (clip-aware: content genuinely hidden by an inner @@ -181,19 +186,24 @@ test.describe("phone-width density guard", () => { ).toBeVisible(); } - const dims = await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - bodyScrollWidth: document.body.scrollWidth, - innerWidth: window.innerWidth, - })); + // The document/body pair alone cannot see the app's sideways scroll: + // `AuthShell` scrolls inside `
`, whose + // `overflow-y:auto` makes `overflow-x` compute to `auto`, so an + // over-wide child grows `main.scrollWidth` while the document stays + // exactly viewport-wide. The shared helper asserts both areas and + // names the offending element. + await expectNoHorizontalOverflow( + page, + `${route} @${viewport.width}x${viewport.height}`, + ); + const bodyScrollWidth = await page.evaluate( + () => document.body.scrollWidth, + ); + const innerWidth = await page.evaluate(() => window.innerWidth); expect( - dims.scrollWidth, - `page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, - ).toBeLessThanOrEqual(dims.innerWidth + 1); - expect( - dims.bodyScrollWidth, - `body scrollWidth=${dims.bodyScrollWidth}`, - ).toBeLessThanOrEqual(dims.innerWidth + 1); + bodyScrollWidth, + `body scrollWidth=${bodyScrollWidth}`, + ).toBeLessThanOrEqual(innerWidth + 1); const escapes = await page.evaluate(collectTileEscapes); expect( diff --git a/e2e/mobile-horizontal-overflow.spec.ts b/e2e/mobile-horizontal-overflow.spec.ts new file mode 100644 index 000000000..857209a20 --- /dev/null +++ b/e2e/mobile-horizontal-overflow.spec.ts @@ -0,0 +1,202 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { + expectNoHorizontalOverflow, + measureHorizontalOverflow, + PHONE_WIDTHS, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * The content-heavy routes must not scroll sideways at any phone width. + * + * This replaces a per-surface habit of asserting + * `documentElement.scrollWidth <= innerWidth` on whatever happened to render. + * That assertion had two holes, and a real report walked through both: + * + * 1. It measured the DOCUMENT. `AuthShell` scrolls inside + * `
`, whose `overflow-y:auto` makes `overflow-x` + * compute to `auto` — so an over-wide child grows `main.scrollWidth`, the + * user pans the page sideways, and the document never widens by a pixel. + * 2. It ran against an EMPTY page. A surface with no content fits any + * viewport, so the guard proved nothing about the surface people use. + * + * `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and + * naming the widest offending element on failure. The fixtures below close (2) + * by giving each route a body before it is measured. + * + * Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360) + * CSS widths — a single width hides anything whose overflow is width-dependent. + * Runs on `chromium-mobile` only; the desktop project skips the whole file. + */ + +interface RouteCase { + path: string; + /** Something that must be on-screen before measuring — proves it is populated. */ + ready: string; + setup?: (page: import("@playwright/test").Page) => Promise; +} + +const ROUTE_CASES: RouteCase[] = [ + { + path: "/insights", + // The "signals of the day" section is the one whose heading carries the + // long provenance sentence, so it is also the readiness signal. + ready: '[data-slot="coincident-deviation-section"]', + setup: mockPopulatedInsights, + }, + { + path: "/", + ready: "main#main-content", + setup: (page) => mockDashboardSnapshot(page), + }, + { path: "/measurements", ready: "main#main-content" }, + { path: "/medications", ready: "main#main-content" }, +]; + +test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + for (const routeCase of ROUTE_CASES) { + test(`${routeCase.path} does not scroll sideways on any phone width`, async ({ + page, + }) => { + await routeCase.setup?.(page); + + for (const width of PHONE_WIDTHS) { + await page.setViewportSize({ width, height: 851 }); + await page.goto(routeCase.path, { waitUntil: "domcontentloaded" }); + await expect(page.locator(routeCase.ready).first()).toBeVisible(); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`); + } + }); + } + + /** + * The fixture itself has to stay honest. If `mockPopulatedInsights` ever + * stops populating the overview — a renamed route, a changed envelope — the + * width assertions above would keep passing against an empty page and the + * guard would quietly go back to proving nothing. Assert the page is + * actually carrying content, so fixture rot fails loudly instead. + */ + test("the /insights fixture really populates the overview", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 390, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const signals = page.locator('[data-slot="coincident-deviation-section"]'); + await expect(signals).toBeVisible(); + + // The prose in the heading's trailing action slot — the shape that widened + // the page. It must be present AND long enough to be able to. + const method = signals.locator('[data-slot="provenance-explainer-method"]'); + await expect(method).toBeVisible(); + const text = (await method.textContent()) ?? ""; + expect( + text.length, + `provenance caption too short to exercise the overflow class: "${text}"`, + ).toBeGreaterThan(60); + + // And the surface as a whole is not the empty state. + await expect( + page.locator('[data-slot="section-heading"]').first(), + ).toBeVisible(); + const headingCount = await page + .locator('[data-slot="section-heading"]') + .count(); + expect(headingCount, "overview rendered no sections").toBeGreaterThan(1); + }); + + /** + * A long trailing caption must WRAP inside the row, not extend it. Asserting + * the wrap directly (rather than only the page-level width) pins the fix at + * the element that broke: a `shrink-0` action slot sized itself to the + * caption's max-content width, ~1.2-1.5k px depending on locale. + */ + test("a long heading action caption wraps instead of widening the row", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 390, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const heading = page + .locator( + '[data-slot="coincident-deviation-section"] [data-slot="section-heading"]', + ) + .first(); + await expect(heading).toBeVisible(); + + const box = await heading.boundingBox(); + const method = heading.locator('[data-slot="provenance-explainer-method"]'); + const methodBox = await method.boundingBox(); + expect(box, "heading has no box").not.toBeNull(); + expect(methodBox, "provenance caption has no box").not.toBeNull(); + + expect( + Math.round(methodBox!.width), + `caption width ${methodBox!.width} exceeds its heading row ${box!.width} — the action slot is sizing to max-content instead of wrapping`, + ).toBeLessThanOrEqual(Math.round(box!.width) + 1); + expect( + Math.round(box!.width), + "heading row is wider than the viewport", + ).toBeLessThanOrEqual(390); + }); + + /** + * The helper must be able to SEE overflow through the `
` scroll + * container — the exact blind spot that let the previous guard pass. Inject + * a deliberately over-wide, un-contained block and assert the measurement + * reports it. Without this, a helper that silently measured the wrong + * element would make every assertion above vacuous. + */ + test("the overflow measurement detects an over-wide block inside
", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await page.setViewportSize({ width: 390, height: 851 }); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await expect(page.locator("main#main-content")).toBeVisible(); + + const clean = await measureHorizontalOverflow(page); + expect(clean.mainScrollWidth).toBeLessThanOrEqual( + (clean.mainClientWidth ?? 0) + 1, + ); + + await page.evaluate(() => { + const probe = document.createElement("div"); + probe.id = "overflow-canary"; + probe.style.width = "1500px"; + probe.style.height = "10px"; + document.querySelector("main#main-content")!.appendChild(probe); + }); + + const dirty = await measureHorizontalOverflow(page); + expect( + dirty.mainScrollWidth, + "an intentionally 1500px-wide block did not widen main's scroll area — the helper is measuring the wrong element", + ).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1); + expect( + dirty.offenders.some((o) => o.selector.includes("overflow-canary")), + `the canary was not reported as an offender; got: ${dirty.offenders + .map((o) => o.selector) + .join(" | ")}`, + ).toBe(true); + // The document stayed exactly as wide as the viewport while `
` grew + // by 1500px — this is the blind spot, asserted rather than described. + expect( + dirty.docScrollWidth, + "document width changed, so the document-only assertion would have caught this after all", + ).toBeLessThanOrEqual(dirty.innerWidth + 1); + }); +}); diff --git a/e2e/utils/horizontal-overflow.ts b/e2e/utils/horizontal-overflow.ts new file mode 100644 index 000000000..f957d8c96 --- /dev/null +++ b/e2e/utils/horizontal-overflow.ts @@ -0,0 +1,205 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Shared horizontal-overflow guard for phone widths. + * + * WHY THIS MEASURES `
` AND NOT JUST THE DOCUMENT + * ---------------------------------------------------- + * The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot + * observe the app's real sideways scroll. `AuthShell` puts every route inside + * + *
+ * + * and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`, + * the other computes to `auto`. So `
` is itself a horizontal scroll + * container: an over-wide child grows `main.scrollWidth`, the user pans the + * page sideways, and `documentElement.scrollWidth` never budges because + * `
` absorbed the overflow. A document-only assertion is green through + * exactly the defect a person reports as "I can scroll sideways off the page". + * + * Both measurements are therefore asserted. On failure the helper names the + * widest element that is actually extending the scroll area (skipping anything + * an ancestor already scrolls or clips — a strip that is *meant* to pan inside + * its own `overflow-x-auto` container is not a defect), with its measured + * width and the viewport width, so a future failure is actionable without a + * screenshot. + */ + +/** Real iPhone + common Android CSS widths. */ +export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const; + +export interface OverflowOffender { + selector: string; + rectWidth: number; + rectRight: number; + scrollWidth: number; + path: string; +} + +export interface OverflowReport { + innerWidth: number; + docScrollWidth: number; + /** null when the route renders outside `AuthShell` (no `
`). */ + mainScrollWidth: number | null; + mainClientWidth: number | null; + offenders: OverflowOffender[]; +} + +/** + * Measure horizontal overflow of the document AND of the `AuthShell` scroll + * container, plus the elements responsible. + */ +export async function measureHorizontalOverflow( + page: Page, +): Promise { + return page.evaluate(() => { + const vw = window.innerWidth; + const de = document.documentElement; + const main = document.querySelector("main#main-content"); + + const report = { + innerWidth: vw, + docScrollWidth: de.scrollWidth, + mainScrollWidth: main ? main.scrollWidth : null, + mainClientWidth: main ? main.clientWidth : null, + offenders: [] as Array<{ + selector: string; + rectWidth: number; + rectRight: number; + scrollWidth: number; + path: string; + }>, + }; + + const overflows = + de.scrollWidth > vw + 1 || + (main !== null && main.scrollWidth > main.clientWidth + 1); + if (!overflows) return report; + + const describe = (el: Element): string => { + const parts = [el.tagName.toLowerCase()]; + if (el.id) parts.push(`#${el.id}`); + const slot = el.getAttribute("data-slot"); + if (slot) parts.push(`[data-slot="${slot}"]`); + const cls = (el.getAttribute("class") ?? "").trim(); + if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`); + return parts.join("").slice(0, 180); + }; + + const root = main ?? document.body; + const limit = main + ? main.getBoundingClientRect().left + main.clientWidth + : vw; + const scrollLeft = main ? main.scrollLeft : window.scrollX; + + // An element whose ancestor (below `root`) scrolls or clips is contained by + // design — the UI standards' "wide content scrolls in its own container". + const absorbedByAncestor = (el: Element): boolean => { + let n = el.parentElement; + while (n && n !== root) { + const ox = getComputedStyle(n).overflowX; + if ( + ox === "auto" || + ox === "scroll" || + ox === "hidden" || + ox === "clip" + ) + return true; + n = n.parentElement; + } + return false; + }; + + for (const el of Array.from(root.querySelectorAll("*"))) { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + // Fixed/sticky chrome is positioned against the viewport, not the + // scroll area, so it cannot widen it. + const position = getComputedStyle(el).position; + if (position === "fixed" || position === "sticky") continue; + const right = r.right + scrollLeft; + if (right <= limit + 1) continue; + if (absorbedByAncestor(el)) continue; + + const chain: string[] = []; + let n: Element | null = el; + while (n && n !== de) { + chain.unshift(describe(n).slice(0, 70)); + n = n.parentElement; + } + report.offenders.push({ + selector: describe(el), + rectWidth: Math.round(r.width), + rectRight: Math.round(right), + scrollWidth: el.scrollWidth, + path: chain.slice(-5).join(" > "), + }); + } + + report.offenders.sort((a, b) => b.rectWidth - a.rectWidth); + report.offenders = report.offenders.slice(0, 5); + return report; + }); +} + +function formatReport(label: string, report: OverflowReport): string { + const lines = [ + `${label}: horizontal overflow at viewport ${report.innerWidth}px`, + ` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`, + ` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`, + ]; + if (report.offenders.length === 0) { + lines.push( + " no un-contained offender found — the overflow may come from a scroll container's own content", + ); + } + for (const o of report.offenders) { + lines.push( + ` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`, + ); + lines.push(` path: ${o.path}`); + } + return lines.join("\n"); +} + +/** + * Assert the page does not scroll sideways — neither the document nor the + * `AuthShell` `
` scroll container. + */ +export async function expectNoHorizontalOverflow( + page: Page, + label: string, +): Promise { + const report = await measureHorizontalOverflow(page); + const message = formatReport(label, report); + + expect(report.docScrollWidth, message).toBeLessThanOrEqual( + report.innerWidth + 1, + ); + if (report.mainScrollWidth !== null && report.mainClientWidth !== null) { + expect(report.mainScrollWidth, message).toBeLessThanOrEqual( + report.mainClientWidth + 1, + ); + } +} + +/** + * Settle a route before measuring: let the network go idle, pan the whole + * scroll container so every `next/dynamic` below-the-fold block mounts, then + * return to the top. A block that only mounts on scroll can only overflow once + * it has mounted. + */ +export async function settleForOverflowMeasurement(page: Page): Promise { + await page.waitForLoadState("networkidle").catch(() => {}); + await page.evaluate(async () => { + const main = document.querySelector("main#main-content"); + const el: HTMLElement = main ?? document.documentElement; + const step = el.clientHeight || 800; + for (let y = 0; y < el.scrollHeight; y += step) { + el.scrollTop = y; + await new Promise((r) => setTimeout(r, 100)); + } + el.scrollTop = 0; + }); + await page.waitForLoadState("networkidle").catch(() => {}); +} diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts new file mode 100644 index 000000000..0968f2e9b --- /dev/null +++ b/e2e/utils/mock-populated-insights.ts @@ -0,0 +1,268 @@ +import type { Page } from "@playwright/test"; + +import { + SECTION_MOBILITY, + SECTION_VITALS, +} from "@/components/insights/derived/use-dashboard-derived"; + +/** + * A POPULATED Insights overview fixture. + * + * The mobile-overflow guard exists to prove that a page carrying real content + * does not scroll sideways. Against the shared e2e account — which owns no + * measurements — `/insights` resolves to its empty state, and an empty page + * trivially fits any viewport. Every section below therefore gets a body: + * populated wellness scores, a full vitals grid, and the "signals of the day" + * card whose heading carries the long plain-language provenance caption. + * + * The strings are deliberately long. Prose in a heading's trailing `action` + * slot is exactly what widened the page in the first place, and a fixture with + * short labels would not reproduce it. + */ + +const NOW = "2026-01-15T08:30:00.000Z"; + +function provenance(inputs: string[]) { + return { + inputs, + source: "DAY" as const, + windowDays: 30, + computedAt: NOW, + }; +} + +function coverage() { + return { + requiredInputs: 4, + presentInputs: 4, + historyDays: 90, + missing: [] as string[], + }; +} + +function confidence() { + return { score: 88, band: "high" as const }; +} + +function deviation( + type: string, + value: number, + low: number, + high: number, + direction: "above" | "below" | "in", +) { + return { + type, + value, + center: (low + high) / 2, + low, + high, + outside: direction !== "in", + direction, + }; +} + +/** + * The "signals of the day" payload in its `fired` state — the branch that + * renders `SectionHeading action={}`, i.e. a full + * sentence pinned into the heading row. + */ +const COINCIDENT_FIRED = { + metric: "COINCIDENT_DEVIATION", + status: "ok" as const, + value: { + fired: true, + day: "2026-01-15", + illnessExplained: false, + vitals: [ + deviation("RESTING_HEART_RATE", 71, 52, 62, "above"), + deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"), + deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"), + deviation("OXYGEN_SATURATION", 97, 96, 99, "in"), + deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"), + ], + contributing: [ + deviation("RESTING_HEART_RATE", 71, 52, 62, "above"), + deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"), + deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"), + ], + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance([ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + ]), + reason: null, +}; + +/** A deterministic sparkline series around `center`. */ +function series(center: number, spread: number): number[] { + return Array.from({ length: 30 }, (_, i) => + Number((center + Math.sin(i / 2.5) * spread).toFixed(2)), + ); +} + +/** + * A populated `WellnessScoreValue` entry — the exact wire shape the three + * persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` / + * `STRAIN_SCORE`). + */ +function wellnessScore( + metric: string, + score: number, + band: "green" | "yellow" | "red", +) { + return { + metric, + status: "ok" as const, + value: { + score, + band, + trendDelta: 3.4, + daysInWindow: 28, + asOf: NOW, + series: series(score, 6), + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]), + reason: null, + assessment: null, + }; +} + +/** A populated `VitalsBaselineValue` tile — exact wire shape. */ +function vitalsBaseline(type: string, center: number, spread: number) { + return { + metric: "VITALS_BASELINE", + status: "ok" as const, + value: { + type, + center, + low: Number((center - spread).toFixed(2)), + high: Number((center + spread).toFixed(2)), + spread, + sampleDays: 30, + k: 2, + series: series(center, spread / 2), + }, + coverage: coverage(), + confidence: confidence(), + provenance: provenance([type]), + reason: null, + }; +} + +/** + * The honest "not enough history" arm. Metrics whose value shapes this fixture + * does not reproduce exactly are served as `insufficient` rather than as an + * invented payload — that is a real state those tiles handle (they un-mount), + * and a wrong-shaped `ok` payload would throw in the tile instead of testing + * anything. + */ +function insufficient(metric: string) { + return { + metric, + status: "insufficient" as const, + value: null, + coverage: { + requiredInputs: 4, + presentInputs: 1, + historyDays: 3, + missing: ["SLEEP_DURATION"], + }, + confidence: null, + provenance: provenance([]), + reason: "not_enough_history", + }; +} + +/** Center + spread per vital, in each metric's own units. */ +const VITAL_BANDS: Record = { + RESTING_HEART_RATE: [57, 5], + RESPIRATORY_RATE: [14.1, 1.8], + OXYGEN_SATURATION: [97, 1.5], + BODY_TEMPERATURE: [36.6, 0.45], + BLOOD_GLUCOSE: [92, 13], + WEIGHT: [79.4, 3], +}; + +function buildBatchMetrics(): Record { + const metrics: Record = { + RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"), + STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"), + STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"), + READINESS: insufficient("READINESS"), + SLEEP_SCORE: insufficient("SLEEP_SCORE"), + FITNESS_AGE: insufficient("FITNESS_AGE"), + VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"), + HRV_BALANCE: insufficient("HRV_BALANCE"), + BMI: insufficient("BMI"), + SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"), + }; + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + const [center, spread] = VITAL_BANDS[type] ?? [50, 5]; + metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread); + } + for (const { metric } of SECTION_MOBILITY) { + metrics[metric] = insufficient(metric); + } + return metrics; +} + +/** + * Route-mock a populated Insights overview onto `page`. + * + * Only the reads that decide whether a section renders AT ALL are stubbed; + * everything else (the i18n bundle, every layout decision, the real component + * tree) stays authentic, so the geometry under test is the shipped geometry. + */ +export async function mockPopulatedInsights(page: Page): Promise { + // Without a non-empty comprehensive payload the page short-circuits to its + // "no data yet" empty state and no section mounts at all. + // RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as + // a single-character wildcard, so `**/api/insights/derived?*` also matches + // `/api/insights/derived/batch?…` and the two handlers shadow each other. + await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + totalMeasurements: 4821, + moodSummary: { count: 96 }, + medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }], + }, + error: null, + }), + }), + ); + + // The batched derived read behind the wellness strip + the vitals grid. + await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { metrics: buildBatchMetrics() }, + error: null, + }), + }), + ); + + // 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. + await page.route(/\/api\/insights\/derived\?/, async (route) => { + const metric = new URL(route.request().url()).searchParams.get("metric"); + if (metric !== "COINCIDENT_DEVIATION") return route.fallback(); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }), + }); + }); +} diff --git a/src/components/insights/section-heading.tsx b/src/components/insights/section-heading.tsx index 67a4260e3..441efbd54 100644 --- a/src/components/insights/section-heading.tsx +++ b/src/components/insights/section-heading.tsx @@ -82,7 +82,20 @@ export function SectionHeading({ {action ? ( -
{action}
+ // The action slot must be able to SHRINK. It was `shrink-0`, which + // pins a flex item at its max-content width — fine for the compact + // control the slot was designed for, wrong for the prose every real + // caller passes today (the provenance method caption, the + // device-score subtitle, the sleep-quality subtitle). A sentence at + // max-content is ~1.2-1.5k px, and because the row sits inside the + // AuthShell `
` — whose `overflow-y:auto` makes `overflow-x` + // compute to `auto` — that width became a horizontally scrollable + // page at every phone width, growing with the locale's string length. + // `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap + // to the row width instead. The parent's `flex-wrap` still moves the + // slot onto its own line before anything is squeezed, so a compact + // control keeps its size. +
{action}
) : null} );