Scheduler: native recurrence handling, onActionCompleted persistence, EXDATE RFC5545, UI icon cleanup

This commit is contained in:
RobbStarkAustria
2025-10-14 05:54:36 +00:00
parent e53cc619ec
commit 17c3452310
2 changed files with 100 additions and 109 deletions

View File

@@ -22,6 +22,7 @@ import { fetchEvents, fetchEventById } from './apiEvents';
import { fetchGroups } from './apiGroups'; import { fetchGroups } from './apiGroups';
import { getGroupColor } from './groupColors'; import { getGroupColor } from './groupColors';
import { deleteEvent, deleteEventOccurrence } from './apiEvents'; import { deleteEvent, deleteEventOccurrence } from './apiEvents';
import { detachEventOccurrence, updateEvent } from './apiEvents';
import CustomEventModal from './components/CustomEventModal'; import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients'; import { fetchMediaById } from './apiClients';
import { listHolidays, type Holiday } from './apiHolidays'; import { listHolidays, type Holiday } from './apiHolidays';
@@ -66,6 +67,7 @@ type Event = {
Icon?: string; // <--- Icon ergänzen! Icon?: string; // <--- Icon ergänzen!
Type?: string; // <--- Typ ergänzen, falls benötigt Type?: string; // <--- Typ ergänzen, falls benötigt
OccurrenceOfId?: string; // Serieninstanz OccurrenceOfId?: string; // Serieninstanz
RecurrenceID?: string; // Syncfusion linkage to master series
Recurrence?: boolean; // Marker für Serientermin Recurrence?: boolean; // Marker für Serientermin
RecurrenceRule?: string | null; RecurrenceRule?: string | null;
RecurrenceEnd?: string | null; RecurrenceEnd?: string | null;
@@ -192,7 +194,7 @@ const Appointments: React.FC = () => {
const [holidays, setHolidays] = useState<Holiday[]>([]); const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({}); const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0); // Removed schedulerKey remount mechanism; rely on refreshEvents()
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
const [showInactive, setShowInactive] = React.useState(true); const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false); const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
@@ -323,7 +325,7 @@ const Appointments: React.FC = () => {
const expandedEvents: Event[] = []; const expandedEvents: Event[] = [];
for (const e of data) { for (const e of data) {
if (e.RecurrenceRule) { if (e.RecurrenceRule) {
// Parse EXDATE list // Parse EXDATE list
const exdates = new Set<string>(); const exdates = new Set<string>();
if (e.RecurrenceException) { if (e.RecurrenceException) {
@@ -333,101 +335,23 @@ const Appointments: React.FC = () => {
}); });
} }
// Manual expansion for DAILY and WEEKLY recurrence // Let Syncfusion handle ALL recurrence patterns natively for proper badge display
if (e.RecurrenceRule.includes('FREQ=DAILY') || e.RecurrenceRule.includes('FREQ=WEEKLY')) { expandedEvents.push({
const startTime = parseEventDate(e.StartTime); Id: e.Id,
const endTime = parseEventDate(e.EndTime); Subject: e.Subject,
const duration = endTime.getTime() - startTime.getTime(); StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
// Extract INTERVAL if present IsAllDay: e.IsAllDay,
let interval = 1; MediaId: e.MediaId,
const intervalMatch = e.RecurrenceRule.match(/INTERVAL=(\d+)/); Icon: e.Icon,
if (intervalMatch) { Type: e.Type,
interval = parseInt(intervalMatch[1], 10); OccurrenceOfId: e.OccurrenceOfId,
} Recurrence: true,
RecurrenceRule: e.RecurrenceRule,
// Extract end date from UNTIL if present RecurrenceEnd: e.RecurrenceEnd ?? null,
let endDate: Date | null = null; SkipHolidays: e.SkipHolidays ?? false,
const untilMatch = e.RecurrenceRule.match(/UNTIL=(\d{8}T\d{6}Z)/); RecurrenceException: e.RecurrenceException || undefined,
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 { } else {
// Non-recurring event - add as-is // Non-recurring event - add as-is
expandedEvents.push({ expandedEvents.push({
@@ -637,7 +561,7 @@ const Appointments: React.FC = () => {
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln // Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
React.useEffect(() => { React.useEffect(() => {
updateHolidaysInView(); updateHolidaysInView();
}, [holidays, updateHolidaysInView, schedulerKey]); }, [holidays, updateHolidaysInView]);
return ( return (
<div> <div>
@@ -816,12 +740,10 @@ const Appointments: React.FC = () => {
// Force immediate data refresh // Force immediate data refresh
await fetchAndSetEvents(); await fetchAndSetEvents();
// Force Syncfusion scheduler to refresh its internal cache // Defer refresh to avoid interfering with current React commit
if (scheduleRef.current) { setTimeout(() => {
scheduleRef.current.dataBind?.(); scheduleRef.current?.refreshEvents?.();
scheduleRef.current.refreshEvents?.(); }, 0);
setSchedulerKey(prev => prev + 1);
}
}} }}
initialData={modalInitialData} initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -832,7 +754,6 @@ const Appointments: React.FC = () => {
/> />
<ScheduleComponent <ScheduleComponent
ref={scheduleRef} ref={scheduleRef}
key={schedulerKey} // <-- dynamischer Key
height="750px" height="750px"
locale="de" locale="de"
currentView="Week" currentView="Week"
@@ -844,14 +765,83 @@ const Appointments: React.FC = () => {
startTime: 'StartTime', startTime: 'StartTime',
endTime: 'EndTime', endTime: 'EndTime',
isBlock: 'IsBlock', isBlock: 'IsBlock',
recurrenceID: 'RecurrenceID', // Map for proper series recognition
recurrenceRule: 'RecurrenceRule',
recurrenceException: 'RecurrenceException',
}, },
template: eventTemplate, // <--- Hier das Template setzen! template: eventTemplate, // <--- Hier das Template setzen!
}} }}
actionComplete={(args) => { actionComplete={async (args: ActionEventArgs) => {
updateHolidaysInView(); updateHolidaysInView();
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion) // Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) { if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
fetchAndSetEvents(); fetchAndSetEvents();
return;
}
// Persist UI-driven changes (drag/resize/editor fallbacks)
if (args && args.requestType === 'eventChanged') {
try {
type SchedulerEvent = Partial<Event> & {
Id?: string | number;
OccurrenceOfId?: string | number;
RecurrenceRule?: string | null;
isHoliday?: boolean;
IsBlock?: boolean;
StartTime?: Date | string;
EndTime?: Date | string;
Subject?: string;
};
const raw = args.data as unknown;
const changed = (Array.isArray(raw) ? raw[0] : raw) as SchedulerEvent | undefined;
if (!changed) return;
// Ignore holiday visuals and blocks
if (changed.isHoliday || changed.IsBlock) return;
// Build payload (only fields we can safely update here)
const payload: Record<string, unknown> = {};
if (changed.Subject) payload.title = changed.Subject;
if (changed.StartTime) {
const s = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime);
payload.start = s.toISOString();
}
if (changed.EndTime) {
const e = changed.EndTime instanceof Date ? changed.EndTime : new Date(changed.EndTime);
payload.end = e.toISOString();
}
// Single occurrence change from a recurring master (our manual expansion marks OccurrenceOfId)
if (changed.OccurrenceOfId) {
if (!changed.StartTime) return; // cannot determine occurrence date
const occStart = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime as string);
const occDate = occStart.toISOString().split('T')[0];
await detachEventOccurrence(Number(changed.OccurrenceOfId), occDate, payload);
} else if (changed.RecurrenceRule) {
// Change to master series (non-manually expanded recurrences)
await updateEvent(String(changed.Id), payload);
} else if (changed.Id) {
// Regular single event
await updateEvent(String(changed.Id), payload);
}
// Refresh events and scheduler cache after persisting
await fetchAndSetEvents();
// Defer refresh to avoid React/Syncfusion DOM race
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
} catch (err) {
console.error('Persisting geänderten Termin fehlgeschlagen:', err);
}
return;
}
// If events were created via built-in UI (rare, as we use custom modal), reload as fallback
if (args && args.requestType === 'eventCreated') {
await fetchAndSetEvents();
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
} }
}} }}
cellClick={args => { cellClick={args => {

View File

@@ -68,14 +68,15 @@ def get_events():
for ex in all_exceptions: for ex in all_exceptions:
if ex.is_skipped: if ex.is_skipped:
exd = ex.exception_date exd = ex.exception_date
# Create the EXDATE timestamp using the master event's original start time # Create the EXDATE timestamp in Syncfusion's expected format
# This should match the time when the original occurrence would have happened # Use the exact time of the occurrence that would have happened
occ_dt = datetime( occ_dt = datetime(
exd.year, exd.month, exd.day, exd.year, exd.month, exd.day,
base_start.hour, base_start.minute, base_start.second, base_start.hour, base_start.minute, base_start.second,
tzinfo=UTC tzinfo=UTC
) )
token = occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ') # Format as compact ISO without separators (yyyyMMddThhmmssZ) - RFC 5545 format
token = occ_dt.strftime('%Y%m%dT%H%M%SZ')
tokens.append(token) tokens.append(token)
if tokens: if tokens:
recurrence_exception = ','.join(tokens) recurrence_exception = ','.join(tokens)