Compare commits

...
Author SHA1 Message Date
d3201792a4 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:22:11 +02:00
893c88adf1 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.

Fixes: 4068fab5b1ea ("improved calendar and fixed url temporarily")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-22 21:22:11 +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
b11d80aac2 README: describe the calendar as it is built today
`build.sh` no longer post-processes anything: since the switch to ICS files it
just deletes `public/` and runs `hugo`. The `CALENDAR` placeholder, the Python
dependency on `icalendar`, `python-dateutil` and `pytz`, and the `de_DE.UTF-8`
locale requirement are all gone, and nothing in `packages.nix`, `devShells.nix`
or `flake.nix` pulls in Python any more. Drop those claims.

Describe instead where the two calendar views actually come from: the
"Nächste Veranstaltungen" table and the calendar page are rendered in the
browser from `/calendars/all.ics`, which is published next to the site and is
not built from this repository. That also replaces the old advice to preview
them via `./build.sh` plus a local HTTP server, which no longer helps.

Without that file the two of them stay empty. Hugo serves everything below
`static/` from the root of the site, so it can simply be put there, and the
command that downloads it is worth writing down. Checked from an empty state:
afterwards the file is served under `/calendars/all.ics` by `hugo serve` and the
table on the start page fills with the upcoming events. It is a copy of
published data and has no business in the repository, so ignore it.

Mention the per-section `.ics` feeds Hugo does generate, so the ICS templates
under `layouts/` are not mistaken for the source of `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:02 +02:00
11 changed files with 10293 additions and 475 deletions

5
.gitignore vendored
View file

@ -202,3 +202,8 @@ $RECYCLE.BIN/
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
### CCCB ###
# The calendar the site reads its events from is published next to the site and
# only downloaded here to look at the site locally, see the README.
/static/calendars/

View file

@ -29,20 +29,34 @@ This is the website of the CCCB.
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
The *"Nächste Veranstaltungen"* table on the home page is generated by post-processing in `./build.sh`, not by Hugo, so
it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to
ready the site for upload, run:
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
generated by Hugo and is not part of this repo — it is published separately on the web server. Without it both tables
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
locally, under `hugo serve` as well as in a built site:
```shell
mkdir -p static/calendars
curl -o static/calendars/all.ics https://berlin.ccc.de/calendars/all.ics
```
The file is only there to look at the site, it is not checked in. Download it again whenever you want the events that
are currently published.
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
The site must not make the browser load anything from an external server, so third party JavaScript is checked into
`assets/js/vendor/` and bundled in by Hugo instead of being pulled from a CDN. See the README there before updating it.
To build the site for upload, run:
```shell
./build.sh
python3 -m http.server -d public 1313
```
`build.sh` replaces the `CALENDAR` placeholder in `index.html` with the upcoming-events table. It depends on Python
with the `icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` locale (used to format weekday
names). Inside `nix develop` these are provided automatically.
To build with *nix*: `nix build '.?submodules=1#production-content'`
This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead:
`nix build '.?submodules=1#production-content'`
## Making a change
@ -59,8 +73,7 @@ To build with *nix*: `nix build '.?submodules=1#production-content'`
## Nix stuff
- After entering the shell with `nix develop`, hugo is available and `hugo serve` should work
- Python including required packages will be available, so the `build.sh` should work without a venv
- After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` should work
- You can build the staging and production builds with `nix build .#staging-content` and
`nix build .#production-content`
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last

View file

@ -107,6 +107,10 @@
font-size: 0.9em;
margin-bottom: 5px;
}
.event-description {
/* Descriptions carry their own line breaks, keep them. */
white-space: pre-line;
}
.no-events {
font-style: italic;
color: var(--color-text-secondary);

36
assets/css/upcoming.css Normal file
View file

@ -0,0 +1,36 @@
/* The rows of the table are added by JavaScript and end up as direct children
of the table, so the table styling of the theme, which addresses tbody, does
not reach them. Separate the date from the name of the event ourselves, and
keep the date on one line, it is one piece of information and reads badly
broken after the weekday. */
#upcoming td:first-child {
padding-inline-end: 1em;
white-space: nowrap;
}
/* The table stands in a prose column, which the theme limits to 65 characters
so that running text stays readable. A date and the name of an event next to
each other do not fit into that, so the names were 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 text around
it keeps its width. */
section.prose:has(> #upcoming) {
max-width: none;
}
section.prose:has(> #upcoming) > :not(#upcoming) {
max-width: 65ch;
margin-inline: auto;
}
/* The theme lays a table out as a block, "table { display: block; overflow:
auto }", so that a wide one can be scrolled sideways. A block fills its
parent instead of shrinking to its content the way a table does, so across
the whole width of the page the entries would stay at its left edge.
fit-content asks for the width of the content, which the automatic margins
then centre, and it never exceeds the column, so a display too narrow for
the table still wraps the names instead of overflowing. */
#upcoming {
width: fit-content;
margin-inline: auto;
}

View file

@ -1,449 +1,418 @@
document.addEventListener('DOMContentLoaded', function() {
(function(){
let events = [];
let eventsByDate = {};
import ICAL from "./vendor/ical.js";
// Funktion zum Parsen der ICS-Datei
function parseICS(icsText) {
let events = [];
let lines = icsText.split(/\r?\n/);
let event = null;
lines.forEach(line => {
if (line.startsWith("BEGIN:VEVENT")) {
event = {};
} else if (line.startsWith("END:VEVENT")) {
if (event) events.push(event);
event = null;
} else if (event) {
let colonIndex = line.indexOf(":");
if (colonIndex > -1) {
let key = line.substring(0, colonIndex);
let value = line.substring(colonIndex + 1);
const icsUrl = "/calendars/all.ics";
// Handle properties with parameters (like TZID)
const baseKey = key.split(";")[0];
// 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.
const timeZone = "Europe/Berlin";
if (baseKey === "DTSTART") {
event.start = value;
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} else if (baseKey === "DTEND") {
event.end = value;
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} else if (baseKey === "SUMMARY") {
event.summary = value;
} else if (baseKey === "DESCRIPTION") {
event.description = value;
} else if (baseKey === "RRULE") {
event.rrule = value;
}
}
}
});
return events;
}
const monthNames = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
function parseDateString(icsDateStr) {
// Handle different date formats
if (!icsDateStr) return null;
// For basic date format: YYYYMMDD
if (icsDateStr.length === 8) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
else if (icsDateStr.includes("T")) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
return null;
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
timeZone,
hour: "2-digit",
minute: "2-digit",
});
let calendar = null;
let eventsByDate = {};
let currentYear;
let currentMonth;
let currentMonthElem;
let calendarBody;
let eventPanel;
let eventDateElem;
let eventDetailsElem;
/**
* The day a point in time falls on in Berlin.
*
* @param {Date} date The point in time
* @returns {string} The day as "YYYY-MM-DD"
*/
function dayKey(date) {
const parts = {};
for (const part of dayKeyFormat.formatToParts(date)) {
parts[part.type] = part.value;
}
return `${parts.year}-${parts.month}-${parts.day}`;
}
/**
* 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 {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 lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
const lastKey = dayKey(lastInstant);
const days = [];
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);
if (key === lastKey) {
break;
}
// Step to noon UTC of the next day, which is inside the same Berlin day
// whether daylight saving time is in effect or not.
const [year, month, day] = key.split("-").map(Number);
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
}
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}}
*/
function toOccurrence(event, startDate, endDate) {
return {
summary: event.summary ?? "",
description: event.description ?? "",
url: eventUrl(event),
start: startDate.toJSDate(),
end: endDate.toJSDate(),
allDay: startDate.isDate,
};
}
/**
* Collect everything that takes place in the given month, keyed by day.
*
* @param {number} year The year of the month
* @param {number} month The month, January is 0
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
*/
function occurrencesOfMonth(year, month) {
const byDate = {};
if (!calendar) {
return byDate;
}
// These bounds only limit how far the recurrences have to be expanded, which
// day an occurrence ends up on is decided by its Berlin day below. They are
// deliberately generous so that no occurrence is cut off at the edge of the
// month by a time zone difference.
const from = new Date(year, month, 1);
const to = new Date(year, month + 1, 1);
from.setDate(from.getDate() - 2);
to.setDate(to.getDate() + 2);
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
const add = (occurrence) => {
for (const key of daysCovered(occurrence.start, occurrence.end)) {
if (!key.startsWith(monthPrefix)) {
continue;
}
if (!byDate[key]) {
byDate[key] = [];
}
byDate[key].push(occurrence);
}
};
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;
}
// Extract date components from different date formats
function getDateComponents(icsDateStr) {
if (!icsDateStr) return null;
// Details resolve time, name and URL of an occurrence that was
// modified via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
// Basic handling - extract YYYY, MM, DD regardless of format
const year = parseInt(icsDateStr.substring(0, 4));
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
const day = parseInt(icsDateStr.substring(6, 8));
return { year, month, day };
}
function expandRecurringEvents(event, year, month) {
if (!event.rrule) return [event];
const rruleStr = event.rrule;
// Get start date components
const startComponents = getDateComponents(event.start);
if (!startComponents) return [event];
const startDate = new Date(
startComponents.year,
startComponents.month,
startComponents.day
);
const rangeStart = new Date(year, month, 1);
const rangeEnd = new Date(year, month + 1, 0);
const expandedEvents = [];
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
const dayCode = bydayMatch[1];
const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
};
const targetDay = dayMap[dayCode];
if (targetDay !== undefined) {
// Create events for each matching day in the month
let day = 1;
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day);
if (testDate.getDay() === targetDay && testDate >= startDate) {
const newEvent = {...event};
const eventDate = formatDateForICS(testDate);
// Preserve time portion from original event
const timePart = event.start.includes('T') ?
event.start.substring(event.start.indexOf('T')) : '';
const endTimePart = event.end.includes('T') ?
event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = eventDate + timePart;
newEvent.end = eventDate + endTimePart;
expandedEvents.push(newEvent);
}
day++;
}
}
}
}
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
return [];
}
const bydays = bydayMatch[1].split(',');
const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
};
bydays.forEach(byday => {
const occurrence = parseInt(byday) || 1;
const dayCode = byday.slice(-2);
const dayIndex = dayMap[dayCode];
let day = 1;
let count = 0;
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day);
if (testDate.getDay() === dayIndex) {
count++;
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
const newEvent = {...event};
const eventDate = new Date(year, month, day);
// Preserve time portion from original event
const timePart = event.start.includes('T') ?
event.start.substring(event.start.indexOf('T')) : '';
const endTimePart = event.end.includes('T') ?
event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = formatDateForICS(eventDate) + timePart;
newEvent.end = formatDateForICS(eventDate) + endTimePart;
expandedEvents.push(newEvent);
}
}
day++;
}
});
}
}
return expandedEvents.length > 0 ? expandedEvents : [event];
if (details.endDate.toJSDate() > from) {
add(toOccurrence(details.item, details.startDate, details.endDate));
}
// Kalender initialisieren
let currentYear, currentMonth;
const currentMonthElem = document.getElementById("current-month");
const calendarBody = document.getElementById("calendar-body");
const eventPanel = document.getElementById("event-panel");
const eventDateElem = document.getElementById("event-date");
const eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function(){
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function(){
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
}
} 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) => a.start - b.start);
}
return byDate;
}
function updateEventsForMonth(year, month) {
eventsByDate = occurrencesOfMonth(year, month);
renderCalendar(year, month);
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++) {
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++) {
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr;
if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
// Gruppe Events nach Typ
const events = eventsByDate[dateKey];
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
const hasRegular = events.some(e => {
const title = e.summary.toLowerCase();
return !title.includes("members only") &&
!title.includes("subbotnik") &&
!title.includes("bastelabend") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
});
function updateEventsForMonth(year, month) {
// Clear existing events for this month view
eventsByDate = {};
// Process each event, expanding recurring ones
events.forEach(ev => {
if (ev.rrule) {
// For recurring events, expand them for current month
const expandedEvents = expandRecurringEvents(ev, year, month);
expandedEvents.forEach(expandedEv => {
let dateKey = parseDateString(expandedEv.start);
if (dateKey) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(expandedEv);
}
});
} else {
// For regular events, check if they fall in current month
let dateKey = parseDateString(ev.start);
if (dateKey) {
// Check if this event belongs to current month view
const eventYear = parseInt(dateKey.split('-')[0]);
const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
if (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(ev);
}
}
}
});
renderCalendar(year, month);
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++){
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++){
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr;
if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
// Gruppe Events nach Typ
const events = eventsByDate[dateKey];
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
const hasRegular = events.some(e => {
const title = e.summary.toLowerCase();
return !title.includes("members only") &&
!title.includes("subbotnik") &&
!title.includes("bastelabend") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
});
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
function createEventLink(eventTitle) {
if (eventTitle.startsWith("Datengarten")) {
// Extract the number after "Datengarten "
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
if (match && match[1]) {
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
}
}
// For other titles, convert to lowercase and use as path
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return `https://berlin.ccc.de/page/${slug}/`;
}
function showEventDetails(dateKey) {
const events = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
if (events && events.length > 0) {
events.forEach(ev => {
let eventItem = document.createElement("div");
eventItem.className = "event-item";
let eventTitle = document.createElement("div");
eventTitle.className = "event-title";
// Create a link for the event title
let titleLink = document.createElement("h");
titleLink.textContent = ev.summary;
titleLink.target = "_blank";
eventTitle.appendChild(titleLink);
eventItem.appendChild(eventTitle);
let eventTime = document.createElement("div");
eventTime.className = "event-time";
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
eventItem.appendChild(eventTime);
if (ev.description) {
let eventDescription = document.createElement("div");
eventDescription.className = "event-description";
// Check if the description is a URL and make it a clickable link
if (ev.description.trim().startsWith('http')) {
let linkElement = document.createElement("a");
linkElement.href = ev.description.trim();
linkElement.textContent = ev.description.trim();
linkElement.target = "_blank";
eventDescription.innerHTML = '';
eventDescription.appendChild(linkElement);
} else {
eventDescription.textContent = ev.description;
}
eventItem.appendChild(eventDescription);
}
eventDetailsElem.appendChild(eventItem);
});
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
eventPanel.style.display = "block";
}
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
function formatTime(icsTimeStr) {
// Format time for display
if (!icsTimeStr) return "";
if (icsTimeStr.length === 8) {
// All-day event
return "Ganztägig";
} else if (icsTimeStr.includes("T")) {
// Time-specific event (with or without timezone)
const timeStart = icsTimeStr.indexOf("T") + 1;
const hour = icsTimeStr.substring(timeStart, timeStart + 2);
const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4);
return `${hour}:${minute}`;
}
return icsTimeStr;
}
function formatDateForICS(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}${month}${day}`;
}
// ICS-Datei abrufen und Events verarbeiten
fetch('/calendars/all.ics')
.then(response => response.text())
.then(data => {
events = parseICS(data);
// Initialize with current date
let today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
// Process events for current month
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
})();
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
/**
* Build the entry of a single event in the detail panel.
*
* @param {Object} occurrence The occurrence to show
* @returns {HTMLElement} The entry
*/
function renderEventItem(occurrence) {
const item = document.createElement("div");
item.className = "event-item";
const title = document.createElement("div");
title.className = "event-title";
if (occurrence.url) {
const link = document.createElement("a");
link.href = occurrence.url;
link.textContent = occurrence.summary;
title.appendChild(link);
} else {
title.textContent = occurrence.summary;
}
item.appendChild(title);
const time = document.createElement("div");
time.className = "event-time";
time.textContent = formatTimeRange(occurrence);
item.appendChild(time);
if (occurrence.description) {
const description = document.createElement("div");
description.className = "event-description";
description.textContent = occurrence.description;
item.appendChild(description);
}
return item;
}
function showEventDetails(dateKey) {
const occurrences = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
if (occurrences && occurrences.length > 0) {
for (const occurrence of occurrences) {
eventDetailsElem.appendChild(renderEventItem(occurrence));
}
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
eventPanel.style.display = "block";
}
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
/**
* Describe when an occurrence takes place, in Berlin time.
*
* @param {Object} occurrence The occurrence to describe
* @returns {string} The description
*/
function formatTimeRange(occurrence) {
if (occurrence.allDay) {
return "Ganztägig";
}
const start = timeOfDayFormat.format(occurrence.start);
const end = timeOfDayFormat.format(occurrence.end);
return `Start: ${start}, End: ${end}`;
}
document.addEventListener("DOMContentLoaded", function() {
currentMonthElem = document.getElementById("current-month");
calendarBody = document.getElementById("calendar-body");
eventPanel = document.getElementById("event-panel");
eventDateElem = document.getElementById("event-date");
eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function() {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function() {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
// Show the grid of the current month right away, the events are filled in
// once the calendar has been loaded.
const today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
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,13 +1,25 @@
import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js";
import ICAL from "./vendor/ical.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 start after this date
* @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}[]}
* @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);
@ -21,6 +33,12 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
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;
}
@ -35,30 +53,33 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
break;
}
const start = occurrence.toJSDate();
// Recurrences are chronological, so we're done
// once we pass the end of our search window.
if (start > end) {
if (occurrence.toJSDate() > end) {
break;
}
if (start > now) {
// 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,
name: event.summary ?? "",
url: event.url ?? "",
start: details.startDate.toJSDate(),
name: details.item.summary ?? "",
url: eventUrl(details.item),
});
}
}
} else {
const start = event.startDate.toJSDate();
if (start > now && start <= end) {
if (start <= end && event.endDate.toJSDate() > now) {
events.push({
start,
name: event.summary ?? "",
url: event.url ?? "",
url: eventUrl(event),
});
}
}
@ -80,7 +101,15 @@ document.addEventListener("DOMContentLoaded", () => {
const table = document.getElementById("upcoming");
fetch(ics)
.then(response => response.text())
.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");
@ -88,26 +117,35 @@ document.addEventListener("DOMContentLoaded", () => {
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",
}).replace(",", "");
});
colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`;
colBegin.innerText = `${formattedStart} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
if (event.url) {
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
} else {
colName.innerText = event.name;
}
colName.appendChild(a);
row.appendChild(colName);
table.appendChild(row);
});
});
})
.catch(err => console.error("Fehler beim Laden der Termine:", err));
});

19
assets/js/vendor/README.md vendored Normal file
View file

@ -0,0 +1,19 @@
# Vendored JavaScript
Third party code is checked in here instead of being loaded from a CDN, so that the website does not make the visitor's
browser fetch anything from an external server.
Hugo bundles and minifies these files into the scripts that reference them, so the unminified source is checked in.
## ical.js
- Version: 2.2.1
- Source: <https://unpkg.com/ical.js@2.2.1/dist/ical.js>
- Upstream: <https://github.com/kewisch/ical.js>
- License: MPL-2.0 (see the header of `ical.js`)
To update, download the `dist/ical.js` of the wanted release and replace the file, keeping the version above in sync:
```shell
curl -o assets/js/vendor/ical.js https://unpkg.com/ical.js@<version>/dist/ical.js
```

9732
assets/js/vendor/ical.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -25,9 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
### Nächste Veranstaltungen
<table id="upcoming" class="table table-condensed">
</table>
{{< upcoming >}}
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
<script type="module" src="/js/upcoming.js"></script>

View file

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

View file

@ -0,0 +1,5 @@
{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}}
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
<table id="upcoming" class="table table-condensed"></table>
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>