+ {menu ? (
+
+
+
+ ) : null}
@@ -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"
>
-
+
) : 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 ? (
+
+
+
+ ) : (
+
+ );
+ 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 ;
+ return ;
}
const viewContent = (() => {
@@ -2013,10 +2030,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
{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 && (
-
-
-
+ {t("status.loading_events")}
)}
);
diff --git a/components/protocol/webcal-protocol-client.tsx b/components/protocol/webcal-protocol-client.tsx
index 7bd697ffe..5a571d61a 100644
--- a/components/protocol/webcal-protocol-client.tsx
+++ b/components/protocol/webcal-protocol-client.tsx
@@ -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 (
-
- {openingText}
-
- );
+ // The calendar is where this goes next: its skeleton, with "Opening…" as
+ // the page's status.
+ return
;
}
diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts
index ee151e6bc..0ba9fd0be 100644
--- a/lib/boot-skeleton/__tests__/boot-script.test.ts
+++ b/lib/boot-skeleton/__tests__/boot-script.test.ts
@@ -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', () => {
diff --git a/lib/boot-skeleton/boot-script.ts b/lib/boot-skeleton/boot-script.ts
index fa40c8776..2c8fcb396 100644
--- a/lib/boot-skeleton/boot-script.ts
+++ b/lib/boot-skeleton/boot-script.ts
@@ -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')) || {};