import ICAL from "./vendor/ical.js"; const icsUrl = "/calendars/all.ics"; // The club is in Berlin, so the calendar shows Berlin days and Berlin times, // no matter which time zone the browser of the visitor is set to. const timeZone = "Europe/Berlin"; const monthNames = [ "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ]; const dayKeyFormat = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }); const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", { timeZone, hour: "2-digit", minute: "2-digit", }); let calendar = null; let eventsByDate = {}; let currentYear; let currentMonth; let currentMonthElem; let calendarBody; let eventPanel; let eventDateElem; let eventDetailsElem; /** * The day a point in time falls on in Berlin. * * @param {Date} date The point in time * @returns {string} The day as "YYYY-MM-DD" */ function dayKey(date) { const parts = {}; for (const part of dayKeyFormat.formatToParts(date)) { parts[part.type] = part.value; } return `${parts.year}-${parts.month}-${parts.day}`; } /** * The day an occurrence time falls on in Berlin. * * A date has neither a time nor a zone, its digits are the day itself. * toJSDate() would read them as midnight in the zone of the browser, which * far enough east or west of Berlin lands on the day before or after. * * @param {ICAL.Time} time The time * @returns {string} The day as "YYYY-MM-DD" */ function timeDayKey(time) { if (time.isDate) { const month = String(time.month).padStart(2, "0"); const day = String(time.day).padStart(2, "0"); return `${time.year}-${month}-${day}`; } return dayKey(time.toJSDate()); } /** * The days an event covers, so that an event running over several days is * shown on each of them and not only on the day it starts. * * @param {ICAL.Time} start Start of the event * @param {ICAL.Time} end End of the event * @returns {string[]} The days as "YYYY-MM-DD" */ function daysCovered(start, end) { // The end is not part of the event: one ending at midnight belongs to the day // before, and an all day event ends on the day before its DTEND. const last = end.clone(); if (end.isDate) { last.adjust(-1, 0, 0, 0); } else { last.adjust(0, 0, 0, -1); } const lastKey = timeDayKey(last); const days = []; let key = timeDayKey(start); // The guard keeps a broken event from looping forever, a year of dots on the // same event is well past the point where the calendar is still useful. while (days.length <= 366) { days.push(key); // The keys sort as the days do, so this also stops an event whose end lies // before its start after the day it starts on. if (key >= lastKey) { break; } // Step to noon UTC of the next day, which is inside the same Berlin day // whether daylight saving time is in effect or not. const [year, month, day] = key.split("-").map(Number); key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12))); } return days; } /** * Read the URL of an event. * * ICAL.Event does not expose the URL property, so read it from the component. * * The value ends up in the href of a link, and the calendar is exported from a * CalDAV server, so whoever may write to it decides what that value is. A * "javascript:" URL there would run on our page as soon as a visitor clicks * the event, so hand on nothing but http and https. * * @param {ICAL.Event} event The event to read the URL of * @returns {string} The URL, empty when the event has none or it is not http(s) */ function eventUrl(event) { const url = event.component.getFirstPropertyValue("url") ?? ""; if (!url) { return ""; } try { // A relative URL is resolved against the page and keeps its scheme. const { protocol } = new URL(url, document.baseURI); return protocol === "http:" || protocol === "https:" ? url : ""; } catch { // Not a URL at all. return ""; } } /** * Reduce one occurrence of an event to what the calendar displays. * * @param {ICAL.Event} event The event the occurrence belongs to * @param {ICAL.Time} startDate Start of this occurrence * @param {ICAL.Time} endDate End of this occurrence * @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}} */ function toOccurrence(event, startDate, endDate) { return { summary: event.summary ?? "", description: event.description ?? "", url: eventUrl(event), start: startDate.toJSDate(), end: endDate.toJSDate(), allDay: startDate.isDate, // Taken from the ICAL times, which still know whether they name a day or a // point in time; the JS dates above no longer do. days: daysCovered(startDate, endDate), }; } /** * Group the occurrences that were modified on their own by the UID of the event * they belong to. * * Unless it is told which exceptions belong to an event, ICAL.Event relates * every VEVENT with a RECURRENCE-ID in the file to every recurring event, and * it keys them by the recurrence id alone. Two series that meet at the same * time would therefore take over each other's modifications. * * @param {ICAL.Component[]} components The VEVENTs of the calendar * @returns {Map} The exceptions per UID */ function exceptionsByUid(components) { const exceptions = new Map(); for (const component of components) { if (!component.hasProperty("recurrence-id")) { continue; } const uid = component.getFirstPropertyValue("uid"); const ofEvent = exceptions.get(uid); if (ofEvent) { ofEvent.push(component); } else { exceptions.set(uid, [component]); } } return exceptions; } /** * How far the recurrences of an event have to be iterated. * * The iteration walks the unmodified recurrence times, so an occurrence that * was moved to an earlier time is only reached through the time it originally * had, which can lie past the end of the month. Keep going for as long as the * largest move towards the past can still carry an occurrence into it. * * @param {ICAL.Event} event The event whose recurrences are iterated * @param {Date} to End of the window * @returns {Date} The recurrence time to stop at */ function iterationEnd(event, to) { let last = to.getTime(); for (const exception of Object.values(event.exceptions)) { const movedBy = exception.recurrenceId.toJSDate().getTime() - exception.startDate.toJSDate().getTime(); if (movedBy > 0) { last = Math.max(last, to.getTime() + movedBy); } } return new Date(last); } /** * Collect everything that takes place in the given month, keyed by day. * * @param {number} year The year of the month * @param {number} month The month, January is 0 * @returns {Object} The occurrences per "YYYY-MM-DD" */ function occurrencesOfMonth(year, month) { const byDate = {}; if (!calendar) { return byDate; } // These bounds only limit how far the recurrences have to be expanded, which // day an occurrence ends up on is decided by its Berlin day below. They are // deliberately generous so that no occurrence is cut off at the edge of the // month by a time zone difference. const from = new Date(year, month, 1); const to = new Date(year, month + 1, 1); from.setDate(from.getDate() - 2); to.setDate(to.getDate() + 2); const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`; const add = (occurrence) => { for (const key of occurrence.days) { if (!key.startsWith(monthPrefix)) { continue; } if (!byDate[key]) { byDate[key] = []; } byDate[key].push(occurrence); } }; const components = calendar.getAllSubcomponents("vevent"); const exceptions = exceptionsByUid(components); for (const component of components) { // Occurrences modified via RECURRENCE-ID are reached through the event they // belong to, handling them here as well would show them twice. if (component.hasProperty("recurrence-id")) { continue; } const event = new ICAL.Event(component, { exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [], }); if (!event.startDate) { continue; } if (event.isRecurring()) { const iterator = event.iterator(); const iterateUntil = iterationEnd(event, to); while (true) { const occurrence = iterator.next(); // Recurrences are chronological, so we are done once one starts after // the month, and after the occurrences a modification can still move // back into it. if (!occurrence || occurrence.toJSDate() >= iterateUntil) { break; } // Details resolve time, name and URL of an occurrence that was // modified via RECURRENCE-ID. const details = event.getOccurrenceDetails(occurrence); if (details.endDate.toJSDate() > from) { add(toOccurrence(details.item, details.startDate, details.endDate)); } } } else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) { add(toOccurrence(event, event.startDate, event.endDate)); } } for (const occurrences of Object.values(byDate)) { occurrences.sort((a, b) => { // An all day event has no time of day to sort by, the JS date of its // start is midnight in the zone of the browser. Put it first instead. if (a.allDay !== b.allDay) { return a.allDay ? -1 : 1; } return a.start - b.start; }); } return byDate; } function updateEventsForMonth(year, month) { eventsByDate = occurrencesOfMonth(year, month); renderCalendar(year, month); } function renderCalendar(year, month) { // Setze die Monatsbeschriftung (in Deutsch) currentMonthElem.textContent = monthNames[month] + " " + year; calendarBody.innerHTML = ""; let firstDay = new Date(year, month, 1); let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc. let daysInMonth = new Date(year, month + 1, 0).getDate(); let row = document.createElement("tr"); // Leere Zellen vor dem 1. Tag for (let i = 0; i < firstDayIndex; i++) { let cell = document.createElement("td"); row.appendChild(cell); } // Tage hinzufügen for (let day = 1; day <= daysInMonth; day++) { if (row.children.length === 7) { calendarBody.appendChild(row); row = document.createElement("tr"); } let cell = document.createElement("td"); cell.innerHTML = "" + day + ""; let dayStr = day < 10 ? "0" + day : day; let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1); let dateKey = year + "-" + monthStr + "-" + dayStr; if (eventsByDate[dateKey]) { let dotsContainer = document.createElement("div"); dotsContainer.className = "event-dots-container"; cell.classList.add("has-event"); // Gruppe Events nach Typ const events = eventsByDate[dateKey]; const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only")); const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik")); const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend")); const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend")); const hasRegular = events.some(e => { const title = e.summary.toLowerCase(); return !title.includes("members only") && !title.includes("subbotnik") && !title.includes("bastelabend") && !title.includes("spieleabend"); }); // Füge Dots entsprechend der Event-Typen hinzu if (hasMembersOnly) { let dot = document.createElement("div"); dot.className = "event-dot event-dot-red"; dotsContainer.appendChild(dot); } if (hasSubbotnik || hasBastelabend || hasSpieleabend) { let dot = document.createElement("div"); dot.className = "event-dot event-dot-orange"; dotsContainer.appendChild(dot); } if (hasRegular) { let dot = document.createElement("div"); dot.className = "event-dot event-dot-greenyellow"; dotsContainer.appendChild(dot); } cell.appendChild(dotsContainer); cell.dataset.dateKey = dateKey; cell.addEventListener("click", function() { // Clear previous selections document.querySelectorAll('.selected-day').forEach(el => { el.classList.remove('selected-day'); }); cell.classList.add('selected-day'); showEventDetails(dateKey); }); } row.appendChild(cell); } // Falls die letzte Zeile nicht komplett ist while (row.children.length < 7) { let cell = document.createElement("td"); row.appendChild(cell); } calendarBody.appendChild(row); } /** * Build the entry of a single event in the detail panel. * * @param {Object} occurrence The occurrence to show * @returns {HTMLElement} The entry */ function renderEventItem(occurrence) { const item = document.createElement("div"); item.className = "event-item"; const title = document.createElement("div"); title.className = "event-title"; if (occurrence.url) { const link = document.createElement("a"); link.href = occurrence.url; link.textContent = occurrence.summary; title.appendChild(link); } else { title.textContent = occurrence.summary; } item.appendChild(title); const time = document.createElement("div"); time.className = "event-time"; time.textContent = formatTimeRange(occurrence); item.appendChild(time); if (occurrence.description) { const description = document.createElement("div"); description.className = "event-description"; description.textContent = occurrence.description; item.appendChild(description); } return item; } function showEventDetails(dateKey) { const occurrences = eventsByDate[dateKey]; eventDateElem.textContent = formatDate(dateKey); eventDetailsElem.innerHTML = ""; if (occurrences && occurrences.length > 0) { for (const occurrence of occurrences) { eventDetailsElem.appendChild(renderEventItem(occurrence)); } } else { let noEvents = document.createElement("div"); noEvents.className = "no-events"; noEvents.textContent = "Keine Veranstaltungen an diesem Tag."; eventDetailsElem.appendChild(noEvents); } eventPanel.style.display = "block"; } function formatDate(dateStr) { // Convert YYYY-MM-DD to DD.MM.YYYY const parts = dateStr.split("-"); return `${parts[2]}.${parts[1]}.${parts[0]}`; } /** * Describe when an occurrence takes place, in Berlin time. * * @param {Object} occurrence The occurrence to describe * @returns {string} The description */ function formatTimeRange(occurrence) { if (occurrence.allDay) { return "Ganztägig"; } const start = timeOfDayFormat.format(occurrence.start); const end = timeOfDayFormat.format(occurrence.end); return `Beginn: ${start}, Ende: ${end}`; } document.addEventListener("DOMContentLoaded", function() { currentMonthElem = document.getElementById("current-month"); calendarBody = document.getElementById("calendar-body"); eventPanel = document.getElementById("event-panel"); eventDateElem = document.getElementById("event-date"); eventDetailsElem = document.getElementById("event-details"); document.getElementById("prev-month").addEventListener("click", function() { currentMonth--; if (currentMonth < 0) { currentMonth = 11; currentYear--; } updateEventsForMonth(currentYear, currentMonth); }); document.getElementById("next-month").addEventListener("click", function() { currentMonth++; if (currentMonth > 11) { currentMonth = 0; currentYear++; } updateEventsForMonth(currentYear, currentMonth); }); // Show the grid of the current month right away, the events are filled in // once the calendar has been loaded. const today = new Date(); currentYear = today.getFullYear(); currentMonth = today.getMonth(); updateEventsForMonth(currentYear, currentMonth); fetch(icsUrl) .then(response => { // Without this an error page would be handed to the parser below, which // then fails with a confusing complaint about the calendar syntax. if (!response.ok) { throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`); } return response.text(); }) .then(icsText => { calendar = new ICAL.Component(ICAL.parse(icsText)); updateEventsForMonth(currentYear, currentMonth); }) .catch(err => console.error("Fehler beim Laden der ICS-Datei:", err)); });