diff --git a/assets/js/calendar.js b/assets/js/calendar.js index b12529c..b05181b 100644 --- a/assets/js/calendar.js +++ b/assets/js/calendar.js @@ -1,4 +1,6 @@ -import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js"; +import ICAL from "./vendor/ical.js"; + +const icsUrl = "/calendars/all.ics"; // The club is in Berlin, so the calendar shows Berlin days and Berlin times, // no matter which time zone the browser of the visitor is set to. @@ -49,59 +51,29 @@ function dayKey(date) { return `${parts.year}-${parts.month}-${parts.day}`; } -/** - * The day an occurrence time falls on in Berlin. - * - * A date has neither a time nor a zone, its digits are the day itself. - * toJSDate() would read them as midnight in the zone of the browser, which - * far enough east or west of Berlin lands on the day before or after. - * - * @param {ICAL.Time} time The time - * @returns {string} The day as "YYYY-MM-DD" - */ -function timeDayKey(time) { - if (time.isDate) { - const month = String(time.month).padStart(2, "0"); - const day = String(time.day).padStart(2, "0"); - - return `${time.year}-${month}-${day}`; - } - - return dayKey(time.toJSDate()); -} - /** * The days an event covers, so that an event running over several days is * shown on each of them and not only on the day it starts. * - * @param {ICAL.Time} start Start of the event - * @param {ICAL.Time} end End of the event + * @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 last = end.clone(); - - if (end.isDate) { - last.adjust(-1, 0, 0, 0); - } else { - last.adjust(0, 0, 0, -1); - } - - const lastKey = timeDayKey(last); + const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1)); + const lastKey = dayKey(lastInstant); const days = []; - let key = timeDayKey(start); + 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); - // The keys sort as the days do, so this also stops an event whose end lies - // before its start after the day it starts on. - if (key >= lastKey) { + if (key === lastKey) { break; } @@ -114,13 +86,25 @@ function daysCovered(start, end) { 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, days: string[]}} + * @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}} */ function toOccurrence(event, startDate, endDate) { return { @@ -130,9 +114,6 @@ function toOccurrence(event, startDate, endDate) { start: startDate.toJSDate(), end: endDate.toJSDate(), allDay: startDate.isDate, - // Taken from the ICAL times, which still know whether they name a day or a - // point in time; the JS dates above no longer do. - days: daysCovered(startDate, endDate), }; } @@ -162,7 +143,7 @@ function occurrencesOfMonth(year, month) { const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`; const add = (occurrence) => { - for (const key of occurrence.days) { + for (const key of daysCovered(occurrence.start, occurrence.end)) { if (!key.startsWith(monthPrefix)) { continue; } @@ -175,20 +156,42 @@ function occurrencesOfMonth(year, month) { } }; - for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) { - add(toOccurrence(event, startDate, endDate)); + for (const component of calendar.getAllSubcomponents("vevent")) { + const event = new ICAL.Event(component); + + // Occurrences modified via RECURRENCE-ID are reached through the event they + // belong to, handling them here as well would show them twice. + if (event.isRecurrenceException() || !event.startDate) { + continue; + } + + if (event.isRecurring()) { + const iterator = event.iterator(); + + while (true) { + const occurrence = iterator.next(); + + // Recurrences are chronological, so we are done once one starts after + // the month. + if (!occurrence || occurrence.toJSDate() >= to) { + break; + } + + // Details resolve time, name and URL of an occurrence that was + // modified via RECURRENCE-ID. + const details = event.getOccurrenceDetails(occurrence); + + if (details.endDate.toJSDate() > from) { + add(toOccurrence(details.item, details.startDate, details.endDate)); + } + } + } else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) { + add(toOccurrence(event, event.startDate, event.endDate)); + } } for (const occurrences of Object.values(byDate)) { - occurrences.sort((a, b) => { - // An all day event has no time of day to sort by, the JS date of its - // start is midnight in the zone of the browser. Put it first instead. - if (a.allDay !== b.allDay) { - return a.allDay ? -1 : 1; - } - - return a.start - b.start; - }); + occurrences.sort((a, b) => a.start - b.start); } return byDate; @@ -363,7 +366,7 @@ function formatTimeRange(occurrence) { const start = timeOfDayFormat.format(occurrence.start); const end = timeOfDayFormat.format(occurrence.end); - return `Beginn: ${start}, Ende: ${end}`; + return `Start: ${start}, End: ${end}`; } document.addEventListener("DOMContentLoaded", function() { @@ -397,9 +400,18 @@ document.addEventListener("DOMContentLoaded", function() { currentMonth = today.getMonth(); updateEventsForMonth(currentYear, currentMonth); - loadCalendar() - .then(loaded => { - calendar = loaded; + 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/assets/js/events.js b/assets/js/events.js deleted file mode 100644 index 0941e26..0000000 --- a/assets/js/events.js +++ /dev/null @@ -1,194 +0,0 @@ -import ICAL from "./vendor/ical.js"; - -// The calendar is published next to the site and is not built from this -// repository, see the README. -const icsUrl = "/calendars/all.ics"; - -/** - * Read the URL of an event. - * - * ICAL.Event does not expose the URL property, so read it from the component. - * - * The value ends up in the href of a link, and the calendar is exported from a - * CalDAV server, so whoever may write to it decides what that value is. A - * "javascript:" URL there would run on our page as soon as a visitor clicks - * the event, so hand on nothing but http and https. - * - * @param {ICAL.Event} event The event to read the URL of - * @returns {string} The URL, empty when the event has none or it is not http(s) - */ -export function eventUrl(event) { - const url = event.component.getFirstPropertyValue("url") ?? ""; - - if (!url) { - return ""; - } - - try { - // A relative URL is resolved against the page and keeps its scheme. - const { protocol } = new URL(url, document.baseURI); - - return protocol === "http:" || protocol === "https:" ? url : ""; - } catch { - // Not a URL at all. - return ""; - } -} - -/** - * Group the occurrences that were modified on their own by the UID of the event - * they belong to. - * - * Unless it is told which exceptions belong to an event, ICAL.Event relates - * every VEVENT with a RECURRENCE-ID in the file to every recurring event, and - * it keys them by the recurrence id alone. Two series that meet at the same - * time would therefore take over each other's modifications. - * - * @param {ICAL.Component[]} components The VEVENTs of the calendar - * @returns {Map} The exceptions per UID - */ -function exceptionsByUid(components) { - const exceptions = new Map(); - - for (const component of components) { - if (!component.hasProperty("recurrence-id")) { - continue; - } - - const uid = component.getFirstPropertyValue("uid"); - const ofEvent = exceptions.get(uid); - - if (ofEvent) { - ofEvent.push(component); - } else { - exceptions.set(uid, [component]); - } - } - - return exceptions; -} - -/** - * How far the recurrences of an event have to be iterated. - * - * The iteration walks the unmodified recurrence times, so an occurrence that - * was moved to an earlier time is only reached through the time it originally - * had, which can lie past the end of the window. Keep going for as long as the - * largest move towards the past can still carry an occurrence into it. - * - * @param {ICAL.Event} event The event whose recurrences are iterated - * @param {Date} to End of the window - * @returns {Date} The recurrence time to stop at - */ -function iterationEnd(event, to) { - let last = to.getTime(); - - for (const exception of Object.values(event.exceptions)) { - const movedBy = exception.recurrenceId.toJSDate().getTime() - - exception.startDate.toJSDate().getTime(); - - if (movedBy > 0) { - last = Math.max(last, to.getTime() + movedBy); - } - } - - return new Date(last); -} - -/** - * Whether an occurrence touches a window. - * - * @param {ICAL.Time} startDate Start of the occurrence - * @param {ICAL.Time} endDate End of the occurrence - * @param {Date} from Start of the window - * @param {Date} to End of the window - * @returns {boolean} True when the two overlap - */ -function touches(startDate, endDate, from, to) { - return startDate.toJSDate() <= to && endDate.toJSDate() > from; -} - -/** - * Load and parse the calendar of the club. - * - * @returns {Promise} The calendar - */ -export async function loadCalendar() { - const response = await fetch(icsUrl); - - // 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 new ICAL.Component(ICAL.parse(await response.text())); -} - -/** - * Every occurrence of the calendar that touches the given window. - * - * A recurring event is expanded, and an occurrence that was modified on its own - * is reported with the time, the name and the URL of that modification. - * - * @param {ICAL.Component} calendar The parsed calendar - * @param {Date} from An occurrence has to still be running at this time - * @param {Date} to An occurrence has to have started by this time - * @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}} - */ -export function* occurrencesBetween(calendar, from, to) { - const components = calendar.getAllSubcomponents("vevent"); - const exceptions = exceptionsByUid(components); - - for (const component of components) { - // Occurrences modified via RECURRENCE-ID are reached through the event they - // belong to, handling them here as well would report them twice. - if (component.hasProperty("recurrence-id")) { - continue; - } - - const event = new ICAL.Event(component, { - exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [], - }); - - if (!event.startDate) { - continue; - } - - if (!event.isRecurring()) { - if (touches(event.startDate, event.endDate, from, to)) { - yield { event, startDate: event.startDate, endDate: event.endDate }; - } - - continue; - } - - const iterator = event.iterator(); - const iterateUntil = iterationEnd(event, to); - - while (true) { - const occurrence = iterator.next(); - - // Recurrences are chronological, so we are done once one starts after the - // window, and after the occurrences a modification can still move back - // into it. - if (!occurrence || occurrence.toJSDate() > iterateUntil) { - break; - } - - // Details resolve time, name and URL of an occurrence that was modified - // via RECURRENCE-ID. - const details = event.getOccurrenceDetails(occurrence); - - // A modification may have moved the occurrence out of the window, so - // judge it by the time it really takes place at. - if (touches(details.startDate, details.endDate, from, to)) { - yield { - event: details.item, - startDate: details.startDate, - endDate: details.endDate, - }; - } - } - } -} diff --git a/assets/js/upcoming.js b/assets/js/upcoming.js index 7f6d63f..287abfa 100644 --- a/assets/js/upcoming.js +++ b/assets/js/upcoming.js @@ -1,49 +1,88 @@ -import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js"; +import ICAL from "./vendor/ical.js"; /** - * When an occurrence starts, as a point in time. + * Read the URL of an event. * - * A date has no time and no zone, its digits are the day itself. toJSDate() - * reads them as midnight in the zone of the browser, which moves an all day - * event by the offset that zone has to Berlin and, far enough east or west, - * onto the day before or after. Keep the digits and read them as UTC instead, - * the table prints an all day event in UTC as well. + * ICAL.Event does not expose the URL property, so read it from the component. * - * @param {ICAL.Time} time Start of the occurrence - * @returns {Date} The point in time to sort and print by + * @param {ICAL.Event} event The event to read the URL of + * @returns {string} The URL, empty when the event has none */ -function startOf(time) { - if (time.isDate) { - return new Date(Date.UTC(time.year, time.month - 1, time.day)); - } - - return time.toJSDate(); +function eventUrl(event) { + return event.component.getFirstPropertyValue("url") ?? ""; } /** - * The upcoming occurrences of a calendar. + * Parse an ICS calendar and return upcoming event occurrences. * - * @param {ICAL.Component} calendar The parsed calendar + * @param {string} icsText The contents of the .ics file * @param {Date} now Events must still be running at this date * @param {number} maxEvents Maximum number of events to return * @param {number} maxDays Maximum number of days into the future - * @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL + * @returns {{start: Date, name: string, url: string}[]} url is empty when the event has no URL */ -function getUpcomingEvents(calendar, now, maxEvents, maxDays) { +function getUpcomingEvents(icsText, now, maxEvents, maxDays) { + const jcal = ICAL.parse(icsText); + const calendar = new ICAL.Component(jcal); + const end = new Date(now.getTime()); end.setDate(end.getDate() + maxDays); const events = []; - // A running event stays listed until it is over, so the window starts at now - // and the walk keeps everything that has not ended yet. - for (const { event, startDate } of occurrencesBetween(calendar, now, end)) { - events.push({ - start: startOf(startDate), - allDay: startDate.isDate, - name: event.summary ?? "", - url: eventUrl(event), - }); + for (const component of calendar.getAllSubcomponents("vevent")) { + const event = new ICAL.Event(component); + + // Occurrences modified via RECURRENCE-ID are reached through the event they + // belong to, listing them here as well would show them twice. + if (event.isRecurrenceException()) { + continue; + } + + if (!event.startDate) { + continue; + } + + if (event.isRecurring()) { + const iterator = event.iterator(); + + while (true) { + const occurrence = iterator.next(); + + if (!occurrence) { + break; + } + + // Recurrences are chronological, so we're done + // once we pass the end of our search window. + if (occurrence.toJSDate() > end) { + break; + } + + // Details resolve time, name and URL of an occurrence that was + // modified via RECURRENCE-ID. + const details = event.getOccurrenceDetails(occurrence); + + // A running event stays listed until it is over, so filter on its end. + if (details.endDate.toJSDate() > now) { + events.push({ + start: details.startDate.toJSDate(), + name: details.item.summary ?? "", + url: eventUrl(details.item), + }); + } + } + } else { + const start = event.startDate.toJSDate(); + + if (start <= end && event.endDate.toJSDate() > now) { + events.push({ + start, + name: event.summary ?? "", + url: eventUrl(event), + }); + } + } } // We have occurrences from multiple events, so sort them @@ -54,34 +93,41 @@ function getUpcomingEvents(calendar, now, maxEvents, maxDays) { } document.addEventListener("DOMContentLoaded", () => { + const ics = "/calendars/all.ics"; const max_days = 20; const max_items = 5; const now = new Date(); const table = document.getElementById("upcoming"); - loadCalendar() - .then(calendar => { - getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => { + fetch(ics) + .then(response => { + // Without this an error page would be handed to the parser below, which + // then fails with a confusing complaint about the calendar syntax. + if (!response.ok) { + throw new Error(`${ics}: ${response.status} ${response.statusText}`); + } + + return response.text(); + }) + .then(icsText => { + getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => { const row = document.createElement("tr"); const colBegin = document.createElement("td"); - // The events take place in Berlin, so name their time in Berlin time - // instead of in the time zone the visitor happens to be in. An all day - // event has no time of day and carries its date in UTC, see startOf(). - const whenFormat = event.allDay - ? { timeZone: "UTC" } - : { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" }; - const formattedStart = event.start.toLocaleString("de-DE", { + // The events take place in Berlin, so name their time in Berlin time + // instead of in the time zone the visitor happens to be in. + timeZone: "Europe/Berlin", weekday: "long", day: "2-digit", month: "2-digit", - ...whenFormat, + hour: "2-digit", + minute: "2-digit", }); - colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`; + colBegin.innerText = `${formattedStart} Uhr`; row.appendChild(colBegin); const colName = document.createElement("td"); diff --git a/layouts/shortcodes/calendar.html b/layouts/shortcodes/calendar.html index 3965301..ff0f996 100644 --- a/layouts/shortcodes/calendar.html +++ b/layouts/shortcodes/calendar.html @@ -1,9 +1,9 @@ {{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }} -{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }} +{{ $css := resources.Get "css/calendar.css" }}
{{ with $css }} - + {{ end }} {{ with $js }}