feat: show the calendar's grid while the app loads

This commit is contained in:
Linus Rath
2026-11-01 19:48:20 +01:00
parent 8130074680
commit c06b4b5e28
8 changed files with 869 additions and 28 deletions
+16 -1
View File
@@ -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 <WebcalProtocolClient openingText={t("opening_webcal")} />;
return (
<WebcalProtocolClient
openingText={t("opening_webcal")}
labels={{
mail: tSidebar("mail"),
calendar: tSidebar("calendar"),
contacts: tSidebar("contacts"),
files: tSidebar("files"),
settings: tSidebar("settings"),
}}
text={calendarSkeletonText(tCalendar)}
/>
);
}
+115
View File
@@ -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;
}
}
+564 -7
View File
@@ -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.<view>: the view menu button and the phone's pills. */
views?: Record<CalendarViewMode, string>;
/** 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 <html>. */
const VIEW_SHOWN: Record<CalendarViewMode, string> = {
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 <html>. */
const VIEW_LABEL: Record<CalendarViewMode, string> = {
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<CalendarViewMode, string> = {
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 (
<span className={cn("flex items-center justify-center h-8 w-8 shrink-0 rounded-md text-muted-foreground [&_svg]:size-4", className)}>
{children}
</span>
);
}
/** IconButton size icon-lg: the phone toolbar's 44px buttons. */
function PhoneIcon({ outline, className, children }: { outline?: boolean; className?: string; children: ReactNode }) {
return (
<span
className={cn(
"flex items-center justify-center h-11 w-11 shrink-0 rounded-md [&_svg]:size-5",
outline && "border border-input bg-background",
className,
)}
>
{children}
</span>
);
}
/** An outline xs button (Today, the view menu). */
function OutlineButton({ className, children }: { className?: string; children?: ReactNode }) {
return (
<span className={cn("flex items-center gap-1 h-8 px-2.5 shrink-0 rounded-md border border-input bg-background text-sm font-medium", className)}>
{children}
</span>
);
}
/** 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 ? <span className="whitespace-nowrap">{text}</span> : <Bar className="h-2.5" w={w} />;
}
/** A placeholder bar in a line box of the given height class. */
function Line({ className, bar, w }: { className: string; bar: string; w: string }) {
return (
<span className={cn("flex items-center", className)}>
<Bar className={bar} w={w} />
</span>
);
}
/** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */
function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
return (
<>
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<ToolbarIcon className="me-1">
<Menu />
</ToolbarIcon>
{/* The sidebar's "+" stands in for this one while the sidebar shows. */}
<ToolbarIcon className="lg:hidden lg:boot-cal-collapsed:flex">
<Plus />
</ToolbarIcon>
<OutlineButton className={cn("me-1", NOT_TASKS)}>
<Word text={text?.today} w="2.5rem" />
</OutlineButton>
<ToolbarIcon className={NOT_TASKS}>
<ChevronLeft />
</ToolbarIcon>
<ToolbarIcon className={NOT_TASKS}>
<ChevronRight />
</ToolbarIcon>
<Line className="ms-2 h-6 min-w-0" bar="h-3.5" w="9rem" />
<div className="flex-1" />
<span
className={cn(
"relative flex items-center w-40 lg:w-56 xl:w-64 me-1 h-8 ps-8 pe-8 shrink-0 rounded-md border border-input bg-background",
NOT_TASKS,
)}
>
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
{text?.search ? (
<span className="text-sm text-muted-foreground truncate">{text.search}</span>
) : (
<Bar className="h-2.5" w="6rem" />
)}
<span className="absolute end-1 top-1/2 -translate-y-1/2 mx-1 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden">
/
</span>
</span>
<ToolbarIcon>
<MoreVertical />
</ToolbarIcon>
<OutlineButton className="ms-1">
{views ? MODES.map((v) => <span key={v} className={VIEW_LABEL[v]}>{views[v]}</span>) : <Bar className="h-2.5" w="2.25rem" />}
<ChevronDown className="w-3 h-3" />
</OutlineButton>
</div>
<div className="md:hidden border-b border-border">
<div className="flex flex-col gap-1 px-2 py-2">
<div className="flex flex-wrap items-center gap-1">
<PhoneIcon className="-ms-1">
<Menu />
</PhoneIcon>
<PhoneIcon className={NOT_TASKS}>
<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"
/>
<PhoneIcon className={NOT_TASKS}>
<ChevronRight />
</PhoneIcon>
<span className={cn("flex items-center h-8 px-2.5 ms-0.5 shrink-0 text-sm font-medium", NOT_TASKS)}>
<Word text={text?.today} w="2.25rem" />
</span>
{/* PhoneHeaderActions: the shell's ⋮ and the account avatar. */}
<div className="flex items-center gap-1 flex-shrink-0 ms-auto">
<PhoneIcon>
<MoreVertical />
</PhoneIcon>
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
<Circle className="w-8 h-8" />
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{MODES.map((v) => (
<span
key={v}
className={cn("flex flex-auto items-center justify-center min-h-11 px-1.5 text-[0.6875rem] font-medium text-muted-foreground", PILL_ON[v])}
>
{views ? views[v] : <Bar className="h-2" w="2rem" />}
</span>
))}
</div>
<PhoneIcon outline className={NOT_TASKS}>
<Search />
</PhoneIcon>
<PhoneIcon outline>
<CalendarDays />
</PhoneIcon>
</div>
</div>
</div>
</>
);
}
/** The inline sidebar of a desktop: header, mini month and calendar list. */
function SidebarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const weekNumberCell = <span className="hidden boot-cal-weeknums:block" />;
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 (
<div
className="hidden lg:flex lg:boot-cal-collapsed:hidden flex-col border-e border-border bg-secondary overflow-hidden flex-shrink-0"
style={{ width: "var(--boot-cal-sidebar-w, 256px)" }}
>
{/* CalendarSidebarHeader */}
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
{text?.title ? (
<span className="text-sm font-semibold truncate">{text.title}</span>
) : (
<Bar className="h-3" w="4.5rem" />
)}
<span className="flex items-center justify-center h-8 w-8 shrink-0">
<Plus className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-hidden py-3">
{/* MiniCalendar */}
<div className="px-3">
<div className="flex items-center justify-between mb-2 text-muted-foreground">
<span className={chevron}>
<ChevronLeft />
</span>
<span className="inline-flex items-center gap-1 h-7 px-2">
<Bar className="h-3" w="6.5rem" />
<ChevronDown className="w-3.5 h-3.5" />
</span>
<span className={chevron}>
<ChevronRight />
</span>
</div>
<div className={columns}>
{weekNumberCell}
{repeat(7, () => (
<Line className="justify-center h-[0.9375rem] my-1" bar="h-1.5" w="0.875rem" />
))}
</div>
{/* 4 to 6 weeks: as many as the month has (--boot-cal-mini-rows). */}
<div className={cn(columns, "sk-cal-mini-days overflow-hidden auto-rows-[1.75rem] pointer-coarse:auto-rows-[2.75rem] items-center justify-items-center")}>
{repeat(6, () => (
<>
{weekNumberCell}
{repeat(7, () => <Bar className="h-2" w="0.75rem" />)}
</>
))}
</div>
</div>
{/* CalendarSidebarPanel: one section of calendars. */}
<div className="mt-4">
{/* The tasks entry, when tasks are on. */}
<div
className="hidden boot-cal-tasks-on:flex items-center gap-2 px-3 mb-2 min-h-8 pointer-coarse:min-h-11 text-sm"
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<Word text={text?.tasks} w="3rem" />
</div>
{/* A section header, its add button taking the row to 32px. */}
<div className="flex items-center gap-1 px-3 h-8 text-muted-foreground">
<ChevronDown className="w-3 h-3 flex-shrink-0" />
<Bar className="h-2" w="4rem" />
</div>
{repeat(4, (i) => (
<div
className="flex items-center gap-2 ps-5 pe-3 min-h-8 pointer-coarse:min-h-11"
style={{ paddingBlock: "var(--density-sidebar-py, 4px)" }}
>
<Block className="w-3.5 h-3.5 rounded-sm flex-shrink-0" />
<Bar className="h-2.5" w={textWidth(i, 30, 35)} />
</div>
))}
</div>
</div>
</div>
);
}
/**
* 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 (
<div className={cn("relative flex-shrink-0 h-[calc(100%-5px)] overflow-hidden bg-background", className)}>
{repeat(24, (i) => (
<div className="sk-cal-hour relative">
<Bar
className={cn("absolute end-2 h-2", i === 0 ? "sk-cal-first-label top-1.5" : "top-0 -translate-y-1/2")}
w={labelW}
/>
</div>
))}
</div>
);
}
/** The all-hours toggle in the gutter's header cell, while the hours are limited. */
function HoursToggle({ className }: { className: string }) {
return (
<div className={cn("flex items-end justify-center pb-1 flex-shrink-0", className)}>
<span className="sk-cal-hours-toggle flex items-center justify-center rounded p-1 text-muted-foreground">
<UnfoldVertical className="h-3.5 w-3.5" />
</span>
</div>
);
}
/** The weekday and date of a column header. */
function DayHead({ className, date }: { className?: string; date: string }) {
return (
<>
<Line className={cn("h-[1.03125rem]", className)} bar="h-2" w="1.5rem" />
<Line className={cn("justify-center", date)} bar="h-3.5" w="1.125rem" />
</>
);
}
/** CalendarWeekView: gutter, one column per day, hour rows that fill the pane. */
function WeekSkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-week flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
<HoursToggle className="w-14 max-md:w-10" />
<div className="sk-cal-cols grid border-s border-border">
{repeat(8, () => (
<div className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border">
<DayHead date="w-10 h-10 max-md:w-8 max-md:h-8" />
</div>
))}
</div>
</div>
<div className="sk-cal-hours flex flex-shrink-0">
<HourGutter className="w-14 max-md:w-10" labelW="1.5rem" />
<div className="sk-cal-cols relative grid h-full border-s border-border">
{repeat(8, () => <div className="sk-cal-slots border-e border-border" />)}
</div>
</div>
</div>
);
}
/** CalendarDayView: the same grid with one wide column. */
function DaySkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-day flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="relative z-10 flex flex-shrink-0 border-b border-border bg-background">
<HoursToggle className="w-16 max-md:w-10" />
<div className="flex flex-col flex-1 min-w-0 items-start gap-0.5 px-3 max-md:px-2 pt-2 pb-1.5 border-e border-border">
<DayHead className="w-10 justify-center" date="w-10 h-10" />
</div>
</div>
<div className="sk-cal-hours flex flex-shrink-0">
<HourGutter className="w-16 max-md:w-10" labelW="1.75rem" />
<div className="sk-cal-slots relative flex-1 h-full border-e border-border" />
</div>
</div>
);
}
/** CalendarMonthView: weekday header and six week rows filling the pane. */
function MonthSkeleton({ className }: { className: string }) {
const cell = "border-e border-border last:border-e-0";
return (
<div aria-hidden="true" className={cn("sk-cal-view sk-cal-month flex-col flex-1 min-h-0 min-w-0 overflow-hidden", className)}>
<div className="grid grid-cols-7 flex-shrink-0 border-b border-border">
{repeat(7, () => (
<div className={cn(cell, "flex justify-center py-2 max-md:py-1.5")}>
<Line className="h-4 max-md:h-[1.03125rem]" bar="h-2" w="1.5rem" />
</div>
))}
</div>
{repeat(6, () => (
<div className="sk-cal-month-week grid grid-cols-7 flex-shrink-0 border-b border-border">
{repeat(7, () => (
<div className={cn(cell, "p-1")}>
<Line className="justify-center h-6 max-md:h-7" bar="h-2.5" w="0.875rem" />
</div>
))}
</div>
))}
</div>
);
}
/** CalendarAgendaView: the range line, then day rows with their events. */
function AgendaSkeleton({ className }: { className: string }) {
return (
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
<div className="flex justify-center px-4 py-2">
<Line className="h-6" bar="h-2.5" w="7rem" />
</div>
{repeat(6, (i) => (
<div className="flex items-start gap-2 px-2 sm:px-4 py-1.5 border-b border-border">
<div className="flex items-center gap-2 w-24 sm:w-32 flex-shrink-0 py-1.5">
<Line className="justify-center w-9 h-9 flex-shrink-0" bar="h-3.5" w="1.125rem" />
<Bar className="h-2" w="2.5rem" />
</div>
<div className="flex-1 min-w-0 py-0.5">
{repeat(1 + (i % 2), (j) => (
<div className="flex items-start gap-3 px-2" style={{ paddingBlock: "var(--density-item-py)" }}>
<Line className="w-28 sm:w-32 h-5 flex-shrink-0" bar="h-2.5" w="5.5rem" />
<Circle className="w-[9px] h-[9px] mt-[5.5px]" />
<Line className="flex-1 min-w-0 h-5" bar="h-3" w={textWidth(i * 2 + j, 30, 40)} />
</div>
))}
</div>
</div>
))}
</div>
);
}
/** The tasks view: TaskToolbar and TaskListView. */
function TasksSkeleton({ className }: { className: string }) {
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">
<div className="flex border border-border rounded-md overflow-hidden">
{repeat(4, () => (
<Line className="h-7 px-3" bar="h-2" w="2.5rem" />
))}
</div>
<div className="flex-1" />
<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" />
</div>
</div>
<div className="divide-y divide-border">
{repeat(8, (i) => (
<div className="flex items-start gap-3 px-4 py-3">
<span className="mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground/40" />
<div className="flex-1 min-w-0">
<Line className="h-5" bar="h-3" w={textWidth(i, 25, 35)} />
<Line className="h-4 mt-0.5" bar="h-2" w="6rem" />
</div>
</div>
))}
</div>
</div>
);
}
const VIEWS: Record<CalendarViewMode, (props: { className: string }) => ReactNode> = {
month: MonthSkeleton,
week: WeekSkeleton,
day: DaySkeleton,
agenda: AgendaSkeleton,
tasks: TasksSkeleton,
};
/**
* The grid of a calendar view where calendar-app's renderView() puts it.
* With `view` it draws that one (the loaded page while its calendars load,
* with `label` as its status); without, the one `data-boot-calendar-view`
* names.
*/
export function CalendarViewSkeleton({ view, label }: { view?: CalendarViewMode; label?: string }) {
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" })
: (Object.keys(VIEWS) as CalendarViewMode[]).map((v) => {
const View = VIEWS[v];
return <View key={v} className={VIEW_SHOWN[v]} />;
})}
</div>
);
}
/**
* The calendar page's own content: everything in the row next to the rail.
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
* has no rail, no Mountain View shell and fills its pane (h-full).
* has no rail, no Mountain View shell and fills its pane (h-full). Pro runs
* on desktops only, so the breakpoints pick the desktop layout there too.
*/
export function CalendarSkeletonBody({ embedded = false }: { embedded?: boolean }) {
return <div className="flex-1 min-w-0" data-embedded={embedded ? "" : undefined} />;
export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: boolean; text?: CalendarSkeletonText }) {
return (
<div aria-hidden="true" className="flex flex-col flex-1 min-w-0 min-h-0" data-embedded={embedded ? "" : undefined}>
<div className="flex flex-1 min-w-0 min-h-0">
<SidebarSkeleton text={text} />
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<ToolbarSkeleton text={text} />
<div className="relative flex flex-1 overflow-hidden isolate">
<div className="flex flex-1 min-w-0">
<CalendarViewSkeleton />
</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">
<Plus className="h-6 w-6" />
</span>
</div>
</div>
</div>
</div>
);
}
/** Skeleton of the calendar page. Stub: filled in per surface. */
export function CalendarSkeleton({ label, labels }: CalendarSkeletonProps) {
/** Skeleton of the calendar page. */
export function CalendarSkeleton({ label, labels, text }: CalendarSkeletonProps) {
return (
<AppShellSkeleton surface="calendar" label={label} labels={labels} railBelow="md" active="calendar">
<CalendarSkeletonBody />
<AppShellSkeleton
surface="calendar"
label={label}
labels={labels}
railBelow="md"
active="calendar"
mvMenu={false}
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
>
<CalendarSkeletonBody text={text} />
</AppShellSkeleton>
);
}
+20 -6
View File
@@ -148,14 +148,25 @@ export function BottomBarSkeleton({
}
/** Mountain View's 64px top bar (components/layout/mv-top-bar.tsx), desktop only. */
export function MvTopBarSkeleton({ search = true, className }: { search?: boolean; className?: string }) {
export function MvTopBarSkeleton({
search = true,
menu = true,
className,
}: {
search?: boolean;
/** The burger that folds the sidebar; calendar's bar has none (showMenuButton={false}). */
menu?: boolean;
className?: string;
}) {
const round = "grid place-items-center w-12 h-12 rounded-full text-muted-foreground";
return (
<div aria-hidden="true" className={cn("items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-[var(--mv-canvas)]", className)}>
<div className="flex items-center gap-1 shrink-0">
<span className={round}>
<Menu className="w-5 h-5" />
</span>
<div className={cn("flex items-center gap-1 shrink-0", !menu && "ps-3")}>
{menu ? (
<span className={round}>
<Menu className="w-5 h-5" />
</span>
) : null}
<span className="flex items-center gap-2 pe-4 min-w-0">
<Bar className="h-5" w="7rem" />
</span>
@@ -201,6 +212,8 @@ interface AppShellSkeletonProps {
labels?: RailLabels;
/** Whether the Mountain View top bar holds the search field (mail does). */
mvSearch?: boolean;
/** Whether the Mountain View top bar starts with the sidebar burger. */
mvMenu?: boolean;
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
rowClassName?: string;
children: ReactNode;
@@ -221,6 +234,7 @@ export function AppShellSkeleton({
railBelow,
labels,
mvSearch = false,
mvMenu = true,
rowClassName,
children,
}: AppShellSkeletonProps) {
@@ -234,7 +248,7 @@ export function AppShellSkeleton({
label={label}
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden"
>
<MvTopBarSkeleton search={mvSearch} className="hidden lg:mv:flex" />
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
<RailSkeleton
active={active}
+25 -7
View File
@@ -21,7 +21,9 @@ import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
import { Button } from "@/components/ui/button";
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
import { SkeletonRegion } from "@/components/boot-skeleton/kit";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
@@ -150,6 +152,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const describeError = useCalendarErrorMessage();
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("calendar"));
const skeletonLabels = useSkeletonLabels();
const tWebcalAction = useTranslations("calendar.webcal_action");
const { demoMode } = useConfig();
// Subscriptions fetch the feed through /api/fetch-ical, which the demo has no
@@ -1845,13 +1848,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</Dialog>
) : null;
if (!isAuthenticated) return null;
// Until the session says what the account has, the page is drawn as the
// skeleton the server sent (components/boot-skeleton): the same markup, so
// nothing moves while the sign-in is restored or the redirects decide.
const skeletonText = calendarSkeletonText(t);
const skeleton = isEmbedded ? (
<SkeletonRegion surface="calendar" label={skeletonLabels.label} className="flex flex-col h-full">
<CalendarSkeletonBody embedded text={skeletonText} />
</SkeletonRegion>
) : (
<CalendarSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} text={skeletonText} />
);
if (!isAuthenticated) return skeleton;
// Turned off by policy: nothing, while the effect above leaves for mail.
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
if (!supportsCalendar) return renderWebcalAccountPicker() ?? skeleton;
const renderView = () => {
// 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 <PaneSpinner label={t("status.loading_calendars")} className="flex-1" />;
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} />;
}
const viewContent = (() => {
@@ -2013,10 +2030,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
)}
{viewContent}
{/* The first events: the grid is drawn already and they fill in on
it, so this is said rather than shown (a dimmed grid with a
spinner flashed between the skeleton and the events). */}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
<Spinner label={t("status.loading_events")} />
</div>
<span role="status" className="sr-only">{t("status.loading_events")}</span>
)}
</div>
);
+10 -6
View File
@@ -4,6 +4,8 @@ import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
import { CalendarSkeleton, type CalendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
import type { RailLabels } from "@/components/boot-skeleton/shell";
type StandaloneNavigator = Navigator & { standalone?: boolean };
@@ -39,9 +41,13 @@ function shouldOpenFallbackAppTab(): boolean {
interface WebcalProtocolClientProps {
openingText: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
/** The calendar skeleton's words (calendar.*). */
text?: CalendarSkeletonText;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
export function WebcalProtocolClient({ openingText, labels, text }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
@@ -65,9 +71,7 @@ export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps)
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
// The calendar is where this goes next: its skeleton, with "Opening…" as
// the page's status.
return <CalendarSkeleton label={openingText} labels={labels} text={text} />;
}
@@ -248,6 +248,87 @@ describe('boot script: calendar', () => {
boot('/en/calendar');
expect(attr('collapsed')).toBe('calendar');
});
it('gives the first shown hour, 0 for the whole day', () => {
boot('/en/calendar/week');
expect(cssVar('--boot-cal-start')).toBe('8');
settings({ calendarDayStartHour: 6, calendarDayEndHour: 22 });
boot('/en/calendar/week');
expect(cssVar('--boot-cal-start')).toBe('6');
settings({ calendarLimitHours: false, calendarDayStartHour: 6, calendarDayEndHour: 22 });
boot('/en/calendar/week');
expect(cssVar('--boot-cal-start')).toBe('0');
});
describe('with a fixed clock', () => {
afterEach(() => vi.useRealTimers());
it('opens the hour grid an hour before now, at the top when now is outside the shown hours', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 10, 4, 15, 30));
boot('/en/calendar/week');
expect(cssVar('--boot-cal-scroll')).toBe('6');
settings({ calendarLimitHours: false });
boot('/en/calendar/day');
expect(cssVar('--boot-cal-scroll')).toBe('14');
vi.setSystemTime(new Date(2026, 10, 4, 21, 0));
settings({});
boot('/en/calendar/week');
expect(cssVar('--boot-cal-scroll')).toBe('-1');
});
it('reads the clock in the time zone the settings name', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(Date.UTC(2026, 10, 4, 14, 0)));
settings({ calendarLimitHours: false, timeZone: 'Asia/Tokyo' });
boot('/en/calendar/week');
// 23:00 in Tokyo.
expect(cssVar('--boot-cal-scroll')).toBe('22');
settings({ calendarLimitHours: false, timeZone: 'Not/AZone' });
boot('/en/calendar/week');
expect(cssVar('--boot-cal-scroll')).toBe(String(new Date().getHours() - 1));
});
it('counts the mini month rows of the linked month, else this one', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 1, 10, 12, 0));
// February 2026 starts on a Sunday and has 28 days.
boot('/en/calendar');
expect(cssVar('--boot-cal-mini-rows')).toBe('5');
settings({ firstDayOfWeek: 0 });
boot('/en/calendar');
expect(cssVar('--boot-cal-mini-rows')).toBe('4');
// November 2026 starts on a Sunday: six rows from Monday, five from Sunday.
settings({});
boot('/en/calendar/week/2026-11-04');
expect(cssVar('--boot-cal-mini-rows')).toBe('6');
boot('/en/calendar/2026-11-04');
expect(cssVar('--boot-cal-mini-rows')).toBe('6');
settings({ firstDayOfWeek: 0 });
boot('/en/calendar/week/2026-11-04');
expect(cssVar('--boot-cal-mini-rows')).toBe('5');
});
});
it('marks week numbers in the mini month', () => {
boot('/en/calendar');
expect(attr('cal-weeknums')).toBeNull();
settings({ showWeekNumbers: true });
boot('/en/calendar');
expect(attr('cal-weeknums')).toBe('on');
boot('/en/mail');
expect(attr('cal-weeknums')).toBeNull();
});
it('marks calendar tasks as on', () => {
boot('/en/calendar');
expect(attr('cal-tasks')).toBeNull();
settings({ enableCalendarTasks: true });
boot('/en/calendar');
expect(attr('cal-tasks')).toBe('on');
boot('/en/contacts');
expect(attr('cal-tasks')).toBeNull();
});
});
describe('boot script: settings', () => {
+38 -1
View File
@@ -230,7 +230,44 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
hours = end - start;
}
root.style.setProperty('--boot-cal-hours', String(hours));
} else mark('calendar-view', null);
var firstHour = hours === 24 ? 0 : (start as number);
root.style.setProperty('--boot-cal-start', String(firstHour));
// The wall clock in the calendar's time zone (lib/timezone.ts, displayNow).
var now = new Date();
var clock = [now.getFullYear(), now.getMonth() + 1, now.getDate(), now.getHours()];
if (typeof s.timeZone === 'string' && s.timeZone && s.timeZone !== 'auto') {
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: s.timeZone, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', hourCycle: 'h23',
}).formatToParts(now);
var names = ['year', 'month', 'day', 'hour'];
for (var p = 0; p < parts.length; p++) {
var at = names.indexOf(parts[p].type);
if (at !== -1) clock[at] = Number(parts[p].value) % (at === 3 ? 24 : 100000);
}
} catch { /* an unknown zone: the browser's own, as resolveTimeZone does */ }
}
// The hour row a time grid opens at (hooks/use-display-hours.ts): an hour
// before now when now is inside the shown hours, else the top (-1).
var nowHour = clock[3];
root.style.setProperty('--boot-cal-scroll', String(nowHour >= firstHour && nowHour < firstHour + hours ? nowHour - 1 - firstHour : -1));
// Week rows of the sidebar's mini month: the linked date's month, else
// this one (calendar-app.tsx, miniMonth).
var linkDate = /^\d{4}-\d{2}-\d{2}$/.test(seg[2] || '') ? seg[2] : /^\d{4}-\d{2}-\d{2}$/.test(view) ? view : '';
var year = linkDate ? Number(linkDate.slice(0, 4)) : clock[0];
var month = linkDate ? Number(linkDate.slice(5, 7)) : clock[1];
var weekStart = s.firstDayOfWeek === 0 ? 0 : s.firstDayOfWeek === 6 ? 6 : 1;
var lead = (new Date(year, month - 1, 1).getDay() - weekStart + 7) % 7;
var rows = Math.ceil((lead + new Date(year, month, 0).getDate()) / 7);
root.style.setProperty('--boot-cal-mini-rows', String(rows >= 4 && rows <= 6 ? rows : 6));
mark('cal-weeknums', s.showWeekNumbers === true ? 'on' : null);
// Tasks add a view pill and a sidebar row (enableCalendarTasks).
mark('cal-tasks', s.enableCalendarTasks === true ? 'on' : null);
} else {
mark('calendar-view', null);
mark('cal-weeknums', null);
mark('cal-tasks', null);
}
// --- Sizes the user dragged ------------------------------------------
var widths = parse(readLocal('column-widths')) || {};