fix(insights): let a section heading's trailing caption wrap instead of widening the page

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 <main> — 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 <main> 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 <main>
  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 <main>, 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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 17:44:15 +02:00
1 parent 78644ab77e
commit 29188b2262
6 files changed
+782 -47

No files matched your search

+69 -32
View File
@@ -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` `<main>`
* 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<Record<string, unknown>> {
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();
+24 -14
View File
@@ -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`
* `<main>` scroll container exceeds the viewport width (1 px sub-pixel
* tolerance). The `<main>` 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 `<main>` 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 `<main id="main-content">`, 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(
+202
View File
@@ -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
* `<main id="main-content">`, 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<void>;
}
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 `<main>` 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 <main>", 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 `<main>` 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);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared horizontal-overflow guard for phone widths.
*
* WHY THIS MEASURES `<main>` AND NOT JUST THE DOCUMENT
* ----------------------------------------------------
* The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
* observe the app's real sideways scroll. `AuthShell` puts every route inside
*
* <main id="main-content" class="… overflow-y-auto …">
*
* and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
* the other computes to `auto`. So `<main>` 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
* `<main>` 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 `<main id>`). */
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<OverflowReport> {
return page.evaluate(() => {
const vw = window.innerWidth;
const de = document.documentElement;
const main = document.querySelector<HTMLElement>("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` `<main>` scroll container.
*/
export async function expectNoHorizontalOverflow(
page: Page,
label: string,
): Promise<void> {
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<void> {
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(async () => {
const main = document.querySelector<HTMLElement>("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(() => {});
}
+268
View File
@@ -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={<CoincidentProvenance/>}`, 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<string, [number, number]> = {
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<string, unknown> {
const metrics: Record<string, unknown> = {
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<void> {
// 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 }),
});
});
}
+14 -1
View File
@@ -82,7 +82,20 @@ export function SectionHeading({
</div>
</div>
{action ? (
<div className="flex shrink-0 items-center gap-2">{action}</div>
// 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 `<main>` — 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.
<div className="flex min-w-0 items-center gap-2">{action}</div>
) : null}
</div>
);