test(charts): mobile-viewport e2e spec for header layout + tick density

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:12:11 +02:00
1 parent 60a91afaee
commit a7390852f8
3 files changed
+537

No files matched your search

+127
View File
@@ -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`):
- `<sm`: stack the header into two rows. Title + chips on row 1; range
tabs + cog right-aligned on row 2. Tabs use `flex-nowrap` and
`px-2 sm:px-3` so they always fit a single row at 280 px.
- Bucket-aggregation chip + comparison caption + comparison-unavailable
fallback all `hidden sm:inline-flex` — they're decorative and the
range tabs already communicate the visible window. The cog dropdown
surfaces overlays explicitly.
- `≥sm`: original side-by-side layout, all chips visible.
- Applied to HealthChart (BP / weight / pulse / BMI / sleep / steps),
MoodChart, MedicationComplianceChart consistently.
**Fix 2 — universal X-axis tick-density helper** (same commit):
- New `src/lib/charts/x-axis-density.ts` with `chooseTickInterval(N, w)`:
- 280 px (Fold) → max 4 ticks
- 393 px (Pixel 5) / 390 px (iPhone 12) → max 6 ticks
- 481-768 px (small tablet) → max 8 ticks
- > 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.
+177
View File
@@ -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));
+233
View File
@@ -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);
}
});
});