From e47eceb242ddd1c27ceeff5cb9523832532d8701 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:42:52 +0100 Subject: [PATCH] feat: draw the calendar toolbar's own words and task list in its skeleton --- app/boot-skeleton.css | 8 +- .../__tests__/hydration.test.tsx | 44 +++++++ .../boot-skeleton/calendar-skeleton.tsx | 84 +++++++++++--- .../calendar-app-boot-skeleton.test.tsx | 109 ++++++++++++++++++ components/calendar/calendar-app.tsx | 2 +- 5 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index 726034dd7..41f9c4ca8 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -138,17 +138,19 @@ @supports (height: round(down, 1.5px, 1px)) and (height: 1cqh) { .sk-cal-week, .sk-cal-day { - --sk-fit: round(down, (100cqh - var(--sk-head)) / var(--boot-cal-hours, 12), 1px); + /* clientHeight, a whole number of pixels */ + --sk-pane: round(nearest, 100cqh, 1px); + --sk-fit: round(down, (var(--sk-pane) - var(--sk-head)) / var(--boot-cal-hours, 12), 1px); --sk-hour: max(var(--sk-fit), clamp(0px, (var(--sk-hour-floor) - var(--sk-fit)) * 1000, var(--sk-hour-min))); /* The scroll the grid opens at: an hour before now, at most to the end. */ --sk-shift: clamp( 0px, var(--boot-cal-scroll, -1) * var(--sk-hour-min) - 8px, - var(--boot-cal-hours, 12) * var(--sk-hour) - (100cqh - var(--sk-head)) + var(--boot-cal-hours, 12) * var(--sk-hour) - (var(--sk-pane) - var(--sk-head)) ); } .sk-cal-month { - --sk-week: max(var(--sk-week-min), round(down, (100cqh - var(--sk-head)) / 6, 1px)); + --sk-week: max(var(--sk-week-min), round(down, (round(nearest, 100cqh, 1px) - var(--sk-head)) / 6, 1px)); } } .sk-cal-hours { diff --git a/components/boot-skeleton/__tests__/hydration.test.tsx b/components/boot-skeleton/__tests__/hydration.test.tsx index 30b4269cd..da73ae06c 100644 --- a/components/boot-skeleton/__tests__/hydration.test.tsx +++ b/components/boot-skeleton/__tests__/hydration.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { renderToString } from 'react-dom/server'; import { hydrateRoot } from 'react-dom/client'; import { RouteSkeleton } from '../route-skeleton'; +import { CalendarSkeleton, CalendarViewSkeleton } from '../calendar-skeleton'; import type { BootSurface } from '@/lib/boot-skeleton/surfaces'; /** @@ -57,3 +58,46 @@ describe('page skeletons hydrate without a mismatch', () => { } }); }); + +describe('the calendar skeleton with its words', () => { + const text = { + title: 'Calendar', today: 'Today', search: 'Search events', tasks: 'Tasks', + views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', tasks: 'Tasks' }, + taskFilters: ['All', 'Pending', 'Completed', 'Overdue'] as [string, string, string, string], + newTask: 'New task', quickAdd: 'Add a task', + }; + + it('hydrates without a mismatch and reads out only its status', async () => { + const element = ; + const html = renderToString(element); + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + const errors: unknown[] = []; + const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); }); + await act(async () => { + hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } }); + }); + consoleError.mockRestore(); + expect(errors).toEqual([]); + expect(container.innerHTML).toBe(html); + + const region = container.querySelector('[data-boot-skeleton="calendar"]')!; + expect(region.textContent).toContain('Search events'); + const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!node.textContent?.trim()) continue; + const el = node.parentElement!; + expect(el.closest('[aria-hidden="true"]') || el.closest('[role="status"]'), el.outerHTML).not.toBeNull(); + } + }); + + it('draws one view, with its status, for the loaded page', () => { + const container = document.createElement('div'); + container.innerHTML = renderToString(); + expect(container.querySelectorAll('[role="status"]')).toHaveLength(1); + expect(container.querySelector('.sk-cal-week')).not.toBeNull(); + expect(container.querySelector('.sk-cal-month')).toBeNull(); + expect(container.querySelectorAll('button, [tabindex]')).toHaveLength(0); + }); +}); diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx index 43af4fed9..28191a02c 100644 --- a/components/boot-skeleton/calendar-skeleton.tsx +++ b/components/boot-skeleton/calendar-skeleton.tsx @@ -53,6 +53,12 @@ export interface CalendarSkeletonText { views?: Record; /** calendar.tasks.label: the sidebar's tasks entry. */ tasks?: string; + /** calendar.tasks.filter_all, _pending, _completed, _overdue */ + taskFilters?: [string, string, string, string]; + /** calendar.tasks.create */ + newTask?: string; + /** calendar.tasks.quick_add_placeholder */ + quickAdd?: string; } const MODES: CalendarViewMode[] = ["month", "week", "day", "agenda", "tasks"]; @@ -71,6 +77,9 @@ export function calendarSkeletonText(t: (key: string) => string): CalendarSkelet tasks: t("views.tasks"), }, tasks: t("tasks.label"), + taskFilters: [t("tasks.filter_all"), t("tasks.filter_pending"), t("tasks.filter_completed"), t("tasks.filter_overdue")], + newTask: t("tasks.create"), + quickAdd: t("tasks.quick_add_placeholder"), }; } @@ -152,6 +161,16 @@ function Line({ className, bar, w }: { className: string; bar: string; w: string ); } +/** The toolbar's title: a date range (a placeholder), or "Tasks" in the tasks view. */ +function Title({ tasks, w }: { tasks?: string; w: string }) { + return ( + <> + + {tasks ? {tasks} : null} + + ); +} + /** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { const views = text?.views; @@ -174,7 +193,9 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { - + + + </span> <div className="flex-1" /> <span className={cn( @@ -211,11 +232,9 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { <ChevronLeft /> </PhoneIcon> {/* Below 400px the title takes a line of its own. */} - <Line - className="flex-1 justify-center h-5 min-w-0 max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start" - bar="h-3" - w="5rem" - /> + <span className="flex flex-1 items-center justify-center h-5 min-w-0 text-sm font-semibold max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start"> + <Title tasks={views?.tasks} w="5rem" /> + </span> <PhoneIcon className={NOT_TASKS}> <ChevronRight /> </PhoneIcon> @@ -476,22 +495,42 @@ function AgendaSkeleton({ className }: { className: string }) { } /** The tasks view: TaskToolbar and TaskListView. */ -function TasksSkeleton({ className }: { className: string }) { +function TasksSkeleton({ className, text }: ViewProps) { + const filters = text?.taskFilters; return ( <div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}> <div className="flex items-center gap-2 px-4 min-h-14 box-content border-b border-border flex-wrap"> + {/* The filter starts at "all" on every load (stores/task-store.ts). */} <div className="flex border border-border rounded-md overflow-hidden"> - {repeat(4, () => ( - <Line className="h-7 px-3" bar="h-2" w="2.5rem" /> + {repeat(4, (i) => ( + <span + className={cn( + "flex items-center h-7 px-3 text-xs font-medium", + i === 0 ? "bg-primary text-primary-foreground" : "text-muted-foreground", + )} + > + {filters ? filters[i] : <Bar className="h-2" w="2.5rem" />} + </span> ))} </div> <div className="flex-1" /> - <Block className="h-9" w="6.5rem" /> + {text?.newTask ? ( + <span className="flex items-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium shadow-sm"> + <Plus className="w-4 h-4 me-1" /> + {text.newTask} + </span> + ) : ( + <Block className="h-9" w="6.5rem" /> + )} </div> <div className="px-4 py-2 border-b border-border"> <div className="flex items-center gap-2 h-5"> <Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" /> - <Bar className="h-2.5" w="10rem" /> + {text?.quickAdd ? ( + <span className="text-sm text-muted-foreground truncate">{text.quickAdd}</span> + ) : ( + <Bar className="h-2.5" w="10rem" /> + )} </div> </div> <div className="divide-y divide-border"> @@ -509,7 +548,12 @@ function TasksSkeleton({ className }: { className: string }) { ); } -const VIEWS: Record<CalendarViewMode, (props: { className: string }) => ReactNode> = { +interface ViewProps { + className: string; + text?: CalendarSkeletonText; +} + +const VIEWS: Record<CalendarViewMode, (props: ViewProps) => ReactNode> = { month: MonthSkeleton, week: WeekSkeleton, day: DaySkeleton, @@ -523,15 +567,23 @@ const VIEWS: Record<CalendarViewMode, (props: { className: string }) => ReactNod * with `label` as its status); without, the one `data-boot-calendar-view` * names. */ -export function CalendarViewSkeleton({ view, label }: { view?: CalendarViewMode; label?: string }) { +export function CalendarViewSkeleton({ + view, + label, + text, +}: { + view?: CalendarViewMode; + label?: string; + text?: CalendarSkeletonText; +}) { return ( <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-calendar-view-skeleton={view ?? ""} aria-busy="true"> {label ? <span role="status" className="sr-only">{label}</span> : null} {view - ? VIEWS[view]({ className: "flex" }) + ? VIEWS[view]({ className: "flex", text }) : (Object.keys(VIEWS) as CalendarViewMode[]).map((v) => { const View = VIEWS[v]; - return <View key={v} className={VIEW_SHOWN[v]} />; + return <View key={v} className={VIEW_SHOWN[v]} text={text} />; })} </div> ); @@ -552,7 +604,7 @@ export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: bo <ToolbarSkeleton text={text} /> <div className="relative flex flex-1 overflow-hidden isolate"> <div className="flex flex-1 min-w-0"> - <CalendarViewSkeleton /> + <CalendarViewSkeleton text={text} /> </div> {/* The floating create button of a phone. */} <span className="md:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground"> diff --git a/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx b/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx new file mode 100644 index 000000000..3169ffd45 --- /dev/null +++ b/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx @@ -0,0 +1,109 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Calendar } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +// The calendar page before its data is there: the boot skeleton while the +// session is restored, then the view's grid in the same shapes while the +// calendars load (components/boot-skeleton/calendar-skeleton.tsx). + +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => false, + useIsDesktop: () => true, + useIsTablet: () => false, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) })); + +import { CalendarApp } from '../calendar-app'; + +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const noop = () => vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never); + useCalendarStore.setState({ + calendars: [], events: [], supportsCalendar: false, selectedCalendarIds: [], viewMode: 'week', + selectedDate: new Date(2026, 10, 4), isLoading: false, isLoadingEvents: false, error: null, + fetchCalendars: noop(), fetchEvents: noop(), refreshAllSubscriptions: noop(), + } as never); +}); + +const bootSkeleton = (root: HTMLElement) => root.querySelector('[data-boot-skeleton="calendar"]'); +const viewSkeleton = (root: HTMLElement) => root.querySelector('[data-calendar-view-skeleton]'); + +describe('CalendarApp before its data is there', () => { + it('draws the calendar skeleton while the sign-in is restored', () => { + // Still checking: no redirect to the login page yet. + useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: noop() } as never); + const { container } = render(<CalendarApp />); + const skeleton = bootSkeleton(container); + expect(skeleton).not.toBeNull(); + expect(skeleton?.getAttribute('aria-busy')).toBe('true'); + expect(skeleton?.querySelector('[role="status"]')?.textContent).toBe('common.loading'); + // The toolbar's words, from the calendar's own messages. + expect(skeleton?.textContent).toContain('views.today'); + }); + + it('draws the skeleton while the session has not said the account has calendars', () => { + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never); + const { container } = render(<CalendarApp />); + expect(bootSkeleton(container)).not.toBeNull(); + expect(container.querySelector('main')).toBeNull(); + }); + + it('draws the grid of the view in the skeleton shapes while the calendars load', () => { + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never); + useCalendarStore.setState({ supportsCalendar: true, isLoading: true, viewMode: 'month' } as never); + const { container } = render(<CalendarApp />); + // The real chrome is up; only the grid waits. + expect(bootSkeleton(container)).toBeNull(); + expect(container.querySelector('main')).not.toBeNull(); + const grid = viewSkeleton(container); + expect(grid?.getAttribute('data-calendar-view-skeleton')).toBe('month'); + expect(grid?.querySelector('[role="status"]')?.textContent).toBe('status.loading_calendars'); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); + + it('draws the real grid, without a spinner over it, while the first events load', () => { + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never); + useCalendarStore.setState({ + supportsCalendar: true, calendars, selectedCalendarIds: ['cal'], isLoadingEvents: true, + } as never); + const { container } = render(<CalendarApp />); + expect(viewSkeleton(container)).toBeNull(); + expect(container.querySelector('[role="grid"]')).not.toBeNull(); + expect(container.querySelector('.animate-spin')).toBeNull(); + expect(Array.from(container.querySelectorAll('[role="status"]')).some((s) => s.textContent === 'status.loading_events')).toBe(true); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index fd536528c..e81939b34 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1868,7 +1868,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The calendars are loading: the view's grid in the skeleton's shapes, // where the real one is about to be drawn. if (isLoading && calendars.length === 0) { - return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} />; + return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} text={skeletonText} />; } const viewContent = (() => {