const toLocalISO = (d) => { const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const holidayCache = new Map(); function calculateEasterSunday(year) { const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31); const day = ((h + l - 7 * m + 114) % 31) + 1; return new Date(year, month - 1, day); } function getGermanHolidays(year) { if (holidayCache.has(year)) return holidayCache.get(year); const holidays = new Map(); const add = (month, day, name) => { holidays.set(toLocalISO(new Date(year, month - 1, day)), name); }; add(1, 1, 'Neujahr'); add(5, 1, 'Tag der Arbeit'); add(10, 3, 'Tag der Deutschen Einheit'); add(12, 25, '1. Weihnachtstag'); add(12, 26, '2. Weihnachtstag'); const easterSunday = calculateEasterSunday(year); const addOffset = (offset, name) => { const d = new Date(easterSunday); d.setDate(easterSunday.getDate() + offset); holidays.set(toLocalISO(d), name); }; addOffset(-2, 'Karfreitag'); addOffset(0, 'Ostersonntag'); addOffset(1, 'Ostermontag'); addOffset(39, 'Christi Himmelfahrt'); addOffset(49, 'Pfingstsonntag'); addOffset(50, 'Pfingstmontag'); holidayCache.set(year, holidays); return holidays; } function getHolidayName(date) { const holidays = getGermanHolidays(date.getFullYear()); return holidays.get(toLocalISO(date)) || ''; } function isDesktopView() { return window.matchMedia && window.matchMedia('(min-width: 992px)').matches; } function getCalendarRange() { const weeks = window._calendarIsAdmin ? 12 : 4; const start = new Date(); start.setHours(0, 0, 0, 0); if (isDesktopView()) { // shift back to Monday (0 = Sunday) const diffToMonday = (start.getDay() + 6) % 7; start.setDate(start.getDate() - diffToMonday); } const end = new Date(start); end.setDate(end.getDate() + (weeks * 7) - 1); return { start, end }; } function renderWeekdayHeader(startDate) { const header = document.querySelector('.calendar-weekday-header'); if (!header) return; header.innerHTML = ''; const formatDayShort = new Intl.DateTimeFormat('de-DE', { weekday: 'short' }); for (let i = 0; i < 7; i++) { const d = new Date(startDate); d.setDate(startDate.getDate() + i); const div = document.createElement('div'); div.textContent = formatDayShort.format(d); header.appendChild(div); } } function buildCalendarStructure(force = false) { const daysContainer = document.getElementById('calendar-days'); if (!daysContainer || (daysContainer.childElementCount > 0 && !force)) return; const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled; const calendarColSpan = 3; window._calendarColSpan = calendarColSpan; window._calendarIsAdmin = isAdmin; daysContainer.innerHTML = ''; const formatDayName = new Intl.DateTimeFormat('de-DE', { weekday: 'long' }); const formatDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); const { start, end } = getCalendarRange(); const todayIso = toLocalISO(new Date()); renderWeekdayHeader(start); const dayCount = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1; for (let i = 0; i < dayCount; i++) { const date = new Date(start); date.setDate(start.getDate() + i); const isoDate = toLocalISO(date); const isSunday = date.getDay() === 0; const holidayName = getHolidayName(date); const holidayBadge = holidayName ? `${holidayName}` : ''; const dayBlock = document.createElement('div'); dayBlock.className = 'calendar-day empty'; dayBlock.dataset.date = isoDate; if (isoDate === toLocalISO(new Date())) { dayBlock.classList.add('calendar-day-today'); } if (isSunday) { dayBlock.classList.add('calendar-day-sunday'); } if (holidayName) { dayBlock.classList.add('calendar-day-holiday'); } if (isDesktopView() && isoDate < todayIso) { dayBlock.classList.add('calendar-day-hidden'); } dayBlock.innerHTML = `
${formatDayName.format(date)}
${formatDate.format(date)}${holidayBadge}
Zeit Titel Ort
Keine Einträge
`; daysContainer.appendChild(dayBlock); } } function resetDayBlock(block) { const tbody = block.querySelector('.calendar-entries-list'); if (!tbody) return; block.classList.add('empty'); tbody.innerHTML = ` Keine Einträge `; } function clearCalendarEntries() { document.querySelectorAll('.calendar-day').forEach(resetDayBlock); } function escapeHtml(str) { return (str || '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function createEmptyRow() { const row = document.createElement('tr'); row.className = 'calendar-empty-row'; row.innerHTML = `Keine Einträge`; return row; } function formatDisplayTime(timeStr) { if (!timeStr) return ''; const match = String(timeStr).match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/); if (match) { const h = match[1].padStart(2, '0'); const m = match[2]; return `${h}:${m}`; } return timeStr; } // Location filter helpers let locationOptions = new Set(); function resetLocationFilterOptions() { locationOptions = new Set(); const select = document.getElementById('calendar-location-filter'); if (!select) return; select.innerHTML = ''; } function addLocationOption(location) { if (!location) return; const normalized = location.trim(); if (!normalized || locationOptions.has(normalized)) return; locationOptions.add(normalized); const select = document.getElementById('calendar-location-filter'); if (!select) return; const opt = document.createElement('option'); opt.value = normalized; opt.textContent = normalized; select.appendChild(opt); } function applyLocationFilter() { const select = document.getElementById('calendar-location-filter'); const filterValue = select ? select.value : ''; document.querySelectorAll('.calendar-day').forEach(day => { const tbody = day.querySelector('.calendar-entries-list'); if (!tbody) return; // Remove any stale empty rows before recalculating tbody.querySelectorAll('.calendar-empty-row').forEach(r => r.remove()); let visibleCount = 0; const entryRows = tbody.querySelectorAll('tr.calendar-entry-row'); entryRows.forEach(row => { const rowLoc = (row.dataset.location || '').trim(); const match = !filterValue || rowLoc === filterValue; row.style.display = match ? '' : 'none'; const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row') ? row.nextElementSibling : null; if (detailsRow) { const showDetails = match && detailsRow.dataset.expanded === 'true'; detailsRow.style.display = showDetails ? '' : 'none'; } if (match) visibleCount += 1; }); if (visibleCount === 0) { day.classList.add('empty'); tbody.appendChild(createEmptyRow()); } else { day.classList.remove('empty'); } }); } function removeCalendarEntryFromUI(id) { if (!id) return; const selector = `.calendar-entry-row[data-id="${id}"], .calendar-details-row[data-parent-id="${id}"]`; const rows = document.querySelectorAll(selector); rows.forEach(row => { const tbody = row.parentElement; const dayBlock = row.closest('.calendar-day'); row.remove(); const remainingEntries = tbody ? tbody.querySelectorAll('tr.calendar-entry-row').length : 0; if (remainingEntries === 0 && dayBlock) { resetDayBlock(dayBlock); } }); } // Helper to insert entries sorted by time inside an existing day block function addCalendarEntry(entry) { if (!entry || !entry.date) return; if (entry.id) { const existing = document.querySelector(`.calendar-entry-row[data-id="${entry.id}"]`); if (existing) return; } const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`); if (!block) return; const tbody = block.querySelector('.calendar-entries-list'); if (!tbody) return; const emptyRow = tbody.querySelector('.calendar-empty-row'); if (emptyRow) emptyRow.remove(); block.classList.remove('empty'); const row = document.createElement('tr'); const timeValue = formatDisplayTime(entry.time || ''); row.dataset.time = timeValue; row.dataset.id = entry.id || ''; row.dataset.date = entry.date; row.dataset.title = entry.title || ''; row.dataset.location = entry.location || ''; row.dataset.details = entry.details || ''; row.className = 'calendar-entry-row'; row.innerHTML = ` ${timeValue || '-'} ${entry.title || ''} ${entry.details ? '' : ''} ${entry.location || ''} `; const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row')); const insertIndex = rows.findIndex(r => { const existingTime = r.dataset.time || ''; return timeValue && (!existingTime || timeValue < existingTime); }); if (insertIndex === -1) { tbody.appendChild(row); } else { tbody.insertBefore(row, rows[insertIndex]); } const detailsRow = document.createElement('tr'); detailsRow.className = 'calendar-details-row'; detailsRow.dataset.parentId = entry.id || ''; detailsRow.dataset.expanded = 'false'; const actionsHtml = window._calendarIsAdmin ? `
` : ''; const detailsText = entry.details ? escapeHtml(entry.details) : ''; detailsRow.innerHTML = `
${detailsText}
${actionsHtml}`; detailsRow.style.display = 'none'; row.insertAdjacentElement('afterend', detailsRow); applyLocationFilter(); } async function loadCalendarEntries() { const fetchToken = (window._calendarFetchToken = (window._calendarFetchToken || 0) + 1); const { start, end } = getCalendarRange(); const todayIso = toLocalISO(start); const endIso = toLocalISO(end); clearCalendarEntries(); resetLocationFilterOptions(); try { const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`); if (!response.ok) throw new Error('Fehler beim Laden'); const entries = await response.json(); if (fetchToken !== window._calendarFetchToken) return; entries.forEach(entry => { addLocationOption(entry.location); addCalendarEntry(entry); }); applyLocationFilter(); } catch (err) { console.error('Kalender laden fehlgeschlagen', err); } } async function saveCalendarEntry(entry) { const isUpdate = Boolean(entry.id); const url = isUpdate ? `/api/calendar/${entry.id}` : '/api/calendar'; const method = isUpdate ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(entry) }); if (!response.ok) { const msg = await response.text(); throw new Error(msg || 'Speichern fehlgeschlagen'); } return response.json(); } async function createRecurringEntries(entry, repeatCount) { const count = Math.max(1, Math.min(52, repeatCount || 1)); const baseDate = entry.date; if (!baseDate) return []; const base = new Date(baseDate); const savedEntries = []; for (let i = 0; i < count; i++) { const next = new Date(base); next.setDate(base.getDate() + i * 7); const entryForWeek = { ...entry, date: toLocalISO(next) }; const saved = await saveCalendarEntry(entryForWeek); savedEntries.push(saved); } return savedEntries; } async function deleteCalendarEntry(id) { const response = await fetch(`/api/calendar/${id}`, { method: 'DELETE' }); if (!response.ok) { const msg = await response.text(); throw new Error(msg || 'Löschen fehlgeschlagen'); } return true; } // Expose for future dynamic usage window.addCalendarEntry = addCalendarEntry; window.loadCalendarEntries = loadCalendarEntries; window.deleteCalendarEntry = deleteCalendarEntry; window.removeCalendarEntryFromUI = removeCalendarEntryFromUI; window.applyLocationFilter = applyLocationFilter; window.exportCalendar = async function exportCalendar() { try { const res = await fetch('/api/calendar/export'); if (!res.ok) throw new Error('Export fehlgeschlagen'); const blob = await res.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'kalender.xlsx'; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); } catch (err) { console.error(err); alert('Export fehlgeschlagen.'); } }; window.importCalendar = async function importCalendar(file) { const formData = new FormData(); formData.append('file', file); const res = await fetch('/api/calendar/import', { method: 'POST', body: formData }); if (!res.ok) { const msg = await res.text(); throw new Error(msg || 'Import fehlgeschlagen'); } return res.json(); }; function initCalendar() { const section = document.getElementById('calendar-section'); if (!section || section.dataset.bound) return; section.dataset.bound = '1'; const addBtn = document.getElementById('add-calendar-entry-btn'); const modalEl = document.getElementById('calendarModal'); const saveBtn = document.getElementById('saveCalendarEntryBtn'); const form = document.getElementById('calendarForm'); const daysContainer = document.getElementById('calendar-days'); const locationFilter = document.getElementById('calendar-location-filter'); const repeatCountInput = document.getElementById('calendarRepeatCount'); const recurrenceRow = document.getElementById('calendarRecurrenceRow'); const calendarModal = modalEl && window.bootstrap ? new bootstrap.Modal(modalEl) : null; if (calendarModal) { window.calendarModal = calendarModal; if (!modalEl.dataset.focusGuard) { modalEl.addEventListener('hide.bs.modal', () => { const active = document.activeElement; if (active && modalEl.contains(active)) { active.blur(); requestAnimationFrame(() => { if (document.body) document.body.focus({ preventScroll: true }); }); } }); modalEl.dataset.focusGuard = '1'; } } let calendarHasLoaded = false; buildCalendarStructure(); let lastViewportIsDesktop = isDesktopView(); let resizeDebounce; const handleViewportChange = () => { const nowDesktop = isDesktopView(); if (nowDesktop === lastViewportIsDesktop) return; lastViewportIsDesktop = nowDesktop; buildCalendarStructure(true); if (calendarHasLoaded) { loadCalendarEntries(); } }; window.addEventListener('resize', () => { if (resizeDebounce) clearTimeout(resizeDebounce); resizeDebounce = setTimeout(handleViewportChange, 150); }); const setRecurrenceMode = (isEditing) => { if (!recurrenceRow) return; recurrenceRow.style.display = isEditing ? 'none' : ''; if (!isEditing && repeatCountInput) { repeatCountInput.value = '1'; } }; if (addBtn && calendarModal) { addBtn.addEventListener('click', () => { if (form) form.reset(); const today = new Date(); const dateInput = document.getElementById('calendarDate'); if (dateInput) dateInput.value = toLocalISO(today); const idInput = document.getElementById('calendarEntryId'); if (idInput) idInput.value = ''; setRecurrenceMode(false); calendarModal.show(); }); } if (saveBtn && calendarModal) { saveBtn.addEventListener('click', async () => { if (!form || !form.reportValidity()) return; const entry = { id: document.getElementById('calendarEntryId')?.value || '', date: document.getElementById('calendarDate')?.value, time: document.getElementById('calendarTime')?.value, title: document.getElementById('calendarTitle')?.value, location: document.getElementById('calendarLocation')?.value, details: document.getElementById('calendarDetails')?.value }; const isEditing = Boolean(entry.id); const repeatCount = (!isEditing && window._calendarIsAdmin && repeatCountInput) ? Math.max(1, Math.min(52, parseInt(repeatCountInput.value, 10) || 1)) : 1; try { if (isEditing) { removeCalendarEntryFromUI(entry.id); const saved = await saveCalendarEntry(entry); addLocationOption(saved.location); addCalendarEntry(saved); } else { const savedEntries = await createRecurringEntries(entry, repeatCount); savedEntries.forEach(saved => { addLocationOption(saved.location); addCalendarEntry(saved); }); } calendarModal.hide(); } catch (err) { console.error(err); alert('Speichern fehlgeschlagen.'); } }); } if (window._calendarIsAdmin) { const exportBtn = document.getElementById('export-calendar-btn'); const importBtn = document.getElementById('import-calendar-btn'); const importInput = document.getElementById('import-calendar-input'); if (exportBtn) { exportBtn.addEventListener('click', (e) => { e.preventDefault(); window.exportCalendar(); }); } if (importBtn && importInput) { importBtn.addEventListener('click', (e) => { e.preventDefault(); importInput.value = ''; importInput.click(); }); importInput.addEventListener('change', async () => { if (!importInput.files || importInput.files.length === 0) return; const file = importInput.files[0]; try { await importCalendar(file); loadCalendarEntries(); } catch (err) { console.error(err); alert('Import fehlgeschlagen.'); } }); } } if (daysContainer && typeof admin_enabled !== 'undefined' && admin_enabled) { daysContainer.addEventListener('click', async (e) => { const editBtn = e.target.closest('.calendar-edit'); const deleteBtn = e.target.closest('.calendar-delete'); if (!editBtn && !deleteBtn) return; const row = e.target.closest('tr'); if (!row) return; let entryRow = row; if (row.classList.contains('calendar-details-row') && row.previousElementSibling && row.previousElementSibling.classList.contains('calendar-entry-row')) { entryRow = row.previousElementSibling; } const entry = { id: entryRow.dataset.id, date: entryRow.dataset.date, time: entryRow.dataset.time, title: entryRow.dataset.title, location: entryRow.dataset.location, details: entryRow.dataset.details }; if (editBtn) { if (form) form.reset(); document.getElementById('calendarEntryId').value = entry.id || ''; document.getElementById('calendarDate').value = entry.date || ''; document.getElementById('calendarTime').value = entry.time || ''; document.getElementById('calendarTitle').value = entry.title || ''; document.getElementById('calendarLocation').value = entry.location || ''; document.getElementById('calendarDetails').value = entry.details || ''; setRecurrenceMode(true); if (calendarModal) calendarModal.show(); return; } if (deleteBtn) { if (!entry.id) return; const confirmDelete = window.confirm('Diesen Eintrag löschen?'); if (!confirmDelete) return; try { await deleteCalendarEntry(entry.id); removeCalendarEntryFromUI(entry.id); applyLocationFilter(); } catch (err) { console.error(err); alert('Löschen fehlgeschlagen.'); } } }); } const tabButtons = document.querySelectorAll('.tab-button'); tabButtons.forEach(btn => { if (btn.getAttribute('data-tab') === 'calendar') { btn.addEventListener('click', () => { if (!calendarHasLoaded) { calendarHasLoaded = true; loadCalendarEntries(); } }); } }); // Immediately load calendar entries for users landing directly on the calendar tab (non-admins may not switch tabs) if (!calendarHasLoaded) { calendarHasLoaded = true; loadCalendarEntries(); } if (locationFilter) { locationFilter.addEventListener('change', () => applyLocationFilter()); } // Show/hide details inline on row click (any user) if (daysContainer) { daysContainer.addEventListener('click', (e) => { if (e.target.closest('.calendar-edit') || e.target.closest('.calendar-delete')) return; const row = e.target.closest('tr.calendar-entry-row'); if (!row) return; const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row') ? row.nextElementSibling : null; if (!detailsRow) return; const isExpanded = detailsRow.dataset.expanded === 'true'; const shouldShow = !isExpanded; detailsRow.dataset.expanded = shouldShow ? 'true' : 'false'; // Respect current filter when toggling visibility const filterValue = (document.getElementById('calendar-location-filter')?.value || '').trim(); const rowLoc = (row.dataset.location || '').trim(); const match = !filterValue || rowLoc === filterValue; detailsRow.style.display = (shouldShow && match) ? '' : 'none'; }); } } window.initCalendar = initCalendar;