forked from cccb-website-team/www
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.
- A time that names a zone is converted to Europe/Berlin instead of being read
off 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. A date is a different matter, see
further down.
- 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. The times of
an event are labelled in German like the rest of the page, "Beginn" and "Ende"
instead of "Start" and "End".
An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, and deriving the Berlin day from that afterwards moves the event
by the offset between the two, so the promise of the same days everywhere would
have held for every event except the ones that consist of nothing but days. An
event on 30. and 31.08. would have been marked on:
Berlin 30. 31.08.
Los Angeles 30. 31.08. 01.09.
Tokio 29. 30. 31.08.
Take the day from the ICAL time, which still knows whether it names a day or a
point in time, and convert only the latter. Stepping to the end of the event
moves by a day where it is made of days and by a second where it is not, which
also expresses "the end is not part of the event" in the terms of the event
itself. The occurrences of a day are sorted by their start, which for an all
day event is that same midnight, so order them before the timed events instead.
Resolving an occurrence that was modified on its own needs two more things to
be right. Unless it is told which modifications belong to an event,
`ICAL.Event` relates every VEVENT with a RECURRENCE-ID in the file to every
recurring event and keys them by the recurrence id alone; the UID is only
compared with `strictExceptions`, which then throws instead of skipping. A
modification would therefore also override the occurrence another series holds
at the same instant, so the detail panel of that day would show the wrong event
and the modified one twice. Group the modifications by the UID of the event
they belong to and hand each event its own.
And the expansion walks the unmodified recurrence times, so an occurrence
pulled forward into the month from a later one would never be reached: the walk
stops at its original time, and the month it was moved out of drops it because
it no longer falls into it, which loses it from the calendar altogether.
Iterate far enough that the largest move towards the past can still reach the
month. Moving a Plenum or a Club Discordia to the week before, out of the way
of a holiday, is exactly what produces such a modification. With a weekly
series whose occurrence of 07.09. is moved to 28.08.:
before: August 03. 10. 17. 24. 31.
September 14. 21. 28.
after: August 03. 10. 17. 24. 28. 31.
September 14. 21. 28.
The URL 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 is;
`URL:javascript:alert(1)` on an event would run that script when a visitor
clicks the name. Pass on nothing but http and https.
The stylesheet of the page goes through `minify | fingerprint` while the script
next to it is rewritten, so both are delivered the way the assets of the start
page already are: smaller, under a name that carries their content hash, and
with an integrity hash in the tag.
The published calendar has neither all day events nor RECURRENCE-ID today, both
can be created in the CalDAV calendar the export comes from. Checked in Berlin,
Tokyo, Los Angeles and Kiritimati: an all day event over 30. and 31.08. is
marked on those two days in all four, one from 31.08. to 02.09. is marked
across the month boundary, and the month view of the published calendar is the
same in all of them.
Fixes: 4068fab565 ("improved calendar and fixed url temporarily")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
parent
a0e1ef046a
commit
60ab628270
3 changed files with 546 additions and 442 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -1,250 +1,340 @@
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
import ICAL from "./vendor/ical.js";
|
||||||
(function(){
|
|
||||||
let events = [];
|
|
||||||
let eventsByDate = {};
|
|
||||||
|
|
||||||
// Funktion zum Parsen der ICS-Datei
|
const icsUrl = "/calendars/all.ics";
|
||||||
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);
|
|
||||||
|
|
||||||
// Handle properties with parameters (like TZID)
|
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
||||||
const baseKey = key.split(";")[0];
|
// no matter which time zone the browser of the visitor is set to.
|
||||||
|
const timeZone = "Europe/Berlin";
|
||||||
|
|
||||||
if (baseKey === "DTSTART") {
|
const monthNames = [
|
||||||
event.start = value;
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
} else if (baseKey === "DTEND") {
|
];
|
||||||
event.end = value;
|
|
||||||
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
} else if (baseKey === "SUMMARY") {
|
timeZone,
|
||||||
event.summary = value;
|
year: "numeric",
|
||||||
} else if (baseKey === "DESCRIPTION") {
|
month: "2-digit",
|
||||||
event.description = value;
|
day: "2-digit",
|
||||||
} else if (baseKey === "RRULE") {
|
});
|
||||||
event.rrule = value;
|
|
||||||
}
|
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
}
|
timeZone,
|
||||||
}
|
hour: "2-digit",
|
||||||
});
|
minute: "2-digit",
|
||||||
return events;
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
function parseDateString(icsDateStr) {
|
}
|
||||||
// Handle different date formats
|
|
||||||
if (!icsDateStr) return null;
|
|
||||||
|
|
||||||
// For basic date format: YYYYMMDD
|
/**
|
||||||
if (icsDateStr.length === 8) {
|
* The day an occurrence time falls on in Berlin.
|
||||||
let year = icsDateStr.substring(0, 4);
|
*
|
||||||
let month = icsDateStr.substring(4, 6);
|
* A date has neither a time nor a zone, its digits are the day itself.
|
||||||
let day = icsDateStr.substring(6, 8);
|
* toJSDate() would read them as midnight in the zone of the browser, which
|
||||||
return `${year}-${month}-${day}`;
|
* far enough east or west of Berlin lands on the day before or after.
|
||||||
}
|
*
|
||||||
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
|
* @param {ICAL.Time} time The time
|
||||||
else if (icsDateStr.includes("T")) {
|
* @returns {string} The day as "YYYY-MM-DD"
|
||||||
let year = icsDateStr.substring(0, 4);
|
*/
|
||||||
let month = icsDateStr.substring(4, 6);
|
function timeDayKey(time) {
|
||||||
let day = icsDateStr.substring(6, 8);
|
if (time.isDate) {
|
||||||
return `${year}-${month}-${day}`;
|
const month = String(time.month).padStart(2, "0");
|
||||||
}
|
const day = String(time.day).padStart(2, "0");
|
||||||
return null;
|
|
||||||
|
return `${time.year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract date components from different date formats
|
return dayKey(time.toJSDate());
|
||||||
function getDateComponents(icsDateStr) {
|
}
|
||||||
if (!icsDateStr) return null;
|
|
||||||
|
|
||||||
// Basic handling - extract YYYY, MM, DD regardless of format
|
/**
|
||||||
const year = parseInt(icsDateStr.substring(0, 4));
|
* The days an event covers, so that an event running over several days is
|
||||||
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
|
* shown on each of them and not only on the day it starts.
|
||||||
const day = parseInt(icsDateStr.substring(6, 8));
|
*
|
||||||
|
* @param {ICAL.Time} start Start of the event
|
||||||
|
* @param {ICAL.Time} end End of the event
|
||||||
|
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function daysCovered(start, end) {
|
||||||
|
// The end is not part of the event: one ending at midnight belongs to the day
|
||||||
|
// before, and an all day event ends on the day before its DTEND.
|
||||||
|
const last = end.clone();
|
||||||
|
|
||||||
return { year, month, day };
|
if (end.isDate) {
|
||||||
}
|
last.adjust(-1, 0, 0, 0);
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
// For regular events, check if they fall in current month
|
last.adjust(0, 0, 0, -1);
|
||||||
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) {
|
const lastKey = timeDayKey(last);
|
||||||
if (!eventsByDate[dateKey]) {
|
const days = [];
|
||||||
eventsByDate[dateKey] = [];
|
|
||||||
|
let key = timeDayKey(start);
|
||||||
|
|
||||||
|
// The guard keeps a broken event from looping forever, a year of dots on the
|
||||||
|
// same event is well past the point where the calendar is still useful.
|
||||||
|
while (days.length <= 366) {
|
||||||
|
days.push(key);
|
||||||
|
|
||||||
|
// The keys sort as the days do, so this also stops an event whose end lies
|
||||||
|
// before its start after the day it starts on.
|
||||||
|
if (key >= lastKey) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
eventsByDate[dateKey].push(ev);
|
|
||||||
|
// 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.
|
||||||
|
*
|
||||||
|
* The value ends up in the href of a link, and the calendar is exported from a
|
||||||
|
* CalDAV server, so whoever may write to it decides what that value is. A
|
||||||
|
* "javascript:" URL there would run on our page as soon as a visitor clicks
|
||||||
|
* the event, so hand on nothing but http and https.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event to read the URL of
|
||||||
|
* @returns {string} The URL, empty when the event has none or it is not http(s)
|
||||||
|
*/
|
||||||
|
function eventUrl(event) {
|
||||||
|
const url = event.component.getFirstPropertyValue("url") ?? "";
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A relative URL is resolved against the page and keeps its scheme.
|
||||||
|
const { protocol } = new URL(url, document.baseURI);
|
||||||
|
|
||||||
|
return protocol === "http:" || protocol === "https:" ? url : "";
|
||||||
|
} catch {
|
||||||
|
// Not a URL at all.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce one occurrence of an event to what the calendar displays.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event the occurrence belongs to
|
||||||
|
* @param {ICAL.Time} startDate Start of this occurrence
|
||||||
|
* @param {ICAL.Time} endDate End of this occurrence
|
||||||
|
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
|
||||||
|
*/
|
||||||
|
function toOccurrence(event, startDate, endDate) {
|
||||||
|
return {
|
||||||
|
summary: event.summary ?? "",
|
||||||
|
description: event.description ?? "",
|
||||||
|
url: eventUrl(event),
|
||||||
|
start: startDate.toJSDate(),
|
||||||
|
end: endDate.toJSDate(),
|
||||||
|
allDay: startDate.isDate,
|
||||||
|
// Taken from the ICAL times, which still know whether they name a day or a
|
||||||
|
// point in time; the JS dates above no longer do.
|
||||||
|
days: daysCovered(startDate, endDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 month. 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 occurrence.days) {
|
||||||
|
if (!key.startsWith(monthPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byDate[key]) {
|
||||||
|
byDate[key] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
byDate[key].push(occurrence);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 show them twice.
|
||||||
|
if (component.hasProperty("recurrence-id")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new ICAL.Event(component, {
|
||||||
|
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
renderCalendar(year, month);
|
if (!event.startDate) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCalendar(year, month) {
|
if (event.isRecurring()) {
|
||||||
|
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 month, 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);
|
||||||
|
|
||||||
|
if (details.endDate.toJSDate() > from) {
|
||||||
|
add(toOccurrence(details.item, details.startDate, details.endDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
|
||||||
|
add(toOccurrence(event, event.startDate, event.endDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const occurrences of Object.values(byDate)) {
|
||||||
|
occurrences.sort((a, b) => {
|
||||||
|
// An all day event has no time of day to sort by, the JS date of its
|
||||||
|
// start is midnight in the zone of the browser. Put it first instead.
|
||||||
|
if (a.allDay !== b.allDay) {
|
||||||
|
return a.allDay ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.start - b.start;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEventsForMonth(year, month) {
|
||||||
|
eventsByDate = occurrencesOfMonth(year, month);
|
||||||
|
renderCalendar(year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar(year, month) {
|
||||||
// Setze die Monatsbeschriftung (in Deutsch)
|
// 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;
|
currentMonthElem.textContent = monthNames[month] + " " + year;
|
||||||
calendarBody.innerHTML = "";
|
calendarBody.innerHTML = "";
|
||||||
|
|
||||||
|
|
@ -254,13 +344,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
let row = document.createElement("tr");
|
let row = document.createElement("tr");
|
||||||
// Leere Zellen vor dem 1. Tag
|
// Leere Zellen vor dem 1. Tag
|
||||||
for (let i = 0; i < firstDayIndex; i++){
|
for (let i = 0; i < firstDayIndex; i++) {
|
||||||
let cell = document.createElement("td");
|
let cell = document.createElement("td");
|
||||||
row.appendChild(cell);
|
row.appendChild(cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tage hinzufügen
|
// Tage hinzufügen
|
||||||
for (let day = 1; day <= daysInMonth; day++){
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
if (row.children.length === 7) {
|
if (row.children.length === 7) {
|
||||||
calendarBody.appendChild(row);
|
calendarBody.appendChild(row);
|
||||||
row = document.createElement("tr");
|
row = document.createElement("tr");
|
||||||
|
|
@ -327,69 +417,56 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
row.appendChild(cell);
|
row.appendChild(cell);
|
||||||
}
|
}
|
||||||
calendarBody.appendChild(row);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEventLink(eventTitle) {
|
item.appendChild(title);
|
||||||
if (eventTitle.startsWith("Datengarten")) {
|
|
||||||
// Extract the number after "Datengarten "
|
const time = document.createElement("div");
|
||||||
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
|
time.className = "event-time";
|
||||||
if (match && match[1]) {
|
time.textContent = formatTimeRange(occurrence);
|
||||||
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
|
item.appendChild(time);
|
||||||
}
|
|
||||||
|
if (occurrence.description) {
|
||||||
|
const description = document.createElement("div");
|
||||||
|
description.className = "event-description";
|
||||||
|
description.textContent = occurrence.description;
|
||||||
|
item.appendChild(description);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other titles, convert to lowercase and use as path
|
return item;
|
||||||
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
}
|
||||||
return `https://berlin.ccc.de/page/${slug}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEventDetails(dateKey) {
|
function showEventDetails(dateKey) {
|
||||||
const events = eventsByDate[dateKey];
|
const occurrences = eventsByDate[dateKey];
|
||||||
eventDateElem.textContent = formatDate(dateKey);
|
eventDateElem.textContent = formatDate(dateKey);
|
||||||
eventDetailsElem.innerHTML = "";
|
eventDetailsElem.innerHTML = "";
|
||||||
|
|
||||||
if (events && events.length > 0) {
|
if (occurrences && occurrences.length > 0) {
|
||||||
events.forEach(ev => {
|
for (const occurrence of occurrences) {
|
||||||
let eventItem = document.createElement("div");
|
eventDetailsElem.appendChild(renderEventItem(occurrence));
|
||||||
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 {
|
} else {
|
||||||
let noEvents = document.createElement("div");
|
let noEvents = document.createElement("div");
|
||||||
noEvents.className = "no-events";
|
noEvents.className = "no-events";
|
||||||
|
|
@ -398,52 +475,75 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
eventPanel.style.display = "block";
|
eventPanel.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
// Convert YYYY-MM-DD to DD.MM.YYYY
|
// Convert YYYY-MM-DD to DD.MM.YYYY
|
||||||
const parts = dateStr.split("-");
|
const parts = dateStr.split("-");
|
||||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(icsTimeStr) {
|
/**
|
||||||
// Format time for display
|
* Describe when an occurrence takes place, in Berlin time.
|
||||||
if (!icsTimeStr) return "";
|
*
|
||||||
|
* @param {Object} occurrence The occurrence to describe
|
||||||
if (icsTimeStr.length === 8) {
|
* @returns {string} The description
|
||||||
// All-day event
|
*/
|
||||||
|
function formatTimeRange(occurrence) {
|
||||||
|
if (occurrence.allDay) {
|
||||||
return "Ganztägig";
|
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 start = timeOfDayFormat.format(occurrence.start);
|
||||||
const year = date.getFullYear();
|
const end = timeOfDayFormat.format(occurrence.end);
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
||||||
const day = date.getDate().toString().padStart(2, '0');
|
return `Beginn: ${start}, Ende: ${end}`;
|
||||||
return `${year}${month}${day}`;
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
// ICS-Datei abrufen und Events verarbeiten
|
// Show the grid of the current month right away, the events are filled in
|
||||||
fetch('/calendars/all.ics')
|
// once the calendar has been loaded.
|
||||||
.then(response => response.text())
|
const today = new Date();
|
||||||
.then(data => {
|
|
||||||
events = parseICS(data);
|
|
||||||
|
|
||||||
// Initialize with current date
|
|
||||||
let today = new Date();
|
|
||||||
currentYear = today.getFullYear();
|
currentYear = today.getFullYear();
|
||||||
currentMonth = today.getMonth();
|
currentMonth = today.getMonth();
|
||||||
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
|
|
||||||
// Process events for current month
|
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);
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
|
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue