The response of the fetch went to the parser without ever looking at it. When
the calendar could not be loaded the error page of the web server was parsed as
a calendar, which threw inside a promise nobody was waiting on. The result was
an empty table, an unhandled rejection in the console and an error message
about broken calendar syntax that says nothing about the actual problem, a
calendar that is not there.
Refuse a response that is not ok, naming the status, and log failures in the
chain, like the calendar page already does.
before: Uncaught (in promise) Error: invalid line (no token ";" or ":")
"<html>404 Not Found</html>"
after: Fehler beim Laden der Termine:
Error: /calendars/all.ics: 404 Not Found
The table stays empty either way, there is nothing to show without a calendar.
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>
151 lines
4.4 KiB
JavaScript
151 lines
4.4 KiB
JavaScript
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.
|
|
*
|
|
* @param {string} icsText The contents of the .ics file
|
|
* @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, 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
if (!event.startDate) {
|
|
continue;
|
|
}
|
|
|
|
if (event.isRecurring()) {
|
|
const iterator = event.iterator();
|
|
|
|
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() > end) {
|
|
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.
|
|
if (details.endDate.toJSDate() > now) {
|
|
events.push({
|
|
start: details.startDate.toJSDate(),
|
|
name: details.item.summary ?? "",
|
|
url: eventUrl(details.item),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
const start = event.startDate.toJSDate();
|
|
|
|
if (start <= end && event.endDate.toJSDate() > now) {
|
|
events.push({
|
|
start,
|
|
name: event.summary ?? "",
|
|
url: eventUrl(event),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 => {
|
|
// 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 => {
|
|
const row = document.createElement("tr");
|
|
|
|
const colBegin = document.createElement("td");
|
|
|
|
const formattedStart = event.start.toLocaleString("de-DE", {
|
|
// The events take place in Berlin, so name their time in Berlin time
|
|
// instead of in the time zone the visitor happens to be in.
|
|
timeZone: "Europe/Berlin",
|
|
weekday: "long",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
colBegin.innerText = `${formattedStart} 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);
|
|
});
|
|
})
|
|
.catch(err => console.error("Fehler beim Laden der Termine:", err));
|
|
});
|