import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"; /** * Read the URL of an event. * * ICAL.Event does not expose the URL property, so read it from the component. * * @param {ICAL.Event} event The event to read the URL of * @returns {string} The URL, empty when the event has none */ function eventUrl(event) { return event.component.getFirstPropertyValue("url") ?? ""; } /** * Parse an ICS calendar and return upcoming event occurrences. * * @param {string} icsText The contents of the .ics file * @param {Date} now Events must still be running at this date * @param {number} maxEvents Maximum number of events to return * @param {number} maxDays Maximum number of days into the future * @returns {{start: Date, name: string, url: string}[]} url is empty when the event has no URL */ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { const jcal = ICAL.parse(icsText); const calendar = new ICAL.Component(jcal); const end = new Date(now.getTime()); end.setDate(end.getDate() + maxDays); const events = []; for (const component of calendar.getAllSubcomponents("vevent")) { const event = new ICAL.Event(component); // Occurrences modified via RECURRENCE-ID are reached through the event they // belong to, listing them here as well would show them twice. if (event.isRecurrenceException()) { continue; } if (!event.startDate) { continue; } if (event.isRecurring()) { const iterator = event.iterator(); while (true) { const occurrence = iterator.next(); if (!occurrence) { break; } // Recurrences are chronological, so we're done // once we pass the end of our search window. if (occurrence.toJSDate() > end) { break; } // Details resolve time, name and URL of an occurrence that was // modified via RECURRENCE-ID. const details = event.getOccurrenceDetails(occurrence); // A running event stays listed until it is over, so filter on its end. if (details.endDate.toJSDate() > now) { events.push({ start: details.startDate.toJSDate(), name: details.item.summary ?? "", url: eventUrl(details.item), }); } } } else { const start = event.startDate.toJSDate(); if (start <= end && event.endDate.toJSDate() > now) { events.push({ start, name: event.summary ?? "", url: eventUrl(event), }); } } } // We have occurrences from multiple events, so sort them // before applying the maximum event count. events.sort((a, b) => a.start - b.start); return events.slice(0, maxEvents); } document.addEventListener("DOMContentLoaded", () => { const ics = "/calendars/all.ics"; const max_days = 20; const max_items = 5; const now = new Date(); const table = document.getElementById("upcoming"); fetch(ics) .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(`${ics}: ${response.status} ${response.statusText}`); } return response.text(); }) .then(icsText => { getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => { const row = document.createElement("tr"); const colBegin = document.createElement("td"); const formattedStart = event.start.toLocaleString("de-DE", { // The events take place in Berlin, so name their time in Berlin time // instead of in the time zone the visitor happens to be in. timeZone: "Europe/Berlin", weekday: "long", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); colBegin.innerText = `${formattedStart} Uhr`; row.appendChild(colBegin); const colName = document.createElement("td"); if (event.url) { const a = document.createElement("a"); a.href = event.url; a.text = event.name; colName.appendChild(a); } else { colName.innerText = event.name; } row.appendChild(colName); table.appendChild(row); }); }) .catch(err => console.error("Fehler beim Laden der Termine:", err)); });