From 93174ce860491952d7f9f37b2ad637cc4600e1b4 Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 19:56:48 +0200 Subject: [PATCH] Do not list a moved event occurrence twice An occurrence of a recurring event that was changed on its own carries a RECURRENCE-ID and is stored as an additional VEVENT next to the event it belongs to. `getAllSubcomponents("vevent")` returns those components as well, and since they have no RRULE of their own they were handled as separate single events. The occurrence therefore ended up in the list twice: once from expanding the recurring event, which resolves the modified time through the exception, and once more from the extra VEVENT. To make it worse the two rows disagreed, because name and URL were taken from the recurring event while the time came from the modification, so the first row showed the new time under the old name. Skip components that are a recurrence exception, they are already covered by the event they modify, and take name and URL from the occurrence details, which point at the modification where there is one and at the event itself otherwise. Events whose RECURRENCE-ID refers to an event that is not in the file are dropped by this, which cannot happen in a full calendar export. The published calendar currently contains no RECURRENCE-ID at all, so nothing changes for it today. It is exported from a CalDAV server though, and moving a single Club Discordia or Plenum out of the way of a holiday is exactly what creates such a modification. With a recurring Plenum whose 25.08. occurrence is moved two hours earlier and renamed: before: 25.08. 18:00 CCCB Plenum 25.08. 18:00 CCCB Plenum (verschoben) after: 25.08. 18:00 CCCB Plenum (verschoben) Fixes: c28f04c6e85c ("switch to ics files; make calendars work; fix some minor issues") Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens --- static/js/upcoming.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/static/js/upcoming.js b/static/js/upcoming.js index f031b4d..b172d83 100644 --- a/static/js/upcoming.js +++ b/static/js/upcoming.js @@ -1,5 +1,17 @@ import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"; +/** + * 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") ?? ""; +} + /** * Parse an ICS calendar and return upcoming event occurrences. * @@ -21,12 +33,15 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { for (const component of calendar.getAllSubcomponents("vevent")) { const event = new ICAL.Event(component); - if (!event.startDate) { + // 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; } - // ICAL.Event does not expose the URL property, so read it from the component. - const url = component.getFirstPropertyValue("url") ?? ""; + if (!event.startDate) { + continue; + } if (event.isRecurring()) { const iterator = event.iterator(); @@ -44,15 +59,16 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { break; } - // Details resolve the times of occurrences overridden by RECURRENCE-ID. + // 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: event.summary ?? "", - url, + name: details.item.summary ?? "", + url: eventUrl(details.item), }); } } @@ -63,7 +79,7 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { events.push({ start, name: event.summary ?? "", - url, + url: eventUrl(event), }); } }