Files
govoplan-calendar/webui/src/features/calendar/CalendarPage.tsx

3011 lines
128 KiB
TypeScript

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<CalendarCollection[]>([]);
const [syncSources, setSyncSources] = useState<CalendarSyncSource[]>([]);
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
const [eventCalendarId, setEventCalendarId] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const eventsRef = useRef<CalendarEvent[]>([]);
const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null });
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 [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
const [syncingSourceId, setSyncingSourceId] = useState("");
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 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<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, 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<boolean> {
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<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): Promise<boolean> {
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 (
<div className="workspace-data-page module-entry-page calendar-page calendar-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548" className="calendar-loading-frame">
<div className="calendar-shell">
<aside className="calendar-sidebar">
<div className="calendar-sidebar-heading">i18n:govoplan-calendar.calendars.94445018</div>
<div className="calendar-list">
{calendars.map((calendar) => {
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const syncing = source ? syncingSourceId === source.id : false;
return (
<div
key={calendar.id}
className={[
"calendar-list-row",
calendar.id === targetCalendarId ? "is-target" : "",
syncing ? "is-syncing" : ""].
filter(Boolean).join(" ")}
aria-busy={syncing || undefined}>
<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={i18nMessage("i18n:govoplan-calendar.show_value.60e2ce8e", { value0: calendar.name })}
title={i18nMessage("i18n:govoplan-calendar.show_value.60e2ce8e", { value0: 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>
<TableActionGroup
className="calendar-list-actions"
actions={[
source && canSyncCalendars && {
id: "sync",
label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick: () => void handleSyncSource(source),
disabled: saving || syncing
},
(canManageCalendars || canDeleteCalendars) && {
id: "edit",
label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
icon: <Pencil size={15} />,
onClick: () => openCalendarEditor(calendar)
}
]}
/>
</div>);
})}
{!calendars.length && !loading &&
<p className="calendar-empty-note">i18n:govoplan-calendar.no_calendars.3a7e4a7a</p>
}
{canManageCalendars &&
<div className="calendar-create-action">
<Button type="button" onClick={() => setCalendarDialog({ kind: "create" })} disabled={saving}>
<Plus size={16} /> i18n:govoplan-calendar.add_calendar.124c55eb
</Button>
</div>
}
</div>
<div className="calendar-sidebar-heading is-secondary">i18n:govoplan-calendar.agenda.891e9d6d</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>i18n:govoplan-calendar.no_events.e339ba73</p>}
</div>
</aside>
<section className="calendar-main-panel" aria-label="i18n:govoplan-calendar.calendar.adab5090">
<div className="calendar-view-toolbar" aria-label="i18n:govoplan-calendar.calendar_controls.974f4fa1">
<div className="calendar-toolbar-left">
<SegmentedControl
className="calendar-mode-switch"
size="equal"
ariaLabel="i18n:govoplan-calendar.calendar_views.9e6b9c2b"
value={mode}
onChange={setMode}
options={modeOptions}
/>
</div>
<div className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
<AdminIconButton
label="i18n:govoplan-calendar.previous.50f94286"
icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
<AdminIconButton
label="i18n:govoplan-calendar.next.bc981983"
icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong>
</div>
</div>
</div>
<div className="calendar-toolbar-right">
<AdminIconButton
label="i18n:govoplan-calendar.refresh.56e3badc"
icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
</Button>
}
</div>
</div>
<div className={`calendar-view-shell is-${mode}`}>
{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>
</LoadingFrame>
{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} />
}
{calendarDialog &&
<CalendarCollectionDialog
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
state={calendarDialog}
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
saving={saving}
syncingSourceId={syncingSourceId}
canWrite={canManageCalendars}
canDelete={canDeleteCalendars}
canManageSources={canDeleteCalendars}
canSyncSources={canSyncCalendars}
onCancel={() => setCalendarDialog(null)}
onSave={handleCalendarSave}
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
onSync={handleSyncSource}
onDiscover={handleCalDavDiscovery} />
}
{calendarDeleteDialog &&
<CalendarCollectionDeleteDialog
state={calendarDeleteDialog}
calendars={calendars}
syncSources={syncSources}
saving={saving}
onCancel={() => setCalendarDeleteDialog(null)}
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">
{["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) => <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">i18n:govoplan-calendar.all_day.11457433</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={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: item.event.summary })}>
{canWrite &&
<span
className="calendar-event-resize-handle is-start"
draggable
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
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={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: item.event.summary })}>
<EventInlineLabel event={item.event} />
</button>
{canWrite &&
<span
className="calendar-event-resize-handle is-end"
draggable
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
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={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: event.summary })}>
<EventInlineLabel event={event} />
</button>);
}
function CalendarCollectionDialog({
state,
source,
saving,
syncingSourceId,
canWrite,
canDelete,
canManageSources,
canSyncSources,
onCancel,
onSave,
onRequestDelete,
onSync,
onDiscover
}: {state: CalendarCollectionDialogState;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? "");
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? "");
const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
const [discoveryError, setDiscoveryError] = useState("");
const formId = "calendar-collection-form";
const isExistingSyncSource = isEdit && Boolean(source);
const canEditSource = canManageSources;
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource ||
canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret));
const saveDisabled =
saving ||
!canWrite ||
!name.trim() ||
sourceDetailsInvalid;
const syncing = source ? syncingSourceId === source.id : false;
const collectionDraft = {
sourceMode,
name,
color,
davUrl,
collectionUrl,
displayName,
authType,
username,
password,
bearerToken,
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
useUnsavedDraftGuard({
dirty: collectionDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
function currentPayload(): CalendarCollectionFormPayload {
return {
sourceMode,
name,
color,
caldav: {
collection_url: effectiveCollectionUrl,
dav_url: davUrl,
display_name: displayName,
auth_type: effectiveAuthType,
username,
password,
bearer_token: bearerToken,
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
}
};
}
async function saveCurrent(): Promise<boolean> {
if (saveDisabled) return false;
return onSave(currentPayload());
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function selectSourceMode(next: CalendarSourceMode) {
setSourceMode(next);
setDiscoveryError("");
setDiscoveredCalendars([]);
if (next === "graph") {
setAuthType("bearer");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("me/calendar");
return;
}
if (next === "ews") {
setAuthType("basic");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx");
return;
}
if (next === "ics" || next === "webcal") {
setAuthType("none");
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
setSyncDirection("two_way");
}
}
function handleDavUrlChange(next: string) {
setDavUrl(next);
setCollectionUrl(next);
setSelectedDiscoveredUrl("");
setDiscoveredCalendars([]);
setDiscoveryError("");
}
function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) {
setSelectedDiscoveredUrl(candidate.collection_url);
setCollectionUrl(candidate.collection_url);
if (candidate.display_name) {
if (!isExistingSyncSource) setDisplayName(candidate.display_name);
if (!isEdit && !name.trim()) setName(candidate.display_name);
}
const discoveredColor = normalizeHexColor(candidate.color);
if (!isEdit && discoveredColor) setColor(discoveredColor);
}
function handleDiscoveredCalendarChange(event: ChangeEvent<HTMLSelectElement>) {
const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value);
if (candidate) applyDiscoveredCalendar(candidate);
}
async function handleDiscover() {
const url = davUrl.trim();
if (!url) return;
setDiscovering(true);
setDiscoveryError("");
try {
const payload: CalendarCalDavDiscoveryPayload = {
url,
source_id: source?.id ?? null,
auth_type: authType,
username: authType === "basic" ? username.trim() || null : null
};
if (authType === "basic" && password.trim()) payload.password = password;
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
const response = await onDiscover(payload);
setDiscoveredCalendars(response.calendars);
if (response.calendars.length > 0) {
applyDiscoveredCalendar(response.calendars[0]);
} else {
setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a");
}
} catch (err) {
setDiscoveryError(errorText(err));
} finally {
setDiscovering(false);
}
}
return (
<Dialog
open
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{calendar && canDelete &&
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
{!isEdit &&
<SegmentedControl
className="calendar-source-switch"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-calendar.calendar_source_type.92cdb42f"
value={sourceSwitchMode(sourceMode)}
disabled={saving}
onChange={selectSourceMode}
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
]}
/>
}
<div className="calendar-dialog-name-color-row">
<label>
<span>i18n:govoplan-calendar.name.709a2322</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>i18n:govoplan-calendar.color.1d0c8304</span>
<ColorPickerField value={color} onChange={setColor} disabled={saving || !canWrite} />
</label>
</div>
{sourceMode === "local" ?
<section className="calendar-source-pane">
<h3>i18n:govoplan-calendar.local_calendar.ed3f72f8</h3>
<p className="calendar-form-note">i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504</p>
</section> :
<section className="calendar-source-pane">
<div className="calendar-source-pane-heading">
<h3>{calendarSourcePaneTitle(sourceMode)}</h3>
{source &&
<span className={[
"calendar-sync-status",
syncing ? "is-syncing" : "",
source.last_status === "error" && !syncing ? "is-error" : ""].
filter(Boolean).join(" ")}>
{syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"}
</span>
}
</div>
<div className="calendar-caldav-setup">
<label className="calendar-dialog-wide">
<span>{calendarSourceUrlLabel(sourceMode)}</span>
<input
value={davUrl}
onChange={(item) => handleDavUrlChange(item.target.value)}
placeholder={calendarSourceUrlPlaceholder(sourceMode)}
required={sourceMode !== "local"}
maxLength={1000}
disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.authentication.ee1acfa5</span>
<select value={effectiveAuthType} onChange={(item) => setAuthType(item.target.value as CalendarCalDavAuthType)} disabled={saving || !canEditSource || sourceMode === "graph"}>
{sourceMode !== "graph" && <option value="basic">i18n:govoplan-calendar.basic.aa2c96da</option>}
{sourceMode !== "graph" && sourceMode !== "ews" && <option value="none">i18n:govoplan-calendar.none.6eef6648</option>}
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
</select>
</label>
{effectiveAuthType === "basic" &&
<>
<label>
<span>i18n:govoplan-calendar.username.84c29015</span>
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
</label>
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
</>
}
{effectiveAuthType === "bearer" &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{sourceMode === "caldav" &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button>
}
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
{discoveredCalendars.map((candidate) =>
<option key={candidate.collection_url} value={candidate.collection_url}>
{candidate.display_name || candidate.collection_url}
</option>
)}
</select>
</label>
}
<details className="calendar-advanced-settings">
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label>
</div>
<div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
<label>
<span>i18n:govoplan-calendar.interval.011efcd5</span>
<input type="number" min={1} step={1} value={syncIntervalMinutes} onChange={(item) => setSyncIntervalMinutes(Math.max(1, Number(item.target.value) || 1))} disabled={saving || !canEditMutableSourceSettings} />
</label>
<label>
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
</label>
</div>
</details>
{source &&
<div className="calendar-sync-status-panel">
<dl>
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions">
<Button
type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || !canSyncSources}>
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
</Button>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
</div>
</div>
}
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section>
}
</form>
</Dialog>);
}
function CalendarCollectionDeleteDialog({
state,
calendars,
syncSources,
saving,
onCancel,
onDelete
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state;
const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source]));
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const moveTargets = calendars.filter((item) =>
item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null)
);
const firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0;
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() {
void onDelete(calendar, {
event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
});
}
return (
<Dialog
open
role="alertdialog"
title={`${actionLabel} calendar`}
className="calendar-delete-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="button" variant="danger" onClick={confirm} disabled={confirmDisabled}>
<Trash2 size={16} /> {saving ? "i18n:govoplan-calendar.working.049ac820" : actionLabel}
</Button>
</>
}>
<div className="calendar-delete-dialog-body">
<p className="calendar-delete-warning">
{loadingEventCount ?
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
</p>
{!loadingEventCount && eventCount === null && <p className="calendar-form-note">i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055</p>}
{canMoveEvents &&
<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>
<input
type="radio"
name="calendar-delete-event-action"
value="move"
checked={eventAction === "move"}
onChange={() => setEventAction("move")} />
<span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label>
{eventAction === "move" &&
<>
<label className="calendar-delete-target-label">
<span>i18n:govoplan-calendar.target_calendar.e533fe1d</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 &&
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</>
}
</fieldset>
}
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
<p className="calendar-form-note">{source ?
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" :
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}</p>
}
</div>
</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<boolean>;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 initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
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 [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
const [uid, setUid] = useState(event?.uid ?? "");
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
const [etag, setEtag] = useState(event?.etag ?? "");
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
const eventDraft = {
summary,
calendarId,
description,
location,
startDate,
endDate,
startTime,
endTime,
allDay,
endMode,
durationSeconds,
uid,
recurrenceId,
sequence,
status,
transparency,
classification,
timezone,
categoriesText,
organizerJson,
attendeesJson,
rruleText,
rdateJson,
exdateJson,
remindersJson,
attachmentsJson,
relatedToJson,
sourceKind,
sourceHref,
etag,
icalendarJson,
metadataJson
};
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
useUnsavedDraftGuard({
dirty: eventDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
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 saveCurrent(): Promise<boolean> {
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
const usesDuration = !allDay && endMode === "duration";
const endAt = allDay ?
addDays(localDateTime(endDate, "00:00"), 1) :
usesDuration ?
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
localDateTime(endDate, endTime);
if (usesDuration && parsedDurationSeconds <= 0) {
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
return false;
}
if (endAt <= startAt) {
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
return false;
}
let advanced: CalendarEventAdvancedPayload;
try {
advanced = {
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
categories: commaSeparatedValues(categoriesText),
rrule: parseRRuleInput(rruleText),
rdate: parseJsonArray(rdateJson, "RDATE"),
exdate: parseJsonArray(exdateJson, "EXDATE"),
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
};
} catch (err) {
setFormError(errorText(err));
return false;
}
const payload: CalendarEventCreatePayload = {
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
sequence: Math.max(0, Number(sequence) || 0),
status,
transparency,
classification,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
duration_seconds: usesDuration ? parsedDurationSeconds : null,
all_day: allDay,
timezone: timezone.trim() || null,
source_kind: sourceKind.trim() || "local",
source_href: sourceHref.trim() || null,
etag: etag.trim() || null,
...advanced
};
if (!event) {
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function requestDelete() {
if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void onDelete(event);
}
return (
<Dialog
open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
className="calendar-event-dialog calendar-vevent-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 ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</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>i18n:govoplan-calendar.description.55f8ebc8</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.location.d219c681</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
</label>
</div>
{!allDay &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
</select>
</label>
{endMode === "duration" &&
<label>
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
</label>
}
</div>
}
{(allDay || endMode === "end") &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
</label>
</div>
}
<details className="calendar-advanced-settings calendar-vevent-details">
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.uid.d946adf5</span>
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.status.bae7d5be</span>
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.class.41ff354b</span>
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-wide">
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
<label>
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
</label>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.source_href.819fa147</span>
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
</div>
</section>
</details>
</form>
</Dialog>);
}
function jsonTextareaValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
const value = parseJsonText(text, label, {});
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
return value;
}
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
if (!text.trim()) return null;
const value = parseJsonText(text, label, null);
if (value === null) return null;
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
return value;
}
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
const value = parseJsonText(text, label, []);
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
return value.map((item, index) => {
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
return item;
});
}
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
const trimmed = text.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed) as unknown;
} catch (err) {
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
}
}
function parseRRuleInput(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
const result: Record<string, unknown> = {};
for (const item of trimmed.split(";")) {
const [rawKey, ...rawValueParts] = item.split("=");
const key = rawKey.trim().toUpperCase();
const rawValue = rawValueParts.join("=").trim();
if (!key || !rawValue) continue;
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
}
return Object.keys(result).length ? result : null;
}
function serializeRRuleText(value: Record<string, unknown>): string {
return Object.entries(value).
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
join(";");
}
function commaSeparatedValues(text: string): string[] {
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
const next = { ...value };
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
if (usesDuration) {
standard.uses_duration = true;
} else {
delete standard.uses_duration;
}
if (Object.keys(standard).length > 0) {
next.standard = standard;
} else {
delete next.standard;
}
return next;
}
function eventUsesDuration(event: CalendarEvent): boolean {
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
return standard?.uses_duration === true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit<CalendarSyncSourceCreatePayload, "calendar_id"> {
const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url);
const result: Omit<CalendarSyncSourceCreatePayload, "calendar_id"> = {
source_kind: sourceKind,
collection_url: payload.collection_url.trim(),
display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
};
if (result.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
if (result.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
return result;
}
function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
};
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
return result;
}
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
return sourceMode === "local" ? "caldav" : sourceMode;
}
function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode {
return sourceMode === "webcal" ? "ics" : sourceMode;
}
function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) {
if (authType === "basic" && password.trim()) return { password };
if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken };
return {};
}
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
return "i18n:govoplan-calendar.local_calendar.ed3f72f8";
}
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
}
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287";
}
function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean {
return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way";
}
function calendarBulkMoveExternalAction(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
return undefined;
}
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
}
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
function calendarDeleteWarning(
calendar: CalendarCollection,
eventCount: number,
eventAction: CalendarDeleteEventAction,
targetCalendarId: string,
moveTargets: CalendarCollection[])
: string {
const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9";
const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events";
if (eventAction === "move") {
const target = moveTargets.find((item) => item.id === targetCalendarId);
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" });
}
if (calendarIsExternal(calendar)) {
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord });
}
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: 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 "i18n:govoplan-calendar.all_day.11457433";
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 dateTimeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(value);
}
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();
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) return trimmed.slice(0, 7).toLowerCase();
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 calendarDraftKey(value: unknown): string {
return JSON.stringify(value);
}
function mergeCalendarEventDelta(current: CalendarEvent[], response: CalendarEventDeltaResponse): CalendarEvent[] {
if (response.full) return response.events.slice().sort(compareCalendarEvents);
const rows = new Map(current.map((event) => [event.id, event]));
for (const deleted of response.deleted) {
if (!deleted.resource_type || deleted.resource_type === "calendar_event") rows.delete(deleted.id);
}
for (const event of response.events) rows.set(event.id, event);
return Array.from(rows.values()).sort(compareCalendarEvents);
}
function compareCalendarEvents(left: CalendarEvent, right: CalendarEvent): number {
return new Date(left.start_at).getTime() - new Date(right.start_at).getTime() || left.summary.localeCompare(right.summary) || left.id.localeCompare(right.id);
}
function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
}