mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat: show the calendar's grid while the app loads
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')) || {};
|
||||
|
||||
Reference in New Issue
Block a user