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:
Marc-André Bombeck committed 2026-06-07 14:15:47 +02:00
1 parent cd9a0b6152
commit bf77f5243d
4 files changed
+308 -64

No files matched your search

+1 -1
View File
@@ -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",
)}
+8 -3
View File
@@ -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
+85 -60
View File
@@ -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>