Files
govoplan-calendar/webui/src/features/calendar/CalendarPage.tsx
2026-07-07 15:49:06 +02:00

2009 lines
79 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type FormEvent,
type WheelEvent as ReactWheelEvent
} from "react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
Button,
Dialog,
DismissibleAlert,
LoadingIndicator,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
createCalendar,
createCalendarEvent,
deleteCalendar,
deleteCalendarEvent,
listCalendarEvents,
listCalendars,
updateCalendar,
updateCalendarEvent,
type CalendarCollection,
type CalendarCollectionDeletePayload,
type CalendarDeleteEventAction,
type CalendarEvent,
type CalendarEventCreatePayload
} from "../../api/calendar";
type CalendarMode = "continuous" | "month" | "week" | "workweek" | "day";
type Range = { start: Date; end: Date };
type EventDialogState = { kind: "create" } | { kind: "edit"; event: CalendarEvent };
type CalendarEditDialogState = {
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
confirmingDelete: boolean;
};
type AgendaGroup = { key: string; date: Date; events: CalendarEvent[] };
type ContinuousViewport = { scrollTop: number; height: number };
type CalendarDayDropTarget = { kind: "day"; key: string };
type CalendarTimeDropTarget = { kind: "time"; key: string; minuteOfDay: number };
type CalendarDropTarget = CalendarDayDropTarget | CalendarTimeDropTarget | null;
type CalendarDragAction =
| { kind: "move"; event: CalendarEvent }
| { kind: "resize-start"; event: CalendarEvent }
| { kind: "resize-end"; event: CalendarEvent };
type CalendarResizeEdge = "start" | "end";
type CalendarViewPreferences = {
dimWeekends: boolean;
dimOffHours: boolean;
workdayStartHour: number;
workdayEndHour: number;
continuousVirtualization: boolean;
continuousOverscanWeeks: number;
alternateContinuousMonths: boolean;
};
const HOUR_ROW_HEIGHT = 64;
const INITIAL_SCROLL_HOUR = 7;
const MAX_TIMED_EVENT_COLUMNS = 3;
const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
const DEFAULT_CALENDAR_COLOR = "#5aa99b";
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY = "govoplan.calendar.viewPreferences";
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
workdayEndHour: 20,
continuousVirtualization: true,
continuousOverscanWeeks: 6,
alternateContinuousMonths: true
};
const modeOptions: { id: CalendarMode; label: string }[] = [
{ id: "continuous", label: "Continuous" },
{ id: "month", label: "Month" },
{ id: "week", label: "Week" },
{ id: "workweek", label: "Workweek" },
{ id: "day", label: "Day" }
];
export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
const [eventCalendarId, setEventCalendarId] = useState("");
const [newCalendarName, setNewCalendarName] = useState("");
const [newCalendarColor, setNewCalendarColor] = useState(DEFAULT_CALENDAR_COLOR);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
const [calendarEditDialog, setCalendarEditDialog] = useState<CalendarEditDialogState | null>(null);
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
const [draggingEventId, setDraggingEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [dropTarget, setDropTarget] = useState<CalendarDropTarget>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const continuousPrependPendingRef = useRef(false);
const continuousAppendPendingRef = useRef(false);
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
const dragActionRef = useRef<CalendarDragAction | null>(null);
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
const calendarColorById = useMemo(() => new Map(calendars.map((calendar) => [calendar.id, calendar.color || DEFAULT_CALENDAR_COLOR])), [calendars]);
const targetCalendarId = eventCalendarId || calendars[0]?.id || "";
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]);
const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]);
const canWrite = hasScope(auth, "calendar:event:write");
const canDelete = canWrite || hasScope(auth, "calendar:event:delete");
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
const heading = headingForMode(mode, focusDate, visibleRange);
useEffect(() => {
void loadCalendars();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
saveCalendarMode(mode);
}, [mode]);
useEffect(() => {
if (calendars.length) {
void loadEvents();
} else {
setEvents([]);
}
}, [calendars.length, visibleRange.start.getTime(), visibleRange.end.getTime()]);
useEffect(() => {
if (mode !== "continuous") return undefined;
const frame = window.requestAnimationFrame(() => {
const node = scrollRef.current;
if (!node || node.scrollTop > 0) return;
const firstWeek = node.querySelector<HTMLElement>(".calendar-week-row");
const rowHeight = firstWeek?.offsetHeight || 92;
node.scrollTop = Math.max(0, continuousWeeks.before * rowHeight - node.clientHeight / 3);
updateContinuousViewport(node);
});
return () => window.cancelAnimationFrame(frame);
}, [focusDate, mode]);
useEffect(() => {
if (mode !== "continuous" || !pendingContinuousScrollDayRef.current) return;
const targetDay = pendingContinuousScrollDayRef.current;
pendingContinuousScrollDayRef.current = null;
const frame = window.requestAnimationFrame(() => {
scrollContinuousDayIntoView(targetDay, "smooth");
});
return () => window.cancelAnimationFrame(frame);
}, [days, mode]);
async function loadCalendars() {
setLoading(true);
setError("");
try {
const response = await listCalendars(settings);
setCalendars(response.calendars);
const ids = response.calendars.map((calendar) => calendar.id);
setVisibleCalendarIds((current) => {
const next = current.filter((id) => ids.includes(id));
return next.length > 0 ? next : ids;
});
setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? "");
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}
async function loadEvents() {
setLoading(true);
setError("");
try {
const response = await listCalendarEvents(settings, {
start_at: visibleRange.start.toISOString(),
end_at: visibleRange.end.toISOString()
});
setEvents(response.events);
} catch (err) {
setError(errorText(err));
setEvents([]);
} finally {
setLoading(false);
}
}
function toggleCalendarVisibility(calendarId: string) {
setVisibleCalendarIds((current) => {
const next = current.includes(calendarId) ? current.filter((id) => id !== calendarId) : [...current, calendarId];
if (next.length > 0 && !next.includes(eventCalendarId)) setEventCalendarId(next[0]);
return next;
});
setEventCalendarId((current) => current || calendarId);
}
function openCalendarEditor(calendar: CalendarCollection) {
setCalendarEditDialog({
calendar,
eventCount: null,
loadingEventCount: true,
confirmingDelete: false
});
void listCalendarEvents(settings, { calendar_id: calendar.id })
.then((response) => {
setCalendarEditDialog((current) => current?.calendar.id === calendar.id
? { ...current, eventCount: response.events.length, loadingEventCount: false }
: current);
})
.catch((err) => {
setCalendarEditDialog((current) => current?.calendar.id === calendar.id
? { ...current, eventCount: null, loadingEventCount: false }
: current);
setError(errorText(err));
});
}
async function handleCalendarSave(calendar: CalendarCollection, payload: { name: string; color: string }) {
const name = payload.name.trim();
if (!name || !canManageCalendars) return;
setSaving(true);
setError("");
try {
const updated = await updateCalendar(settings, calendar.id, {
name,
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR
});
setCalendars((current) => current.map((item) => item.id === calendar.id ? updated : item));
setCalendarEditDialog(null);
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
async function handleCalendarDelete(calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) {
if (!canDeleteCalendars) return;
const currentDialog = calendarEditDialog;
if (!currentDialog || currentDialog.calendar.id !== calendar.id) return;
if (!currentDialog.confirmingDelete) {
setCalendarEditDialog({ ...currentDialog, confirmingDelete: true });
return;
}
setSaving(true);
setError("");
try {
await deleteCalendar(settings, calendar.id, payload);
setCalendarEditDialog(null);
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
setEventCalendarId((current) => current === calendar.id ? "" : current);
await loadCalendars();
if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents();
} else {
setEvents([]);
}
} catch (err) {
setError(errorText(err));
}
finally {
setSaving(false);
}
}
async function handleCreateCalendar(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
const name = newCalendarName.trim();
if (!name || !canManageCalendars) return;
setSaving(true);
setError("");
try {
const calendar = await createCalendar(settings, {
name,
color: normalizeHexColor(newCalendarColor) || DEFAULT_CALENDAR_COLOR,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
});
setCalendars((current) => [...current, calendar].sort(compareCalendars));
setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]);
setEventCalendarId(calendar.id);
setNewCalendarName("");
setNewCalendarColor(DEFAULT_CALENDAR_COLOR);
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
function moveFocus(direction: -1 | 1) {
if (mode === "continuous") return;
if (mode === "month") {
setFocusDate((current) => addMonths(current, direction));
return;
}
const daysToMove = mode === "day" ? 1 : 7;
setFocusDate((current) => addDays(current, direction * daysToMove));
}
function handleToday() {
const today = startOfDay(new Date());
if (mode !== "continuous") {
setFocusDate(today);
return;
}
pendingContinuousScrollDayRef.current = today;
setFocusDate(today);
window.requestAnimationFrame(() => {
if (pendingContinuousScrollDayRef.current) {
pendingContinuousScrollDayRef.current = null;
scrollContinuousDayIntoView(today, "smooth");
}
});
}
function handleContinuousScroll() {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
updateContinuousViewport(node);
if (node.scrollTop < 160) prependContinuousWeeks();
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) appendContinuousWeeks();
}
function handleContinuousWheel(event: ReactWheelEvent<HTMLDivElement>) {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
if (event.deltaY < 0 && node.scrollTop <= 0) prependContinuousWeeks();
}
function prependContinuousWeeks() {
const node = scrollRef.current;
if (!node || continuousPrependPendingRef.current) return;
continuousPrependPendingRef.current = true;
const oldHeight = node.scrollHeight;
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
window.requestAnimationFrame(() => {
const nextNode = scrollRef.current;
if (nextNode) nextNode.scrollTop += nextNode.scrollHeight - oldHeight;
continuousPrependPendingRef.current = false;
});
}
function updateContinuousViewport(node: HTMLDivElement) {
const next = { scrollTop: node.scrollTop, height: node.clientHeight };
setContinuousViewport((current) => (
Math.abs(current.scrollTop - next.scrollTop) < 1 && Math.abs(current.height - next.height) < 1 ? current : next
));
}
function scrollContinuousDayIntoView(day: Date, behavior: ScrollBehavior = "auto") {
const node = scrollRef.current;
if (!node || days.length === 0) return;
const firstWeek = startOfWeek(days[0]);
const targetWeek = startOfWeek(day);
const weekIndex = Math.max(0, Math.floor(daysBetweenCount(firstWeek, targetWeek) / 7));
node.scrollTo({ top: weekIndex * CONTINUOUS_WEEK_ROW_HEIGHT, behavior });
window.requestAnimationFrame(() => updateContinuousViewport(node));
}
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
if (!canWrite) {
dragEvent.preventDefault();
return;
}
dragActionRef.current = action;
setDraggingEventId(action.event.id);
dragEvent.dataTransfer.effectAllowed = "move";
dragEvent.dataTransfer.setData("text/plain", action.event.id);
}
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
beginEventDrag(dragEvent, { kind: "move", event: calendarEvent });
}
function handleEventResizeDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) {
dragEvent.stopPropagation();
if (calendarEvent.all_day) {
dragEvent.preventDefault();
return;
}
beginEventDrag(dragEvent, { kind: edge === "start" ? "resize-start" : "resize-end", event: calendarEvent });
}
function handleEventDragEnd() {
dragActionRef.current = null;
setDraggingEventId("");
setDropTarget(null);
}
function allowEventDrop(dragEvent: ReactDragEvent<HTMLElement>, canDrop: (action: CalendarDragAction) => boolean): boolean {
const action = dragActionRef.current;
if (!canWrite || !action || !canDrop(action)) return false;
dragEvent.preventDefault();
dragEvent.dataTransfer.dropEffect = "move";
return true;
}
function handleEventDragOverDay(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
if (!allowEventDrop(dragEvent, (action) => action.kind === "move")) return;
const key = dayKey(day);
setDropTarget((current) => (current?.kind === "day" && current.key === key ? current : { kind: "day", key }));
}
function handleEventDragOverTime(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
if (!allowEventDrop(dragEvent, (action) => action.kind === "move" || action.kind === "resize-start" || action.kind === "resize-end")) return;
const key = dayKey(day);
const minuteOfDay = dropSlotMinuteOfDay(dragEvent);
setDropTarget((current) => (
current?.kind === "time" && current.key === key && current.minuteOfDay === minuteOfDay
? current
: { kind: "time", key, minuteOfDay }
));
}
function handleDropTargetLeave(dragEvent: ReactDragEvent<HTMLElement>) {
const related = dragEvent.relatedTarget;
if (related instanceof Node && dragEvent.currentTarget.contains(related)) return;
setDropTarget(null);
}
function handleEventDropOnDay(dropEvent: ReactDragEvent<HTMLElement>, day: Date) {
dropEvent.preventDefault();
const action = dragActionRef.current;
if (!action || action.kind !== "move" || !canWrite) return;
setDropTarget(null);
void moveEvent(action.event, moveEventToDay(action.event, day));
}
function handleEventDropOnTime(dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) {
dropEvent.preventDefault();
const action = dragActionRef.current;
if (!action || !canWrite) return;
setDropTarget(null);
const next = action.kind === "move"
? moveEventToTime(action.event, day, minuteOfDay)
: resizeEventToTime(action.event, action.kind === "resize-start" ? "start" : "end", day, minuteOfDay);
void moveEvent(action.event, next);
}
async function moveEvent(calendarEvent: CalendarEvent, next: { startAt: Date; endAt: Date | null; allDay: boolean }) {
setSaving(true);
setError("");
try {
await updateCalendarEvent(settings, calendarEvent.id, {
start_at: next.startAt.toISOString(),
end_at: next.endAt ? next.endAt.toISOString() : null,
all_day: next.allDay
});
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
handleEventDragEnd();
setSaving(false);
}
}
function appendContinuousWeeks() {
if (continuousAppendPendingRef.current) return;
continuousAppendPendingRef.current = true;
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
window.requestAnimationFrame(() => {
continuousAppendPendingRef.current = false;
});
}
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null) {
setSaving(true);
setError("");
try {
if (event) {
await updateCalendarEvent(settings, event.id, payload);
} else {
await createCalendarEvent(settings, payload);
}
if (payload.calendar_id) {
setVisibleCalendarIds((current) => current.includes(payload.calendar_id as string) ? current : [...current, payload.calendar_id as string]);
setEventCalendarId(payload.calendar_id);
}
setEventDialog(null);
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
async function handleDelete(event: CalendarEvent) {
setSaving(true);
setError("");
try {
await deleteCalendarEvent(settings, event.id);
setEventDialog(null);
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
return (
<div className="workspace-data-page module-entry-page calendar-page calendar-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`calendar-shell ${loading ? "is-loading" : ""}`}>
<aside className="calendar-sidebar">
<div className="calendar-sidebar-heading">Calendars</div>
<div className="calendar-list">
{calendars.map((calendar) => (
<div key={calendar.id} className={calendar.id === targetCalendarId ? "calendar-list-row is-target" : "calendar-list-row"}>
<button
type="button"
className={visibleCalendarIds.includes(calendar.id) ? "calendar-visibility-switch is-on" : "calendar-visibility-switch"}
role="switch"
aria-checked={visibleCalendarIds.includes(calendar.id)}
aria-label={`Show ${calendar.name}`}
title={`Show ${calendar.name}`}
style={{ "--calendar-list-color": normalizeHexColor(calendar.color) || DEFAULT_CALENDAR_COLOR } as CSSProperties}
onClick={() => toggleCalendarVisibility(calendar.id)}
>
<span />
</button>
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
{calendar.name}
</button>
{(canManageCalendars || canDeleteCalendars) && (
<Button
type="button"
className="calendar-row-icon-button"
title={`Edit ${calendar.name}`}
aria-label={`Edit ${calendar.name}`}
onClick={() => openCalendarEditor(calendar)}
>
<Pencil size={15} />
</Button>
)}
</div>
))}
{!calendars.length && !loading && (
<p className="calendar-empty-note">No calendars</p>
)}
{canManageCalendars && (
<form className="calendar-create-form" onSubmit={handleCreateCalendar}>
<input
value={newCalendarName}
onChange={(event) => setNewCalendarName(event.target.value)}
placeholder="New calendar"
aria-label="New calendar name"
disabled={saving}
maxLength={255}
/>
<input
type="color"
value={newCalendarColor}
aria-label="New calendar color"
disabled={saving}
onChange={(event) => setNewCalendarColor(event.target.value)}
/>
<Button type="submit" className="calendar-icon-button" title="Add calendar" aria-label="Add calendar" disabled={saving || !newCalendarName.trim()}>
<Plus size={16} />
</Button>
</form>
)}
</div>
<div className="calendar-sidebar-heading is-secondary">Agenda</div>
<div className="calendar-agenda">
{agendaGroups.map((group) => (
<section key={group.key} className="calendar-agenda-group">
<h3>{agendaDateLabel(group.date)}</h3>
{group.events.map((event) => (
<button
key={`${group.key}-${event.id}`}
type="button"
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId((current) => current === event.id ? "" : current)}
onFocus={() => setHoveredEventId(event.id)}
onBlur={() => setHoveredEventId((current) => current === event.id ? "" : current)}
onClick={() => setEventDialog({ kind: "edit", event })}
>
<EventInlineLabel event={event} />
</button>
))}
</section>
))}
{!agendaGroups.length && !loading && <p>No events</p>}
</div>
</aside>
<section className="calendar-main-panel" aria-label="Calendar">
<div className="calendar-view-toolbar" aria-label="Calendar controls">
<div className="calendar-toolbar-left">
<div className="calendar-mode-switch" role="tablist" aria-label="Calendar views">
{modeOptions.map((option) => (
<button
key={option.id}
type="button"
role="tab"
aria-selected={mode === option.id}
className={mode === option.id ? "calendar-mode-tab is-active" : "calendar-mode-tab"}
onClick={() => setMode(option.id)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="Calendar navigation">
<Button type="button" className="calendar-icon-button" title="Previous" aria-label="Previous" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}>
<ChevronLeft size={18} />
</Button>
<Button type="button" onClick={handleToday}>Today</Button>
<Button type="button" className="calendar-icon-button" title="Next" aria-label="Next" onClick={() => moveFocus(1)} disabled={mode === "continuous"}>
<ChevronRight size={18} />
</Button>
<div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong>
</div>
</div>
</div>
<div className="calendar-toolbar-right">
<Button type="button" className="calendar-icon-button" title="Refresh" aria-label="Refresh" onClick={() => void loadEvents()}>
<RefreshCw size={18} />
</Button>
{canWrite && (
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> New
</Button>
)}
</div>
</div>
<div className={`calendar-view-shell is-${mode}`}>
{loading && <div className="calendar-loading"><LoadingIndicator label="Loading calendar..." /></div>}
{mode === "continuous" ? (
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll} onWheel={handleContinuousWheel}>
<CalendarWeekRows
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
focusDate={focusDate}
variant="continuous"
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
viewport={continuousViewport}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay}
/>
</div>
) : mode === "month" ? (
<CalendarWeekRows
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
focusDate={focusDate}
variant="month"
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay}
/>
) : (
<CalendarTimeGrid
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
mode={mode}
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventResizeDragStart={handleEventResizeDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onEventDragOverTime={handleEventDragOverTime}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay}
onEventDropOnTime={handleEventDropOnTime}
/>
)}
</div>
</section>
</div>
{eventDialog && calendars.length > 0 && (
<CalendarEventDialog
calendars={calendars}
defaultCalendarId={targetCalendarId}
event={eventDialog.kind === "edit" ? eventDialog.event : null}
focusDate={focusDate}
saving={saving}
canWrite={canWrite}
canDelete={canDelete}
onCancel={() => setEventDialog(null)}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
{calendarEditDialog && (
<CalendarCollectionDialog
key={calendarEditDialog.calendar.id}
state={calendarEditDialog}
calendars={calendars}
saving={saving}
canWrite={canManageCalendars}
canDelete={canDeleteCalendars}
onCancel={() => setCalendarEditDialog(null)}
onSave={handleCalendarSave}
onDelete={handleCalendarDelete}
/>
)}
</div>
);
}
function CalendarWeekRows({
days,
eventsByDay,
calendarColorById,
focusDate,
variant,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
viewport,
onEventSelect,
onEventHover,
onEventDragStart,
onEventDragEnd,
onEventDragOverDay,
onDropTargetLeave,
onEventDropOnDay
}: {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
focusDate: Date;
variant: "month" | "continuous";
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
viewport?: ContinuousViewport;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
onEventDropOnDay: (dropEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
}) {
const weeks = chunk(days, 7);
const virtualWindow = variant === "continuous" && preferences.continuousVirtualization
? continuousVirtualWindow(weeks.length, viewport ?? { scrollTop: 0, height: 0 }, preferences.continuousOverscanWeeks)
: { start: 0, end: weeks.length, topSpacerHeight: 0, bottomSpacerHeight: 0 };
const visibleWeeks = weeks.slice(virtualWindow.start, virtualWindow.end);
return (
<div className={`calendar-week-rows is-${variant}`}>
<div className="calendar-weekday-header">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => <span key={label}>{label}</span>)}
</div>
{virtualWindow.topSpacerHeight > 0 && <div className="calendar-week-spacer" style={{ height: virtualWindow.topSpacerHeight }} aria-hidden="true" />}
{visibleWeeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const outsideFocusMonth = variant === "month" && !sameMonth(day, focusDate);
return (
<section
key={key}
className={[
"calendar-day-cell",
outsideFocusMonth ? "is-muted" : "",
sameDay(day, new Date()) ? "is-today" : "",
dropTarget?.kind === "day" && dropTarget.key === key ? "is-drop-target" : "",
variant === "continuous" && preferences.alternateContinuousMonths ? (day.getMonth() % 2 === 0 ? "is-even-month" : "is-odd-month") : ""
].filter(Boolean).join(" ")}
onDragOver={canWrite ? (event) => onEventDragOverDay(event, day) : undefined}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={canWrite ? (event) => onEventDropOnDay(event, day) : undefined}
>
<header>
<span className="calendar-day-number">{day.getDate()}</span>
{day.getDate() === 1 && <small>{monthYearLabel(day)}</small>}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, variant === "month" ? 5 : 3).map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
{dayEvents.length > (variant === "month" ? 5 : 3) && <span className="calendar-more-events">+{dayEvents.length - (variant === "month" ? 5 : 3)}</span>}
</div>
</section>
);
})}
</div>
))}
{virtualWindow.bottomSpacerHeight > 0 && <div className="calendar-week-spacer" style={{ height: virtualWindow.bottomSpacerHeight }} aria-hidden="true" />}
</div>
);
}
function CalendarTimeGrid({
days,
eventsByDay,
calendarColorById,
mode,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverDay,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnDay,
onEventDropOnTime
}: {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
mode: CalendarMode;
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
onEventResizeDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
onEventDragOverTime: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
onEventDropOnDay: (dropEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
onEventDropOnTime: (dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) => void;
}) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const hours = Array.from({ length: 24 }, (_item, index) => index);
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
const dimWeekends = preferences.dimWeekends && mode === "week";
const gridStyle = timeGridStyle(columns, preferences);
const allDayEventsByDay = days.map((day) => ({
key: dayKey(day),
date: day,
events: (eventsByDay.get(dayKey(day)) ?? []).filter((event) => event.all_day)
}));
const hasAllDayEvents = allDayEventsByDay.some((day) => day.events.length > 0);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
}, [days.length, days[0]?.getTime()]);
return (
<div className="calendar-time-view">
<div className="calendar-time-header" style={{ gridTemplateColumns: columns }}>
<div className="calendar-time-corner" />
{days.map((day) => (
<header key={dayKey(day)} className={[sameDay(day, new Date()) ? "is-today" : "", dimWeekends && isWeekend(day) ? "is-weekend" : ""].filter(Boolean).join(" ")}>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
</div>
{hasAllDayEvents && (
<div className="calendar-all-day-strip" style={{ gridTemplateColumns: columns }}>
<div className="calendar-all-day-label">All day</div>
{allDayEventsByDay.map((day) => (
<div
key={day.key}
className={[
"calendar-all-day-cell",
dimWeekends && isWeekend(day.date) ? "is-weekend" : "",
dropTarget?.kind === "day" && dropTarget.key === day.key ? "is-drop-target" : ""
].filter(Boolean).join(" ")}
onDragOver={canWrite ? (event) => onEventDragOverDay(event, day.date) : undefined}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={canWrite ? (event) => onEventDropOnDay(event, day.date) : undefined}
>
{day.events.map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
</div>
))}
</div>
)}
<div className="calendar-time-scroll" ref={scrollRef}>
<div className="calendar-time-grid" style={gridStyle}>
<div className="calendar-hour-label-column">
{hours.map((hour) => <div className="calendar-hour-label" key={hour}>{String(hour).padStart(2, "0")}:00</div>)}
</div>
{days.map((day) => (
<TimedDayColumn
key={dayKey(day)}
day={day}
events={eventsByDay.get(dayKey(day)) ?? []}
calendarColorById={calendarColorById}
dimWeekend={dimWeekends && isWeekend(day)}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget?.kind === "time" && dropTarget.key === dayKey(day) ? dropTarget : null}
onEventSelect={onEventSelect}
onEventHover={onEventHover}
onEventDragStart={onEventDragStart}
onEventResizeDragStart={onEventResizeDragStart}
onEventDragEnd={onEventDragEnd}
onEventDragOverTime={onEventDragOverTime}
onDropTargetLeave={onDropTargetLeave}
onEventDropOnTime={onEventDropOnTime}
/>
))}
</div>
</div>
</div>
);
}
function TimedDayColumn({
day,
events,
calendarColorById,
dimWeekend,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnTime
}: {
day: Date;
events: CalendarEvent[];
calendarColorById: Map<string, string>;
dimWeekend: boolean;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarTimeDropTarget | null;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
onEventResizeDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) => void;
onEventDragEnd: () => void;
onEventDragOverTime: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
onEventDropOnTime: (dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) => void;
}) {
const layout = useMemo(() => layoutTimedEventsForDay(events, day), [day, events]);
return (
<div
className={["calendar-day-time-column", dimWeekend ? "is-weekend" : "", dropTarget ? "is-drop-target" : ""].filter(Boolean).join(" ")}
onDragOver={canWrite ? (event) => onEventDragOverTime(event, day) : undefined}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={canWrite ? (event) => onEventDropOnTime(event, day, dropSlotMinuteOfDay(event)) : undefined}
>
{dropTarget && (
<div
className="calendar-time-drop-marker"
style={{ top: `${dropTarget.minuteOfDay / 60 * HOUR_ROW_HEIGHT}px` }}
aria-hidden="true"
>
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
</div>
)}
{layout.items.map((item) => (
<div
key={item.event.id}
className={[
"calendar-timed-event",
draggingEventId === item.event.id ? "is-dragging" : "",
hoveredEventId === item.event.id ? "is-linked-hover" : ""
].filter(Boolean).join(" ")}
style={timedEventStyle(item, calendarColorById.get(item.event.calendar_id))}
draggable={canWrite}
onMouseEnter={() => onEventHover(item.event.id)}
onMouseLeave={() => onEventHover("")}
onDragStart={canWrite ? (event) => onEventDragStart(event, item.event) : undefined}
onDragEnd={canWrite ? onEventDragEnd : undefined}
title={`Edit ${item.event.summary}`}
>
{canWrite && (
<span
className="calendar-event-resize-handle is-start"
draggable
title="Change start time"
onDragStart={(event) => onEventResizeDragStart(event, item.event, "start")}
onDragEnd={onEventDragEnd}
/>
)}
<button
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(item.event.id)}
onBlur={() => onEventHover("")}
title={`Edit ${item.event.summary}`}
>
<EventInlineLabel event={item.event} />
</button>
{canWrite && (
<span
className="calendar-event-resize-handle is-end"
draggable
title="Change end time"
onDragStart={(event) => onEventResizeDragStart(event, item.event, "end")}
onDragEnd={onEventDragEnd}
/>
)}
</div>
))}
{layout.overflows.map((overflow) => (
<button
key={overflow.key}
type="button"
className="calendar-timed-overflow"
style={timedOverflowStyle(overflow)}
title={overflow.events.map((event) => event.summary).join(", ")}
onClick={() => onEventSelect(overflow.events[0])}
>
+{overflow.events.length}
</button>
))}
</div>
);
}
function CalendarEventChip({
event,
color,
compact = false,
canDrag = false,
dragging = false,
linkedHover = false,
onSelect,
onHover,
onDragStart,
onDragEnd
}: {
event: CalendarEvent;
color?: string | null;
compact?: boolean;
canDrag?: boolean;
dragging?: boolean;
linkedHover?: boolean;
onSelect: (event: CalendarEvent) => void;
onHover?: (eventId: string) => void;
onDragStart?: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
onDragEnd?: () => void;
}) {
return (
<button
type="button"
className={[
compact ? "calendar-event-chip is-compact" : "calendar-event-chip",
dragging ? "is-dragging" : "",
linkedHover ? "is-linked-hover" : ""
].filter(Boolean).join(" ")}
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(event.id) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={canDrag && onDragStart ? (dragEvent) => onDragStart(dragEvent, event) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
title={`Edit ${event.summary}`}
>
<EventInlineLabel event={event} />
</button>
);
}
function CalendarCollectionDialog({
state,
calendars,
saving,
canWrite,
canDelete,
onCancel,
onSave,
onDelete
}: {
state: CalendarEditDialogState;
calendars: CalendarCollection[];
saving: boolean;
canWrite: boolean;
canDelete: boolean;
onCancel: () => void;
onSave: (calendar: CalendarCollection, payload: { name: string; color: string }) => Promise<void>;
onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;
}) {
const { calendar, eventCount, loadingEventCount, confirmingDelete } = state;
const [name, setName] = useState(calendar.name);
const [color, setColor] = useState(normalizeHexColor(calendar.color) || DEFAULT_CALENDAR_COLOR);
const moveTargets = calendars.filter((item) => item.id !== calendar.id);
const firstMoveTargetId = moveTargets[0]?.id || "";
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const formId = "calendar-collection-form";
const actionLabel = calendarDeleteActionLabel(calendar);
const actionVerb = actionLabel.toLowerCase();
const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = moveTargets.length > 0;
const effectiveEventAction: CalendarDeleteEventAction = hasEvents ? eventAction : "delete";
const deleteDisabled = (
saving ||
loadingEventCount ||
eventCount === null ||
!canDelete ||
(hasEvents && effectiveEventAction === "move" && !targetCalendarId)
);
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void onSave(calendar, { name, color });
}
function submitDelete() {
const payload: CalendarCollectionDeletePayload = {
event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault
};
void onDelete(calendar, payload);
}
return (
<Dialog
open
title="Edit calendar"
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={(
<>
<div>
{canDelete && (
<Button type="button" variant="danger" onClick={submitDelete} disabled={deleteDisabled}>
<Trash2 size={16} /> {confirmingDelete ? `Confirm ${actionVerb}` : actionLabel}
</Button>
)}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite || !name.trim()}>{saving ? "Saving..." : "Save"}</Button>
</div>
</>
)}
>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{confirmingDelete && (
<p className="calendar-delete-warning">
{calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
</p>
)}
{!loadingEventCount && eventCount === null && canDelete && <p className="calendar-form-note">Event count could not be loaded.</p>}
<div className="calendar-dialog-name-color-row">
<label>
<span>Name</span>
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-color-label">
<span>Color</span>
<input type="color" value={color} onChange={(item) => setColor(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
{canDelete && hasEvents && (
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="delete"
checked={eventAction === "delete"}
onChange={() => setEventAction("delete")}
/>
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
</label>
<label className={!canMoveEvents ? "is-disabled" : ""}>
<input
type="radio"
name="calendar-delete-event-action"
value="move"
checked={eventAction === "move"}
onChange={() => setEventAction("move")}
disabled={!canMoveEvents}
/>
<span>Move events to another calendar</span>
</label>
{eventAction === "move" && canMoveEvents && (
<>
<label className="calendar-delete-target-label">
<span>Target calendar</span>
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
{moveTargets.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</label>
{calendar.is_default && (
<label className="calendar-checkbox-row">
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
<span>Make target calendar the default</span>
</label>
)}
</>
)}
{!canMoveEvents && <p className="calendar-form-note">No other calendar is available, so events can only be {calendarIsExternal(calendar) ? "removed locally" : "deleted"}.</p>}
</fieldset>
)}
</form>
</Dialog>
);
}
function EventInlineLabel({ event }: { event: CalendarEvent }) {
return (
<span className="calendar-event-line">
<span className="calendar-event-time">{eventTimeLabel(event)}</span>
<span className="calendar-event-separator">-</span>
<strong>{event.summary}</strong>
</span>
);
}
function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
focusDate,
saving,
canWrite,
canDelete,
onCancel,
onSave,
onDelete
}: {
calendars: CalendarCollection[];
defaultCalendarId: string;
event: CalendarEvent | null;
focusDate: Date;
saving: boolean;
canWrite: boolean;
canDelete: boolean;
onCancel: () => void;
onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<void>;
onDelete: (event: CalendarEvent) => Promise<void>;
}) {
const initialStart = event ? new Date(event.start_at) : focusDate;
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
const [summary, setSummary] = useState(event?.summary ?? "");
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [startDate, setStartDate] = useState(toInputDate(initialStart));
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
const [startTime, setStartTime] = useState(toInputTime(initialStart));
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
const [allDay, setAllDay] = useState(event?.all_day ?? false);
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
function handleAllDayChange(next: boolean) {
setAllDay(next);
if (next) {
setStartTime("00:00");
setEndTime("00:00");
} else if (startTime === "00:00" && endTime === "00:00") {
setStartTime("09:00");
setEndTime("10:00");
}
}
function handleStartDateChange(next: string) {
setStartDate(next);
if (endDate < next) setEndDate(next);
}
function handleStartTimeChange(next: string) {
setStartTime(next);
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
}
async function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const endAt = allDay ? addDays(localDateTime(endDate, "00:00"), 1) : localDateTime(endDate, endTime);
if (endAt <= startAt) {
setFormError(allDay ? "End date must be on or after start date." : "End date and time must be after start date and time.");
return;
}
await onSave({
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
all_day: allDay
}, event);
}
function requestDelete() {
if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void onDelete(event);
}
return (
<Dialog
open
title={event ? "Edit event" : "New event"}
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={(
<>
<div>
{event && canDelete && (
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
<Trash2 size={16} /> {confirmingDelete ? "Confirm delete" : "Delete"}
</Button>
)}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "Saving..." : "Save"}</Button>
</div>
</>
)}
>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>Title</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>Calendar</span>
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
{calendars.map((calendar) => (
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
))}
</select>
</label>
<label>
<span>Description</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>Location</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="Whole day" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<div className="calendar-dialog-date-row">
<label>
<span>Start date</span>
<input type="date" value={startDate} onChange={(item) => handleStartDateChange(item.target.value)} required disabled={saving || !canWrite} />
</label>
<label>
<span>Start time</span>
<input type="time" value={startTime} onChange={(item) => handleStartTimeChange(item.target.value)} disabled={saving || !canWrite || allDay} />
</label>
</div>
<div className="calendar-dialog-date-row">
<label>
<span>End date</span>
<input type="date" value={endDate} min={startDate} onChange={(item) => setEndDate(item.target.value)} required disabled={saving || !canWrite} />
</label>
<label>
<span>End time</span>
<input type="time" value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={(item) => setEndTime(item.target.value)} disabled={saving || !canWrite || allDay} />
</label>
</div>
</form>
</Dialog>
);
}
function rangeForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
return { start, end: addDays(start, 42) };
}
if (mode === "day") return { start: startOfDay(focusDate), end: addDays(startOfDay(focusDate), 1) };
if (mode === "continuous") {
const start = addDays(startOfWeek(focusDate), -continuousWeeks.before * 7);
const end = addDays(startOfWeek(focusDate), continuousWeeks.after * 7);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
function daysForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
function groupEventsByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
let last = event.all_day && event.end_at ? addDays(startOfDay(end), -1) : startOfDay(end);
if (last < cursor) last = cursor;
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(key, items.sort((left, right) => left.start_at.localeCompare(right.start_at)));
}
return grouped;
}
function agendaGroupsForDays(days: Date[], eventsByDay: Map<string, CalendarEvent[]>): AgendaGroup[] {
const groups: AgendaGroup[] = [];
for (const day of days) {
const key = dayKey(day);
const events = (eventsByDay.get(key) ?? []).slice().sort(compareAgendaEvents);
if (events.length > 0) groups.push({ key, date: day, events });
}
return groups;
}
function compareCalendars(left: CalendarCollection, right: CalendarCollection): number {
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
return left.name.localeCompare(right.name);
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "Remove" : "Delete";
}
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "Remove local events with this calendar" : "Delete events with this calendar";
}
function calendarDeleteWarning(
calendar: CalendarCollection,
eventCount: number,
eventAction: CalendarDeleteEventAction,
targetCalendarId: string,
moveTargets: CalendarCollection[]
): string {
const action = calendarIsExternal(calendar) ? "Removing" : "Deleting";
const eventWord = eventCount === 1 ? "event" : "events";
if (eventAction === "move") {
const target = moveTargets.find((item) => item.id === targetCalendarId);
return `${action} this calendar will move ${eventCount} ${eventWord} to ${target?.name || "the selected calendar"}.`;
}
if (calendarIsExternal(calendar)) {
return `${action} this calendar will remove ${eventCount} local ${eventWord} from GovOPlaN.`;
}
return `${action} this calendar will delete ${eventCount} ${eventWord}.`;
}
function calendarIsExternal(calendar: CalendarCollection): boolean {
const metadata = calendar.metadata;
if (!metadata || typeof metadata !== "object") return false;
const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind");
if (sourceKind && sourceKind !== "local") return true;
const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind");
if (connectionKind && connectionKind !== "local") return true;
return Boolean(
metadataFlag(metadata, "external") ||
metadataFlag(metadata, "remote") ||
metadataFlag(metadata, "sync") ||
metadataFlag(metadata, "caldav") ||
metadataText(metadata, "connector_id") ||
metadataText(metadata, "connectorId") ||
metadataText(metadata, "sync_href") ||
metadataText(metadata, "syncHref")
);
}
function metadataText(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
return metadata[key] === true;
}
function compareAgendaEvents(left: CalendarEvent, right: CalendarEvent): number {
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
return left.start_at.localeCompare(right.start_at) || left.summary.localeCompare(right.summary);
}
function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "All day";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
function headingForMode(mode: CalendarMode, focusDate: Date, range: Range): string {
if (mode === "day") return dayMonthYearLabel(focusDate);
if (mode === "month") return monthYearLabel(focusDate);
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(addDays(range.end, -1))}`;
}
function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) rows.push(items.slice(index, index + size));
return rows;
}
function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
function addHours(value: Date, hours: number): Date {
const next = new Date(value);
next.setHours(next.getHours() + hours);
return next;
}
function addMinutes(value: Date, minutes: number): Date {
const next = new Date(value);
next.setMinutes(next.getMinutes() + minutes);
return next;
}
function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (let cursor = startOfDay(start); cursor < end; cursor = addDays(cursor, 1)) days.push(cursor);
return days;
}
function dayKey(value: Date): string {
return toInputDate(value);
}
function sameDay(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
}
function sameMonth(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
}
function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function toInputTime(value: Date): string {
return `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
}
function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
function loadCalendarViewPreferences(): CalendarViewPreferences {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
try {
const raw = window.localStorage.getItem(CALENDAR_VIEW_PREFERENCES_STORAGE_KEY);
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
let workdayStartHour = clampNumber(parsed.workdayStartHour, 0, 23, DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour);
let workdayEndHour = clampNumber(parsed.workdayEndHour, 1, 24, DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour);
if (workdayEndHour <= workdayStartHour) {
workdayStartHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
}
return {
dimWeekends: typeof parsed.dimWeekends === "boolean" ? parsed.dimWeekends : DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
dimOffHours: typeof parsed.dimOffHours === "boolean" ? parsed.dimOffHours : DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
workdayStartHour,
workdayEndHour,
continuousVirtualization: typeof parsed.continuousVirtualization === "boolean" ? parsed.continuousVirtualization : DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
continuousOverscanWeeks: clampNumber(parsed.continuousOverscanWeeks, 2, 20, DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks),
alternateContinuousMonths: typeof parsed.alternateContinuousMonths === "boolean" ? parsed.alternateContinuousMonths : DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths
};
} catch {
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
}
}
function loadCalendarMode(): CalendarMode {
if (typeof window === "undefined") return "continuous";
try {
const storedMode = window.localStorage.getItem(CALENDAR_MODE_STORAGE_KEY);
return isCalendarMode(storedMode) ? storedMode : "continuous";
} catch {
return "continuous";
}
}
function saveCalendarMode(mode: CalendarMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
} catch {
// Storage can fail in private browsing or locked-down deployments; mode still works for the current page.
}
}
function isCalendarMode(value: unknown): value is CalendarMode {
return value === "continuous" || value === "month" || value === "week" || value === "workweek" || value === "day";
}
function normalizeHexColor(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
}
function calendarEventColorStyle(color: string | null | undefined): CSSProperties {
const normalizedColor = normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
return {
"--calendar-event-color": normalizedColor,
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58)
} as CSSProperties;
}
function mixHexWithWhite(hex: string, whiteRatio: number): string {
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
const ratio = Math.min(1, Math.max(0, whiteRatio));
const red = parseInt(normalized.slice(1, 3), 16);
const green = parseInt(normalized.slice(3, 5), 16);
const blue = parseInt(normalized.slice(5, 7), 16);
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(green * (1 - ratio) + 255 * ratio)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
}
function continuousVirtualWindow(weekCount: number, viewport: ContinuousViewport, overscanWeeks: number): { start: number; end: number; topSpacerHeight: number; bottomSpacerHeight: number } {
if (weekCount === 0) return { start: 0, end: 0, topSpacerHeight: 0, bottomSpacerHeight: 0 };
const visibleStart = Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT);
const visibleCount = Math.max(1, Math.ceil((viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) / CONTINUOUS_WEEK_ROW_HEIGHT));
const start = Math.max(0, visibleStart - overscanWeeks);
const end = Math.min(weekCount, visibleStart + visibleCount + overscanWeeks);
return {
start,
end,
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
bottomSpacerHeight: Math.max(0, (weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT)
};
}
function timeGridStyle(columns: string, preferences: CalendarViewPreferences): CSSProperties {
return {
gridTemplateColumns: columns,
"--calendar-work-start-y": `${preferences.workdayStartHour * HOUR_ROW_HEIGHT}px`,
"--calendar-work-end-y": `${preferences.workdayEndHour * HOUR_ROW_HEIGHT}px`,
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0"
} as CSSProperties;
}
function moveEventToDay(event: CalendarEvent, day: Date): { startAt: Date; endAt: Date | null; allDay: boolean } {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
if (event.all_day) {
const durationDays = Math.max(1, Math.round(daysBetweenCount(startOfDay(start), end ? startOfDay(end) : addDays(startOfDay(start), 1))));
const startAt = startOfDay(day);
return { startAt, endAt: addDays(startAt, durationDays), allDay: true };
}
const startAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), start.getHours(), start.getMinutes());
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
return { startAt, endAt: new Date(startAt.getTime() + durationMs), allDay: false };
}
function moveEventToTime(event: CalendarEvent, day: Date, minuteOfDay: number): { startAt: Date; endAt: Date | null; allDay: boolean } {
const snappedMinute = Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay)));
const startAt = dateAtMinuteOfDay(day, snappedMinute);
const durationMs = event.all_day ? 60 * 60 * 1000 : eventDurationMs(event, 60 * 60 * 1000);
return { startAt, endAt: new Date(startAt.getTime() + durationMs), allDay: false };
}
function resizeEventToTime(event: CalendarEvent, edge: CalendarResizeEdge, day: Date, minuteOfDay: number): { startAt: Date; endAt: Date | null; allDay: boolean } {
const target = dateAtMinuteOfDay(day, Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))));
const currentStart = new Date(event.start_at);
const fallbackEnd = addMinutes(currentStart, 30);
const currentEnd = event.end_at && new Date(event.end_at) > currentStart ? new Date(event.end_at) : fallbackEnd;
const minimumDurationMs = 15 * 60000;
if (edge === "start") {
const latestStart = new Date(currentEnd.getTime() - minimumDurationMs);
return { startAt: target < latestStart ? target : latestStart, endAt: currentEnd, allDay: false };
}
const earliestEnd = new Date(currentStart.getTime() + minimumDurationMs);
return { startAt: currentStart, endAt: target > earliestEnd ? target : earliestEnd, allDay: false };
}
function eventDurationMs(event: CalendarEvent, fallback: number): number {
if (!event.end_at) return fallback;
const start = new Date(event.start_at);
const end = new Date(event.end_at);
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
}
function dropMinuteOfDay(event: ReactDragEvent<HTMLElement>): number {
const rect = event.currentTarget.getBoundingClientRect();
const offset = Math.min(Math.max(event.clientY - rect.top, 0), HOUR_ROW_HEIGHT * 24);
return Math.floor(offset / HOUR_ROW_HEIGHT * 60);
}
function dropSlotMinuteOfDay(event: ReactDragEvent<HTMLElement>): number {
return Math.min(23 * 60 + 45, Math.max(0, snapMinute(dropMinuteOfDay(event))));
}
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(minuteOfDay / 60), minuteOfDay % 60);
}
function snapMinute(value: number): number {
return Math.round(value / 15) * 15;
}
function minuteOfDayLabel(value: number): string {
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
}
function isWeekend(value: Date): boolean {
const day = value.getDay();
return day === 0 || day === 6;
}
function daysBetweenCount(start: Date, end: Date): number {
return Math.round((startOfDay(end).getTime() - startOfDay(start).getTime()) / 86400000);
}
function addMinutesToTime(time: string, minutes: number): string {
const [hourPart, minutePart] = time.split(":");
const total = Math.min(23 * 60 + 59, Math.max(0, Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes));
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
type TimedEventSegment = {
event: CalendarEvent;
start: Date;
end: Date;
top: number;
height: number;
};
type TimedEventLayoutItem = TimedEventSegment & {
column: number;
columns: number;
};
type TimedOverflowLayoutItem = {
key: string;
top: number;
column: number;
columns: number;
events: CalendarEvent[];
};
function layoutTimedEventsForDay(events: CalendarEvent[], day: Date): { items: TimedEventLayoutItem[]; overflows: TimedOverflowLayoutItem[] } {
const segments = timedSegmentsForDay(events, day);
const clusters = clusterTimedSegments(segments);
const items: TimedEventLayoutItem[] = [];
const overflows: TimedOverflowLayoutItem[] = [];
for (const cluster of clusters) {
const columnEnds: Date[] = [];
const assigned = cluster.map((segment) => {
let column = columnEnds.findIndex((end) => end <= segment.start);
if (column === -1) column = columnEnds.length;
columnEnds[column] = segment.end;
return { ...segment, column };
});
const totalColumns = Math.max(1, columnEnds.length);
const visibleColumns = totalColumns > MAX_TIMED_EVENT_COLUMNS ? MAX_TIMED_EVENT_COLUMNS : totalColumns;
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
const hidden = totalColumns > MAX_TIMED_EVENT_COLUMNS ? assigned.filter((item) => item.column >= overflowColumn) : [];
for (const item of assigned) {
if (hidden.includes(item)) continue;
items.push({ ...item, columns: visibleColumns });
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${hidden[0].event.id}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
events: hidden.map((item) => item.event)
});
}
}
return { items, overflows };
}
function timedSegmentsForDay(events: CalendarEvent[], day: Date): TimedEventSegment[] {
const dayStart = startOfDay(day);
const dayEnd = addDays(dayStart, 1);
return events
.filter((event) => !event.all_day)
.map((event): TimedEventSegment | null => {
const eventStart = new Date(event.start_at);
const rawEventEnd = event.end_at ? new Date(event.end_at) : addMinutes(eventStart, 30);
const eventEnd = rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
const start = eventStart < dayStart ? dayStart : eventStart;
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
if (end <= start) return null;
const startMinutes = minutesBetween(dayStart, start);
const durationMinutes = minutesBetween(start, end);
return {
event,
start,
end,
top: Math.max(0, startMinutes / 60 * HOUR_ROW_HEIGHT),
height: Math.max(26, durationMinutes / 60 * HOUR_ROW_HEIGHT)
};
})
.filter((segment): segment is TimedEventSegment => segment !== null)
.sort((left, right) => left.start.getTime() - right.start.getTime() || right.end.getTime() - left.end.getTime());
}
function clusterTimedSegments(segments: TimedEventSegment[]): TimedEventSegment[][] {
const clusters: TimedEventSegment[][] = [];
let current: TimedEventSegment[] = [];
let currentEnd: Date | null = null;
for (const segment of segments) {
if (!current.length || (currentEnd && segment.start < currentEnd)) {
current.push(segment);
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) currentEnd = segment.end;
continue;
}
clusters.push(current);
current = [segment];
currentEnd = segment.end;
}
if (current.length) clusters.push(current);
return clusters;
}
function minutesBetween(start: Date, end: Date): number {
return Math.max(0, (end.getTime() - start.getTime()) / 60000);
}
function timedEventStyle(item: TimedEventLayoutItem, color?: string | null): CSSProperties {
return {
top: `${item.top}px`,
height: `${item.height}px`,
left: `calc(${item.column * 100 / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
...calendarEventColorStyle(color)
};
}
function timedOverflowStyle(item: TimedOverflowLayoutItem): CSSProperties {
return {
top: `${item.top}px`,
left: `calc(${item.column * 100 / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`
};
}
function monthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(value);
}
function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short" }).format(value);
}
function dayMonthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short", year: "numeric" }).format(value);
}
function agendaDateLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "2-digit", month: "short", year: "numeric" }).format(value);
}
function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(value);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(value);
}
function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "Calendar request failed.";
}