From bf77f5243dbc50f0a3c592ac1eea540abb31d4d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 14:15:47 +0200 Subject: [PATCH] Fix cycle ring width, tab strip and calendar tap targets on narrow phones - Cycle ring: the fixed 120 px shrink-0 ring overflowed grid cells on 360 px Android widths. It now caps at its target size but is free to shrink to fill its cell, so it never pushes the page wider than the viewport. - Cycle tab strip: the four long German labels overflowed the equal-width flex-1 cells and forced a horizontal scroll at 375 px. Below sm the triggers size to their content (text-xs, no flex-1) and scroll cleanly; from sm up they reclaim the equal-width layout. - Calendar day cells: bump the 40 px min-height to 44 px to clear the mobile tap-target floor. - Add a Pixel-5 spec asserting no horizontal scroll on /insights and /cycle plus the calendar tap-target floor. The cycle view also now feeds the profile's typical lengths into the wheel so a low-data tracker gets the canonical four-phase ring. --- e2e/cycle-insights-mobile.spec.ts | 214 ++++++++++++++++++++++++ src/components/cycle/cycle-calendar.tsx | 2 +- src/components/cycle/cycle-ring.tsx | 11 +- src/components/cycle/cycle-view.tsx | 145 +++++++++------- 4 files changed, 308 insertions(+), 64 deletions(-) create mode 100644 e2e/cycle-insights-mobile.spec.ts diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts new file mode 100644 index 000000000..cb278f5d0 --- /dev/null +++ b/e2e/cycle-insights-mobile.spec.ts @@ -0,0 +1,214 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Pixel-5 mobile smoke for the Insights overview + the cycle vertical + * (v1.15.10). Mirrors `mobile-viewport.spec.ts`. 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 + * 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 + * height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump. + * + * 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, + * and calendar render deterministically without seeding cycle data. + * + * Runs only on the `chromium-mobile` project (Pixel 5). The desktop project + * skips this whole describe block. + */ + +function ymd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run + * so the wheel resolves a current phase + day and the calendar paints cells. */ +function buildCalendarDays(): Array> { + const days: Array> = []; + const start = new Date(); + start.setDate(start.getDate() - 12); + // 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today. + const plan: string[] = [ + ...Array(5).fill("MENSTRUAL"), + ...Array(6).fill("FOLLICULAR"), + "OVULATORY", + "LUTEAL", + "LUTEAL", + ]; + for (let i = 0; i < plan.length; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + days.push({ + date: ymd(d), + phase: plan[i], + isPredictedPeriod: false, + isFertileWindow: plan[i] === "OVULATORY", + isPredictedOvulation: false, + isPeriodLogged: plan[i] === "MENSTRUAL", + flow: plan[i] === "MENSTRUAL" ? "MEDIUM" : null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }); + } + return days; +} + +const CALENDAR_BODY = { + data: { + profile: { + goal: "GENERAL_HEALTH", + rawChartMode: false, + predictionEnabled: true, + cyclesObserved: 1, + }, + prediction: null, + days: buildCalendarDays(), + meta: { generatedAt: new Date().toISOString() }, + }, + error: null, +}; + +const PROFILE_BODY = { + data: { + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + rawChartMode: false, + predictionEnabled: true, + discreetNotifications: false, + sensitiveCategoryEncryption: false, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + updatedAt: new Date().toISOString(), + }, + error: null, +}; + +const HISTORY_BODY = { + data: { + cycles: [], + stats: { + avgLengthDays: null, + lengthVariabilityDays: null, + avgPeriodLengthDays: null, + regularity: "LEARNING", + }, + }, + error: null, +}; + +const INSIGHTS_BODY = { + data: { rows: [], headline: null, symptomPatterns: [] }, + error: null, +}; + +async function assertNoHorizontalScroll(page: import("@playwright/test").Page) { + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); +} + +test.describe("cycle + insights mobile smoke", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + + // Flip the cycle feature gate ON by rewriting the real /api/auth/me + // response — every other field stays authentic so the auth shell behaves + // exactly as in production. + await page.route("**/api/auth/me", async (route) => { + const res = await route.fetch(); + const json = await res.json().catch(() => null); + if (!json?.data) return route.fulfill({ response: res }); + json.data.cycleTrackingEnabled = true; + return route.fulfill({ + response: res, + body: JSON.stringify(json), + headers: { ...res.headers(), "content-type": "application/json" }, + }); + }); + + // Deterministic cycle reads. + await page.route(/\/api\/cycle\/calendar(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(CALENDAR_BODY), + }), + ); + await page.route("**/api/cycle/profile", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(PROFILE_BODY), + }), + ); + await page.route(/\/api\/cycle\/cycles(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(HISTORY_BODY), + }), + ); + await page.route("**/api/cycle/insights", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(INSIGHTS_BODY), + }), + ); + }); + + 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 ({ + page, + }) => { + 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); + + // 2) Calendar day cells clear the 44 px tap-target height floor. + const cells = await page.locator('[role="gridcell"]').all(); + const failures: string[] = []; + for (const cell of cells) { + if (!(await cell.isVisible().catch(() => false))) continue; + const box = await cell.boundingBox(); + if (!box) continue; + if (box.height < 44) { + const label = (await cell.getAttribute("aria-label")) ?? "(cell)"; + failures.push(`${label.slice(0, 30)} → ${box.height.toFixed(1)}px`); + } + } + expect( + failures, + `Calendar cells below 44 px tall:\n ${failures.join("\n ")}`, + ).toEqual([]); + }); +}); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index 5712e2cec..7573ee4e1 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -221,7 +221,7 @@ export function CycleCalendar({ } onClick={() => onSelectDay(date)} className={cn( - "relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors", + "relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors", "hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none", isToday && "font-semibold", )} diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx index 21e94632d..5474f19e2 100644 --- a/src/components/cycle/cycle-ring.tsx +++ b/src/components/cycle/cycle-ring.tsx @@ -160,11 +160,16 @@ export function CycleRing({ data-phase={phase ?? "none"} role="img" aria-label={ariaLabel} - className={cn("relative shrink-0", className)} + // v1.15.10 — responsive width. The ring used a fixed `width: size` + + // `shrink-0`, so at the 120 px tile size it overflowed grid cells on + // ≤360 px Android widths. It now caps at `size` (`maxWidth`) but is free + // to shrink to fill its cell (`w-full`, square via `aspect-square`), so it + // never pushes the page wider than the viewport. The day number + phase + // caption scale with the box; the SVG already paints `h-full w-full`. + className={cn("relative aspect-square w-full", className)} style={ { - width: size, - height: size, + maxWidth: size, // The bloom on the active arc is tinted to the live phase hue. "--ring-glow": markerHue, } as React.CSSProperties diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 131d21dae..26bf834e7 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -98,9 +98,16 @@ export function CycleView() { } }); + // Pass the profile's typical lengths so a low-data tracker (few logged + // cycles) draws the canonical four-phase ring instead of one dominant arc. const wheel = useMemo( - () => deriveWheelState(calendar.data?.days ?? [], today), - [calendar.data, today], + () => + deriveWheelState(calendar.data?.days ?? [], today, { + typicalCycleLength: profileQuery.data?.typicalCycleLength, + typicalPeriodLength: profileQuery.data?.typicalPeriodLength, + lutealPhaseLength: profileQuery.data?.lutealPhaseLength, + }), + [calendar.data, today, profileQuery.data], ); function openSheet(date: string) { @@ -149,64 +156,64 @@ export function CycleView() {
{/* Wheel + compact phase card — grouped so they stick together. */}
- {/* Wheel — the signature ring on the premium wellness-tile surface. */} -
- {loading ? ( -
- -
- ) : calendarError ? ( -
-

- {t("cycle.loadError")} + {/* Wheel — the signature ring on the premium wellness-tile surface. */} +

+ {loading ? ( +
+ +
+ ) : calendarError ? ( +
+

+ {t("cycle.loadError")} +

+ +
+ ) : ( + + )} + {!calendarError ? ( +

+ {t("cycle.ring.caption")}

+ ) : null} + {/* First-period CTA — only when no cycle is active yet. */} + {!loading && !calendarError && wheel.dayOfCycle == null ? ( -
- ) : ( - - )} - {!calendarError ? ( -

- {t("cycle.ring.caption")} -

- ) : null} - {/* First-period CTA — only when no cycle is active yet. */} - {!loading && !calendarError && wheel.dayOfCycle == null ? ( - - ) : null} -
+ ) : null} +
{/* Phase-education card beneath the wheel — the single instance, including the "you often notice this here" chip row sourced from @@ -224,17 +231,35 @@ export function CycleView() {
- - + {/* v1.15.10 — the four German labels (Kalender/Prognosen/ + Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width + cells at 375 px and forced a horizontal scroll. Below `sm` the + triggers size to their content (`text-xs`, no `flex-1`) and the + strip scrolls cleanly if needed; from `sm` up they reclaim the + equal-width `flex-1` layout. */} + + {t("cycle.tabs.calendar")} - + {t("cycle.tabs.predictions")} - + {t("cycle.tabs.insights")} - + {t("cycle.tabs.settings")}