Merge pull request 'Fixes fuer unseren Kalender auf der website' (#57) from hauke/www:kalender into staging
Some checks failed
deploy blog / deploy (push) Failing after 1m25s

Reviewed-on: #57
This commit is contained in:
hauke 2026-08-23 01:14:45 +02:00
commit 746583df5b
13 changed files with 10533 additions and 579 deletions

5
.gitignore vendored
View file

@ -202,3 +202,8 @@ $RECYCLE.BIN/
*.lnk *.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv # 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. 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 The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
ready the site for upload, run: 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 ```shell
./build.sh ./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 This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead:
with the `icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` locale (used to format weekday `nix build '.?submodules=1#production-content'`
names). Inside `nix develop` these are provided automatically.
To build with *nix*: `nix build '.?submodules=1#production-content'`
## Making a change ## Making a change
@ -59,8 +73,7 @@ To build with *nix*: `nix build '.?submodules=1#production-content'`
## Nix stuff ## Nix stuff
- After entering the shell with `nix develop`, hugo is available and `hugo serve` should work - After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` should work
- Python including required packages will be available, so the `build.sh` should work without a venv
- You can build the staging and production builds with `nix build .#staging-content` and - You can build the staging and production builds with `nix build .#staging-content` and
`nix build .#production-content` `nix build .#production-content`
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last - 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; font-size: 0.9em;
margin-bottom: 5px; margin-bottom: 5px;
} }
.event-description {
/* Descriptions carry their own line breaks, keep them. */
white-space: pre-line;
}
.no-events { .no-events {
font-style: italic; font-style: italic;
color: var(--color-text-secondary); 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,406 @@
document.addEventListener('DOMContentLoaded', function() { import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
(function(){
let events = [];
let eventsByDate = {};
// Funktion zum Parsen der ICS-Datei // The club is in Berlin, so the calendar shows Berlin days and Berlin times,
function parseICS(icsText) { // no matter which time zone the browser of the visitor is set to.
let events = []; const timeZone = "Europe/Berlin";
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);
// Handle properties with parameters (like TZID) const monthNames = [
const baseKey = key.split(";")[0]; "Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
if (baseKey === "DTSTART") { const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
event.start = value; timeZone,
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; year: "numeric",
} else if (baseKey === "DTEND") { month: "2-digit",
event.end = value; day: "2-digit",
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; });
} else if (baseKey === "SUMMARY") {
event.summary = value; const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
} else if (baseKey === "DESCRIPTION") { timeZone,
event.description = value; hour: "2-digit",
} else if (baseKey === "RRULE") { minute: "2-digit",
event.rrule = value; });
}
} let calendar = null;
} let eventsByDate = {};
}); let currentYear;
return events; let currentMonth;
}
let currentMonthElem;
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD" let calendarBody;
function parseDateString(icsDateStr) { let eventPanel;
// Handle different date formats let eventDateElem;
if (!icsDateStr) return null; let eventDetailsElem;
// For basic date format: YYYYMMDD /**
if (icsDateStr.length === 8) { * The day a point in time falls on in Berlin.
let year = icsDateStr.substring(0, 4); *
let month = icsDateStr.substring(4, 6); * @param {Date} date The point in time
let day = icsDateStr.substring(6, 8); * @returns {string} The day as "YYYY-MM-DD"
return `${year}-${month}-${day}`; */
} function dayKey(date) {
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss const parts = {};
else if (icsDateStr.includes("T")) {
let year = icsDateStr.substring(0, 4); for (const part of dayKeyFormat.formatToParts(date)) {
let month = icsDateStr.substring(4, 6); parts[part.type] = part.value;
let day = icsDateStr.substring(6, 8); }
return `${year}-${month}-${day}`;
} return `${parts.year}-${parts.month}-${parts.day}`;
return null; }
}
/**
// Extract date components from different date formats * The day an occurrence time falls on in Berlin.
function getDateComponents(icsDateStr) { *
if (!icsDateStr) return null; * 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
// Basic handling - extract YYYY, MM, DD regardless of format * far enough east or west of Berlin lands on the day before or after.
const year = parseInt(icsDateStr.substring(0, 4)); *
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months * @param {ICAL.Time} time The time
const day = parseInt(icsDateStr.substring(6, 8)); * @returns {string} The day as "YYYY-MM-DD"
*/
return { year, month, day }; function timeDayKey(time) {
} if (time.isDate) {
const month = String(time.month).padStart(2, "0");
function expandRecurringEvents(event, year, month) { const day = String(time.day).padStart(2, "0");
if (!event.rrule) return [event];
return `${time.year}-${month}-${day}`;
const rruleStr = event.rrule; }
// Get start date components return dayKey(time.toJSDate());
const startComponents = getDateComponents(event.start); }
if (!startComponents) return [event];
/**
const startDate = new Date( * The days an event covers, so that an event running over several days is
startComponents.year, * shown on each of them and not only on the day it starts.
startComponents.month, *
startComponents.day * @param {ICAL.Time} start Start of the event
); * @param {ICAL.Time} end End of the event
* @returns {string[]} The days as "YYYY-MM-DD"
const rangeStart = new Date(year, month, 1); */
const rangeEnd = new Date(year, month + 1, 0); function daysCovered(start, end) {
const expandedEvents = []; // 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.
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) { const last = end.clone();
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) { if (end.isDate) {
const dayCode = bydayMatch[1]; last.adjust(-1, 0, 0, 0);
const dayMap = { } else {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 last.adjust(0, 0, 0, -1);
}; }
const targetDay = dayMap[dayCode];
const lastKey = timeDayKey(last);
if (targetDay !== undefined) { const days = [];
// Create events for each matching day in the month
let day = 1; let key = timeDayKey(start);
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day); // The guard keeps a broken event from looping forever, a year of dots on the
if (testDate.getDay() === targetDay && testDate >= startDate) { // same event is well past the point where the calendar is still useful.
const newEvent = {...event}; while (days.length <= 366) {
const eventDate = formatDateForICS(testDate); days.push(key);
// Preserve time portion from original event // The keys sort as the days do, so this also stops an event whose end lies
const timePart = event.start.includes('T') ? // before its start after the day it starts on.
event.start.substring(event.start.indexOf('T')) : ''; if (key >= lastKey) {
const endTimePart = event.end.includes('T') ? break;
event.end.substring(event.end.indexOf('T')) : ''; }
newEvent.start = eventDate + timePart; // Step to noon UTC of the next day, which is inside the same Berlin day
newEvent.end = eventDate + endTimePart; // whether daylight saving time is in effect or not.
expandedEvents.push(newEvent); const [year, month, day] = key.split("-").map(Number);
} key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
day++; }
}
} return days;
} }
}
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) { /**
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/); * Reduce one occurrence of an event to what the calendar displays.
if (bydayMatch) { *
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/); * @param {ICAL.Event} event The event the occurrence belongs to
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1; * @param {ICAL.Time} startDate Start of this occurrence
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth()); * @param {ICAL.Time} endDate End of this occurrence
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) { * @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
return []; */
} function toOccurrence(event, startDate, endDate) {
const bydays = bydayMatch[1].split(','); return {
const dayMap = { summary: event.summary ?? "",
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 description: event.description ?? "",
}; url: eventUrl(event),
start: startDate.toJSDate(),
bydays.forEach(byday => { end: endDate.toJSDate(),
const occurrence = parseInt(byday) || 1; allDay: startDate.isDate,
const dayCode = byday.slice(-2); // Taken from the ICAL times, which still know whether they name a day or a
const dayIndex = dayMap[dayCode]; // point in time; the JS dates above no longer do.
days: daysCovered(startDate, endDate),
let day = 1; };
let count = 0; }
while (day <= rangeEnd.getDate()) { /**
const testDate = new Date(year, month, day); * Collect everything that takes place in the given month, keyed by day.
if (testDate.getDay() === dayIndex) { *
count++; * @param {number} year The year of the month
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) { * @param {number} month The month, January is 0
const newEvent = {...event}; * @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
const eventDate = new Date(year, month, day); */
function occurrencesOfMonth(year, month) {
// Preserve time portion from original event const byDate = {};
const timePart = event.start.includes('T') ?
event.start.substring(event.start.indexOf('T')) : ''; if (!calendar) {
const endTimePart = event.end.includes('T') ? return byDate;
event.end.substring(event.end.indexOf('T')) : ''; }
newEvent.start = formatDateForICS(eventDate) + timePart; // These bounds only limit how far the recurrences have to be expanded, which
newEvent.end = formatDateForICS(eventDate) + endTimePart; // day an occurrence ends up on is decided by its Berlin day below. They are
expandedEvents.push(newEvent); // 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);
day++; 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")}-`;
return expandedEvents.length > 0 ? expandedEvents : [event]; const add = (occurrence) => {
} for (const key of occurrence.days) {
if (!key.startsWith(monthPrefix)) {
// Kalender initialisieren continue;
let currentYear, currentMonth; }
const currentMonthElem = document.getElementById("current-month");
const calendarBody = document.getElementById("calendar-body"); if (!byDate[key]) {
const eventPanel = document.getElementById("event-panel"); byDate[key] = [];
const eventDateElem = document.getElementById("event-date"); }
const eventDetailsElem = document.getElementById("event-details");
byDate[key].push(occurrence);
document.getElementById("prev-month").addEventListener("click", function(){ }
currentMonth--; };
if (currentMonth < 0) {
currentMonth = 11; for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
currentYear--; add(toOccurrence(event, startDate, endDate));
} }
updateEventsForMonth(currentYear, currentMonth);
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;
});
}
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');
}); });
document.getElementById("next-month").addEventListener("click", function(){ cell.classList.add('selected-day');
currentMonth++; showEventDetails(dateKey);
if (currentMonth > 11) { });
currentMonth = 0; }
currentYear++; row.appendChild(cell);
} }
updateEventsForMonth(currentYear, currentMonth); // Falls die letzte Zeile nicht komplett ist
}); while (row.children.length < 7) {
let cell = document.createElement("td");
function updateEventsForMonth(year, month) { row.appendChild(cell);
// Clear existing events for this month view }
eventsByDate = {}; calendarBody.appendChild(row);
}
// Process each event, expanding recurring ones
events.forEach(ev => { /**
if (ev.rrule) { * Build the entry of a single event in the detail panel.
// For recurring events, expand them for current month *
const expandedEvents = expandRecurringEvents(ev, year, month); * @param {Object} occurrence The occurrence to show
expandedEvents.forEach(expandedEv => { * @returns {HTMLElement} The entry
let dateKey = parseDateString(expandedEv.start); */
if (dateKey) { function renderEventItem(occurrence) {
if (!eventsByDate[dateKey]) { const item = document.createElement("div");
eventsByDate[dateKey] = []; item.className = "event-item";
}
eventsByDate[dateKey].push(expandedEv); const title = document.createElement("div");
} title.className = "event-title";
});
} else { if (occurrence.url) {
// For regular events, check if they fall in current month const link = document.createElement("a");
let dateKey = parseDateString(ev.start); link.href = occurrence.url;
if (dateKey) { link.textContent = occurrence.summary;
// Check if this event belongs to current month view title.appendChild(link);
const eventYear = parseInt(dateKey.split('-')[0]); } else {
const eventMonth = parseInt(dateKey.split('-')[1]) - 1; title.textContent = occurrence.summary;
}
if (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) { item.appendChild(title);
eventsByDate[dateKey] = [];
} const time = document.createElement("div");
eventsByDate[dateKey].push(ev); time.className = "event-time";
} time.textContent = formatTimeRange(occurrence);
} item.appendChild(time);
}
}); if (occurrence.description) {
const description = document.createElement("div");
renderCalendar(year, month); description.className = "event-description";
} description.textContent = occurrence.description;
item.appendChild(description);
function renderCalendar(year, month) { }
// Setze die Monatsbeschriftung (in Deutsch)
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; return item;
currentMonthElem.textContent = monthNames[month] + " " + year; }
calendarBody.innerHTML = "";
function showEventDetails(dateKey) {
let firstDay = new Date(year, month, 1); const occurrences = eventsByDate[dateKey];
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc. eventDateElem.textContent = formatDate(dateKey);
let daysInMonth = new Date(year, month + 1, 0).getDate(); eventDetailsElem.innerHTML = "";
let row = document.createElement("tr"); if (occurrences && occurrences.length > 0) {
// Leere Zellen vor dem 1. Tag for (const occurrence of occurrences) {
for (let i = 0; i < firstDayIndex; i++){ eventDetailsElem.appendChild(renderEventItem(occurrence));
let cell = document.createElement("td"); }
row.appendChild(cell); } else {
} let noEvents = document.createElement("div");
noEvents.className = "no-events";
// Tage hinzufügen noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
for (let day = 1; day <= daysInMonth; day++){ eventDetailsElem.appendChild(noEvents);
if (row.children.length === 7) { }
calendarBody.appendChild(row);
row = document.createElement("tr"); eventPanel.style.display = "block";
} }
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>"; function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
let dayStr = day < 10 ? "0" + day : day; const parts = dateStr.split("-");
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1); return `${parts[2]}.${parts[1]}.${parts[0]}`;
let dateKey = year + "-" + monthStr + "-" + dayStr; }
if (eventsByDate[dateKey]) { /**
let dotsContainer = document.createElement("div"); * Describe when an occurrence takes place, in Berlin time.
dotsContainer.className = "event-dots-container"; *
cell.classList.add("has-event"); * @param {Object} occurrence The occurrence to describe
* @returns {string} The description
// Gruppe Events nach Typ */
const events = eventsByDate[dateKey]; function formatTimeRange(occurrence) {
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only")); if (occurrence.allDay) {
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik")); return "Ganztägig";
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 start = timeOfDayFormat.format(occurrence.start);
const title = e.summary.toLowerCase(); const end = timeOfDayFormat.format(occurrence.end);
return !title.includes("members only") &&
!title.includes("subbotnik") && return `Beginn: ${start}, Ende: ${end}`;
!title.includes("bastelabend") && }
!title.includes("spieleabend");
}); document.addEventListener("DOMContentLoaded", function() {
currentMonthElem = document.getElementById("current-month");
// Füge Dots entsprechend der Event-Typen hinzu calendarBody = document.getElementById("calendar-body");
if (hasMembersOnly) { eventPanel = document.getElementById("event-panel");
let dot = document.createElement("div"); eventDateElem = document.getElementById("event-date");
dot.className = "event-dot event-dot-red"; eventDetailsElem = document.getElementById("event-details");
dotsContainer.appendChild(dot);
} document.getElementById("prev-month").addEventListener("click", function() {
if (hasSubbotnik || hasBastelabend || hasSpieleabend) { currentMonth--;
let dot = document.createElement("div"); if (currentMonth < 0) {
dot.className = "event-dot event-dot-orange"; currentMonth = 11;
dotsContainer.appendChild(dot); currentYear--;
} }
if (hasRegular) { updateEventsForMonth(currentYear, currentMonth);
let dot = document.createElement("div"); });
dot.className = "event-dot event-dot-greenyellow"; document.getElementById("next-month").addEventListener("click", function() {
dotsContainer.appendChild(dot); currentMonth++;
} if (currentMonth > 11) {
currentMonth = 0;
cell.appendChild(dotsContainer); currentYear++;
cell.dataset.dateKey = dateKey; }
cell.addEventListener("click", function() { updateEventsForMonth(currentYear, currentMonth);
// Clear previous selections });
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day'); // Show the grid of the current month right away, the events are filled in
}); // once the calendar has been loaded.
cell.classList.add('selected-day'); const today = new Date();
showEventDetails(dateKey); currentYear = today.getFullYear();
}); currentMonth = today.getMonth();
} updateEventsForMonth(currentYear, currentMonth);
row.appendChild(cell);
} loadCalendar()
// Falls die letzte Zeile nicht komplett ist .then(loaded => {
while (row.children.length < 7) { calendar = loaded;
let cell = document.createElement("td"); updateEventsForMonth(currentYear, currentMonth);
row.appendChild(cell); })
} .catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
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));
})();
}); });

194
assets/js/events.js Normal file
View file

@ -0,0 +1,194 @@
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,
};
}
}
}
}

105
assets/js/upcoming.js Normal file
View file

@ -0,0 +1,105 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
/**
* 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();
}
/**
* The upcoming occurrences of a calendar.
*
* @param {ICAL.Component} calendar The parsed calendar
* @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(calendar, now, maxEvents, maxDays) {
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),
});
}
// 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 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 => {
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);
});
})
.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 ### Nächste Veranstaltungen
<table id="upcoming" class="table table-condensed"> {{< upcoming >}}
</table>
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/). Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
<script type="module" src="/js/upcoming.js"></script>

View file

@ -1,12 +1,12 @@
{{ $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" }} {{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
<div class="calendar-container"> <div class="calendar-container">
{{ with $css }} {{ with $css }}
<link rel="stylesheet" href="{{ .RelPermalink }}"> <link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
{{ end }} {{ end }}
{{ with $js }} {{ with $js }}
<script src="{{ .RelPermalink }}"></script> <script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
{{ end }} {{ end }}
<div id="calendar"> <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>

View file

@ -1,113 +0,0 @@
import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js";
/**
* 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 {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}[]}
*/
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
const jcal = ICAL.parse(icsText);
const calendar = new ICAL.Component(jcal);
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
for (const component of calendar.getAllSubcomponents("vevent")) {
const event = new ICAL.Event(component);
if (!event.startDate) {
continue;
}
if (event.isRecurring()) {
const iterator = event.iterator();
while (true) {
const occurrence = iterator.next();
if (!occurrence) {
break;
}
const start = occurrence.toJSDate();
// Recurrences are chronological, so we're done
// once we pass the end of our search window.
if (start > end) {
break;
}
if (start > now) {
events.push({
start,
name: event.summary ?? "",
url: event.url ?? "",
});
}
}
} else {
const start = event.startDate.toJSDate();
if (start > now && start <= end) {
events.push({
start,
name: event.summary ?? "",
url: event.url ?? "",
});
}
}
}
// 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");
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).replace(",", "");
colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
row.appendChild(colName);
table.appendChild(row);
});
});
});