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 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 {Date} start Start of the event * @param {Date} 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 lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1)); const lastKey = dayKey(lastInstant); const days = []; let key = dayKey(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); 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. * * @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") ?? ""; } /** * 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}} */ function toOccurrence(event, startDate, endDate) { return { summary: event.summary ?? "", description: event.description ?? "", url: eventUrl(event), start: startDate.toJSDate(), end: endDate.toJSDate(), allDay: startDate.isDate, }; } /** * 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 daysCovered(occurrence.start, occurrence.end)) { if (!key.startsWith(monthPrefix)) { continue; } if (!byDate[key]) { byDate[key] = []; } byDate[key].push(occurrence); } }; 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, handling them here as well would show them twice. if (event.isRecurrenceException() || !event.startDate) { continue; } if (event.isRecurring()) { const iterator = event.iterator(); while (true) { const occurrence = iterator.next(); // Recurrences are chronological, so we are done once one starts after // the month. if (!occurrence || occurrence.toJSDate() >= to) { 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) => 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 `Start: ${start}, End: ${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)); });