feat: implement comprehensive recurring event single occurrence editing

- Add detach functionality for individual occurrences of recurring events
- Create POST /api/events/<id>/occurrences/<date>/detach endpoint
- Implement EventException-based EXDATE generation for master events
- Add user confirmation dialog for single vs series editing choice
- Implement manual recurrence expansion with DST timezone tolerance
- Support FREQ=DAILY and FREQ=WEEKLY with BYDAY patterns and UNTIL dates
- Create standalone events from detached occurrences without affecting master series
- Add GET /api/events/<id> endpoint for fetching master event data
- Allow editing recurring series even when master event date is in the past
- Replace browser confirm dialogs with Syncfusion dialog components
- Remove debug logging while preserving error handling
- Update documentation for recurring event functionality

BREAKING: Frontend now manually expands recurring events instead of relying on Syncfusion's EXDATE handling

This enables users to edit individual occurrences of recurring events (creating standalone events)
or edit the entire series (updating all future occurrences) through an intuitive UI workflow.
The system properly handles timezone transitions, holiday exclusions, and complex recurrence patterns.
This commit is contained in:
RobbStarkAustria
2025-10-12 20:04:23 +00:00
parent 773628c324
commit e53cc619ec
6 changed files with 739 additions and 120 deletions

View File

@@ -11,16 +11,17 @@ import {
ViewDirective,
} from '@syncfusion/ej2-react-schedule';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
import type {
EventRenderedArgs,
ActionEventArgs,
RenderCellEventArgs,
} from '@syncfusion/ej2-react-schedule';
import { fetchEvents } from './apiEvents';
import { fetchEvents, fetchEventById } from './apiEvents';
import { fetchGroups } from './apiGroups';
import { getGroupColor } from './groupColors';
import { deleteEvent } from './apiEvents';
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { listHolidays, type Holiday } from './apiHolidays';
@@ -204,6 +205,34 @@ const Appointments: React.FC = () => {
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Confirmation dialog state
const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false);
const [confirmDialogData, setConfirmDialogData] = React.useState<{
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
// Helper function to show confirmation dialog
const showConfirmDialog = (title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmDialogData({
title,
message,
onConfirm: () => {
setConfirmDialogOpen(false);
resolve(true);
},
onCancel: () => {
setConfirmDialogOpen(false);
resolve(false);
}
});
setConfirmDialogOpen(true);
});
};
// Gruppen laden
useEffect(() => {
@@ -290,24 +319,139 @@ const Appointments: React.FC = () => {
end: endRange!,
expand: false,
});
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: !!e.RecurrenceRule,
RecurrenceRule: e.RecurrenceRule ?? null,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: e.RecurrenceException ?? undefined,
recurrenceException: e.RecurrenceException ?? undefined, // for Syncfusion
}));
setEvents(mapped);
// Manually expand recurring events and filter out EXDATE occurrences
const expandedEvents: Event[] = [];
for (const e of data) {
if (e.RecurrenceRule) {
// Parse EXDATE list
const exdates = new Set<string>();
if (e.RecurrenceException) {
e.RecurrenceException.split(',').forEach((dateStr: string) => {
const trimmed = dateStr.trim();
exdates.add(trimmed);
});
}
// Manual expansion for DAILY and WEEKLY recurrence
if (e.RecurrenceRule.includes('FREQ=DAILY') || e.RecurrenceRule.includes('FREQ=WEEKLY')) {
const startTime = parseEventDate(e.StartTime);
const endTime = parseEventDate(e.EndTime);
const duration = endTime.getTime() - startTime.getTime();
// Extract INTERVAL if present
let interval = 1;
const intervalMatch = e.RecurrenceRule.match(/INTERVAL=(\d+)/);
if (intervalMatch) {
interval = parseInt(intervalMatch[1], 10);
}
// Extract end date from UNTIL if present
let endDate: Date | null = null;
const untilMatch = e.RecurrenceRule.match(/UNTIL=(\d{8}T\d{6}Z)/);
if (untilMatch) {
endDate = new Date(untilMatch[1].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'));
}
// Extract COUNT or calculate from UNTIL
let maxOccurrences = 100; // Safety limit
const countMatch = e.RecurrenceRule.match(/COUNT=(\d+)/);
if (countMatch) {
maxOccurrences = parseInt(countMatch[1], 10);
} else if (endDate) {
// Calculate approximate number of occurrences until end date
const daysDiff = Math.ceil((endDate.getTime() - startTime.getTime()) / (24 * 60 * 60 * 1000));
maxOccurrences = Math.min(daysDiff + 5, 200); // Add buffer for safety
}
// Check if WEEKLY has BYDAY=MO,TU,WE,TH,FR,SA,SU (which means daily)
const isDaily = e.RecurrenceRule.includes('FREQ=DAILY') ||
(e.RecurrenceRule.includes('FREQ=WEEKLY') &&
e.RecurrenceRule.includes('BYDAY=MO,TU,WE,TH,FR,SA,SU'));
const incrementDays = isDaily ? interval : (interval * 7); // WEEKLY = 7 days * interval unless BYDAY=all days
// Generate occurrences
for (let i = 0; i < maxOccurrences; i++) {
const occurrenceStart = new Date(startTime);
occurrenceStart.setDate(startTime.getDate() + (i * incrementDays));
// Stop if we've passed the UNTIL date
if (endDate && occurrenceStart > endDate) {
break;
}
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Check if this occurrence is in EXDATE list
const isExcluded = Array.from(exdates).some(exdate => {
const exDateTime = new Date(exdate);
const timeDiff = Math.abs(occurrenceStart.getTime() - exDateTime.getTime());
// Allow up to 2 hours difference to handle DST transitions
return timeDiff < 7200000; // Within 2 hours (7200000ms)
});
if (!isExcluded) {
expandedEvents.push({
Id: `${e.Id}-occurrence-${i}`,
Subject: e.Subject,
StartTime: occurrenceStart,
EndTime: occurrenceEnd,
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.Id, // Reference to master event
Recurrence: false, // Individual occurrences are not recurring
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined,
});
}
}
} else {
// For non-DAILY recurrence, fall back to Syncfusion handling (but clear EXDATE to avoid bugs)
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: true,
RecurrenceRule: e.RecurrenceRule,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined, // Clear to avoid Syncfusion bugs
});
}
} else {
// Non-recurring event - add as-is
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: false,
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined,
});
}
}
setEvents(expandedEvents);
} catch (err) {
console.error('Fehler beim Laden der Termine:', err);
}
@@ -668,9 +812,16 @@ const Appointments: React.FC = () => {
onSave={async () => {
setModalOpen(false);
setEditMode(false);
// Reload events using the same logic as fetchAndSetEvents
// Force immediate data refresh
await fetchAndSetEvents();
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
// Force Syncfusion scheduler to refresh its internal cache
if (scheduleRef.current) {
scheduleRef.current.dataBind?.();
scheduleRef.current.refreshEvents?.();
setSchedulerKey(prev => prev + 1);
}
}}
initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -722,11 +873,50 @@ const Appointments: React.FC = () => {
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
// Removed event logging
let media = null;
if (event.MediaId) {
// Determine if this is single occurrence editing
let isSingleOccurrence = false;
if (event.OccurrenceOfId) {
// This is a manually expanded occurrence from a recurring series
// Ask user if they want to edit single occurrence or entire series
isSingleOccurrence = await showConfirmDialog(
'Termin aus wiederholender Serie',
'Dies ist ein Termin aus einer wiederholenden Serie.\n\nMöchten Sie nur diesen Termin bearbeiten oder die ganze Serie?'
);
} else if (event.RecurrenceRule) {
// This is a recurring event - ask user what they want to edit
isSingleOccurrence = await showConfirmDialog(
'Wiederholender Termin',
'Dies ist ein wiederholender Termin.\n\nMöchten Sie nur diesen Termin bearbeiten oder die ganze Serie?'
);
} else {
// Regular single event
isSingleOccurrence = false;
}
// Fetch master event data if editing entire series from an occurrence
let eventDataToUse = event;
if (event.OccurrenceOfId && !isSingleOccurrence) {
try {
const mediaData = await fetchMediaById(event.MediaId);
const masterEventData = await fetchEventById(event.OccurrenceOfId);
eventDataToUse = {
...masterEventData,
// Keep the scheduler-specific properties from the occurrence
StartTime: parseEventDate(masterEventData.StartTime),
EndTime: parseEventDate(masterEventData.EndTime),
};
} catch (err) {
console.error('Failed to load master event data:', err);
// Fall back to occurrence data if master event can't be loaded
}
}
let media = null;
if (eventDataToUse.MediaId) {
try {
const mediaData = await fetchMediaById(eventDataToUse.MediaId);
media = {
id: mediaData.id,
path: mediaData.file_path,
@@ -736,46 +926,54 @@ const Appointments: React.FC = () => {
console.error('Fehler beim Laden der Mediainfos:', err);
}
}
// Parse recurrence info if present (supports FREQ=WEEKLY;BYDAY=...;[UNTIL=...])
let repeat = false;
let weekdays: number[] = [];
let repeatUntil: Date | null = null;
const rr = (event.RecurrenceRule as string) || '';
if (rr && rr.includes('FREQ=WEEKLY')) {
repeat = true;
const m = rr.match(/BYDAY=([^;]+)/);
if (m && m[1]) {
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
}
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
if (mu && mu[1]) {
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
const untilIso = mu[1]
.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
repeatUntil = new Date(untilIso);
} else if (event.RecurrenceEnd) {
repeatUntil = new Date(event.RecurrenceEnd);
// Only parse recurrence info if editing the entire series (not a single occurrence)
if (!isSingleOccurrence) {
const rr = (eventDataToUse.RecurrenceRule as string) || '';
if (rr && rr.includes('FREQ=WEEKLY')) {
repeat = true;
const m = rr.match(/BYDAY=([^;]+)/);
if (m && m[1]) {
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
}
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
if (mu && mu[1]) {
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
const untilIso = mu[1]
.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
repeatUntil = new Date(untilIso);
} else if (eventDataToUse.RecurrenceEnd) {
repeatUntil = new Date(eventDataToUse.RecurrenceEnd);
}
}
}
setModalInitialData({
Id: event.Id,
title: event.Subject,
startDate: event.StartTime,
startTime: event.StartTime,
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat,
weekdays,
repeatUntil,
skipHolidays: event.SkipHolidays ?? false,
media, // Metadaten werden nur bei Bedarf geladen!
slideshowInterval: event.SlideshowInterval ?? 10,
websiteUrl: event.WebsiteUrl ?? '',
});
// Removed modal initial data logging
const modalData = {
Id: (event.OccurrenceOfId && !isSingleOccurrence) ? event.OccurrenceOfId : event.Id, // Use master ID for series edit, occurrence ID for single edit
OccurrenceOfId: event.OccurrenceOfId, // Master event ID if this is an occurrence
occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing
isSingleOccurrence,
title: eventDataToUse.Subject,
startDate: eventDataToUse.StartTime,
startTime: eventDataToUse.StartTime,
endTime: eventDataToUse.EndTime,
description: eventDataToUse.Description ?? '',
type: eventDataToUse.Type ?? 'presentation',
repeat: isSingleOccurrence ? false : repeat, // Disable recurrence for single occurrence
weekdays: isSingleOccurrence ? [] : weekdays,
repeatUntil: isSingleOccurrence ? null : repeatUntil,
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
media,
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
};
setModalInitialData(modalData);
setEditMode(true);
setModalOpen(true);
}
@@ -846,9 +1044,26 @@ const Appointments: React.FC = () => {
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
for (const ev of toDelete) {
try {
await deleteEvent(ev.Id); // Deine API-Funktion
// 1) Single occurrence of a recurring event → delete occurrence only
if (ev.OccurrenceOfId && ev.StartTime) {
const occurrenceDate = ev.StartTime instanceof Date
? ev.StartTime.toISOString().split('T')[0]
: new Date(ev.StartTime).toISOString().split('T')[0];
await deleteEventOccurrence(ev.OccurrenceOfId, occurrenceDate);
continue;
}
// 2) Recurring master being removed unexpectedly → block deletion (safety)
// Syncfusion can sometimes raise eventRemove during edits; do NOT delete the series here.
if (ev.RecurrenceRule) {
console.warn('Blocked deletion of recurring master event via eventRemove.');
// If the user truly wants to delete the series, provide an explicit UI path.
continue;
}
// 3) Single non-recurring event → delete normally
await deleteEvent(ev.Id);
} catch (err) {
// Optional: Fehlerbehandlung
console.error('Fehler beim Löschen:', err);
}
}
@@ -915,6 +1130,42 @@ const Appointments: React.FC = () => {
</ViewsDirective>
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
</ScheduleComponent>
{/* Confirmation Dialog */}
{confirmDialogData && (
<DialogComponent
target="#root"
visible={confirmDialogOpen}
width="500px"
header={confirmDialogData.title}
showCloseIcon={true}
close={() => {
setConfirmDialogOpen(false);
confirmDialogData.onCancel();
}}
isModal={true}
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<button
className="e-btn e-primary"
onClick={confirmDialogData.onConfirm}
>
Einzeltermin bearbeiten
</button>
<button
className="e-btn e-normal"
onClick={confirmDialogData.onCancel}
>
Serie bearbeiten
</button>
</div>
)}
>
<div style={{ padding: '20px', whiteSpace: 'pre-line', fontSize: '14px' }}>
{confirmDialogData.message}
</div>
</DialogComponent>
)}
</div>
);
};