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} );