Compare commits

..
Author SHA1 Message Date
fad1e6be61 Separate date and name in the upcoming events table
The two columns of the table on the start page touched each other, so the entry
read "Donnerstag, 27.08., 19:00 UhrClub Discordia".

The table is created with the classes "table table-condensed", which no
stylesheet of the site defines, and the table styling that the theme applies
inside prose addresses "tbody td". The table is delivered empty and its rows
are added through the DOM, where a tr appended to a table stays a direct child
instead of being put into a tbody the way the HTML parser would. The rows are
therefore outside of any tbody and the padding of the theme never applied.

Give the column holding the date its own padding, and keep the date on one
line, it is one piece of information and reads badly broken after the weekday.

The padding alone does not fit, though. The table stands in a prose column that
the theme limits to 65 characters so that running text stays readable, and a
date and the name of an event next to each other are wider than that, so the
names would be wrapped over several lines. Lift the limit off the column and
put it back on everything in it except the table, which leaves the table room
to grow while the heading and the paragraph around it keep their width.

That much space then has to be filled sensibly. The theme lays a table out as a
block, "table { display: block; overflow: auto }", so that a wide one can be
scrolled sideways, and a block fills its parent instead of shrinking to its
content the way a table does. Spanning the page the entries would all sit at
its left edge. Ask for the width of the content with fit-content, which the
automatic margins then centre.

Addressing the table by its id keeps all of this to the start page and takes
precedence over the theme, whose prose rules are written with :where() and
carry no specificity.

Measured in a browser at 1280, 768 and 500 pixels: date and name share one line
at the first two, the table is 602 pixels wide with the same distance left and
right, and at 500 the column is narrower than the table, so the table fills it
and only the longest name wraps. The page never scrolls sideways.

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:42:47 +02:00
bca186c6d9 Parse the calendar with ical.js
The calendar page brought its own ICS parser and its own RRULE expansion. Both
only covered the cases that happened to be needed when they were written, and
the calendar has moved on since. Against the published calendar, for September
2026:

  Spieleabend  FREQ=WEEKLY;INTERVAL=2;BYDAY=SA  shown 05. 12. 19. 26., correct 05. 19.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01.,               correct 08.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01.,               correct 22.

INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as
often as it takes place. BYSETPOS was not implemented at all, and since
parseInt("TU") is NaN the fallback turned both Plenum rules into "first
Tuesday", putting two Plenums on a day without one and none on the two days
with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing
more than one weekday were not handled either.

The text was no better. Content lines longer than 75 characters are continued
on the next line, of which there are 477 in the calendar, and the parser did
not join them, so it cut values off in the middle of a word. It also split
every line at the first colon, which lands inside the parameter of
DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so
"\n" was shown as those two characters. 30 of 31 descriptions were wrong:

  before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr"
  after:  "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin"

Hand the parsing and the expansion to ical.js, which is vendored for the start
page anyway. The month view now asks the library for the occurrences that touch
the month, which removes the reimplementation along with all of the above.

While the events are being reduced to what the view needs:

- An event is entered on every day it covers, so the Amateurfunk trip from
  30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is
  not part of it, so one ending at midnight stays on the day before.
- Days and times are derived in Europe/Berlin instead of from the digits of the
  ICS string. Every event currently carries TZID=Europe/Berlin, so the wall
  clock time shown does not change, but a UTC timestamp would have been shown
  in UTC.
- The URL of the event is used for the link in the detail panel. Events without
  one are shown without a link, as on the start page. This replaces
  createEventLink(), which guessed URLs from the title and was never called,
  and the panel no longer builds an <h> element, which is not an element.

Descriptions may contain line breaks, so keep them in the panel.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-22 21:42:41 +02:00
8745b246e6 Vendor ical.js instead of loading it from unpkg.com
The upcoming events table pulled its ICS parser straight from a CDN with
`import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"`. That sends
every visitor of the start page to unpkg.com, which hands their IP address and
user agent to a third party before any of our own code runs. The URL is not
even pinned to a version, so whatever ical.js publishes next is executed on our
site without anybody looking at it, and the start page silently breaks when the
CDN is unreachable.

Check the parser into `assets/js/vendor/` and let Hugo bundle it. This is what
`js.Build` is for: it runs the esbuild that is built into Hugo, so it resolves
the import at build time and needs no node_modules and no extra tooling in the
build environment. The result is minified and fingerprinted like the other
scripts of the site, and the script tag carries a subresource integrity hash.

Since the bundle now has a content hash in its name, its URL cannot be written
by hand in the markdown any more. Move the table and the script tag into an
`upcoming` shortcode, which is the same pattern `calendar.html` already uses,
and move `upcoming.js` from `static/` to `assets/` so Hugo can process it.

The vendored file is the unminified `dist/ical.js` of the pinned release: it
carries the MPL-2.0 header and is the source form of what we ship, and Hugo
minifies it for delivery anyway. `assets/js/vendor/README.md` records the
version, where it came from and how to update it.

The built bundle renders the same table as before, checked against
https://berlin.ccc.de/calendars/all.ics.

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:22:11 +02:00
093826b800 Report errors while loading the calendar
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>
2026-08-22 21:22:11 +02:00
6efb2e8e52 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. Only the printing was wrong, picking and
sorting the events works on absolute points in time and was not affected.

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-22 21:22:11 +02:00
93174ce860 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>
2026-08-22 21:22:11 +02:00
a67cceb8d3 Keep running events in the upcoming list until they end
The "Nächste Veranstaltungen" table selected events with `start > now`, so an
event disappeared from the list the moment it began. Someone looking at the
start page at 20:30 no longer saw the Plenum that had started at 20:00 and ran
until 22:00, which is exactly when that information is most useful.

Select on the end of the event instead, so an event stays listed for as long as
it is still running. A currently running event sorts first, because the list is
ordered by start time.

For recurring events the end of the individual occurrence is needed, not the
end of the series, so go through `getOccurrenceDetails()`. That also resolves
occurrences overridden via RECURRENCE-ID. The chronological break out of the
iteration keeps using the raw occurrence time, which stays monotonic even when
an override moves a single occurrence. `ICAL.Event.endDate` falls back to
DURATION and, for all-day events, to the following day, so events without an
explicit DTEND keep working.

The `maxDays` window still applies to the start of an event, so a long running
event does not extend the window.

This is not a regression from the commit below, the Python generator it
replaced filtered on `dtstart >= start` in the same way.

Checked against https://berlin.ccc.de/calendars/all.ics: the Plenum (20:00 to
22:00) is now listed at 20:30 and 21:59 and gone at 22:01, and the multi-day
Amateurfunk trip (30.10. 12:00 to 01.11. 18:00) stays listed throughout.

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:22:11 +02:00
004b52aa7b 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:22:11 +02:00
4 changed files with 157 additions and 293 deletions

View file

@ -1,4 +1,6 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
import ICAL from "./vendor/ical.js";
const icsUrl = "/calendars/all.ics";
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
// no matter which time zone the browser of the visitor is set to.
@ -49,59 +51,29 @@ function dayKey(date) {
return `${parts.year}-${parts.month}-${parts.day}`;
}
/**
* The day an occurrence time falls on in Berlin.
*
* A date has neither a time nor a zone, its digits are the day itself.
* toJSDate() would read them as midnight in the zone of the browser, which
* far enough east or west of Berlin lands on the day before or after.
*
* @param {ICAL.Time} time The time
* @returns {string} The day as "YYYY-MM-DD"
*/
function timeDayKey(time) {
if (time.isDate) {
const month = String(time.month).padStart(2, "0");
const day = String(time.day).padStart(2, "0");
return `${time.year}-${month}-${day}`;
}
return dayKey(time.toJSDate());
}
/**
* The days an event covers, so that an event running over several days is
* shown on each of them and not only on the day it starts.
*
* @param {ICAL.Time} start Start of the event
* @param {ICAL.Time} end End of the event
* @param {Date} start Start of the event
* @param {Date} end End of the event
* @returns {string[]} The days as "YYYY-MM-DD"
*/
function daysCovered(start, end) {
// The end is not part of the event: one ending at midnight belongs to the day
// before, and an all day event ends on the day before its DTEND.
const last = end.clone();
if (end.isDate) {
last.adjust(-1, 0, 0, 0);
} else {
last.adjust(0, 0, 0, -1);
}
const lastKey = timeDayKey(last);
const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
const lastKey = dayKey(lastInstant);
const days = [];
let key = timeDayKey(start);
let key = dayKey(start);
// The guard keeps a broken event from looping forever, a year of dots on the
// same event is well past the point where the calendar is still useful.
while (days.length <= 366) {
days.push(key);
// The keys sort as the days do, so this also stops an event whose end lies
// before its start after the day it starts on.
if (key >= lastKey) {
if (key === lastKey) {
break;
}
@ -114,13 +86,25 @@ function daysCovered(start, end) {
return days;
}
/**
* 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") ?? "";
}
/**
* Reduce one occurrence of an event to what the calendar displays.
*
* @param {ICAL.Event} event The event the occurrence belongs to
* @param {ICAL.Time} startDate Start of this occurrence
* @param {ICAL.Time} endDate End of this occurrence
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}}
*/
function toOccurrence(event, startDate, endDate) {
return {
@ -130,9 +114,6 @@ function toOccurrence(event, startDate, endDate) {
start: startDate.toJSDate(),
end: endDate.toJSDate(),
allDay: startDate.isDate,
// Taken from the ICAL times, which still know whether they name a day or a
// point in time; the JS dates above no longer do.
days: daysCovered(startDate, endDate),
};
}
@ -162,7 +143,7 @@ function occurrencesOfMonth(year, month) {
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
const add = (occurrence) => {
for (const key of occurrence.days) {
for (const key of daysCovered(occurrence.start, occurrence.end)) {
if (!key.startsWith(monthPrefix)) {
continue;
}
@ -175,20 +156,42 @@ function occurrencesOfMonth(year, month) {
}
};
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
add(toOccurrence(event, startDate, endDate));
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, handling them here as well would show them twice.
if (event.isRecurrenceException() || !event.startDate) {
continue;
}
if (event.isRecurring()) {
const iterator = event.iterator();
while (true) {
const occurrence = iterator.next();
// Recurrences are chronological, so we are done once one starts after
// the month.
if (!occurrence || occurrence.toJSDate() >= to) {
break;
}
// Details resolve time, name and URL of an occurrence that was
// modified via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
if (details.endDate.toJSDate() > from) {
add(toOccurrence(details.item, details.startDate, details.endDate));
}
}
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
add(toOccurrence(event, event.startDate, event.endDate));
}
}
for (const occurrences of Object.values(byDate)) {
occurrences.sort((a, b) => {
// An all day event has no time of day to sort by, the JS date of its
// start is midnight in the zone of the browser. Put it first instead.
if (a.allDay !== b.allDay) {
return a.allDay ? -1 : 1;
}
return a.start - b.start;
});
occurrences.sort((a, b) => a.start - b.start);
}
return byDate;
@ -363,7 +366,7 @@ function formatTimeRange(occurrence) {
const start = timeOfDayFormat.format(occurrence.start);
const end = timeOfDayFormat.format(occurrence.end);
return `Beginn: ${start}, Ende: ${end}`;
return `Start: ${start}, End: ${end}`;
}
document.addEventListener("DOMContentLoaded", function() {
@ -397,9 +400,18 @@ document.addEventListener("DOMContentLoaded", function() {
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
loadCalendar()
.then(loaded => {
calendar = loaded;
fetch(icsUrl)
.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(`${icsUrl}: ${response.status} ${response.statusText}`);
}
return response.text();
})
.then(icsText => {
calendar = new ICAL.Component(ICAL.parse(icsText));
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));

View file

@ -1,194 +0,0 @@
import ICAL from "./vendor/ical.js";
// The calendar is published next to the site and is not built from this
// repository, see the README.
const icsUrl = "/calendars/all.ics";
/**
* 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)
*/
export 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;
}
/**
* 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} to End of the window
* @returns {Date} The recurrence time to stop at
*/
function iterationEnd(event, to) {
let last = to.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, to.getTime() + movedBy);
}
}
return new Date(last);
}
/**
* Whether an occurrence touches a window.
*
* @param {ICAL.Time} startDate Start of the occurrence
* @param {ICAL.Time} endDate End of the occurrence
* @param {Date} from Start of the window
* @param {Date} to End of the window
* @returns {boolean} True when the two overlap
*/
function touches(startDate, endDate, from, to) {
return startDate.toJSDate() <= to && endDate.toJSDate() > from;
}
/**
* Load and parse the calendar of the club.
*
* @returns {Promise<ICAL.Component>} The calendar
*/
export async function loadCalendar() {
const response = await fetch(icsUrl);
// 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(`${icsUrl}: ${response.status} ${response.statusText}`);
}
return new ICAL.Component(ICAL.parse(await response.text()));
}
/**
* Every occurrence of the calendar that touches the given window.
*
* A recurring event is expanded, and an occurrence that was modified on its own
* is reported with the time, the name and the URL of that modification.
*
* @param {ICAL.Component} calendar The parsed calendar
* @param {Date} from An occurrence has to still be running at this time
* @param {Date} to An occurrence has to have started by this time
* @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}}
*/
export function* occurrencesBetween(calendar, from, to) {
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, handling them here as well would report 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()) {
if (touches(event.startDate, event.endDate, from, to)) {
yield { event, startDate: event.startDate, endDate: event.endDate };
}
continue;
}
const iterator = event.iterator();
const iterateUntil = iterationEnd(event, to);
while (true) {
const occurrence = iterator.next();
// Recurrences are chronological, so we are done once one starts after the
// window, and after the occurrences a modification can still move back
// into it.
if (!occurrence || 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 modification may have moved the occurrence out of the window, so
// judge it by the time it really takes place at.
if (touches(details.startDate, details.endDate, from, to)) {
yield {
event: details.item,
startDate: details.startDate,
endDate: details.endDate,
};
}
}
}
}

View file

@ -1,49 +1,88 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
import ICAL from "./vendor/ical.js";
/**
* When an occurrence starts, as a point in time.
* Read the URL of an event.
*
* 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.
* ICAL.Event does not expose the URL property, so read it from the component.
*
* @param {ICAL.Time} time Start of the occurrence
* @returns {Date} The point in time to sort and print by
* @param {ICAL.Event} event The event to read the URL of
* @returns {string} The URL, empty when the event has none
*/
function startOf(time) {
if (time.isDate) {
return new Date(Date.UTC(time.year, time.month - 1, time.day));
}
return time.toJSDate();
function eventUrl(event) {
return event.component.getFirstPropertyValue("url") ?? "";
}
/**
* The upcoming occurrences of a calendar.
* Parse an ICS calendar and return upcoming event occurrences.
*
* @param {ICAL.Component} calendar The parsed calendar
* @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
* @returns {{start: Date, name: string, url: string}[]} url is empty when the event has no URL
*/
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
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 = [];
// A running event stays listed until it is over, so the window starts at now
// and the walk keeps everything that has not ended yet.
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
events.push({
start: startOf(startDate),
allDay: startDate.isDate,
name: event.summary ?? "",
url: eventUrl(event),
});
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
@ -54,34 +93,41 @@ function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
}
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");
loadCalendar()
.then(calendar => {
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
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");
// 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", {
// 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",
...whenFormat,
hour: "2-digit",
minute: "2-digit",
});
colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`;
colBegin.innerText = `${formattedStart} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");

View file

@ -1,9 +1,9 @@
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
{{ $css := resources.Get "css/calendar.css" }}
<div class="calendar-container">
{{ with $css }}
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
<link rel="stylesheet" href="{{ .RelPermalink }}">
{{ end }}
{{ with $js }}
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>