diff --git a/assets/css/calendar.css b/assets/css/calendar.css index 2806966..5bec886 100644 --- a/assets/css/calendar.css +++ b/assets/css/calendar.css @@ -107,6 +107,10 @@ font-size: 0.9em; margin-bottom: 5px; } + .event-description { + /* Descriptions carry their own line breaks, keep them. */ + white-space: pre-line; + } .no-events { font-style: italic; color: var(--color-text-secondary); diff --git a/assets/js/calendar.js b/assets/js/calendar.js index 18b4768..b05181b 100644 --- a/assets/js/calendar.js +++ b/assets/js/calendar.js @@ -1,449 +1,418 @@ -document.addEventListener('DOMContentLoaded', function() { - (function(){ - let events = []; - let eventsByDate = {}; +import ICAL from "./vendor/ical.js"; - // Funktion zum Parsen der ICS-Datei - function parseICS(icsText) { - let events = []; - let lines = icsText.split(/\r?\n/); - let event = null; - lines.forEach(line => { - if (line.startsWith("BEGIN:VEVENT")) { - event = {}; - } else if (line.startsWith("END:VEVENT")) { - if (event) events.push(event); - event = null; - } else if (event) { - let colonIndex = line.indexOf(":"); - if (colonIndex > -1) { - let key = line.substring(0, colonIndex); - let value = line.substring(colonIndex + 1); +const icsUrl = "/calendars/all.ics"; - // Handle properties with parameters (like TZID) - const baseKey = key.split(";")[0]; +// 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"; - if (baseKey === "DTSTART") { - event.start = value; - event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; - } else if (baseKey === "DTEND") { - event.end = value; - event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; - } else if (baseKey === "SUMMARY") { - event.summary = value; - } else if (baseKey === "DESCRIPTION") { - event.description = value; - } else if (baseKey === "RRULE") { - event.rrule = value; - } - } - } - }); - return events; - } +const monthNames = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +]; - // Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD" - function parseDateString(icsDateStr) { - // Handle different date formats - if (!icsDateStr) return null; - - // For basic date format: YYYYMMDD - if (icsDateStr.length === 8) { - let year = icsDateStr.substring(0, 4); - let month = icsDateStr.substring(4, 6); - let day = icsDateStr.substring(6, 8); - return `${year}-${month}-${day}`; - } - // For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss - else if (icsDateStr.includes("T")) { - let year = icsDateStr.substring(0, 4); - let month = icsDateStr.substring(4, 6); - let day = icsDateStr.substring(6, 8); - return `${year}-${month}-${day}`; - } - return null; +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; } - // Extract date components from different date formats - function getDateComponents(icsDateStr) { - if (!icsDateStr) return null; + // Details resolve time, name and URL of an occurrence that was + // modified via RECURRENCE-ID. + const details = event.getOccurrenceDetails(occurrence); - // Basic handling - extract YYYY, MM, DD regardless of format - const year = parseInt(icsDateStr.substring(0, 4)); - const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months - const day = parseInt(icsDateStr.substring(6, 8)); - - return { year, month, day }; - } - - function expandRecurringEvents(event, year, month) { - if (!event.rrule) return [event]; - - const rruleStr = event.rrule; - - // Get start date components - const startComponents = getDateComponents(event.start); - if (!startComponents) return [event]; - - const startDate = new Date( - startComponents.year, - startComponents.month, - startComponents.day - ); - - const rangeStart = new Date(year, month, 1); - const rangeEnd = new Date(year, month + 1, 0); - const expandedEvents = []; - - if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) { - const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/); - if (bydayMatch) { - const dayCode = bydayMatch[1]; - const dayMap = { - 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 - }; - const targetDay = dayMap[dayCode]; - - if (targetDay !== undefined) { - // Create events for each matching day in the month - let day = 1; - while (day <= rangeEnd.getDate()) { - const testDate = new Date(year, month, day); - if (testDate.getDay() === targetDay && testDate >= startDate) { - const newEvent = {...event}; - const eventDate = formatDateForICS(testDate); - - // Preserve time portion from original event - const timePart = event.start.includes('T') ? - event.start.substring(event.start.indexOf('T')) : ''; - const endTimePart = event.end.includes('T') ? - event.end.substring(event.end.indexOf('T')) : ''; - - newEvent.start = eventDate + timePart; - newEvent.end = eventDate + endTimePart; - expandedEvents.push(newEvent); - } - day++; - } - } - } - } - else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) { - const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/); - if (bydayMatch) { - const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/); - const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1; - const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth()); - if (monthsFromStart < 0 || monthsFromStart % interval !== 0) { - return []; - } - const bydays = bydayMatch[1].split(','); - const dayMap = { - 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 - }; - - bydays.forEach(byday => { - const occurrence = parseInt(byday) || 1; - const dayCode = byday.slice(-2); - const dayIndex = dayMap[dayCode]; - - let day = 1; - let count = 0; - - while (day <= rangeEnd.getDate()) { - const testDate = new Date(year, month, day); - if (testDate.getDay() === dayIndex) { - count++; - if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) { - const newEvent = {...event}; - const eventDate = new Date(year, month, day); - - // Preserve time portion from original event - const timePart = event.start.includes('T') ? - event.start.substring(event.start.indexOf('T')) : ''; - const endTimePart = event.end.includes('T') ? - event.end.substring(event.end.indexOf('T')) : ''; - - newEvent.start = formatDateForICS(eventDate) + timePart; - newEvent.end = formatDateForICS(eventDate) + endTimePart; - expandedEvents.push(newEvent); - } - } - day++; - } - }); - } - } - - return expandedEvents.length > 0 ? expandedEvents : [event]; + if (details.endDate.toJSDate() > from) { + add(toOccurrence(details.item, details.startDate, details.endDate)); } - - // Kalender initialisieren - let currentYear, currentMonth; - const currentMonthElem = document.getElementById("current-month"); - const calendarBody = document.getElementById("calendar-body"); - const eventPanel = document.getElementById("event-panel"); - const eventDateElem = document.getElementById("event-date"); - const 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); + } + } 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'); }); - - function updateEventsForMonth(year, month) { - // Clear existing events for this month view - eventsByDate = {}; - - // Process each event, expanding recurring ones - events.forEach(ev => { - if (ev.rrule) { - // For recurring events, expand them for current month - const expandedEvents = expandRecurringEvents(ev, year, month); - expandedEvents.forEach(expandedEv => { - let dateKey = parseDateString(expandedEv.start); - if (dateKey) { - if (!eventsByDate[dateKey]) { - eventsByDate[dateKey] = []; - } - eventsByDate[dateKey].push(expandedEv); - } - }); - } else { - // For regular events, check if they fall in current month - let dateKey = parseDateString(ev.start); - if (dateKey) { - // Check if this event belongs to current month view - const eventYear = parseInt(dateKey.split('-')[0]); - const eventMonth = parseInt(dateKey.split('-')[1]) - 1; - - if (eventYear === year && eventMonth === month) { - if (!eventsByDate[dateKey]) { - eventsByDate[dateKey] = []; - } - eventsByDate[dateKey].push(ev); - } - } - } - }); - - renderCalendar(year, month); - } - - function renderCalendar(year, month) { - // Setze die Monatsbeschriftung (in Deutsch) - const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; - 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); - } - - function createEventLink(eventTitle) { - if (eventTitle.startsWith("Datengarten")) { - // Extract the number after "Datengarten " - const match = eventTitle.match(/Datengarten\s+(\d+)/i); - if (match && match[1]) { - return `https://berlin.ccc.de/datengarten/${match[1]}/`; - } - } - - // For other titles, convert to lowercase and use as path - const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); - return `https://berlin.ccc.de/page/${slug}/`; - } - - function showEventDetails(dateKey) { - const events = eventsByDate[dateKey]; - eventDateElem.textContent = formatDate(dateKey); - eventDetailsElem.innerHTML = ""; - - if (events && events.length > 0) { - events.forEach(ev => { - let eventItem = document.createElement("div"); - eventItem.className = "event-item"; - - let eventTitle = document.createElement("div"); - eventTitle.className = "event-title"; - - // Create a link for the event title - let titleLink = document.createElement("h"); - titleLink.textContent = ev.summary; - titleLink.target = "_blank"; - eventTitle.appendChild(titleLink); - - eventItem.appendChild(eventTitle); - - let eventTime = document.createElement("div"); - eventTime.className = "event-time"; - eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`; - eventItem.appendChild(eventTime); - - if (ev.description) { - let eventDescription = document.createElement("div"); - eventDescription.className = "event-description"; - - // Check if the description is a URL and make it a clickable link - if (ev.description.trim().startsWith('http')) { - let linkElement = document.createElement("a"); - linkElement.href = ev.description.trim(); - linkElement.textContent = ev.description.trim(); - linkElement.target = "_blank"; - eventDescription.innerHTML = ''; - eventDescription.appendChild(linkElement); - } else { - eventDescription.textContent = ev.description; - } - - eventItem.appendChild(eventDescription); - } - - eventDetailsElem.appendChild(eventItem); - }); - } 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]}`; - } - - function formatTime(icsTimeStr) { - // Format time for display - if (!icsTimeStr) return ""; - - if (icsTimeStr.length === 8) { - // All-day event - return "Ganztägig"; - } else if (icsTimeStr.includes("T")) { - // Time-specific event (with or without timezone) - const timeStart = icsTimeStr.indexOf("T") + 1; - const hour = icsTimeStr.substring(timeStart, timeStart + 2); - const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4); - return `${hour}:${minute}`; - } - return icsTimeStr; - } - - function formatDateForICS(date) { - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - return `${year}${month}${day}`; - } - - // ICS-Datei abrufen und Events verarbeiten - fetch('/calendars/all.ics') - .then(response => response.text()) - .then(data => { - events = parseICS(data); - - // Initialize with current date - let today = new Date(); - currentYear = today.getFullYear(); - currentMonth = today.getMonth(); - - // Process events for current month - updateEventsForMonth(currentYear, currentMonth); - }) - .catch(err => console.error('Fehler beim Laden der ICS-Datei:', err)); - })(); + 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)); }); diff --git a/layouts/shortcodes/calendar.html b/layouts/shortcodes/calendar.html index 52812c7..ff0f996 100644 --- a/layouts/shortcodes/calendar.html +++ b/layouts/shortcodes/calendar.html @@ -1,4 +1,4 @@ -{{ $js := resources.Get "js/calendar.js" }} +{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }} {{ $css := resources.Get "css/calendar.css" }}
@@ -6,7 +6,7 @@ {{ end }} {{ with $js }} - + {{ end }}