Parse the calendar with ical.js
The calendar page brought its own ICS parser and its own RRULE expansion. Both
only covered the cases that happened to be needed when they were written, and
the calendar has moved on since. Against the published calendar, for September
2026:
Spieleabend FREQ=WEEKLY;INTERVAL=2;BYDAY=SA shown 05. 12. 19. 26., correct 05. 19.
CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01., correct 08.
CCCB Plenum FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01., correct 22.
INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as
often as it takes place. BYSETPOS was not implemented at all, and since
parseInt("TU") is NaN the fallback turned both Plenum rules into "first
Tuesday", putting two Plenums on a day without one and none on the two days
with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing
more than one weekday were not handled either.
The text was no better. Content lines longer than 75 characters are continued
on the next line, of which there are 477 in the calendar, and the parser did
not join them, so it cut values off in the middle of a word. It also split
every line at the first colon, which lands inside the parameter of
DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so
"\n" was shown as those two characters. 30 of 31 descriptions were wrong:
before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr"
after: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin"
Hand the parsing and the expansion to ical.js, which is vendored for the start
page anyway. The month view now asks the library for the occurrences that touch
the month, which removes the reimplementation along with all of the above.
While the events are being reduced to what the view needs:
- An event is entered on every day it covers, so the Amateurfunk trip from
30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is
not part of it, so one ending at midnight stays on the day before.
- Days and times are derived in Europe/Berlin instead of from the digits of the
ICS string. Every event currently carries TZID=Europe/Berlin, so the wall
clock time shown does not change, but a UTC timestamp would have been shown
in UTC.
- The URL of the event is used for the link in the detail panel. Events without
one are shown without a link, as on the start page. This replaces
createEventLink(), which guessed URLs from the title and was never called,
and the panel no longer builds an <h> element, which is not an element.
Descriptions may contain line breaks, so keep them in the panel.
Fixes: 4068fab5b1ea ("improved calendar and fixed url temporarily")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
parent
8745b246e6
commit
893c88adf1
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,449 +1,418 @@
|
||||||
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;
|
|
||||||
} else if (baseKey === "SUMMARY") {
|
|
||||||
event.summary = value;
|
|
||||||
} else if (baseKey === "DESCRIPTION") {
|
|
||||||
event.description = value;
|
|
||||||
} else if (baseKey === "RRULE") {
|
|
||||||
event.rrule = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
function parseDateString(icsDateStr) {
|
timeZone,
|
||||||
// Handle different date formats
|
year: "numeric",
|
||||||
if (!icsDateStr) return null;
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
// For basic date format: YYYYMMDD
|
});
|
||||||
if (icsDateStr.length === 8) {
|
|
||||||
let year = icsDateStr.substring(0, 4);
|
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
let month = icsDateStr.substring(4, 6);
|
timeZone,
|
||||||
let day = icsDateStr.substring(6, 8);
|
hour: "2-digit",
|
||||||
return `${year}-${month}-${day}`;
|
minute: "2-digit",
|
||||||
}
|
});
|
||||||
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
|
|
||||||
else if (icsDateStr.includes("T")) {
|
let calendar = null;
|
||||||
let year = icsDateStr.substring(0, 4);
|
let eventsByDate = {};
|
||||||
let month = icsDateStr.substring(4, 6);
|
let currentYear;
|
||||||
let day = icsDateStr.substring(6, 8);
|
let currentMonth;
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
let currentMonthElem;
|
||||||
return null;
|
let calendarBody;
|
||||||
|
let eventPanel;
|
||||||
|
let eventDateElem;
|
||||||
|
let eventDetailsElem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day a point in time falls on in Berlin.
|
||||||
|
*
|
||||||
|
* @param {Date} date The point in time
|
||||||
|
* @returns {string} The day as "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function dayKey(date) {
|
||||||
|
const parts = {};
|
||||||
|
|
||||||
|
for (const part of dayKeyFormat.formatToParts(date)) {
|
||||||
|
parts[part.type] = part.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The days an event covers, so that an event running over several days is
|
||||||
|
* shown on each of them and not only on the day it starts.
|
||||||
|
*
|
||||||
|
* @param {Date} start Start of the event
|
||||||
|
* @param {Date} end End of the event
|
||||||
|
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function daysCovered(start, end) {
|
||||||
|
// The end is not part of the event: one ending at midnight belongs to the day
|
||||||
|
// before, and an all day event ends on the day before its DTEND.
|
||||||
|
const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
|
||||||
|
const lastKey = dayKey(lastInstant);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
let key = dayKey(start);
|
||||||
|
|
||||||
|
// The guard keeps a broken event from looping forever, a year of dots on the
|
||||||
|
// same event is well past the point where the calendar is still useful.
|
||||||
|
while (days.length <= 366) {
|
||||||
|
days.push(key);
|
||||||
|
|
||||||
|
if (key === lastKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step to noon UTC of the next day, which is inside the same Berlin day
|
||||||
|
// whether daylight saving time is in effect or not.
|
||||||
|
const [year, month, day] = key.split("-").map(Number);
|
||||||
|
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the URL of an event.
|
||||||
|
*
|
||||||
|
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event to read the URL of
|
||||||
|
* @returns {string} The URL, empty when the event has none
|
||||||
|
*/
|
||||||
|
function eventUrl(event) {
|
||||||
|
return event.component.getFirstPropertyValue("url") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce one occurrence of an event to what the calendar displays.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event the occurrence belongs to
|
||||||
|
* @param {ICAL.Time} startDate Start of this occurrence
|
||||||
|
* @param {ICAL.Time} endDate End of this occurrence
|
||||||
|
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}}
|
||||||
|
*/
|
||||||
|
function toOccurrence(event, startDate, endDate) {
|
||||||
|
return {
|
||||||
|
summary: event.summary ?? "",
|
||||||
|
description: event.description ?? "",
|
||||||
|
url: eventUrl(event),
|
||||||
|
start: startDate.toJSDate(),
|
||||||
|
end: endDate.toJSDate(),
|
||||||
|
allDay: startDate.isDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect everything that takes place in the given month, keyed by day.
|
||||||
|
*
|
||||||
|
* @param {number} year The year of the month
|
||||||
|
* @param {number} month The month, January is 0
|
||||||
|
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function occurrencesOfMonth(year, month) {
|
||||||
|
const byDate = {};
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These bounds only limit how far the recurrences have to be expanded, which
|
||||||
|
// day an occurrence ends up on is decided by its Berlin day below. They are
|
||||||
|
// deliberately generous so that no occurrence is cut off at the edge of the
|
||||||
|
// month by a time zone difference.
|
||||||
|
const from = new Date(year, month, 1);
|
||||||
|
const to = new Date(year, month + 1, 1);
|
||||||
|
from.setDate(from.getDate() - 2);
|
||||||
|
to.setDate(to.getDate() + 2);
|
||||||
|
|
||||||
|
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
|
||||||
|
|
||||||
|
const add = (occurrence) => {
|
||||||
|
for (const key of daysCovered(occurrence.start, occurrence.end)) {
|
||||||
|
if (!key.startsWith(monthPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byDate[key]) {
|
||||||
|
byDate[key] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
byDate[key].push(occurrence);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||||
|
const event = new ICAL.Event(component);
|
||||||
|
|
||||||
|
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||||
|
// belong to, handling them here as well would show them twice.
|
||||||
|
if (event.isRecurrenceException() || !event.startDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.isRecurring()) {
|
||||||
|
const iterator = event.iterator();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const occurrence = iterator.next();
|
||||||
|
|
||||||
|
// Recurrences are chronological, so we are done once one starts after
|
||||||
|
// the month.
|
||||||
|
if (!occurrence || occurrence.toJSDate() >= to) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract date components from different date formats
|
// Details resolve time, name and URL of an occurrence that was
|
||||||
function getDateComponents(icsDateStr) {
|
// modified via RECURRENCE-ID.
|
||||||
if (!icsDateStr) return null;
|
const details = event.getOccurrenceDetails(occurrence);
|
||||||
|
|
||||||
// Basic handling - extract YYYY, MM, DD regardless of format
|
if (details.endDate.toJSDate() > from) {
|
||||||
const year = parseInt(icsDateStr.substring(0, 4));
|
add(toOccurrence(details.item, details.startDate, details.endDate));
|
||||||
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
|
|
||||||
const day = parseInt(icsDateStr.substring(6, 8));
|
|
||||||
|
|
||||||
return { year, month, day };
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandRecurringEvents(event, year, month) {
|
|
||||||
if (!event.rrule) return [event];
|
|
||||||
|
|
||||||
const rruleStr = event.rrule;
|
|
||||||
|
|
||||||
// Get start date components
|
|
||||||
const startComponents = getDateComponents(event.start);
|
|
||||||
if (!startComponents) return [event];
|
|
||||||
|
|
||||||
const startDate = new Date(
|
|
||||||
startComponents.year,
|
|
||||||
startComponents.month,
|
|
||||||
startComponents.day
|
|
||||||
);
|
|
||||||
|
|
||||||
const rangeStart = new Date(year, month, 1);
|
|
||||||
const rangeEnd = new Date(year, month + 1, 0);
|
|
||||||
const expandedEvents = [];
|
|
||||||
|
|
||||||
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
|
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
|
||||||
if (bydayMatch) {
|
|
||||||
const dayCode = bydayMatch[1];
|
|
||||||
const dayMap = {
|
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
|
||||||
};
|
|
||||||
const targetDay = dayMap[dayCode];
|
|
||||||
|
|
||||||
if (targetDay !== undefined) {
|
|
||||||
// Create events for each matching day in the month
|
|
||||||
let day = 1;
|
|
||||||
while (day <= rangeEnd.getDate()) {
|
|
||||||
const testDate = new Date(year, month, day);
|
|
||||||
if (testDate.getDay() === targetDay && testDate >= startDate) {
|
|
||||||
const newEvent = {...event};
|
|
||||||
const eventDate = formatDateForICS(testDate);
|
|
||||||
|
|
||||||
// Preserve time portion from original event
|
|
||||||
const timePart = event.start.includes('T') ?
|
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
|
||||||
const endTimePart = event.end.includes('T') ?
|
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
|
||||||
|
|
||||||
newEvent.start = eventDate + timePart;
|
|
||||||
newEvent.end = eventDate + endTimePart;
|
|
||||||
expandedEvents.push(newEvent);
|
|
||||||
}
|
|
||||||
day++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
|
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
|
||||||
if (bydayMatch) {
|
|
||||||
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
|
|
||||||
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
|
|
||||||
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
|
|
||||||
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const bydays = bydayMatch[1].split(',');
|
|
||||||
const dayMap = {
|
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
|
||||||
};
|
|
||||||
|
|
||||||
bydays.forEach(byday => {
|
|
||||||
const occurrence = parseInt(byday) || 1;
|
|
||||||
const dayCode = byday.slice(-2);
|
|
||||||
const dayIndex = dayMap[dayCode];
|
|
||||||
|
|
||||||
let day = 1;
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
while (day <= rangeEnd.getDate()) {
|
|
||||||
const testDate = new Date(year, month, day);
|
|
||||||
if (testDate.getDay() === dayIndex) {
|
|
||||||
count++;
|
|
||||||
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
|
|
||||||
const newEvent = {...event};
|
|
||||||
const eventDate = new Date(year, month, day);
|
|
||||||
|
|
||||||
// Preserve time portion from original event
|
|
||||||
const timePart = event.start.includes('T') ?
|
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
|
||||||
const endTimePart = event.end.includes('T') ?
|
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
|
||||||
|
|
||||||
newEvent.start = formatDateForICS(eventDate) + timePart;
|
|
||||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
|
||||||
expandedEvents.push(newEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
day++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandedEvents.length > 0 ? expandedEvents : [event];
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Kalender initialisieren
|
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
|
||||||
let currentYear, currentMonth;
|
add(toOccurrence(event, event.startDate, event.endDate));
|
||||||
const currentMonthElem = document.getElementById("current-month");
|
}
|
||||||
const calendarBody = document.getElementById("calendar-body");
|
}
|
||||||
const eventPanel = document.getElementById("event-panel");
|
|
||||||
const eventDateElem = document.getElementById("event-date");
|
for (const occurrences of Object.values(byDate)) {
|
||||||
const eventDetailsElem = document.getElementById("event-details");
|
occurrences.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
document.getElementById("prev-month").addEventListener("click", function(){
|
|
||||||
currentMonth--;
|
return byDate;
|
||||||
if (currentMonth < 0) {
|
}
|
||||||
currentMonth = 11;
|
|
||||||
currentYear--;
|
function updateEventsForMonth(year, month) {
|
||||||
}
|
eventsByDate = occurrencesOfMonth(year, month);
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
renderCalendar(year, month);
|
||||||
});
|
}
|
||||||
document.getElementById("next-month").addEventListener("click", function(){
|
|
||||||
currentMonth++;
|
function renderCalendar(year, month) {
|
||||||
if (currentMonth > 11) {
|
// Setze die Monatsbeschriftung (in Deutsch)
|
||||||
currentMonth = 0;
|
currentMonthElem.textContent = monthNames[month] + " " + year;
|
||||||
currentYear++;
|
calendarBody.innerHTML = "";
|
||||||
}
|
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
let firstDay = new Date(year, month, 1);
|
||||||
|
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
|
||||||
|
let daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
let row = document.createElement("tr");
|
||||||
|
// Leere Zellen vor dem 1. Tag
|
||||||
|
for (let i = 0; i < firstDayIndex; i++) {
|
||||||
|
let cell = document.createElement("td");
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tage hinzufügen
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
if (row.children.length === 7) {
|
||||||
|
calendarBody.appendChild(row);
|
||||||
|
row = document.createElement("tr");
|
||||||
|
}
|
||||||
|
let cell = document.createElement("td");
|
||||||
|
cell.innerHTML = "<strong>" + day + "</strong>";
|
||||||
|
|
||||||
|
let dayStr = day < 10 ? "0" + day : day;
|
||||||
|
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
|
||||||
|
let dateKey = year + "-" + monthStr + "-" + dayStr;
|
||||||
|
|
||||||
|
if (eventsByDate[dateKey]) {
|
||||||
|
let dotsContainer = document.createElement("div");
|
||||||
|
dotsContainer.className = "event-dots-container";
|
||||||
|
cell.classList.add("has-event");
|
||||||
|
|
||||||
|
// Gruppe Events nach Typ
|
||||||
|
const events = eventsByDate[dateKey];
|
||||||
|
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
|
||||||
|
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
|
||||||
|
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
|
||||||
|
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
|
||||||
|
const hasRegular = events.some(e => {
|
||||||
|
const title = e.summary.toLowerCase();
|
||||||
|
return !title.includes("members only") &&
|
||||||
|
!title.includes("subbotnik") &&
|
||||||
|
!title.includes("bastelabend") &&
|
||||||
|
!title.includes("spieleabend");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Füge Dots entsprechend der Event-Typen hinzu
|
||||||
|
if (hasMembersOnly) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-red";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-orange";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
if (hasRegular) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-greenyellow";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(dotsContainer);
|
||||||
|
cell.dataset.dateKey = dateKey;
|
||||||
|
cell.addEventListener("click", function() {
|
||||||
|
// Clear previous selections
|
||||||
|
document.querySelectorAll('.selected-day').forEach(el => {
|
||||||
|
el.classList.remove('selected-day');
|
||||||
});
|
});
|
||||||
|
cell.classList.add('selected-day');
|
||||||
function updateEventsForMonth(year, month) {
|
showEventDetails(dateKey);
|
||||||
// Clear existing events for this month view
|
});
|
||||||
eventsByDate = {};
|
}
|
||||||
|
row.appendChild(cell);
|
||||||
// Process each event, expanding recurring ones
|
}
|
||||||
events.forEach(ev => {
|
// Falls die letzte Zeile nicht komplett ist
|
||||||
if (ev.rrule) {
|
while (row.children.length < 7) {
|
||||||
// For recurring events, expand them for current month
|
let cell = document.createElement("td");
|
||||||
const expandedEvents = expandRecurringEvents(ev, year, month);
|
row.appendChild(cell);
|
||||||
expandedEvents.forEach(expandedEv => {
|
}
|
||||||
let dateKey = parseDateString(expandedEv.start);
|
calendarBody.appendChild(row);
|
||||||
if (dateKey) {
|
}
|
||||||
if (!eventsByDate[dateKey]) {
|
|
||||||
eventsByDate[dateKey] = [];
|
/**
|
||||||
}
|
* Build the entry of a single event in the detail panel.
|
||||||
eventsByDate[dateKey].push(expandedEv);
|
*
|
||||||
}
|
* @param {Object} occurrence The occurrence to show
|
||||||
});
|
* @returns {HTMLElement} The entry
|
||||||
} else {
|
*/
|
||||||
// For regular events, check if they fall in current month
|
function renderEventItem(occurrence) {
|
||||||
let dateKey = parseDateString(ev.start);
|
const item = document.createElement("div");
|
||||||
if (dateKey) {
|
item.className = "event-item";
|
||||||
// Check if this event belongs to current month view
|
|
||||||
const eventYear = parseInt(dateKey.split('-')[0]);
|
const title = document.createElement("div");
|
||||||
const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
|
title.className = "event-title";
|
||||||
|
|
||||||
if (eventYear === year && eventMonth === month) {
|
if (occurrence.url) {
|
||||||
if (!eventsByDate[dateKey]) {
|
const link = document.createElement("a");
|
||||||
eventsByDate[dateKey] = [];
|
link.href = occurrence.url;
|
||||||
}
|
link.textContent = occurrence.summary;
|
||||||
eventsByDate[dateKey].push(ev);
|
title.appendChild(link);
|
||||||
}
|
} else {
|
||||||
}
|
title.textContent = occurrence.summary;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
item.appendChild(title);
|
||||||
renderCalendar(year, month);
|
|
||||||
}
|
const time = document.createElement("div");
|
||||||
|
time.className = "event-time";
|
||||||
function renderCalendar(year, month) {
|
time.textContent = formatTimeRange(occurrence);
|
||||||
// Setze die Monatsbeschriftung (in Deutsch)
|
item.appendChild(time);
|
||||||
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
|
||||||
currentMonthElem.textContent = monthNames[month] + " " + year;
|
if (occurrence.description) {
|
||||||
calendarBody.innerHTML = "";
|
const description = document.createElement("div");
|
||||||
|
description.className = "event-description";
|
||||||
let firstDay = new Date(year, month, 1);
|
description.textContent = occurrence.description;
|
||||||
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
|
item.appendChild(description);
|
||||||
let daysInMonth = new Date(year, month + 1, 0).getDate();
|
}
|
||||||
|
|
||||||
let row = document.createElement("tr");
|
return item;
|
||||||
// Leere Zellen vor dem 1. Tag
|
}
|
||||||
for (let i = 0; i < firstDayIndex; i++){
|
|
||||||
let cell = document.createElement("td");
|
function showEventDetails(dateKey) {
|
||||||
row.appendChild(cell);
|
const occurrences = eventsByDate[dateKey];
|
||||||
}
|
eventDateElem.textContent = formatDate(dateKey);
|
||||||
|
eventDetailsElem.innerHTML = "";
|
||||||
// Tage hinzufügen
|
|
||||||
for (let day = 1; day <= daysInMonth; day++){
|
if (occurrences && occurrences.length > 0) {
|
||||||
if (row.children.length === 7) {
|
for (const occurrence of occurrences) {
|
||||||
calendarBody.appendChild(row);
|
eventDetailsElem.appendChild(renderEventItem(occurrence));
|
||||||
row = document.createElement("tr");
|
}
|
||||||
}
|
} else {
|
||||||
let cell = document.createElement("td");
|
let noEvents = document.createElement("div");
|
||||||
cell.innerHTML = "<strong>" + day + "</strong>";
|
noEvents.className = "no-events";
|
||||||
|
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
|
||||||
let dayStr = day < 10 ? "0" + day : day;
|
eventDetailsElem.appendChild(noEvents);
|
||||||
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
|
}
|
||||||
let dateKey = year + "-" + monthStr + "-" + dayStr;
|
|
||||||
|
eventPanel.style.display = "block";
|
||||||
if (eventsByDate[dateKey]) {
|
}
|
||||||
let dotsContainer = document.createElement("div");
|
|
||||||
dotsContainer.className = "event-dots-container";
|
function formatDate(dateStr) {
|
||||||
cell.classList.add("has-event");
|
// Convert YYYY-MM-DD to DD.MM.YYYY
|
||||||
|
const parts = dateStr.split("-");
|
||||||
// Gruppe Events nach Typ
|
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
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"));
|
* Describe when an occurrence takes place, in Berlin time.
|
||||||
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
|
*
|
||||||
const hasRegular = events.some(e => {
|
* @param {Object} occurrence The occurrence to describe
|
||||||
const title = e.summary.toLowerCase();
|
* @returns {string} The description
|
||||||
return !title.includes("members only") &&
|
*/
|
||||||
!title.includes("subbotnik") &&
|
function formatTimeRange(occurrence) {
|
||||||
!title.includes("bastelabend") &&
|
if (occurrence.allDay) {
|
||||||
!title.includes("spieleabend");
|
return "Ganztägig";
|
||||||
});
|
}
|
||||||
|
|
||||||
// Füge Dots entsprechend der Event-Typen hinzu
|
const start = timeOfDayFormat.format(occurrence.start);
|
||||||
if (hasMembersOnly) {
|
const end = timeOfDayFormat.format(occurrence.end);
|
||||||
let dot = document.createElement("div");
|
|
||||||
dot.className = "event-dot event-dot-red";
|
return `Start: ${start}, End: ${end}`;
|
||||||
dotsContainer.appendChild(dot);
|
}
|
||||||
}
|
|
||||||
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
let dot = document.createElement("div");
|
currentMonthElem = document.getElementById("current-month");
|
||||||
dot.className = "event-dot event-dot-orange";
|
calendarBody = document.getElementById("calendar-body");
|
||||||
dotsContainer.appendChild(dot);
|
eventPanel = document.getElementById("event-panel");
|
||||||
}
|
eventDateElem = document.getElementById("event-date");
|
||||||
if (hasRegular) {
|
eventDetailsElem = document.getElementById("event-details");
|
||||||
let dot = document.createElement("div");
|
|
||||||
dot.className = "event-dot event-dot-greenyellow";
|
document.getElementById("prev-month").addEventListener("click", function() {
|
||||||
dotsContainer.appendChild(dot);
|
currentMonth--;
|
||||||
}
|
if (currentMonth < 0) {
|
||||||
|
currentMonth = 11;
|
||||||
cell.appendChild(dotsContainer);
|
currentYear--;
|
||||||
cell.dataset.dateKey = dateKey;
|
}
|
||||||
cell.addEventListener("click", function() {
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
// Clear previous selections
|
});
|
||||||
document.querySelectorAll('.selected-day').forEach(el => {
|
document.getElementById("next-month").addEventListener("click", function() {
|
||||||
el.classList.remove('selected-day');
|
currentMonth++;
|
||||||
});
|
if (currentMonth > 11) {
|
||||||
cell.classList.add('selected-day');
|
currentMonth = 0;
|
||||||
showEventDetails(dateKey);
|
currentYear++;
|
||||||
});
|
}
|
||||||
}
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
row.appendChild(cell);
|
});
|
||||||
}
|
|
||||||
// Falls die letzte Zeile nicht komplett ist
|
// Show the grid of the current month right away, the events are filled in
|
||||||
while (row.children.length < 7) {
|
// once the calendar has been loaded.
|
||||||
let cell = document.createElement("td");
|
const today = new Date();
|
||||||
row.appendChild(cell);
|
currentYear = today.getFullYear();
|
||||||
}
|
currentMonth = today.getMonth();
|
||||||
calendarBody.appendChild(row);
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
}
|
|
||||||
|
fetch(icsUrl)
|
||||||
function createEventLink(eventTitle) {
|
.then(response => {
|
||||||
if (eventTitle.startsWith("Datengarten")) {
|
// Without this an error page would be handed to the parser below, which
|
||||||
// Extract the number after "Datengarten "
|
// then fails with a confusing complaint about the calendar syntax.
|
||||||
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
|
if (!response.ok) {
|
||||||
if (match && match[1]) {
|
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
|
||||||
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
|
}
|
||||||
}
|
|
||||||
}
|
return response.text();
|
||||||
|
})
|
||||||
// For other titles, convert to lowercase and use as path
|
.then(icsText => {
|
||||||
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
calendar = new ICAL.Component(ICAL.parse(icsText));
|
||||||
return `https://berlin.ccc.de/page/${slug}/`;
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
}
|
})
|
||||||
|
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||||
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));
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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