www/static/js/upcoming.js
Hauke Mehrtens 83101b3e1b Link upcoming events to their ICS URL
The "Nächste Veranstaltungen" table read the event URL via `ICAL.Event.url`,
but ical.js does not expose a `url` getter on `ICAL.Event` (it only has uid,
summary, description, color, location, sequence, the dates, organizer and
attendees). `event.url` was therefore always `undefined` and the `?? ""`
fallback turned it into an empty string, so every row rendered as
`<a href="">`, a dead link that just reloads the start page.

Read the URL from the VEVENT component instead. This also picks up the
`URL;VALUE=URI:` form used by most events in the published calendar, which is
exported from a CalDAV client and does not use a bare `URL:` property.

Not every event has a URL, so only wrap the name in a link when one is
present and emit plain text otherwise.

Checked against https://berlin.ccc.de/calendars/all.ics (31 events, 2 of them
without a URL):

  before: <td><a href="">CCCB Plenum</a></td>
  after:  <td><a href="https://wiki.berlin.ccc.de/Plenum">CCCB Plenum</a></td>
  after:  <td>Aktionstag gegen Überwachung im Chaos Computer Club Berlin</td>

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>
2026-08-22 21:03:00 +02:00

121 lines
3.1 KiB
JavaScript

import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js";
/**
* Parse an ICS calendar and return upcoming event occurrences.
*
* @param {string} icsText The contents of the .ics file
* @param {Date} now Events must start after this date
* @param {number} maxEvents Maximum number of events to return
* @param {number} maxDays Maximum number of days into the future
* @returns {{start: Date, 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);
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
for (const component of calendar.getAllSubcomponents("vevent")) {
const event = new ICAL.Event(component);
if (!event.startDate) {
continue;
}
// ICAL.Event does not expose the URL property, so read it from the component.
const url = component.getFirstPropertyValue("url") ?? "";
if (event.isRecurring()) {
const iterator = event.iterator();
while (true) {
const occurrence = iterator.next();
if (!occurrence) {
break;
}
const start = occurrence.toJSDate();
// Recurrences are chronological, so we're done
// once we pass the end of our search window.
if (start > end) {
break;
}
if (start > now) {
events.push({
start,
name: event.summary ?? "",
url,
});
}
}
} else {
const start = event.startDate.toJSDate();
if (start > now && start <= end) {
events.push({
start,
name: event.summary ?? "",
url,
});
}
}
}
// We have occurrences from multiple events, so sort them
// before applying the maximum event count.
events.sort((a, b) => a.start - b.start);
return events.slice(0, maxEvents);
}
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 => response.text())
.then(icsText => {
getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).replace(",", "");
colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
if (event.url) {
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
} else {
colName.innerText = event.name;
}
row.appendChild(colName);
table.appendChild(row);
});
});
});