mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
cd9a0b6152
commit
bf77f5243d
4 files changed
+308
-64
No files matched your search
@@ -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<Record<string, unknown>> {
|
||||
const days: Array<Record<string, unknown>> = [];
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 12);
|
||||
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
|
||||
const plan: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
{/* Wheel + compact phase card — grouped so they stick together. */}
|
||||
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
|
||||
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
data-revealed={play ? "true" : undefined}
|
||||
style={
|
||||
tileHue
|
||||
? ({ "--tile-hue": tileHue } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : calendarError ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.loadError")}
|
||||
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
data-revealed={play ? "true" : undefined}
|
||||
style={
|
||||
tileHue
|
||||
? ({ "--tile-hue": tileHue } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : calendarError ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.loadError")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void calendar.refetch()}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* First-period CTA — only when no cycle is active yet. */}
|
||||
{!loading && !calendarError && wheel.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void calendar.refetch()}
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("common.retry")}
|
||||
{t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* First-period CTA — only when no cycle is active yet. */}
|
||||
{!loading && !calendarError && wheel.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
{t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 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() {
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={initialTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="calendar" className="flex-1">
|
||||
{/* 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. */}
|
||||
<TabsList className="w-full max-w-full overflow-x-auto">
|
||||
<TabsTrigger
|
||||
value="calendar"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.calendar")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="predictions" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="predictions"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.predictions")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="insights" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="insights"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
Reference in new issue
Block a user