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: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
Hauke Mehrtens 2026-08-22 19:56:48 +02:00
commit 93174ce860

View file

@ -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),
});
}
}