import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js"; // 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; } /** * 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), }; } /** * 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); } }; for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) { add(toOccurrence(event, startDate, 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); loadCalendar() .then(loaded => { calendar = loaded; updateEventsForMonth(currentYear, currentMonth); }) .catch(err => console.error("Fehler beim Laden der ICS-Datei:", err)); });