From a7390852f822d40aa962ebd23051477181bd827c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:12:11 +0200 Subject: [PATCH] test(charts): mobile-viewport e2e spec for header layout + tick density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Playwright spec that runs only on the chromium-mobile project (Pixel 5, 393 × 851) and gates the two regressions the previous chart commit cleared: - Every chart card on the dashboard keeps its range tabs on a single horizontal row (asserts that all `[data-slot=chart-range-tab]` inside a card share one rounded `top` coordinate). Mock data feeds every chart 30 days of measurements + 30 days of compliance points so the controls row actually paints with all chips and tabs. - Every recharts x-axis paints at most seven visible tick labels at Pixel 5. The helper caps at six; the seven-tick allowance covers Recharts' preserveStartEnd interaction with rounding. The PROD probe script that drove the audit is also committed at .planning/v1419-a2-prod-probe.mjs for future regression runs, with the findings recorded in .planning/phase-A2-mobile-findings.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/phase-A2-mobile-findings.md | 127 ++++++++++++++ .planning/v1419-a2-prod-probe.mjs | 177 +++++++++++++++++++ e2e/charts-mobile.spec.ts | 233 ++++++++++++++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 .planning/phase-A2-mobile-findings.md create mode 100644 .planning/v1419-a2-prod-probe.mjs create mode 100644 e2e/charts-mobile.spec.ts diff --git a/.planning/phase-A2-mobile-findings.md b/.planning/phase-A2-mobile-findings.md new file mode 100644 index 000000000..034666b32 --- /dev/null +++ b/.planning/phase-A2-mobile-findings.md @@ -0,0 +1,127 @@ +# Phase A2 — Mobile chart findings (PROD audit + post-fix verification) + +Marathon: v1.4.19 Wave-A +Agent: A2 (parallel with A1, A3, A4, A5, A6, A7, A8) +Probe script: `.planning/v1419-a2-prod-probe.mjs` +Probe output (JSON + screenshots): `/tmp/v1419-a2-prod/` + +## Method + +Playwright headless against `https://healthlog.bombeck.io` with Marc's +session cookie, four mobile viewports: + +- Pixel 5 — 393 × 851 +- iPhone 12 — 390 × 844 +- iPhone SE — 375 × 667 +- Galaxy Fold compact — 280 × 653 + +For each chart card on `/` (dashboard) and `/insights`: + +- Header height (px from top of card to bottom of controls row) +- Number of distinct vertical rows the range tabs occupy + (`new Set(tabRects.map(t => Math.round(t.top))).size`) +- `card.scrollWidth > card.clientWidth` (horizontal overflow) +- `XAxis` tick count visible inside the SVG + +## Pre-fix readings (live PROD before A2 ship) + +``` +viewport page chart tabs rows header overflow +pixel5 insights Blood Pressure 4 2 92 false +pixel5 insights Pulse 4 2 92 false +iphone12 dashboard Blood Pressure 4 2 92 false +iphone12 dashboard Pulse 4 2 92 false +iphone12 dashboard Mood 4 1 108 false +iphone12 dashboard Medications 3 2 92 false +fold-compact dashboard Blood Pressure 4 4 188 false +fold-compact dashboard Pulse 4 3 188 false +fold-compact dashboard Mood 4 2 108 false +fold-compact dashboard Medications 3 3 188 true ← +``` + +(Charts with header == 44 px and rows == 1 — Weight, BMI — already +fitted because they had no bucket-aggregation chip and no comparison +caption to push the tabs around. The bug was specific to charts whose +title row carried extra chips.) + +Two distinct symptoms confirmed: + +**1. Header label-band overflow.** Marc's complaint about +"Wochendurchschnitt + 7T/30T/90T/Alle wraps and breaks the layout" was +exactly this: the bucket-aggregation chip ("Weekly avg" / "Monthly +avg") + the optional comparison-caption chip + 4 range tabs + the cog +dropdown all sat on one `flex justify-between` row. On Pixel 5 / iPhone +12 the chips ate enough horizontal space that the 4 tabs wrapped to a +2nd row inside the same flex container — header height jumped 44 → 92. +On Galaxy Fold compact (280 px) the tabs split into 3-4 rows and one +chart overflowed horizontally. + +**2. X-axis tick density inconsistent.** Medication chart used the +default Recharts tick rendering with `interval="preserveStartEnd"` — +that's _one tick per data point_, so a 30-day Pixel 5 window painted 30 +overlapping date labels. Weight / BMI / pulse / mood charts didn't show +the same density because they auto-bucketed daily → weekly above 90 +days, but at the 30-day range they still drew a tick every couple of +days. + +## Fixes shipped + +**Fix 1 — header layout** (commit `fix(charts): mobile header layout + unified x-axis tick density`): + +- ` 768 px (desktop) → max 10 ticks +- New `src/hooks/use-viewport-width.ts` for SSR-safe reactive viewport + width with desktop default during SSR. +- Wired into all 4 chart wrappers (HealthChart, MoodChart, + MedicationComplianceChart, ComplianceLineChart). The + ScatterCorrelationChart was deliberately left alone — its X-axis is + numeric (BP / weight values), not a time series, and already supplies + explicit `ticks={...}` arrays. + +## Post-fix gating + +The two regressions are gated by an e2e spec at +`e2e/charts-mobile.spec.ts` that runs only on the `chromium-mobile` +project (Pixel 5 profile). Two assertions per chart: + +- All `[data-slot=chart-range-tab]` inside a card share one rounded + `top` coordinate — i.e. one horizontal row. +- Each `recharts-xAxis` paints ≤ 7 visible tick labels. + +Helper unit tests at `src/lib/charts/__tests__/x-axis-density.test.ts` +pin the interval math for Fold / Pixel 5 / tablet / desktop widths +across 7 / 30 / 90 / 365-point ranges. + +## Constraints honoured + +Touched only the assigned A2 surface (chart wrappers + new +helper/hook). Did not touch `src/lib/insights/*`, `src/components/insights/*`, +`src/lib/ai/prompts/*`, `src/components/settings/*`, or +`src/components/admin/*`. No new dependencies. Pre-commit hooks pass +on every commit. + +## Note on parallel-agent race + +During the second commit a parallel agent staged + committed against +the same index, the first attempt at commit 2 picked up their +`phase-A5-report.md` instead of my files (the staged set got swapped +between my `git add` and `git commit`). The polluted commit was +dropped via `git rebase --onto` before push; the parallel agent's work +was preserved by cherry-picking their amended commit forward. The +final history is clean and ordered by commit time. diff --git a/.planning/v1419-a2-prod-probe.mjs b/.planning/v1419-a2-prod-probe.mjs new file mode 100644 index 000000000..369611224 --- /dev/null +++ b/.planning/v1419-a2-prod-probe.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/** + * v1.4.19 A2 — pre-fix mobile chart audit against PROD. + * + * Captures header layout + x-axis tick density for all chart cards + * across 4 mobile viewports. Output: per-viewport screenshots + + * machine-readable findings file. + */ +import { chromium } from "@playwright/test"; +import { writeFileSync, mkdirSync } from "node:fs"; + +const SESSION = "cmox4d6fj000101p8w9ykhcnm"; +const ORIGIN = "https://healthlog.bombeck.io"; +const OUTDIR = "/tmp/v1419-a2-prod"; +mkdirSync(OUTDIR, { recursive: true }); + +const VIEWPORTS = [ + { name: "pixel5", width: 393, height: 851, deviceScale: 2.75 }, + { name: "iphone12", width: 390, height: 844, deviceScale: 3 }, + { name: "iphone-se", width: 375, height: 667, deviceScale: 2 }, + { name: "fold-compact", width: 280, height: 653, deviceScale: 3 }, +]; + +const PAGES = [ + { path: "/", label: "dashboard" }, + { path: "/insights", label: "insights" }, +]; + +const browser = await chromium.launch(); +const findings = {}; + +for (const vp of VIEWPORTS) { + findings[vp.name] = {}; + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + userAgent: + "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Mobile Safari/537.36", + deviceScaleFactor: vp.deviceScale, + hasTouch: true, + isMobile: true, + colorScheme: "dark", + storageState: { + cookies: [ + { + name: "healthlog_session", + value: SESSION, + domain: "healthlog.bombeck.io", + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + expires: -1, + }, + ], + origins: [], + }, + }); + + for (const pg of PAGES) { + const page = await ctx.newPage(); + page.on("console", () => {}); + page.on("pageerror", () => {}); + try { + await page.goto(`${ORIGIN}${pg.path}`, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + // Wait for at least one chart card or main content + await page + .waitForSelector("main, [data-slot=medication-compliance-chart], svg", { + timeout: 15000, + }) + .catch(() => {}); + await page.waitForTimeout(2500); + + // Scroll to make sure all charts render + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(500); + + const screenshotPath = `${OUTDIR}/${pg.label}-${vp.name}.png`; + await page.screenshot({ path: screenshotPath, fullPage: true }); + + // Find all chart cards + const data = await page.evaluate(() => { + const cards = Array.from( + document.querySelectorAll( + "div[data-slot=medication-compliance-chart], [data-slot=chart-mini], div.bg-card", + ), + ).filter((el) => el.querySelector("svg") !== null); + + const out = []; + for (const card of cards) { + const rect = card.getBoundingClientRect(); + if (rect.height < 100) continue; + + // Find header row + check overflow + const titleEl = card.querySelector("h3, [data-slot=card-title]"); + const title = titleEl?.textContent?.trim() ?? "(unknown)"; + const titleRect = titleEl?.getBoundingClientRect(); + + // Range tabs — use data-slot when present; fall back to + // text-content sniffing for charts that haven't been + // tagged yet. + const taggedTabs = Array.from( + card.querySelectorAll("[data-slot=chart-range-tab]"), + ); + const sniffedTabs = Array.from( + card.querySelectorAll("button"), + ).filter((b) => + /^(7|30|90)\s*(T|Pkt|pts)$|^Alle$|^All$|^(7|30|90)D$/i.test( + (b.textContent ?? "").trim(), + ), + ); + const rangeTabs = taggedTabs.length > 0 ? taggedTabs : sniffedTabs; + const tabRects = rangeTabs.map((b) => { + const r = b.getBoundingClientRect(); + return { + text: b.textContent?.trim(), + top: r.top, + right: r.right, + width: r.width, + }; + }); + const tabTops = new Set(tabRects.map((t) => Math.round(t.top))); + + // Header bar height + const headerEl = card.querySelector(".mb-4, .pb-2"); + const headerRect = headerEl?.getBoundingClientRect(); + + // Check overflow + const overflowsHorizontally = card.scrollWidth > card.clientWidth + 1; + + // X-axis ticks + const xTicks = Array.from( + card.querySelectorAll( + ".recharts-xAxis .recharts-cartesian-axis-tick-value", + ), + ).map((t) => t.textContent?.trim()); + + out.push({ + title, + cardRect: { + top: rect.top, + height: rect.height, + width: rect.width, + }, + titleTop: titleRect?.top, + tabs: tabRects, + tabUniqueRows: tabTops.size, + overflowsHorizontally, + cardScrollWidth: card.scrollWidth, + cardClientWidth: card.clientWidth, + headerHeight: headerRect?.height, + xTickCount: xTicks.length, + xTicks, + }); + } + return out; + }); + + findings[vp.name][pg.label] = { + screenshot: screenshotPath, + cards: data, + }; + } catch (err) { + findings[vp.name][pg.label] = { error: String(err) }; + } + await page.close(); + } + await ctx.close(); +} + +await browser.close(); + +writeFileSync(`${OUTDIR}/findings.json`, JSON.stringify(findings, null, 2)); +console.log("---PROD-AUDIT-COMPLETE---"); +console.log(JSON.stringify(findings, null, 2)); diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts new file mode 100644 index 000000000..fea3ac21c --- /dev/null +++ b/e2e/charts-mobile.spec.ts @@ -0,0 +1,233 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * v1.4.19 A2 — mobile-viewport regression spec for chart cards. + * + * Two assertions per chart visible in the dashboard's initial paint: + * + * 1. Header layout integrity — the range tabs (`7 pts / 30 pts / + * 90 pts / All`) and the cog dropdown sit on a SINGLE horizontal + * row inside the chart card. Pre-fix the bucket-aggregation chip + * ("Weekly avg" / "Monthly avg") + comparison caption pushed the + * tabs onto a second row on Pixel 5 (and 3-4 rows on Galaxy Fold + * compact). The fix stacks title + chips above tabs + cog on + * mobile so the tabs always own the full controls row. + * + * 2. X-axis tick density — Recharts' default rendered ONE tick per + * data point, so a 30-day Pixel 5 window painted 30 overlapping + * labels. The universal helper `chooseTickInterval` caps that at + * 6 ticks for ≤ 480px viewports. + * + * The spec runs only on the `chromium-mobile` project (Pixel 5, + * 393×851). Desktop sizes are exercised separately by the unit-test + * suite for the helper. + */ + +test.describe("charts — mobile-viewport regression", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + + // Mock analytics + measurements so the chart wrappers all clear + // their data-floor gates. Same pattern as `dashboard.spec.ts`. + await page.route("**/api/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + summaries: { + WEIGHT: { + latest: 78.5, + avg7: 78.2, + avg30: 77.9, + slope30: { slope: -0.05, direction: "down" }, + count: 30, + }, + BLOOD_PRESSURE_SYS: { + latest: 124, + avg7: 122, + avg30: 121, + slope30: { slope: 0.1, direction: "flat" }, + count: 30, + }, + BLOOD_PRESSURE_DIA: { + latest: 80, + avg7: 79, + avg30: 78, + slope30: { slope: 0.05, direction: "flat" }, + count: 30, + }, + PULSE: { + latest: 68, + avg7: 70, + avg30: 71, + slope30: { slope: -0.2, direction: "down" }, + count: 30, + }, + }, + bpInTargetPct: 78, + glucoseByContext: {}, + }, + error: null, + }), + }), + ); + + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + + // Return 30 days of measurements per type so each chart paints a + // line with enough points that tick-density actually matters. + await page.route("**/api/measurements*", (route) => { + const url = new URL(route.request().url()); + const type = url.searchParams.get("type") ?? "WEIGHT"; + const baseValue = + type === "BLOOD_PRESSURE_SYS" + ? 124 + : type === "BLOOD_PRESSURE_DIA" + ? 80 + : type === "PULSE" + ? 70 + : 78.5; + const measurements = Array.from({ length: 30 }, (_, i) => ({ + id: `m_${type}_${i}`, + type, + value: baseValue + (i % 5) - 2, + measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(), + notes: null, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { measurements, meta: { total: measurements.length } }, + error: null, + }), + }); + }); + + // Medication compliance — return 30 days of mostly-perfect + // compliance so the chart clears the sparse-data threshold and + // paints a line with the full window. + await page.route("**/api/medications/intake*", (route) => { + const points = Array.from({ length: 30 }, (_, i) => { + const d = new Date(Date.now() - i * 86_400_000); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return { + date: `${yyyy}-${mm}-${dd}`, + scheduled: 2, + taken: i % 7 === 0 ? 1 : 2, + }; + }); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: points, error: null }), + }); + }); + }); + + test("chart header keeps range tabs on a single row at Pixel 5", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // Wait for at least one chart-range-tab to mount. + await page + .locator("[data-slot=chart-range-tab]") + .first() + .waitFor({ timeout: 10_000 }); + + // Group the visible range tabs by their card and assert each + // card's tabs share a single Y coordinate (== one row). + const findings = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll("div.bg-card")).filter( + (c) => c.querySelector("[data-slot=chart-range-tab]") !== null, + ); + return cards.map((card) => { + const title = + card + .querySelector("h3, [data-slot=card-title]") + ?.textContent?.trim() ?? "(unknown)"; + const tabs = Array.from( + card.querySelectorAll("[data-slot=chart-range-tab]"), + ); + const tops = tabs.map((t) => Math.round(t.getBoundingClientRect().top)); + const uniqueRows = new Set(tops).size; + const rect = card.getBoundingClientRect(); + return { + title, + tabCount: tabs.length, + uniqueRows, + overflow: card.scrollWidth > card.clientWidth + 1, + width: rect.width, + }; + }); + }); + + // We must find at least one charted card. + expect(findings.length, "no chart cards detected").toBeGreaterThan(0); + + for (const f of findings) { + expect( + f.uniqueRows, + `${f.title}: tabs split across ${f.uniqueRows} rows (expected 1)`, + ).toBe(1); + expect(f.overflow, `${f.title} card overflows horizontally`).toBe(false); + } + }); + + test("chart x-axis paints ≤ 7 ticks at Pixel 5 with a 30-day window", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // Wait for at least one Recharts SVG to mount. + await page.locator(".recharts-xAxis").first().waitFor({ timeout: 10_000 }); + + // Recharts lays its visible tick labels under + // `.recharts-xAxis .recharts-cartesian-axis-tick`. Count the ones + // that actually have rendered text (the helper also produces minor + // structural ticks without text labels, which we don't care about + // for legibility). + const tickCounts = await page.evaluate(() => { + const charts = Array.from(document.querySelectorAll(".recharts-xAxis")); + return charts.map((axis) => { + const ticks = Array.from( + axis.querySelectorAll(".recharts-cartesian-axis-tick"), + ); + const visibleTicks = ticks.filter( + (t) => (t.querySelector("text")?.textContent?.trim() ?? "").length > 0, + ); + return visibleTicks.length; + }); + }); + + expect(tickCounts.length, "no recharts axes detected").toBeGreaterThan(0); + + for (const count of tickCounts) { + // Pixel 5 = 393 px → max 6 ticks per the helper. Allow 7 for + // Recharts' preserveStartEnd interaction. + expect( + count, + `axis paints ${count} ticks (max 7 allowed)`, + ).toBeLessThanOrEqual(7); + } + }); +});