3011 lines
128 KiB
TypeScript
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";
|
|
}
|