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([]); const [visibleCalendarIds, setVisibleCalendarIds] = useState([]); const [eventCalendarId, setEventCalendarId] = useState(""); const [newCalendarName, setNewCalendarName] = useState(""); const [newCalendarColor, setNewCalendarColor] = useState(DEFAULT_CALENDAR_COLOR); const [events, setEvents] = useState([]); const [mode, setMode] = useState(() => 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(null); const [calendarEditDialog, setCalendarEditDialog] = useState(null); 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 = 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(".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) { 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) { 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, 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, 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 { 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 (
{error && {error}}
{modeOptions.map((option) => ( ))}
{canWrite && ( )}
{loading &&
} {mode === "continuous" ? (
setEventDialog({ kind: "edit", event })} onEventHover={setHoveredEventId} onEventDragStart={handleEventDragStart} onEventDragEnd={handleEventDragEnd} onEventDragOverDay={handleEventDragOverDay} onDropTargetLeave={handleDropTargetLeave} onEventDropOnDay={handleEventDropOnDay} />
) : mode === "month" ? ( setEventDialog({ kind: "edit", event })} onEventHover={setHoveredEventId} onEventDragStart={handleEventDragStart} onEventDragEnd={handleEventDragEnd} onEventDragOverDay={handleEventDragOverDay} onDropTargetLeave={handleDropTargetLeave} onEventDropOnDay={handleEventDropOnDay} /> ) : ( setEventDialog({ kind: "edit", event })} onEventHover={setHoveredEventId} onEventDragStart={handleEventDragStart} onEventResizeDragStart={handleEventResizeDragStart} onEventDragEnd={handleEventDragEnd} onEventDragOverDay={handleEventDragOverDay} onEventDragOverTime={handleEventDragOverTime} onDropTargetLeave={handleDropTargetLeave} onEventDropOnDay={handleEventDropOnDay} onEventDropOnTime={handleEventDropOnTime} /> )}
{eventDialog && calendars.length > 0 && ( setEventDialog(null)} onSave={handleSave} onDelete={handleDelete} /> )} {calendarEditDialog && ( setCalendarEditDialog(null)} onSave={handleCalendarSave} onDelete={handleCalendarDelete} /> )}
); } function CalendarWeekRows({ days, eventsByDay, calendarColorById, focusDate, variant, preferences, canWrite, draggingEventId, hoveredEventId, dropTarget, viewport, onEventSelect, onEventHover, onEventDragStart, onEventDragEnd, onEventDragOverDay, onDropTargetLeave, onEventDropOnDay }: { days: Date[]; eventsByDay: Map; calendarColorById: Map; 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, calendarEvent: CalendarEvent) => void; onEventDragEnd: () => void; onEventDragOverDay: (dragEvent: ReactDragEvent, day: Date) => void; onDropTargetLeave: (dragEvent: ReactDragEvent) => void; onEventDropOnDay: (dropEvent: ReactDragEvent, 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 (
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => {label})}
{virtualWindow.topSpacerHeight > 0 &&