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).