Complete recurrence editing and calendar preferences

This commit is contained in:
2026-07-31 05:46:50 +02:00
parent 1e063f51cc
commit f00cff1d8c
23 changed files with 2354 additions and 368 deletions
+188 -45
View File
@@ -26,14 +26,17 @@ import {
createSyncSource,
deleteCalendar,
deleteCalendarEvent,
deleteCalendarEventOccurrence,
discoverCalDavCalendars,
getCalendarEvent,
getCalendarViewPreferences,
listCalendarEvents,
listCalendarEventsDelta,
listCalendars,
listSyncSources,
syncSyncSource,
updateCalendar,
updateCalendarEvent,
updateCalendarEventOccurrence,
updateSyncSource,
type CalendarCalDavDiscoveryPayload,
type CalendarCollection,
@@ -41,6 +44,7 @@ import {
type CalendarEvent,
type CalendarEventCreatePayload,
type CalendarSyncSource,
type CalendarViewPreferences as CalendarViewPreferencesResponse,
} from
"../../api/calendar";
import {
@@ -66,6 +70,7 @@ import {
agendaDateLabel,
agendaGroupsForDays,
calendarEventColorStyle,
calendarEventInstanceId,
continuousEventWindow,
daysBetweenCount,
daysForMode,
@@ -76,7 +81,6 @@ import {
headingForMode,
loadCalendarMode,
loadCalendarViewPreferences,
mergeCalendarEventDelta,
moveEventToDay,
moveEventToTime,
normalizeHexColor,
@@ -89,10 +93,19 @@ import {
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;};
type EventEditScope = "occurrence" | "series";
type EventDialogState =
| { kind: "create" }
| {
kind: "edit";
occurrence: CalendarEvent;
seriesEvent: CalendarEvent | null;
editScope: EventEditScope;
};
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
const modeOptions: {id: CalendarMode;label: string;}[] = [
@@ -109,8 +122,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
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 eventRequestRef = useRef(0);
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
@@ -132,7 +143,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
const dragActionRef = useRef<CalendarDragAction | null>(null);
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
() => 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]);
@@ -152,6 +165,12 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
const heading = headingForMode(mode, focusDate, visibleRange);
const dialogEvent =
eventDialog?.kind === "edit"
? eventDialog.editScope === "series"
? eventDialog.seriesEvent
: eventDialog.occurrence
: null;
useEffect(() => {
void loadCalendars();
@@ -161,11 +180,35 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
saveCalendarMode(mode);
}, [mode]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const preferences = await getCalendarViewPreferences(settings);
if (active) setViewPreferences(calendarViewPreferences(preferences));
} catch (err) {
if (active) setError(errorText(err));
}
};
const handlePreferenceChange = () => void load();
void load();
window.addEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
return () => {
active = false;
window.removeEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (calendars.length) {
void loadEvents();
} else {
eventsRef.current = [];
setEvents([]);
}
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
@@ -222,29 +265,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
setError("");
const start = eventWindow.start.toISOString();
const end = eventWindow.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);
const response = await listCalendarEvents(settings, {
start_at: start,
end_at: end,
expand_recurring: true
});
if (requestId !== eventRequestRef.current) return;
eventsRef.current = nextEvents;
eventDeltaRef.current = { key: deltaKey, watermark: since };
setEvents(nextEvents);
setEvents(response.events);
} catch (err) {
if (requestId !== eventRequestRef.current) return;
setError(errorText(err));
eventsRef.current = [];
eventDeltaRef.current = { key: "", watermark: null };
setEvents([]);
} finally {
if (requestId === eventRequestRef.current) setLoading(false);
@@ -352,7 +383,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents();
} else {
eventsRef.current = [];
setEvents([]);
}
} catch (err) {
@@ -469,15 +499,50 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
window.requestAnimationFrame(() => updateContinuousViewport(node));
}
function openEventEditor(event: CalendarEvent) {
const recurringOccurrence = Boolean(
event.is_occurrence &&
event.series_event_id &&
event.recurrence_id
);
setEventDialog({
kind: "edit",
occurrence: event,
seriesEvent: recurringOccurrence ? null : event,
editScope: recurringOccurrence ? "occurrence" : "series"
});
if (!recurringOccurrence || !event.series_event_id) return;
const instanceId = calendarEventInstanceId(event);
void getCalendarEvent(settings, event.series_event_id)
.then((seriesEvent) => {
setEventDialog((current) =>
current?.kind === "edit" &&
calendarEventInstanceId(current.occurrence) === instanceId
? { ...current, seriesEvent }
: current
);
})
.catch((err) => setError(errorText(err)));
}
function setEventEditScope(scope: EventEditScope) {
setEventDialog((current) =>
current?.kind === "edit" &&
(scope === "occurrence" || current.seriesEvent)
? { ...current, editScope: scope }
: current
);
}
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
if (!canWrite) {
dragEvent.preventDefault();
return;
}
dragActionRef.current = action;
setDraggingEventId(action.event.id);
setDraggingEventId(calendarEventInstanceId(action.event));
dragEvent.dataTransfer.effectAllowed = "move";
dragEvent.dataTransfer.setData("text/plain", action.event.id);
dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event));
}
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
@@ -553,11 +618,25 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
setSaving(true);
setError("");
try {
await updateCalendarEvent(settings, calendarEvent.id, {
const payload = {
start_at: next.startAt.toISOString(),
end_at: next.endAt ? next.endAt.toISOString() : null,
all_day: next.allDay
});
};
if (
calendarEvent.is_occurrence &&
calendarEvent.series_event_id &&
calendarEvent.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
calendarEvent.series_event_id,
calendarEvent.recurrence_id,
payload
);
} else {
await updateCalendarEvent(settings, calendarEvent.id, payload);
}
await loadEvents();
} catch (err) {
setError(errorText(err));
@@ -576,11 +655,29 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
});
}
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null): Promise<boolean> {
async function handleSave(
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: EventEditScope
): Promise<boolean> {
setSaving(true);
setError("");
try {
if (event) {
const currentDialog = eventDialog;
if (
event &&
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id,
payload
);
} else if (event) {
await updateCalendarEvent(settings, event.id, payload);
} else {
await createCalendarEvent(settings, payload);
@@ -600,11 +697,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
}
}
async function handleDelete(event: CalendarEvent) {
async function handleDelete(
event: CalendarEvent,
editScope: EventEditScope
) {
setSaving(true);
setError("");
try {
await deleteCalendarEvent(settings, event.id);
const currentDialog = eventDialog;
if (
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await deleteCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id
);
} else {
await deleteCalendarEvent(settings, event.id);
}
setEventDialog(null);
await loadEvents();
} catch (err) {
@@ -690,15 +804,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<h3>{agendaDateLabel(group.date)}</h3>
{group.events.map((event) =>
<button
key={`${group.key}-${event.id}`}
key={`${group.key}-${calendarEventInstanceId(event)}`}
type="button"
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
className={["calendar-agenda-item", hoveredEventId === calendarEventInstanceId(event) ? "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 })}>
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onClick={() => openEventEditor(event)}>
<EventInlineLabel event={event} />
</button>
@@ -779,7 +893,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
viewport={continuousViewport}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
@@ -800,7 +914,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
@@ -819,7 +933,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventResizeDragStart={handleEventResizeDragStart}
@@ -838,13 +952,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
{eventDialog && calendars.length > 0 &&
<CalendarEventDialog
key={
eventDialog.kind === "edit"
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
: "new-event"
}
calendars={calendars}
defaultCalendarId={targetCalendarId}
event={eventDialog.kind === "edit" ? eventDialog.event : null}
event={dialogEvent}
editScope={eventDialog.kind === "edit" ? eventDialog.editScope : "series"}
canChooseSeries={
eventDialog.kind === "edit" &&
Boolean(eventDialog.occurrence.is_occurrence)
}
seriesLoaded={
eventDialog.kind !== "edit" ||
eventDialog.seriesEvent !== null
}
focusDate={focusDate}
saving={saving}
canWrite={canWrite}
canDelete={canDelete}
onEditScopeChange={setEventEditScope}
onCancel={() => setEventDialog(null)}
onSave={handleSave}
onDelete={handleDelete} />
@@ -887,3 +1016,17 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
return left.name.localeCompare(right.name);
}
function calendarViewPreferences(
response: CalendarViewPreferencesResponse
): CalendarViewPreferences {
return {
dimWeekends: response.dim_weekends,
dimOffHours: response.dim_off_hours,
workdayStartHour: response.workday_start_hour,
workdayEndHour: response.workday_end_hour,
continuousVirtualization: response.continuous_virtualization,
continuousOverscanWeeks: response.continuous_overscan_weeks,
alternateContinuousMonths: response.alternate_continuous_months
};
}