From cb70a095b2735b6973e2fcb309ae634c2da3540c Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 20:10:25 +0200 Subject: [PATCH 1/4] Parse the calendar with ical.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar page brought its own ICS parser and its own RRULE expansion. Both only covered the cases that happened to be needed when they were written, and the calendar has moved on since. Against the published calendar, for September 2026: Spieleabend FREQ=WEEKLY;INTERVAL=2;BYDAY=SA shown 05. 12. 19. 26., correct 05. 19. CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01., correct 08. CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01., correct 22. INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as often as it takes place. BYSETPOS was not implemented at all, and since parseInt("TU") is NaN the fallback turned both Plenum rules into "first Tuesday", putting two Plenums on a day without one and none on the two days with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing more than one weekday were not handled either. The text was no better. Content lines longer than 75 characters are continued on the next line, of which there are 477 in the calendar, and the parser did not join them, so it cut values off in the middle of a word. It also split every line at the first colon, which lands inside the parameter of DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so "\n" was shown as those two characters. 30 of 31 descriptions were wrong: before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr" after: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin" Hand the parsing and the expansion to ical.js, which is vendored for the start page anyway. The month view now asks the library for the occurrences that touch the month, which removes the reimplementation along with all of the above. While the events are being reduced to what the view needs: - An event is entered on every day it covers, so the Amateurfunk trip from 30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is not part of it, so one ending at midnight stays on the day before. - Days and times are derived in Europe/Berlin instead of from the digits of the ICS string. Every event currently carries TZID=Europe/Berlin, so the wall clock time shown does not change, but a UTC timestamp would have been shown in UTC. - The URL of the event is used for the link in the detail panel. Events without one are shown without a link, as on the start page. This replaces createEventLink(), which guessed URLs from the title and was never called, and the panel no longer builds an element, which is not an element. Descriptions may contain line breaks, so keep them in the panel. Fixes: 4068fab565ef ("improved calendar and fixed url temporarily") Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens --- assets/css/calendar.css | 4 + assets/js/calendar.js | 847 +++++++++++++++---------------- layouts/shortcodes/calendar.html | 4 +- 3 files changed, 414 insertions(+), 441 deletions(-) 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 }}
From 98c3207c00e760fefd493409e4ce5426b0099a81 Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 20:23:00 +0200 Subject: [PATCH 2/4] Separate date and name in the upcoming events table The two columns of the table on the start page touched each other, so the entry read "Donnerstag, 27.08., 19:00 UhrClub Discordia". The table is created with the classes "table table-condensed", which no stylesheet of the site defines, and the table styling that the theme applies inside prose addresses "tbody td". The table is delivered empty and its rows are added through the DOM, where a tr appended to a table stays a direct child instead of being put into a tbody the way the HTML parser would. The rows are therefore outside of any tbody and the padding of the theme never applied. Give the column holding the date its own padding, and keep the date on one line, it is one piece of information and reads badly broken after the weekday. The padding alone does not fit, though. The table stands in a prose column that the theme limits to 65 characters so that running text stays readable, and a date and the name of an event next to each other are wider than that, so the names would be wrapped over several lines. Lift the limit off the column and put it back on everything in it except the table, which leaves the table room to grow while the heading and the paragraph around it keep their width. That much space then has to be filled sensibly. The theme lays a table out as a block, "table { display: block; overflow: auto }", so that a wide one can be scrolled sideways, and a block fills its parent instead of shrinking to its content the way a table does. Spanning the page the entries would all sit at its left edge. Ask for the width of the content with fit-content, which the automatic margins then centre. Addressing the table by its id keeps all of this to the start page and takes precedence over the theme, whose prose rules are written with :where() and carry no specificity. Measured in a browser at 1280, 768 and 500 pixels: date and name share one line at the first two, the table is 602 pixels wide with the same distance left and right, and at 500 the column is narrower than the table, so the table fills it and only the longest name wraps. The page never scrolls sideways. Fixes: c28f04c6e85c ("switch to ics files; make calendars work; fix some minor issues") Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens --- assets/css/upcoming.css | 36 ++++++++++++++++++++++++++++++++ layouts/shortcodes/upcoming.html | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 assets/css/upcoming.css diff --git a/assets/css/upcoming.css b/assets/css/upcoming.css new file mode 100644 index 0000000..05b096d --- /dev/null +++ b/assets/css/upcoming.css @@ -0,0 +1,36 @@ +/* The rows of the table are added by JavaScript and end up as direct children + of the table, so the table styling of the theme, which addresses tbody, does + not reach them. Separate the date from the name of the event ourselves, and + keep the date on one line, it is one piece of information and reads badly + broken after the weekday. */ +#upcoming td:first-child { + padding-inline-end: 1em; + white-space: nowrap; +} + +/* The table stands in a prose column, which the theme limits to 65 characters + so that running text stays readable. A date and the name of an event next to + each other do not fit into that, so the names were wrapped over several + lines. Lift the limit off the column and put it back on everything in it + except the table, which leaves the table room to grow while the text around + it keeps its width. */ +section.prose:has(> #upcoming) { + max-width: none; +} + +section.prose:has(> #upcoming) > :not(#upcoming) { + max-width: 65ch; + margin-inline: auto; +} + +/* The theme lays a table out as a block, "table { display: block; overflow: + auto }", so that a wide one can be scrolled sideways. A block fills its + parent instead of shrinking to its content the way a table does, so across + the whole width of the page the entries would stay at its left edge. + fit-content asks for the width of the content, which the automatic margins + then centre, and it never exceeds the column, so a display too narrow for + the table still wraps the names instead of overflowing. */ +#upcoming { + width: fit-content; + margin-inline: auto; +} diff --git a/layouts/shortcodes/upcoming.html b/layouts/shortcodes/upcoming.html index 18173b3..d0cc46d 100644 --- a/layouts/shortcodes/upcoming.html +++ b/layouts/shortcodes/upcoming.html @@ -1,3 +1,5 @@ +{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}} {{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}} +
From bca186c6d991212a3b23a6fa310e448dbbec1593 Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 20:10:25 +0200 Subject: [PATCH 3/4] Parse the calendar with ical.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar page brought its own ICS parser and its own RRULE expansion. Both only covered the cases that happened to be needed when they were written, and the calendar has moved on since. Against the published calendar, for September 2026: Spieleabend FREQ=WEEKLY;INTERVAL=2;BYDAY=SA shown 05. 12. 19. 26., correct 05. 19. CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01., correct 08. CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01., correct 22. INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as often as it takes place. BYSETPOS was not implemented at all, and since parseInt("TU") is NaN the fallback turned both Plenum rules into "first Tuesday", putting two Plenums on a day without one and none on the two days with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing more than one weekday were not handled either. The text was no better. Content lines longer than 75 characters are continued on the next line, of which there are 477 in the calendar, and the parser did not join them, so it cut values off in the middle of a word. It also split every line at the first colon, which lands inside the parameter of DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so "\n" was shown as those two characters. 30 of 31 descriptions were wrong: before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr" after: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin" Hand the parsing and the expansion to ical.js, which is vendored for the start page anyway. The month view now asks the library for the occurrences that touch the month, which removes the reimplementation along with all of the above. While the events are being reduced to what the view needs: - An event is entered on every day it covers, so the Amateurfunk trip from 30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is not part of it, so one ending at midnight stays on the day before. - Days and times are derived in Europe/Berlin instead of from the digits of the ICS string. Every event currently carries TZID=Europe/Berlin, so the wall clock time shown does not change, but a UTC timestamp would have been shown in UTC. - The URL of the event is used for the link in the detail panel. Events without one are shown without a link, as on the start page. This replaces createEventLink(), which guessed URLs from the title and was never called, and the panel no longer builds an element, which is not an element. Descriptions may contain line breaks, so keep them in the panel. Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens --- assets/css/calendar.css | 4 + assets/js/calendar.js | 847 +++++++++++++++---------------- layouts/shortcodes/calendar.html | 4 +- 3 files changed, 414 insertions(+), 441 deletions(-) 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 }}
From fad1e6be6157ff12a7c2723ad0e9638f2b6fdde6 Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 20:23:00 +0200 Subject: [PATCH 4/4] Separate date and name in the upcoming events table The two columns of the table on the start page touched each other, so the entry read "Donnerstag, 27.08., 19:00 UhrClub Discordia". The table is created with the classes "table table-condensed", which no stylesheet of the site defines, and the table styling that the theme applies inside prose addresses "tbody td". The table is delivered empty and its rows are added through the DOM, where a tr appended to a table stays a direct child instead of being put into a tbody the way the HTML parser would. The rows are therefore outside of any tbody and the padding of the theme never applied. Give the column holding the date its own padding, and keep the date on one line, it is one piece of information and reads badly broken after the weekday. The padding alone does not fit, though. The table stands in a prose column that the theme limits to 65 characters so that running text stays readable, and a date and the name of an event next to each other are wider than that, so the names would be wrapped over several lines. Lift the limit off the column and put it back on everything in it except the table, which leaves the table room to grow while the heading and the paragraph around it keep their width. That much space then has to be filled sensibly. The theme lays a table out as a block, "table { display: block; overflow: auto }", so that a wide one can be scrolled sideways, and a block fills its parent instead of shrinking to its content the way a table does. Spanning the page the entries would all sit at its left edge. Ask for the width of the content with fit-content, which the automatic margins then centre. Addressing the table by its id keeps all of this to the start page and takes precedence over the theme, whose prose rules are written with :where() and carry no specificity. Measured in a browser at 1280, 768 and 500 pixels: date and name share one line at the first two, the table is 602 pixels wide with the same distance left and right, and at 500 the column is narrower than the table, so the table fills it and only the longest name wraps. The page never scrolls sideways. Fixes: c28f04c6e85c ("switch to ics files; make calendars work; fix some minor issues") Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens --- assets/css/upcoming.css | 36 ++++++++++++++++++++++++++++++++ layouts/shortcodes/upcoming.html | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 assets/css/upcoming.css diff --git a/assets/css/upcoming.css b/assets/css/upcoming.css new file mode 100644 index 0000000..05b096d --- /dev/null +++ b/assets/css/upcoming.css @@ -0,0 +1,36 @@ +/* The rows of the table are added by JavaScript and end up as direct children + of the table, so the table styling of the theme, which addresses tbody, does + not reach them. Separate the date from the name of the event ourselves, and + keep the date on one line, it is one piece of information and reads badly + broken after the weekday. */ +#upcoming td:first-child { + padding-inline-end: 1em; + white-space: nowrap; +} + +/* The table stands in a prose column, which the theme limits to 65 characters + so that running text stays readable. A date and the name of an event next to + each other do not fit into that, so the names were wrapped over several + lines. Lift the limit off the column and put it back on everything in it + except the table, which leaves the table room to grow while the text around + it keeps its width. */ +section.prose:has(> #upcoming) { + max-width: none; +} + +section.prose:has(> #upcoming) > :not(#upcoming) { + max-width: 65ch; + margin-inline: auto; +} + +/* The theme lays a table out as a block, "table { display: block; overflow: + auto }", so that a wide one can be scrolled sideways. A block fills its + parent instead of shrinking to its content the way a table does, so across + the whole width of the page the entries would stay at its left edge. + fit-content asks for the width of the content, which the automatic margins + then centre, and it never exceeds the column, so a display too narrow for + the table still wraps the names instead of overflowing. */ +#upcoming { + width: fit-content; + margin-inline: auto; +} diff --git a/layouts/shortcodes/upcoming.html b/layouts/shortcodes/upcoming.html index 18173b3..d0cc46d 100644 --- a/layouts/shortcodes/upcoming.html +++ b/layouts/shortcodes/upcoming.html @@ -1,3 +1,5 @@ +{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}} {{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}} +