import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type FormEvent, type ChangeEvent, type WheelEvent as ReactWheelEvent } from "react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { AdminIconButton, Button, ColorPickerField, DateField, Dialog, DismissibleAlert, LoadingFrame, PasswordField, SegmentedControl, TableActionGroup, TimeField, ToggleSwitch, hasScope, i18nMessage, useUnsavedDraftGuard, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { createCalendar, createCalendarEvent, createSyncSource, deleteCalendar, deleteCalendarEvent, discoverCalDavCalendars, listCalendarEvents, listCalendarEventsDelta, listCalendars, listSyncSources, syncSyncSource, updateCalendar, updateCalendarEvent, updateSyncSource, type CalendarCalDavAuthType, type CalendarCalDavConflictPolicy, type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryPayload, type CalendarCalDavSyncDirection, type CalendarBulkMoveExternalAction, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarDeleteEventAction, type CalendarEvent, type CalendarEventCreatePayload, type CalendarEventDeltaResponse, type CalendarSyncSource, type CalendarSyncSourceCreatePayload, type CalendarSyncSourceKind, type CalendarSyncSourceUpdatePayload } from "../../api/calendar"; type CalendarMode = "continuous" | "month" | "week" | "workweek" | "day"; type CalendarSourceMode = "local" | CalendarSyncSourceKind; type CalendarSourceSwitchMode = "local" | "caldav" | "ics" | "graph" | "ews"; type CalendarEventEndMode = "end" | "duration"; type Range = {start: Date;end: Date;}; type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;}; type CalendarCollectionDialogState = {kind: "create";} | { kind: "edit"; calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; type CalendarDeleteDialogState = { calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; type CalendarCalDavFormPayload = { collection_url: string; dav_url: string; display_name: string; auth_type: CalendarCalDavAuthType; username: string; password: string; bearer_token: string; sync_enabled: boolean; sync_interval_seconds: number; sync_direction: CalendarCalDavSyncDirection; conflict_policy: CalendarCalDavConflictPolicy; }; type CalendarCollectionFormPayload = { sourceMode: CalendarSourceMode; name: string; color: string; caldav: CalendarCalDavFormPayload; }; type CalendarEventAdvancedPayload = Pick< CalendarEventCreatePayload, "organizer" | "attendees" | "categories" | "rrule" | "rdate" | "exdate" | "reminders" | "attachments" | "related_to" | "icalendar" | "metadata">; 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: "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 [calendars, setCalendars] = useState([]); const [syncSources, setSyncSources] = useState([]); const [visibleCalendarIds, setVisibleCalendarIds] = useState([]); const [eventCalendarId, setEventCalendarId] = useState(""); const [events, setEvents] = useState([]); const eventsRef = useRef([]); const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null }); 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 [calendarDialog, setCalendarDialog] = useState(null); const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); 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 = useMemo(() => 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 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); 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, 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] ?? ""); } catch (err) { setError(errorText(err)); } finally { setLoading(false); } } async function loadEvents() { setLoading(true); setError(""); const start = visibleRange.start.toISOString(); const end = visibleRange.end.toISOString(); const deltaKey = `${start}|${end}`; try { let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null; let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : []; let response = null; do { response = await listCalendarEventsDelta(settings, { start_at: start, end_at: end, since }); nextEvents = mergeCalendarEventDelta(nextEvents, response); since = response.watermark || null; } while (response.has_more && response.watermark); eventsRef.current = nextEvents; eventDeltaRef.current = { key: deltaKey, watermark: since }; setEvents(nextEvents); } catch (err) { setError(errorText(err)); eventsRef.current = []; eventDeltaRef.current = { key: "", watermark: null }; 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) { 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.caldav)); 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" }); 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); 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); await loadCalendars(); 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; 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): Promise { 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(); return true; } catch (err) { setError(errorText(err)); return false; } 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}}
} onClick={() => moveFocus(-1)} disabled={mode === "continuous"} /> } onClick={() => moveFocus(1)} disabled={mode === "continuous"} />
} onClick={() => void loadEvents()} /> {canWrite && }
{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} /> } {calendarDialog && setCalendarDialog(null)} onSave={handleCalendarSave} onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)} onSync={handleSyncSource} onDiscover={handleCalDavDiscovery} /> } {calendarDeleteDialog && setCalendarDeleteDialog(null)} 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 (
{["i18n:govoplan-calendar.mon.24b2a099", "i18n:govoplan-calendar.tue.529541bb", "i18n:govoplan-calendar.wed.23408b19", "i18n:govoplan-calendar.thu.3593ccd9", "i18n:govoplan-calendar.fri.bbd6e32e", "i18n:govoplan-calendar.sat.6b782d41", "i18n:govoplan-calendar.sun.48c98cab"].map((label) => {label})}
{virtualWindow.topSpacerHeight > 0 &&