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>
194 lines
6 KiB
JavaScript
194 lines
6 KiB
JavaScript
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<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;
|
|
}
|
|
|
|
/**
|
|
* 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<ICAL.Component>} 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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|