From c06b4b5e2876f32b133ae4398e31bdadb2da2cc8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:48:20 +0100 Subject: [PATCH] feat: show the calendar's grid while the app loads --- app/(main)/protocol/webcal/page.tsx | 17 +- app/boot-skeleton.css | 115 ++++ .../boot-skeleton/calendar-skeleton.tsx | 571 +++++++++++++++++- components/boot-skeleton/shell.tsx | 26 +- components/calendar/calendar-app.tsx | 32 +- .../protocol/webcal-protocol-client.tsx | 16 +- .../__tests__/boot-script.test.ts | 81 +++ lib/boot-skeleton/boot-script.ts | 39 +- 8 files changed, 869 insertions(+), 28 deletions(-) diff --git a/app/(main)/protocol/webcal/page.tsx b/app/(main)/protocol/webcal/page.tsx index 47cdbfa8c..d4641c490 100644 --- a/app/(main)/protocol/webcal/page.tsx +++ b/app/(main)/protocol/webcal/page.tsx @@ -1,8 +1,23 @@ import { getTranslations } from "next-intl/server"; import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client"; +import { calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton"; export default async function WebcalProtocolPage() { const t = await getTranslations("protocol_handlers"); + const tSidebar = await getTranslations("sidebar"); + const tCalendar = await getTranslations("calendar"); - return ; + return ( + + ); } diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index 7ce12389c..726034dd7 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -91,3 +91,118 @@ animation: none; } } + +/* calendar */ + +/* Week numbers beside the sidebar's mini month (showWeekNumbers). */ +@custom-variant boot-cal-weeknums (&:where([data-boot-cal-weeknums="on"] *)); + +/* Calendar tasks are on (enableCalendarTasks): a view pill and a sidebar row. */ +@custom-variant boot-cal-tasks-on (&:where([data-boot-cal-tasks="on"] *)); + +/* The views measure their pane to size their rows; the skeleton does the + same with the pane as a size container. --sk-head is the header's + offsetHeight (whole pixels, as the views read it). */ +.sk-cal-view { + container-type: size; +} +.sk-cal-week, +.sk-cal-day { + /* pt-2 + weekday line + gap-0.5 + date circle + pb-1.5 + border */ + --sk-head: round(nearest, 3.5rem + 1.03125rem + 1px, 1px); + --sk-hour-min: 60px; + /* fillHourHeight takes the fitted height from ceil(min * 0.9) up. */ + --sk-hour-floor: 54px; + --sk-hour: var(--sk-hour-min); + --sk-shift: 0px; +} +.sk-cal-day { + --sk-hour-min: 64px; + --sk-hour-floor: 58px; +} +.sk-cal-month { + /* py-2 + text-xs line + border */ + --sk-head: calc(2rem + 1px); + --sk-week-min: 146px; + --sk-week: var(--sk-week-min); +} +@media (width < 48rem) { + .sk-cal-week { + --sk-head: round(nearest, 3rem + 1.03125rem + 1px, 1px); + } + .sk-cal-month { + --sk-head: round(nearest, 0.75rem + 1.03125rem + 1px, 1px); + --sk-week-min: 52px; + } +} +@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); + --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)) + ); + } + .sk-cal-month { + --sk-week: max(var(--sk-week-min), round(down, (100cqh - var(--sk-head)) / 6, 1px)); + } +} +.sk-cal-hours { + height: calc(var(--boot-cal-hours, 12) * var(--sk-hour)); +} +.sk-cal-hours > * { + top: calc(-1 * var(--sk-shift)); +} +.sk-cal-hour { + height: var(--sk-hour); +} +/* The slots' border-b border-border/50, one per hour, as a background. */ +.sk-cal-slots { + background-image: linear-gradient( + to bottom, + transparent calc(var(--sk-hour) - 1px), + color-mix(in oklab, var(--color-border) 50%, transparent) calc(var(--sk-hour) - 1px) + ); + background-size: 100% var(--sk-hour); +} +/* A range that starts at midnight leaves its first hour unlabelled. */ +.sk-cal-first-label { + opacity: min(1, var(--boot-cal-start, 8)); +} +/* The all-hours toggle shows while the hours are limited. */ +.sk-cal-hours-toggle { + opacity: min(1, 24 - var(--boot-cal-hours, 12)); +} +.sk-cal-month-week { + height: var(--sk-week); +} +.sk-cal-mini-days { + height: calc(var(--boot-cal-mini-rows, 6) * 1.75rem); +} +@media (pointer: coarse) { + .sk-cal-mini-days { + height: calc(var(--boot-cal-mini-rows, 6) * 2.75rem); + } +} +/* Week columns: floor((pane - gutter) / columns), at least 80px; 120px on + phones (calendar-week-view.tsx, measureColWidth). Columns past the pane + are the next days of the strip, cut off. */ +.sk-cal-cols { + --sk-col: calc((100cqw - 3.5rem) / var(--boot-cal-cols, 7)); + grid-auto-flow: column; + grid-auto-columns: var(--sk-col); +} +@supports (width: round(down, 1.5px, 1px)) and (width: 1cqw) { + .sk-cal-cols { + --sk-col: max(80px, round(down, (100cqw - 3.5rem) / var(--boot-cal-cols, 7), 1px)); + } +} +@media (width < 48rem) { + .sk-cal-cols { + --sk-col: 120px; + } +} diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx index 6c8017443..43af4fed9 100644 --- a/components/boot-skeleton/calendar-skeleton.tsx +++ b/components/boot-skeleton/calendar-skeleton.tsx @@ -1,3 +1,32 @@ +/** + * The calendar page as a skeleton: sidebar with the mini month, toolbar and + * the grid of the view the URL or the last visit picked + * (`data-boot-calendar-view`). Class lists mirror the real components + * (components/calendar/calendar-app.tsx, calendar-toolbar.tsx, + * mini-calendar.tsx, calendar-sidebar-panel.tsx and the views), and the + * sizes the views measure at run time (hour rows, week rows, columns) come + * from the `.sk-cal-*` rules in app/boot-skeleton.css. + * + * Dates, weekday names, hour labels and the title depend on the time zone, + * the first day of the week, the time format and the language, so they are + * placeholders; the icons are drawn for real. + */ +import type { ReactNode } from "react"; +import { + CalendarDays, + ChevronDown, + ChevronLeft, + ChevronRight, + ListTodo, + Menu, + MoreVertical, + Plus, + Search, + UnfoldVertical, +} from "@/components/icons"; +import type { CalendarViewMode } from "@/stores/calendar-store"; +import { cn } from "@/lib/cn"; +import { Bar, Block, Circle, repeat, textWidth } from "./kit"; import { AppShellSkeleton, type RailLabels } from "./shell"; export interface CalendarSkeletonProps { @@ -5,22 +34,550 @@ export interface CalendarSkeletonProps { label?: string; /** Rail labels for the phone bottom bar (sidebar.*). */ labels?: RailLabels; + /** The calendar's own words, translated by the caller. */ + text?: CalendarSkeletonText; +} + +/** + * Words of the calendar's chrome that are the same before and after the app + * loads (they depend on the language only). Placeholders where one is missing. + */ +export interface CalendarSkeletonText { + /** calendar.title: the sidebar's heading. */ + title?: string; + /** calendar.views.today */ + today?: string; + /** calendar.search.title: the search field's placeholder. */ + search?: string; + /** calendar.views.: the view menu button and the phone's pills. */ + views?: Record; + /** calendar.tasks.label: the sidebar's tasks entry. */ + tasks?: string; +} + +const MODES: CalendarViewMode[] = ["month", "week", "day", "agenda", "tasks"]; + +/** The skeleton's words from a translator for the "calendar" namespace. */ +export function calendarSkeletonText(t: (key: string) => string): CalendarSkeletonText { + return { + title: t("title"), + today: t("views.today"), + search: t("search.title"), + views: { + month: t("views.month"), + week: t("views.week"), + day: t("views.day"), + agenda: t("views.agenda"), + tasks: t("views.tasks"), + }, + tasks: t("tasks.label"), + }; +} + +/** Shown for the view on . */ +const VIEW_SHOWN: Record = { + month: "hidden boot-cal-month:flex", + week: "hidden boot-cal-week:flex", + day: "hidden boot-cal-day:flex", + agenda: "hidden boot-cal-agenda:flex", + tasks: "hidden boot-cal-tasks:flex", +}; + +/** The view menu button's label for the view on . */ +const VIEW_LABEL: Record = { + month: "hidden boot-cal-month:inline", + week: "hidden boot-cal-week:inline", + day: "hidden boot-cal-day:inline", + agenda: "hidden boot-cal-agenda:inline", + tasks: "hidden boot-cal-tasks:inline", +}; + +/** The phone's view pill drawn as current. */ +const PILL_ON: Record = { + month: "boot-cal-month:bg-primary boot-cal-month:text-primary-foreground", + week: "boot-cal-week:bg-primary boot-cal-week:text-primary-foreground", + day: "boot-cal-day:bg-primary boot-cal-day:text-primary-foreground", + agenda: "boot-cal-agenda:bg-primary boot-cal-agenda:text-primary-foreground", + // Only there when tasks are on. + tasks: "hidden boot-cal-tasks-on:flex boot-cal-tasks:bg-primary boot-cal-tasks:text-primary-foreground", +}; + +/** Tasks have no dates to step through and no events to search. */ +const NOT_TASKS = "boot-cal-tasks:hidden"; + +/** ToolbarButton (components/ui/toolbar.tsx): a 32px muted icon button. */ +function ToolbarIcon({ className, children }: { className?: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** IconButton size icon-lg: the phone toolbar's 44px buttons. */ +function PhoneIcon({ outline, className, children }: { outline?: boolean; className?: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** An outline xs button (Today, the view menu). */ +function OutlineButton({ className, children }: { className?: string; children?: ReactNode }) { + return ( + + {children} + + ); +} + +/** A word when the caller has it, else a placeholder as wide as a short one. */ +function Word({ text, w }: { text?: string; w: string }) { + return text ? {text} : ; +} + +/** A placeholder bar in a line box of the given height class. */ +function Line({ className, bar, w }: { className: string; bar: string; w: string }) { + return ( + + + + ); +} + +/** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */ +function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { + const views = text?.views; + return ( + <> +
+ + + + {/* The sidebar's "+" stands in for this one while the sidebar shows. */} + + + + + + + + + + + + + +
+ + + {text?.search ? ( + {text.search} + ) : ( + + )} + + / + + + + + + + {views ? MODES.map((v) => {views[v]}) : } + + +
+ +
+
+
+ + + + + + + {/* Below 400px the title takes a line of its own. */} + + + + + + + + {/* PhoneHeaderActions: the shell's â‹® and the account avatar. */} +
+ + + + + + +
+
+
+
+ {MODES.map((v) => ( + + {views ? views[v] : } + + ))} +
+ + + + + + +
+
+
+ + ); +} + +/** The inline sidebar of a desktop: header, mini month and calendar list. */ +function SidebarSkeleton({ text }: { text?: CalendarSkeletonText }) { + const weekNumberCell = ; + const columns = "grid grid-cols-7 boot-cal-weeknums:grid-cols-[1.25rem_repeat(7,minmax(0,1fr))]"; + const chevron = "flex items-center justify-center h-8 w-8 [&_svg]:size-4"; + return ( +
+ {/* CalendarSidebarHeader */} +
+ {text?.title ? ( + {text.title} + ) : ( + + )} + + + +
+
+ {/* MiniCalendar */} +
+
+ + + + + + + + + + +
+
+ {weekNumberCell} + {repeat(7, () => ( + + ))} +
+ {/* 4 to 6 weeks: as many as the month has (--boot-cal-mini-rows). */} +
+ {repeat(6, () => ( + <> + {weekNumberCell} + {repeat(7, () => )} + + ))} +
+
+ {/* CalendarSidebarPanel: one section of calendars. */} +
+ {/* The tasks entry, when tasks are on. */} +
+ + +
+ {/* A section header, its add button taking the row to 32px. */} +
+ + +
+ {repeat(4, (i) => ( +
+ + +
+ ))} +
+
+
+ ); +} + +/** + * The hour labels down the gutter, one per hour row. Each sits centred on + * its row's top line; a range that starts later than midnight labels its + * first hour inside the row instead (calendar-week-view.tsx). The gutter + * stops short of the last line, which has no label. + */ +function HourGutter({ className, labelW }: { className: string; labelW: string }) { + return ( +
+ {repeat(24, (i) => ( +
+ +
+ ))} +
+ ); +} + +/** The all-hours toggle in the gutter's header cell, while the hours are limited. */ +function HoursToggle({ className }: { className: string }) { + return ( +
+ + + +
+ ); +} + +/** The weekday and date of a column header. */ +function DayHead({ className, date }: { className?: string; date: string }) { + return ( + <> + + + + ); +} + +/** CalendarWeekView: gutter, one column per day, hour rows that fill the pane. */ +function WeekSkeleton({ className }: { className: string }) { + return ( +