feat(events): reliable holiday skipping for recurrences + UI badge; clean logs

Backend: generate EventException on create/update when skip_holidays or recurrence changes; emit RecurrenceException (EXDATE) with exact occurrence start time (UTC)
API: return master events with RecurrenceRule + RecurrenceException
Frontend: map RecurrenceException → recurrenceException; ensure SkipHolidays instances never render on holidays; place TentTree icon (black) next to main event icon via template
Docs: update README and Copilot instructions for recurrence/holiday behavior
Cleanup: remove dataSource and debug console logs
This commit is contained in:
RobbStarkAustria
2025-10-12 12:00:43 +00:00
parent 7ab4ea14c4
commit 773628c324
15 changed files with 698 additions and 122 deletions

View File

@@ -0,0 +1,31 @@
export interface EventException {
id?: number;
event_id: number;
exception_date: string; // YYYY-MM-DD
is_skipped: boolean;
override_title?: string;
override_description?: string;
override_start?: string;
override_end?: string;
}
export async function createEventException(exception: Omit<EventException, 'id'>) {
const res = await fetch('/api/event_exceptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(exception),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Ausnahme');
return data;
}
export async function listEventExceptions(eventId?: number) {
const params = new URLSearchParams();
if (eventId) params.set('event_id', eventId.toString());
const res = await fetch(`/api/event_exceptions?${params.toString()}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ausnahmen');
return data as EventException[];
}

View File

@@ -8,10 +8,18 @@ export interface Event {
extendedProps: Record<string, unknown>;
}
export async function fetchEvents(groupId: string, showInactive = false) {
const res = await fetch(
`/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}`
);
export async function fetchEvents(
groupId: string,
showInactive = false,
options?: { start?: Date; end?: Date; expand?: boolean }
) {
const params = new URLSearchParams();
params.set('group_id', groupId);
params.set('show_inactive', showInactive ? '1' : '0');
if (options?.start) params.set('start', options.start.toISOString());
if (options?.end) params.set('end', options.end.toISOString());
if (options?.expand) params.set('expand', options.expand ? '1' : '0');
const res = await fetch(`/api/events?${params.toString()}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
return data;

View File

@@ -37,8 +37,8 @@ import {
School,
CheckCircle,
AlertCircle,
TentTree,
} from 'lucide-react';
import { renderToStaticMarkup } from 'react-dom/server';
import caGregorian from './cldr/ca-gregorian.json';
import numbers from './cldr/numbers.json';
import timeZoneNames from './cldr/timeZoneNames.json';
@@ -64,6 +64,12 @@ type Event = {
WebsiteUrl?: string;
Icon?: string; // <--- Icon ergänzen!
Type?: string; // <--- Typ ergänzen, falls benötigt
OccurrenceOfId?: string; // Serieninstanz
Recurrence?: boolean; // Marker für Serientermin
RecurrenceRule?: string | null;
RecurrenceEnd?: string | null;
SkipHolidays?: boolean;
RecurrenceException?: string;
};
type RawEvent = {
@@ -75,6 +81,11 @@ type RawEvent = {
MediaId?: string | number;
Icon?: string; // <--- Icon ergänzen!
Type?: string;
OccurrenceOfId?: string;
RecurrenceRule?: string | null;
RecurrenceEnd?: string | null;
SkipHolidays?: boolean;
RecurrenceException?: string;
};
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
@@ -137,6 +148,8 @@ const eventTemplate = (event: Event) => {
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
// Render TentTree icon if SkipHolidays is true
const showTentTree = !!event.SkipHolidays;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
@@ -145,17 +158,36 @@ const eventTemplate = (event: Event) => {
<IconComponent size={18} color="#000" />
</span>
)}
{showTentTree && (
<span
style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}
title="Ferientage werden berücksichtigt"
>
<TentTree size={16} color="#000" />
</span>
)}
<span style={{ marginTop: 3 }}>{event.Subject}</span>
</div>
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
</div>
);
};
}
// Robust ISO date parsing: append 'Z' only if no timezone offset present
function parseEventDate(iso: string): Date {
if (!iso) return new Date(NaN);
// If string already contains 'Z' or a timezone offset like +00:00/-02:00, don't modify
const hasTZ = /Z$/i.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso);
const s = hasTZ ? iso : `${iso}Z`;
return new Date(s);
}
const Appointments: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
// Debug logging removed
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({});
@@ -164,6 +196,8 @@ const Appointments: React.FC = () => {
const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
const [showHolidayList, setShowHolidayList] = React.useState(true);
// Always let Syncfusion handle recurrence; do not expand on backend
// Remove expandRecurrences toggle and state
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
@@ -208,16 +242,70 @@ const Appointments: React.FC = () => {
return;
}
try {
const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string
// Sichtbaren Bereich bestimmen (optional, for future filtering)
const inst = scheduleRef.current;
let startRange: Date | undefined;
let endRange: Date | undefined;
// Fallback to current week if scheduler is not initialized yet
const today = new Date();
const fallbackStart = new Date(today);
const day = today.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
fallbackStart.setDate(today.getDate() - diffToMonday);
fallbackStart.setHours(0, 0, 0, 0);
const fallbackEnd = new Date(fallbackStart);
fallbackEnd.setDate(fallbackStart.getDate() + 6);
fallbackEnd.setHours(23, 59, 59, 999);
if (inst) {
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (baseDate) {
if (view === 'Day' || view === 'Agenda') {
startRange = new Date(baseDate);
startRange.setHours(0, 0, 0, 0);
endRange = new Date(baseDate);
endRange.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
startRange = new Date(baseDate);
startRange.setDate(baseDate.getDate() - diffToMonday);
startRange.setHours(0, 0, 0, 0);
endRange = new Date(startRange);
endRange.setDate(startRange.getDate() + 6);
endRange.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
startRange = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
endRange = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
}
}
if (!startRange || !endRange) {
startRange = fallbackStart;
endRange = fallbackEnd;
}
// Always request non-expanded events (expand=0)
const data = await fetchEvents(selectedGroupId, showInactive, {
start: startRange!,
end: endRange!,
expand: false,
});
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon, // <--- Icon übernehmen!
Type: e.Type, // <--- Typ übernehmen!
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);
} catch (err) {
@@ -296,8 +384,30 @@ const Appointments: React.FC = () => {
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents]);
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
const filteredEvents = events.filter(ev => {
if (ev.SkipHolidays) {
// If event falls within a holiday, hide it
const s = ev.StartTime instanceof Date ? ev.StartTime : new Date(ev.StartTime);
const e = ev.EndTime instanceof Date ? ev.EndTime : new Date(ev.EndTime);
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(s >= hs && s <= he) ||
(e >= hs && e <= he) ||
(s <= hs && e >= he)
) {
return false;
}
}
}
return true;
});
return [...filteredEvents, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents, holidays]);
// Removed dataSource logging
// Aktive akademische Periode für Datum aus dem Backend ermitteln
const refreshAcademicPeriodFor = React.useCallback(
@@ -529,6 +639,7 @@ const Appointments: React.FC = () => {
/>
Ferien im Kalender anzeigen
</label>
{/* Removed expandRecurrences toggle: Syncfusion now always handles recurrence */}
{/* Right-aligned indicators */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Holidays-in-view badge */}
@@ -546,6 +657,7 @@ const Appointments: React.FC = () => {
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
</span>
</div>
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
</div>
<CustomEventModal
open={modalOpen}
@@ -556,19 +668,9 @@ const Appointments: React.FC = () => {
onSave={async () => {
setModalOpen(false);
setEditMode(false);
if (selectedGroupId) {
const data = await fetchEvents(selectedGroupId, showInactive);
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
}));
setEvents(mapped);
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
}
// Reload events using the same logic as fetchAndSetEvents
await fetchAndSetEvents();
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
}}
initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -585,10 +687,22 @@ const Appointments: React.FC = () => {
currentView="Week"
eventSettings={{
dataSource: dataSource,
fields: { isBlock: 'IsBlock' },
fields: {
id: 'Id',
subject: 'Subject',
startTime: 'StartTime',
endTime: 'EndTime',
isBlock: 'IsBlock',
},
template: eventTemplate, // <--- Hier das Template setzen!
}}
actionComplete={() => updateHolidaysInView()}
actionComplete={(args) => {
updateHolidaysInView();
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
fetchAndSetEvents();
}
}}
cellClick={args => {
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
args.cancel = true;
@@ -608,7 +722,7 @@ const Appointments: React.FC = () => {
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
console.log('Event zum Bearbeiten:', event);
// Removed event logging
let media = null;
if (event.MediaId) {
try {
@@ -622,6 +736,29 @@ 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);
}
}
setModalInitialData({
Id: event.Id,
title: event.Subject,
@@ -630,36 +767,39 @@ const Appointments: React.FC = () => {
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat: event.Repeat ?? false,
weekdays: event.Weekdays ?? [],
repeatUntil: event.RepeatUntil ?? null,
skipHolidays: event.SkipHolidays ?? false,
media, // Metadaten werden nur bei Bedarf geladen!
slideshowInterval: event.SlideshowInterval ?? 10,
websiteUrl: event.WebsiteUrl ?? '',
});
console.log('Modal initial data:', {
Id: event.Id,
title: event.Subject,
startDate: event.StartTime,
startTime: event.StartTime,
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat: event.Repeat ?? false,
weekdays: event.Weekdays ?? [],
repeatUntil: event.RepeatUntil ?? null,
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
setEditMode(true);
setModalOpen(true);
}
}}
eventRendered={(args: EventRenderedArgs) => {
// Always hide events that skip holidays when they fall on holidays, regardless of toggle
if (args.data) {
const ev = args.data as unknown as Partial<Event>;
if (ev.SkipHolidays && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
// Hide events on holidays if not allowed
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
@@ -677,38 +817,6 @@ const Appointments: React.FC = () => {
const groupColor = getGroupColor(selectedGroupId, groups);
const now = new Date();
let IconComponent: React.ElementType | null = null;
switch (args.data.Type) {
case 'presentation':
IconComponent = Presentation;
break;
case 'website':
IconComponent = Globe;
break;
case 'video':
IconComponent = Video;
break;
case 'message':
IconComponent = MessageSquare;
break;
case 'webuntis':
IconComponent = School;
break;
default:
IconComponent = null;
}
// Nur .e-subject verwenden!
const titleElement = args.element.querySelector('.e-subject');
if (titleElement && IconComponent) {
const svgString = renderToStaticMarkup(<IconComponent size={18} color="#78591c" />);
// Immer nur den reinen Text nehmen, kein vorhandenes Icon!
const subjectText = (titleElement as HTMLElement).textContent ?? '';
(titleElement as HTMLElement).innerHTML =
`<span style="vertical-align:middle;display:inline-block;margin-right:6px;">${svgString}</span>` +
subjectText;
}
// Vergangene Termine: Raumgruppenfarbe
if (args.data.EndTime && args.data.EndTime < now) {
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
@@ -751,10 +859,11 @@ const Appointments: React.FC = () => {
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: new Date(e.StartTime),
EndTime: new Date(e.EndTime),
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SkipHolidays: e.SkipHolidays ?? false,
}));
setEvents(mapped);
})

View File

@@ -6,6 +6,7 @@ import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-rea
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
import { updateEvent } from '../apiEvents';
// Holiday exceptions are now created in the backend
type CustomEventData = {
title: string;
@@ -76,7 +77,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
// Default to true so recurrences skip holidays by default
const [skipHolidays, setSkipHolidays] = React.useState(
initialData.skipHolidays !== undefined ? initialData.skipHolidays : true
);
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
@@ -104,7 +108,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays || false);
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
setMedia(initialData.media ?? null);
setSlideshowInterval(initialData.slideshowInterval ?? 10);
@@ -190,6 +194,27 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
// Build recurrence rule if repeat is enabled
let recurrenceRule = null;
let recurrenceEnd = null;
if (repeat && weekdays.length > 0) {
// Convert weekdays to RRULE format (0=Monday -> MO)
const rruleDays = weekdays.map(day => {
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
return dayNames[day];
}).join(',');
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
if (repeatUntil) {
const untilDate = new Date(repeatUntil);
untilDate.setHours(23, 59, 59);
recurrenceEnd = untilDate.toISOString();
// Note: RRULE UNTIL should be in UTC format for consistency
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
recurrenceRule += `;UNTIL=${untilUTC}`;
}
}
const payload: CustomEventData & { [key: string]: unknown } = {
group_id,
title,
@@ -225,6 +250,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
event_type: type,
is_active: 1,
created_by: 1,
recurrence_rule: recurrenceRule,
recurrence_end: recurrenceEnd,
};
if (type === 'presentation') {
@@ -250,6 +277,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
});
res = await res.json();
}
if (res.success) {
onSave(payload);
onClose(); // <--- Box nach erfolgreichem Speichern schließen