www/static/js/upcoming.js
Hauke Mehrtens cd39842ac0 Show event times in Berlin time
`toLocaleString()` was given the German locale but no time zone, so it printed
the time in whatever zone the browser of the visitor is set to. The events
happen in Berlin, so this is only correct for visitors who are in Berlin.
Somebody reading the start page from Sydney was told the Plenum of Tuesday
20:00 takes place on Wednesday at 04:00.

Format in Europe/Berlin explicitly. For an event that names a point in time
only the printing was wrong, picking and sorting work on absolute points in
time and were not affected.

An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, so the point in time that is then printed in Berlin time is off by
the offset between the two, which puts a bogus time of day on the entry and,
far enough east or west, the wrong day. An event on 30.08. was announced as:

  Berlin       Sonntag, 30.08., 00:00 Uhr
  Los Angeles  Sonntag, 30.08., 09:00 Uhr
  Tokio        Samstag, 29.08., 17:00 Uhr

Keep the digits of the date and read them as UTC, which no browser setting
moves, and print an all day event in UTC and without a time of day. Berlin,
Tokyo, Los Angeles and Kiritimati all say "Sonntag, 30.08." now, while a timed
event keeps saying "Sonntag, 30.08., 19:00 Uhr" everywhere. Sorting improves
with it, an all day event no longer changes its place in the list depending on
where the visitor sits.

The published calendar has no all day events today, they can be created in the
CalDAV calendar the export comes from.

Drop the two replacements around the formatted date while touching it. The
first removes the comma after the weekday and the second puts it back, so they
cancel each other out:

  "Samstag, 22.08., 17:00" -> "Samstag 22.08., 17:00" -> "Samstag, 22.08., 17:00"

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-23 01:07:25 +02:00

250 lines
7.8 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.
*
* 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)
*/
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;
}
/**
* When an occurrence starts, as a point in time.
*
* A date has no time and no zone, its digits are the day itself. toJSDate()
* reads them as midnight in the zone of the browser, which moves an all day
* event by the offset that zone has to Berlin and, far enough east or west,
* onto the day before or after. Keep the digits and read them as UTC instead,
* the table prints an all day event in UTC as well.
*
* @param {ICAL.Time} time Start of the occurrence
* @returns {Date} The point in time to sort and print by
*/
function startOf(time) {
if (time.isDate) {
return new Date(Date.UTC(time.year, time.month - 1, time.day));
}
return time.toJSDate();
}
/**
* 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} end End of the window
* @returns {Date} The recurrence time to stop at
*/
function iterationEnd(event, end) {
let last = end.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, end.getTime() + movedBy);
}
}
return new Date(last);
}
/**
* 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, allDay: boolean, 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 = [];
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, listing them here as well would show 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()) {
const iterator = event.iterator();
const iterateUntil = iterationEnd(event, end);
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() > iterateUntil) {
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.
// A modification may have moved the occurrence out of the window, so
// judge it by the time it really takes place at.
if (details.startDate.toJSDate() <= end && details.endDate.toJSDate() > now) {
events.push({
start: startOf(details.startDate),
allDay: details.startDate.isDate,
name: details.item.summary ?? "",
url: eventUrl(details.item),
});
}
}
} else if (event.startDate.toJSDate() <= end && event.endDate.toJSDate() > now) {
events.push({
start: startOf(event.startDate),
allDay: event.startDate.isDate,
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 => response.text())
.then(icsText => {
getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");
// 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. An all day
// event has no time of day and carries its date in UTC, see startOf().
const whenFormat = event.allDay
? { timeZone: "UTC" }
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
...whenFormat,
});
colBegin.innerText = event.allDay ? formattedStart : `${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);
});
});
});