import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type WheelEvent as ReactWheelEvent } from "react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw } from "lucide-react"; import { ToolbarGroup, ActionToolbar, AdminIconButton, Button, DismissibleAlert, DocumentationHelpLink, LoadingFrame, SegmentedControl, TableActionGroup, hasScope, i18nMessage, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { useLocation, useNavigate } from "react-router"; import { createCalendar, createCalendarEvent, createSyncSource, deleteCalendar, deleteCalendarEvent, deleteCalendarEventOccurrence, discoverCalDavCalendars, getCalendarEvent, getCalendarViewPreferences, listCalendarEvents, listCalendars, listSyncSources, syncSyncSource, updateCalendar, updateCalendarEvent, updateCalendarEventOccurrence, updateSyncSource, type CalendarCalDavDiscoveryPayload, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarEvent, type CalendarEventCreatePayload, type CalendarSyncSource, type CalendarViewPreferences as CalendarViewPreferencesResponse, } from "../../api/calendar"; import { CalendarCollectionDeleteDialog, CalendarCollectionDialog, syncSourceConnectionUpdatePayload, syncSourceCreatePayload, type CalendarCollectionDialogState, type CalendarCollectionFormPayload, type CalendarDeleteDialogState, } from "./CalendarCollectionDialogs"; import { CalendarEventDialog } from "./CalendarEventDialog"; import { CalendarOutboxDialog } from "./CalendarOutboxDialog"; import { CalendarMigrationDialog } from "./CalendarMigrationDialog"; import { CalendarTimeGrid, CalendarWeekRows, EventInlineLabel, } from "./CalendarViews"; import { CONTINUOUS_WEEK_ROW_HEIGHT, DEFAULT_CALENDAR_COLOR, addDays, addMonths, agendaDateLabel, agendaGroupsForDays, calendarEventColorStyle, calendarEventInstanceId, continuousEventWindow, daysBetweenCount, daysForMode, dayKey, dropSlotMinuteOfDay, errorText, groupEventsByDay, headingForMode, loadCalendarMode, loadCalendarViewPreferences, moveEventToDay, moveEventToTime, normalizeHexColor, rangeForMode, resizeEventToTime, saveCalendarMode, startOfDay, startOfWeek, type CalendarDragAction, type CalendarDropTarget, type CalendarMode, type CalendarResizeEdge, type CalendarViewPreferences, type ContinuousViewport, } from "./calendarViewModel"; import { CALENDAR_DOCUMENTATION, CALENDAR_I18N, } from "./interfacePatterns"; type EventEditScope = "occurrence" | "series"; type EventDialogState = | { kind: "create" } | { kind: "edit"; occurrence: CalendarEvent; seriesEvent: CalendarEvent | null; editScope: EventEditScope; }; const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 }; const modeOptions: {id: CalendarMode;label: string;}[] = [ { id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" }, { id: "month", label: "i18n:govoplan-calendar.month.082bc378" }, { id: "week", label: "i18n:govoplan-calendar.week.f82be68a" }, { id: "workweek", label: "i18n:govoplan-calendar.workweek.2fef6ea4" }, { id: "day", label: "i18n:govoplan-calendar.day.987b9ced" }]; export default function CalendarPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const location = useLocation(); const navigate = useNavigate(); const [calendars, setCalendars] = useState([]); const [syncSources, setSyncSources] = useState([]); const [visibleCalendarIds, setVisibleCalendarIds] = useState([]); const [eventCalendarId, setEventCalendarId] = useState(""); const [events, setEvents] = useState([]); const eventRequestRef = useRef(0); const [mode, setMode] = useState(() => loadCalendarMode()); const [focusDate, setFocusDate] = useState(() => startOfDay(new Date())); const [continuousWeeks, setContinuousWeeks] = useState(INITIAL_CONTINUOUS_WEEKS); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [eventDialog, setEventDialog] = useState(null); const [calendarDialog, setCalendarDialog] = useState(null); const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null); const [migrationBatchId, setMigrationBatchId] = useState(""); const [syncingSourceId, setSyncingSourceId] = useState(""); const [continuousViewport, setContinuousViewport] = useState({ scrollTop: 0, height: 0 }); const [draggingEventId, setDraggingEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState(""); const [dropTarget, setDropTarget] = useState(null); const scrollRef = useRef(null); const continuousPrependPendingRef = useRef(false); const continuousAppendPendingRef = useRef(false); const pendingContinuousScrollDayRef = useRef(null); const dragActionRef = useRef(null); const [viewPreferences, setViewPreferences] = useState( () => loadCalendarViewPreferences() ); const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]); const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]); const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]); 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 eventWindow = useMemo( () => mode === "continuous" ? continuousEventWindow(days, continuousViewport) : visibleRange, [continuousViewport.height, continuousViewport.scrollTop, days, mode, visibleRange] ); 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 canSyncCalendars = hasScope(auth, "calendar:event:import"); const canManageCalendars = hasScope(auth, "calendar:calendar:write"); const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin"); const heading = headingForMode(mode, focusDate, visibleRange); const dialogEvent = eventDialog?.kind === "edit" ? eventDialog.editScope === "series" ? eventDialog.seriesEvent : eventDialog.occurrence : null; useEffect(() => { void loadCalendars(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { const parameters = new URLSearchParams(location.search); const focusParameter = parameters.get("focusDate"); const requestedFocus = validCalendarLaunchDate(focusParameter); if (requestedFocus) setFocusDate(requestedFocus); if (parameters.get("quickAction") !== "create-event" || loading) return; const requestedStart = validCalendarLaunchDate(parameters.get("startAt")); if (canWrite && calendars.length > 0 && targetCalendarId) { setFocusDate(requestedStart ?? requestedFocus ?? new Date()); setEventDialog({ kind: "create" }); } parameters.delete("quickAction"); parameters.delete("startAt"); if (requestedStart) parameters.set("focusDate", requestedStart.toISOString()); const search = parameters.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : "" }, { replace: true, state: location.state } ); }, [ calendars.length, canWrite, loading, location.pathname, location.search, location.state, navigate, targetCalendarId ]); useEffect(() => { saveCalendarMode(mode); }, [mode]); useEffect(() => { let active = true; const load = async () => { try { const preferences = await getCalendarViewPreferences(settings); if (active) setViewPreferences(calendarViewPreferences(preferences)); } catch (err) { if (active) setError(errorText(err)); } }; const handlePreferenceChange = () => void load(); void load(); window.addEventListener( "govoplan:calendar-preferences-changed", handlePreferenceChange ); return () => { active = false; window.removeEventListener( "govoplan:calendar-preferences-changed", handlePreferenceChange ); }; }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { if (calendars.length) { void loadEvents(); } else { setEvents([]); } }, [calendars.length, eventWindow.start.getTime(), eventWindow.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(".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(): Promise { setLoading(true); setError(""); try { const [response, sourceResponse] = await Promise.all([ listCalendars(settings), listSyncSources(settings) ]); setCalendars(response.calendars); setSyncSources(sourceResponse.sources); 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] ?? ""); return response.calendars; } catch (err) { setError(errorText(err)); return []; } finally { setLoading(false); } } async function loadEvents() { const requestId = ++eventRequestRef.current; setLoading(true); setError(""); const start = eventWindow.start.toISOString(); const end = eventWindow.end.toISOString(); try { const response = await listCalendarEvents(settings, { start_at: start, end_at: end, expand_recurring: true }); if (requestId !== eventRequestRef.current) return; setEvents(response.events); } catch (err) { if (requestId !== eventRequestRef.current) return; setError(errorText(err)); setEvents([]); } finally { if (requestId === eventRequestRef.current) 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) { setCalendarDialog({ kind: "edit", calendar, eventCount: null, loadingEventCount: true }); void listCalendarEvents(settings, { calendar_id: calendar.id }). then((response) => { setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ? { ...current, eventCount: response.events.length, loadingEventCount: false } : current); }). catch((err) => { setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ? { ...current, eventCount: null, loadingEventCount: false } : current); setError(errorText(err)); }); } async function handleCalendarSave(payload: CalendarCollectionFormPayload): Promise { const currentDialog = calendarDialog; const name = payload.name.trim(); if (!name || !canManageCalendars) return false; setSaving(true); setError(""); try { let reloadSources = false; if (currentDialog?.kind === "edit") { const source = syncSourceByCalendarId.get(currentDialog.calendar.id); const updated = await updateCalendar(settings, currentDialog.calendar.id, { name, color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR }); if (source && canDeleteCalendars) { const updatedSource = await updateSyncSource( settings, source.id, syncSourceConnectionUpdatePayload( payload.sourceMode, payload.caldav, source.metadata, ), ); setSyncSources((current) => current.map((item) => item.id === source.id ? updatedSource : item)); reloadSources = true; } setCalendars((current) => current.map((item) => item.id === currentDialog.calendar.id ? updated : item)); } else { const calendar = await createCalendar(settings, { name, color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", owner_type: payload.sourceMode === "open_xchange" && payload.caldav.resource_calendar_ref.trim() ? "resource" : "tenant", owner_id: payload.sourceMode === "open_xchange" ? payload.caldav.resource_calendar_ref.trim() || null : null }); let createdSource: CalendarSyncSource | null = null; if (payload.sourceMode !== "local") { try { createdSource = await createSyncSource(settings, { ...syncSourceCreatePayload(payload.sourceMode, payload.caldav), calendar_id: calendar.id }); reloadSources = true; } catch (sourceError) { await deleteCalendar(settings, calendar.id, { event_action: "delete" }).catch(() => undefined); throw sourceError; } } setCalendars((current) => [...current, calendar].sort(compareCalendars)); setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]); setEventCalendarId(calendar.id); if (createdSource) setSyncSources((current) => [...current, createdSource]); } setCalendarDialog(null); if (reloadSources) await loadCalendars(); return true; } catch (err) { setError(errorText(err)); return false; } finally { setSaving(false); } } async function handleCalendarDelete(calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) { if (!canDeleteCalendars) return; setSaving(true); setError(""); try { await deleteCalendar(settings, calendar.id, payload); setCalendarDialog(null); setCalendarDeleteDialog(null); const remoteMove = payload.external_action === "remote_move"; if (!remoteMove) { setCalendars((current) => current.filter((item) => item.id !== calendar.id)); setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id)); setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id)); setEventCalendarId((current) => current === calendar.id ? "" : current); } const refreshedCalendars = await loadCalendars(); if (remoteMove) { const refreshedSource = refreshedCalendars.find((item) => item.id === calendar.id); const batchId = refreshedSource ? calendarRemoteMoveBatchId(refreshedSource) : ""; if (batchId) setMigrationBatchId(batchId); } if (calendars.length > 1 || payload.event_action === "move") { await loadEvents(); } else { setEvents([]); } } catch (err) { setError(errorText(err)); } finally { setSaving(false); } } function openCalendarDelete(calendar: CalendarCollection, eventCount: number | null = null, loadingEventCount = true) { setCalendarDeleteDialog({ calendar, eventCount, loadingEventCount }); if (!loadingEventCount) return; void listCalendarEvents(settings, { calendar_id: calendar.id }). then((response) => { setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ? { ...current, eventCount: response.events.length, loadingEventCount: false } : current); }). catch((err) => { setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ? { ...current, eventCount: null, loadingEventCount: false } : current); setError(errorText(err)); }); } async function handleSyncSource(source: CalendarSyncSource, payload: {password?: string | null;bearer_token?: string | null;force_full?: boolean;} = {}) { setSyncingSourceId(source.id); setError(""); try { const response = await syncSyncSource(settings, source.id, payload); setSyncSources((current) => current.map((item) => item.id === source.id ? response.source : item)); await loadEvents(); } catch (err) { setError(errorText(err)); } finally { setSyncingSourceId(""); } } async function handleCalDavDiscovery(payload: CalendarCalDavDiscoveryPayload) { return discoverCalDavCalendars(settings, payload); } 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; setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS); 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) { 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 openEventEditor(event: CalendarEvent) { const recurringOccurrence = Boolean( event.is_occurrence && event.series_event_id && event.recurrence_id ); setEventDialog({ kind: "edit", occurrence: event, seriesEvent: recurringOccurrence ? null : event, editScope: recurringOccurrence ? "occurrence" : "series" }); if (!recurringOccurrence || !event.series_event_id) return; const instanceId = calendarEventInstanceId(event); void getCalendarEvent(settings, event.series_event_id) .then((seriesEvent) => { setEventDialog((current) => current?.kind === "edit" && calendarEventInstanceId(current.occurrence) === instanceId ? { ...current, seriesEvent } : current ); }) .catch((err) => setError(errorText(err))); } function setEventEditScope(scope: EventEditScope) { setEventDialog((current) => current?.kind === "edit" && (scope === "occurrence" || current.seriesEvent) ? { ...current, editScope: scope } : current ); } function beginEventDrag(dragEvent: ReactDragEvent, action: CalendarDragAction) { if (!canWrite) { dragEvent.preventDefault(); return; } dragActionRef.current = action; setDraggingEventId(calendarEventInstanceId(action.event)); dragEvent.dataTransfer.effectAllowed = "move"; dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event)); } function handleEventDragStart(dragEvent: ReactDragEvent, calendarEvent: CalendarEvent) { beginEventDrag(dragEvent, { kind: "move", event: calendarEvent }); } function handleEventResizeDragStart(dragEvent: ReactDragEvent, 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, 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, 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, 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) { const related = dragEvent.relatedTarget; if (related instanceof Node && dragEvent.currentTarget.contains(related)) return; setDropTarget(null); } function handleEventDropOnDay(dropEvent: ReactDragEvent, 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, 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 { const payload = { start_at: next.startAt.toISOString(), end_at: next.endAt ? next.endAt.toISOString() : null, all_day: next.allDay }; if ( calendarEvent.is_occurrence && calendarEvent.series_event_id && calendarEvent.recurrence_id ) { await updateCalendarEventOccurrence( settings, calendarEvent.series_event_id, calendarEvent.recurrence_id, payload ); } else { await updateCalendarEvent(settings, calendarEvent.id, payload); } 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, editScope: EventEditScope ): Promise { setSaving(true); setError(""); try { const currentDialog = eventDialog; if ( event && currentDialog?.kind === "edit" && editScope === "occurrence" && currentDialog.occurrence.series_event_id && currentDialog.occurrence.recurrence_id ) { await updateCalendarEventOccurrence( settings, currentDialog.occurrence.series_event_id, currentDialog.occurrence.recurrence_id, payload ); } else 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(); return true; } catch (err) { setError(errorText(err)); return false; } finally { setSaving(false); } } async function handleDelete( event: CalendarEvent, editScope: EventEditScope ) { setSaving(true); setError(""); try { const currentDialog = eventDialog; if ( currentDialog?.kind === "edit" && editScope === "occurrence" && currentDialog.occurrence.series_event_id && currentDialog.occurrence.recurrence_id ) { await deleteCalendarEventOccurrence( settings, currentDialog.occurrence.series_event_id, currentDialog.occurrence.recurrence_id ); } else { await deleteCalendarEvent(settings, event.id); } setEventDialog(null); await loadEvents(); } catch (err) { setError(errorText(err)); } finally { setSaving(false); } } return (
{error && {error}}
{ if (nextMode === "continuous") { setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS); setContinuousViewport({ scrollTop: 0, height: 0 }); } setMode(nextMode); }} options={modeOptions} />
} onClick={() => moveFocus(-1)} disabled={mode === "continuous"} /> } onClick={() => moveFocus(1)} disabled={mode === "continuous"} />
} onClick={() => void loadEvents()} /> {canWrite && }
{mode === "continuous" ?
: mode === "month" ? : }
{eventDialog && calendars.length > 0 && setEventDialog(null)} onSave={handleSave} onDelete={handleDelete} /> } {calendarDialog && setCalendarDialog(null)} onSave={handleCalendarSave} onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)} onSync={handleSyncSource} onOpenOutbox={(source) => { if (calendarDialog.kind === "edit") { setOutboxDialog({ calendar: calendarDialog.calendar, source }); } }} onOpenMigration={(batchId) => { setCalendarDialog(null); setMigrationBatchId(batchId); }} onDiscover={handleCalDavDiscovery} /> } {outboxDialog && setOutboxDialog(null)} /> } {migrationBatchId && setMigrationBatchId("")} onChanged={() => { void loadCalendars(); void loadEvents(); }} /> } {calendarDeleteDialog && setCalendarDeleteDialog(null)} onDelete={handleCalendarDelete} /> }
); } 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 calendarRemoteMoveBatchId(calendar: CalendarCollection): string { const remoteMove = calendar.metadata?.remote_move; if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return ""; const batchId = (remoteMove as Record).batch_id; return typeof batchId === "string" ? batchId : ""; } function validCalendarLaunchDate(value: string | null): Date | null { if (!value) return null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function calendarViewPreferences( response: CalendarViewPreferencesResponse ): CalendarViewPreferences { return { dimWeekends: response.dim_weekends, dimOffHours: response.dim_off_hours, workdayStartHour: response.workday_start_hour, workdayEndHour: response.workday_end_hour, continuousVirtualization: response.continuous_virtualization, continuousOverscanWeeks: response.continuous_overscan_weeks, alternateContinuousMonths: response.alternate_continuous_months }; }