Parse the calendar with ical.js
The calendar page brought its own ICS parser and its own RRULE expansion. Both
only covered the cases that happened to be needed when they were written, and
the calendar has moved on since. Against the published calendar, for September
2026:
Spieleabend FREQ=WEEKLY;INTERVAL=2;BYDAY=SA shown 05. 12. 19. 26., correct 05. 19.
CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01., correct 08.
CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01., correct 22.
INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as
often as it takes place. BYSETPOS was not implemented at all, and since
parseInt("TU") is NaN the fallback turned both Plenum rules into "first
Tuesday", putting two Plenums on a day without one and none on the two days
with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing
more than one weekday were not handled either.
The text was no better. Content lines longer than 75 characters are continued
on the next line, of which there are 477 in the calendar, and the parser did
not join them, so it cut values off in the middle of a word. It also split
every line at the first colon, which lands inside the parameter of
DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so
"\n" was shown as those two characters. 30 of 31 descriptions were wrong:
before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr"
after: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin"
Hand the parsing and the expansion to ical.js, which is vendored for the start
page anyway. The month view now asks the library for the occurrences that touch
the month, which removes the reimplementation along with all of the above.
While the events are being reduced to what the view needs:
- An event is entered on every day it covers, so the Amateurfunk trip from
30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is
not part of it, so one ending at midnight stays on the day before.
- Days and times are derived in Europe/Berlin instead of from the digits of the
ICS string. Every event currently carries TZID=Europe/Berlin, so the wall
clock time shown does not change, but a UTC timestamp would have been shown
in UTC.
- The URL of the event is used for the link in the detail panel. Events without
one are shown without a link, as on the start page. This replaces
createEventLink(), which guessed URLs from the title and was never called,
and the panel no longer builds an <h> element, which is not an element.
Descriptions may contain line breaks, so keep them in the panel.
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
parent
8745b246e6
commit
bca186c6d9
3 changed files with 413 additions and 440 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,209 @@
|
||||||
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 days an event covers, so that an event running over several days is
|
||||||
let year = icsDateStr.substring(0, 4);
|
* shown on each of them and not only on the day it starts.
|
||||||
let month = icsDateStr.substring(4, 6);
|
*
|
||||||
let day = icsDateStr.substring(6, 8);
|
* @param {Date} start Start of the event
|
||||||
return `${year}-${month}-${day}`;
|
* @param {Date} end End of the event
|
||||||
}
|
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||||
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
|
*/
|
||||||
else if (icsDateStr.includes("T")) {
|
function daysCovered(start, end) {
|
||||||
let year = icsDateStr.substring(0, 4);
|
// The end is not part of the event: one ending at midnight belongs to the day
|
||||||
let month = icsDateStr.substring(4, 6);
|
// before, and an all day event ends on the day before its DTEND.
|
||||||
let day = icsDateStr.substring(6, 8);
|
const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
|
||||||
return `${year}-${month}-${day}`;
|
const lastKey = dayKey(lastInstant);
|
||||||
}
|
const days = [];
|
||||||
return null;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract date components from different date formats
|
// Step to noon UTC of the next day, which is inside the same Berlin day
|
||||||
function getDateComponents(icsDateStr) {
|
// whether daylight saving time is in effect or not.
|
||||||
if (!icsDateStr) return null;
|
const [year, month, day] = key.split("-").map(Number);
|
||||||
|
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
|
||||||
// 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) {
|
return days;
|
||||||
if (!event.rrule) return [event];
|
}
|
||||||
|
|
||||||
const rruleStr = event.rrule;
|
/**
|
||||||
|
* 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") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
// Get start date components
|
/**
|
||||||
const startComponents = getDateComponents(event.start);
|
* Reduce one occurrence of an event to what the calendar displays.
|
||||||
if (!startComponents) return [event];
|
*
|
||||||
|
* @param {ICAL.Event} event The event the occurrence belongs to
|
||||||
const startDate = new Date(
|
* @param {ICAL.Time} startDate Start of this occurrence
|
||||||
startComponents.year,
|
* @param {ICAL.Time} endDate End of this occurrence
|
||||||
startComponents.month,
|
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}}
|
||||||
startComponents.day
|
*/
|
||||||
);
|
function toOccurrence(event, startDate, endDate) {
|
||||||
|
return {
|
||||||
const rangeStart = new Date(year, month, 1);
|
summary: event.summary ?? "",
|
||||||
const rangeEnd = new Date(year, month + 1, 0);
|
description: event.description ?? "",
|
||||||
const expandedEvents = [];
|
url: eventUrl(event),
|
||||||
|
start: startDate.toJSDate(),
|
||||||
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
|
end: endDate.toJSDate(),
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
allDay: startDate.isDate,
|
||||||
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
|
* Collect everything that takes place in the given month, keyed by day.
|
||||||
let day = 1;
|
*
|
||||||
while (day <= rangeEnd.getDate()) {
|
* @param {number} year The year of the month
|
||||||
const testDate = new Date(year, month, day);
|
* @param {number} month The month, January is 0
|
||||||
if (testDate.getDay() === targetDay && testDate >= startDate) {
|
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
|
||||||
const newEvent = {...event};
|
*/
|
||||||
const eventDate = formatDateForICS(testDate);
|
function occurrencesOfMonth(year, month) {
|
||||||
|
const byDate = {};
|
||||||
|
|
||||||
// Preserve time portion from original event
|
if (!calendar) {
|
||||||
const timePart = event.start.includes('T') ?
|
return byDate;
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
}
|
||||||
const endTimePart = event.end.includes('T') ?
|
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
|
||||||
|
|
||||||
newEvent.start = eventDate + timePart;
|
// These bounds only limit how far the recurrences have to be expanded, which
|
||||||
newEvent.end = 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);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
day++;
|
|
||||||
|
if (!byDate[key]) {
|
||||||
|
byDate[key] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
byDate[key].push(occurrence);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
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 => {
|
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||||
const occurrence = parseInt(byday) || 1;
|
const event = new ICAL.Event(component);
|
||||||
const dayCode = byday.slice(-2);
|
|
||||||
const dayIndex = dayMap[dayCode];
|
|
||||||
|
|
||||||
let day = 1;
|
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||||
let count = 0;
|
// belong to, handling them here as well would show them twice.
|
||||||
|
if (event.isRecurrenceException() || !event.startDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
while (day <= rangeEnd.getDate()) {
|
if (event.isRecurring()) {
|
||||||
const testDate = new Date(year, month, day);
|
const iterator = event.iterator();
|
||||||
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
|
while (true) {
|
||||||
const timePart = event.start.includes('T') ?
|
const occurrence = iterator.next();
|
||||||
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;
|
// Recurrences are chronological, so we are done once one starts after
|
||||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
// the month.
|
||||||
expandedEvents.push(newEvent);
|
if (!occurrence || occurrence.toJSDate() >= to) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details resolve time, name and URL of an occurrence that was
|
||||||
|
// modified via RECURRENCE-ID.
|
||||||
|
const details = event.getOccurrenceDetails(occurrence);
|
||||||
|
|
||||||
|
if (details.endDate.toJSDate() > from) {
|
||||||
|
add(toOccurrence(details.item, details.startDate, details.endDate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
day++;
|
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
|
||||||
}
|
add(toOccurrence(event, event.startDate, event.endDate));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return expandedEvents.length > 0 ? expandedEvents : [event];
|
for (const occurrences of Object.values(byDate)) {
|
||||||
|
occurrences.sort((a, b) => a.start - b.start);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kalender initialisieren
|
return byDate;
|
||||||
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 {
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
function updateEventsForMonth(year, month) {
|
||||||
|
eventsByDate = occurrencesOfMonth(year, month);
|
||||||
renderCalendar(year, month);
|
renderCalendar(year, month);
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 +213,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 +286,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 +344,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 `Start: ${start}, End: ${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,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" }}
|
{{ $css := resources.Get "css/calendar.css" }}
|
||||||
|
|
||||||
<div class="calendar-container">
|
<div class="calendar-container">
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
||||||
{{ 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