From cd39842ac0fdf556796ee9d0c1b746585448c615 Mon Sep 17 00:00:00 2001 From: Hauke Mehrtens Date: Sat, 22 Aug 2026 19:57:14 +0200 Subject: [PATCH] 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: 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 | 56 ++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/static/js/upcoming.js b/static/js/upcoming.js index dde5f1f..64daf96 100644 --- a/static/js/upcoming.js +++ b/static/js/upcoming.js @@ -64,6 +64,26 @@ function exceptionsByUid(components) { 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. * @@ -98,7 +118,7 @@ function iterationEnd(event, end) { * @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 + * @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); @@ -153,22 +173,20 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { // judge it by the time it really takes place at. if (details.startDate.toJSDate() <= end && details.endDate.toJSDate() > now) { events.push({ - start: details.startDate.toJSDate(), + start: startOf(details.startDate), + allDay: details.startDate.isDate, 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), - }); - } + } 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), + }); } } @@ -195,15 +213,21 @@ document.addEventListener("DOMContentLoaded", () => { 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", - hour: "2-digit", - minute: "2-digit", - }).replace(",", ""); + ...whenFormat, + }); - colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`; + colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`; row.appendChild(colBegin); const colName = document.createElement("td");