From 2284040c6ed93d15fdbe211b3611e42a5605a75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:14:04 +0100 Subject: [PATCH] fix(cycle): hold the calendar's height while the cycle read loads The cycle page's calendar card showed one line of loading text until its read arrived, then grew to the month grid, so the predictions and everything below moved down once the page had drawn. The card now draws the month header and this month's weeks in the grid's own cell size while it loads, with the loading text kept for screen readers. --- .../cycle/__tests__/cycle-calendar.test.tsx | 19 +++++++++- src/components/cycle/cycle-calendar.tsx | 38 +++++++++++++++++++ src/components/cycle/cycle-view.tsx | 6 +-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index 8cec062a3..0c0e1cc14 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { CycleCalendar } from "../cycle-calendar"; +import { CycleCalendar, CycleCalendarSkeleton } from "../cycle-calendar"; import { I18nProvider } from "@/lib/i18n/context"; import type { CalendarDay } from "../types"; @@ -242,3 +242,20 @@ describe("", () => { }); }); }); + +describe("CycleCalendarSkeleton", () => { + // November 2026 starts on a Sunday and spans six weeks; February 2027 + // starts on a Monday and spans four. + for (const today of ["2026-11-11", "2027-02-10"]) { + it(`holds as many weeks as the month it stands in for (${today})`, () => { + const calendar = render(); + const skeleton = render(); + + // Every row but the weekday header is one week. + const weeks = (calendar.match(/role="row"/g)?.length ?? 0) - 1; + const cells = skeleton.match(/aspect-square/g)?.length ?? 0; + expect(weeks).toBeGreaterThan(3); + expect(cells).toBe(weeks * 7); + }); + } +}); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index aa57342ba..4225e836f 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { resolveIntlLocale } from "@/lib/format-locale"; import type { Locale } from "@/lib/i18n/config"; @@ -65,6 +66,43 @@ function mondayIndex(d: Date): number { return (d.getDay() + 6) % 7; } +/** + * The calendar's frame while the page's read is in flight: the month header + * and the weeks of `today`'s month in the grid's own cell size, so the card + * keeps the height the month takes instead of growing from a line of text. + * The loading text stays for screen readers. + */ +export function CycleCalendarSkeleton({ today }: { today: string }) { + const { t } = useTranslations(); + const [y, m] = today.split("-").map(Number); + const daysInMonth = new Date(y, m, 0).getDate(); + const weeks = Math.ceil( + (mondayIndex(new Date(y, m - 1, 1)) + daysInMonth) / 7, + ); + return ( +
+ {t("cycle.calendar.loading")} + +
+ ); +} + type Translate = ReturnType["t"]; const CELL_DATE_FORMATS = new Map(); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index b748a1a8b..2fefabe70 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -16,7 +16,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; -import { CycleCalendar } from "./cycle-calendar"; +import { CycleCalendar, CycleCalendarSkeleton } from "./cycle-calendar"; import { CycleHistoryChart } from "./cycle-history-chart"; import { LogDaySheet, sheetDayContext } from "./log-day-sheet"; import { PhaseEducationCard } from "./phase-education-card"; @@ -347,9 +347,7 @@ export function CycleView() { {loading ? ( -

- {t("cycle.calendar.loading")} -

+ ) : calendarError ? ( // Inside the calendar card already — a borderless row keeps // it in line with the plain loading line above it (§3/§6).