Move what the two calendar views share into one module

Both views read the same file and ask it the same question, only the
answer is presented differently: the start page lists the next few
occurrences, the calendar page marks the occurrences of one month. Since
they were taught to read a calendar properly they also carry the same
code for it, twice and word for word, some 130 lines of `eventUrl()`,
`exceptionsByUid()`, `iterationEnd()`, the fetch with its check of the
response and the walk over the events of the calendar. Every fix so far
had to be written twice, and the next one that is only written once
leaves the two views disagreeing about the same calendar.

Put it into `assets/js/events.js`, which offers what both need:

- `loadCalendar()` fetches and parses `/calendars/all.ics`
- `occurrencesBetween()` walks the occurrences that touch a window,
  expanding recurring events and resolving the ones that were modified
  on their own
- `eventUrl()` reads the URL of an event

`upcoming.js` and `calendar.js` keep what is really theirs, the shape of
their entries and how they are drawn, and both lose their own import of
ical.js: it is the concern of the module that reads the calendar now.
The two of them shrink from 259 and 549 lines to 105 and 406.

Hugo bundles the module into both scripts, so no shortcode changes and
no second request.

The walk keeps an occurrence when it starts at or before the end of the
window and ends after its start. That is the condition the start page
used; the calendar page compared against the start of the month with "<"
instead of "<=". Its window reaches two days past the month, so the day
this can differ on is nowhere near it.

No behaviour changes with this: the upcoming list over 120 days and
every day of the month view from 2025 to 2028, taken from
https://berlin.ccc.de/calendars/all.ics with the name, URL, start, end
and all day flag of each entry, are identical before and after, and so
are the results of the tests for moved occurrences, for modifications
between two series and for all day events in Berlin, Tokyo, Los Angeles
and Kiritimati.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
Hauke Mehrtens 2026-08-23 00:54:58 +02:00
commit 6782da49d4
3 changed files with 215 additions and 318 deletions

View file

@ -1,68 +1,4 @@
import ICAL from "./vendor/ical.js";
/**
* 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)
*/
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<string, ICAL.Component[]>} 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;
}
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
/**
* When an occurrence starts, as a point in time.
@ -85,109 +21,29 @@ function startOf(time) {
}
/**
* How far the recurrences of an event have to be iterated.
* The upcoming occurrences of a calendar.
*
* 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} end End of the window
* @returns {Date} The recurrence time to stop at
*/
function iterationEnd(event, end) {
let last = end.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, end.getTime() + movedBy);
}
}
return new Date(last);
}
/**
* Parse an ICS calendar and return upcoming event occurrences.
*
* @param {string} icsText The contents of the .ics file
* @param {ICAL.Component} calendar The parsed calendar
* @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
*/
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
const jcal = ICAL.parse(icsText);
const calendar = new ICAL.Component(jcal);
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
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, listing them here as well would show them twice.
if (component.hasProperty("recurrence-id")) {
continue;
}
const event = new ICAL.Event(component, {
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
// 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),
});
if (!event.startDate) {
continue;
}
if (event.isRecurring()) {
const iterator = event.iterator();
const iterateUntil = iterationEnd(event, end);
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() > iterateUntil) {
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.
// A modification may have moved the occurrence out of the window, so
// judge it by the time it really takes place at.
if (details.startDate.toJSDate() <= end && details.endDate.toJSDate() > now) {
events.push({
start: startOf(details.startDate),
allDay: details.startDate.isDate,
name: details.item.summary ?? "",
url: eventUrl(details.item),
});
}
}
} else if (event.startDate.toJSDate() <= end && event.endDate.toJSDate() > now) {
events.push({
start: startOf(event.startDate),
allDay: event.startDate.isDate,
name: event.summary ?? "",
url: eventUrl(event),
});
}
}
// We have occurrences from multiple events, so sort them
@ -198,25 +54,15 @@ function getUpcomingEvents(icsText, 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");
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 => {
loadCalendar()
.then(calendar => {
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");