bethaus-app/static/calendar.js
2026-01-26 18:21:36 +00:00

692 lines
24 KiB
JavaScript

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 ? `<span class="calendar-holiday-badge">${holidayName}</span>` : '';
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 = `
<div class="calendar-day-header">
<div class="calendar-day-name">${formatDayName.format(date)}</div>
<div class="calendar-day-date">${formatDate.format(date)}${holidayBadge}</div>
</div>
<div class="table-responsive calendar-table-wrapper">
<table class="table table-sm mb-0">
<thead>
<tr>
<th scope="col">Zeit</th>
<th scope="col">Titel</th>
<th scope="col">Ort</th>
</tr>
</thead>
<tbody class="calendar-entries-list">
<tr class="calendar-empty-row">
<td colspan="${calendarColSpan}" class="text-center text-muted">Keine Einträge</td>
</tr>
</tbody>
</table>
</div>
`;
daysContainer.appendChild(dayBlock);
}
}
function resetDayBlock(block) {
const tbody = block.querySelector('.calendar-entries-list');
if (!tbody) return;
block.classList.add('empty');
tbody.innerHTML = `
<tr class="calendar-empty-row">
<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>
</tr>
`;
}
function clearCalendarEntries() {
document.querySelectorAll('.calendar-day').forEach(resetDayBlock);
}
function escapeHtml(str) {
return (str || '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
}
function createEmptyRow() {
const row = document.createElement('tr');
row.className = 'calendar-empty-row';
row.innerHTML = `<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>`;
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 = '<option value="">Ort (alle)</option>';
}
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 = `
<td>${timeValue || '-'}</td>
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
<td>${entry.location || ''}</td>
`;
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 ? `
<div class="calendar-inline-actions">
<button type="button" class="btn btn-sm btn-outline-secondary calendar-edit" title="Bearbeiten">✎</button>
<button type="button" class="btn btn-sm btn-outline-danger calendar-delete" title="Löschen">🗑</button>
</div>
` : '';
const detailsText = entry.details ? escapeHtml(entry.details) : '';
detailsRow.innerHTML = `<td colspan="${window._calendarColSpan || 3}"><div class="calendar-details-text">${detailsText}</div>${actionsHtml}</td>`;
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;