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
3d2231d7ec
commit
aca6f0eb38
3 changed files with 413 additions and 440 deletions
|
|
@ -107,6 +107,10 @@
|
|||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.event-description {
|
||||
/* Descriptions carry their own line breaks, keep them. */
|
||||
white-space: pre-line;
|
||||
}
|
||||
.no-events {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
|
|
|
|||
|
|
@ -1,250 +1,209 @@
|
|||
document.addEventListener('DOMContentLoaded', function() {
|
||||
(function(){
|
||||
let events = [];
|
||||
import ICAL from "./vendor/ical.js";
|
||||
|
||||
const icsUrl = "/calendars/all.ics";
|
||||
|
||||
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
||||
// no matter which time zone the browser of the visitor is set to.
|
||||
const timeZone = "Europe/Berlin";
|
||||
|
||||
const monthNames = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
];
|
||||
|
||||
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
let calendar = null;
|
||||
let eventsByDate = {};
|
||||
let currentYear;
|
||||
let currentMonth;
|
||||
|
||||
// Funktion zum Parsen der ICS-Datei
|
||||
function parseICS(icsText) {
|
||||
let events = [];
|
||||
let lines = icsText.split(/\r?\n/);
|
||||
let event = null;
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith("BEGIN:VEVENT")) {
|
||||
event = {};
|
||||
} else if (line.startsWith("END:VEVENT")) {
|
||||
if (event) events.push(event);
|
||||
event = null;
|
||||
} else if (event) {
|
||||
let colonIndex = line.indexOf(":");
|
||||
if (colonIndex > -1) {
|
||||
let key = line.substring(0, colonIndex);
|
||||
let value = line.substring(colonIndex + 1);
|
||||
let currentMonthElem;
|
||||
let calendarBody;
|
||||
let eventPanel;
|
||||
let eventDateElem;
|
||||
let eventDetailsElem;
|
||||
|
||||
// Handle properties with parameters (like TZID)
|
||||
const baseKey = key.split(";")[0];
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
if (baseKey === "DTSTART") {
|
||||
event.start = value;
|
||||
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
||||
} else if (baseKey === "DTEND") {
|
||||
event.end = value;
|
||||
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
||||
} else if (baseKey === "SUMMARY") {
|
||||
event.summary = value;
|
||||
} else if (baseKey === "DESCRIPTION") {
|
||||
event.description = value;
|
||||
} else if (baseKey === "RRULE") {
|
||||
event.rrule = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return events;
|
||||
for (const part of dayKeyFormat.formatToParts(date)) {
|
||||
parts[part.type] = part.value;
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
||||
function parseDateString(icsDateStr) {
|
||||
// Handle different date formats
|
||||
if (!icsDateStr) return null;
|
||||
|
||||
// For basic date format: YYYYMMDD
|
||||
if (icsDateStr.length === 8) {
|
||||
let year = icsDateStr.substring(0, 4);
|
||||
let month = icsDateStr.substring(4, 6);
|
||||
let day = icsDateStr.substring(6, 8);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
|
||||
else if (icsDateStr.includes("T")) {
|
||||
let year = icsDateStr.substring(0, 4);
|
||||
let month = icsDateStr.substring(4, 6);
|
||||
let day = icsDateStr.substring(6, 8);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return null;
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
// Extract date components from different date formats
|
||||
function getDateComponents(icsDateStr) {
|
||||
if (!icsDateStr) return null;
|
||||
/**
|
||||
* 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 = [];
|
||||
|
||||
// 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));
|
||||
let key = dayKey(start);
|
||||
|
||||
return { year, month, day };
|
||||
// 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;
|
||||
}
|
||||
|
||||
function expandRecurringEvents(event, year, month) {
|
||||
if (!event.rrule) return [event];
|
||||
// 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)));
|
||||
}
|
||||
|
||||
const rruleStr = event.rrule;
|
||||
return days;
|
||||
}
|
||||
|
||||
// Get start date components
|
||||
const startComponents = getDateComponents(event.start);
|
||||
if (!startComponents) return [event];
|
||||
/**
|
||||
* 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") ?? "";
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
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);
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
// 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')) : '';
|
||||
if (!calendar) {
|
||||
return byDate;
|
||||
}
|
||||
|
||||
newEvent.start = eventDate + timePart;
|
||||
newEvent.end = eventDate + endTimePart;
|
||||
expandedEvents.push(newEvent);
|
||||
// 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;
|
||||
}
|
||||
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 => {
|
||||
const occurrence = parseInt(byday) || 1;
|
||||
const dayCode = byday.slice(-2);
|
||||
const dayIndex = dayMap[dayCode];
|
||||
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||
const event = new ICAL.Event(component);
|
||||
|
||||
let day = 1;
|
||||
let count = 0;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator();
|
||||
|
||||
// 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')) : '';
|
||||
while (true) {
|
||||
const occurrence = iterator.next();
|
||||
|
||||
newEvent.start = formatDateForICS(eventDate) + timePart;
|
||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
||||
expandedEvents.push(newEvent);
|
||||
// Recurrences are chronological, so we are done once one starts after
|
||||
// the month.
|
||||
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
|
||||
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--;
|
||||
return byDate;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eventsByDate = occurrencesOfMonth(year, month);
|
||||
renderCalendar(year, month);
|
||||
}
|
||||
|
||||
function renderCalendar(year, month) {
|
||||
// Setze die Monatsbeschriftung (in Deutsch)
|
||||
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
||||
currentMonthElem.textContent = monthNames[month] + " " + year;
|
||||
calendarBody.innerHTML = "";
|
||||
|
||||
|
|
@ -329,67 +288,54 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
calendarBody.appendChild(row);
|
||||
}
|
||||
|
||||
function createEventLink(eventTitle) {
|
||||
if (eventTitle.startsWith("Datengarten")) {
|
||||
// Extract the number after "Datengarten "
|
||||
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
|
||||
if (match && match[1]) {
|
||||
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// For other titles, convert to lowercase and use as path
|
||||
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
||||
return `https://berlin.ccc.de/page/${slug}/`;
|
||||
item.appendChild(title);
|
||||
|
||||
const time = document.createElement("div");
|
||||
time.className = "event-time";
|
||||
time.textContent = formatTimeRange(occurrence);
|
||||
item.appendChild(time);
|
||||
|
||||
if (occurrence.description) {
|
||||
const description = document.createElement("div");
|
||||
description.className = "event-description";
|
||||
description.textContent = occurrence.description;
|
||||
item.appendChild(description);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function showEventDetails(dateKey) {
|
||||
const events = eventsByDate[dateKey];
|
||||
const occurrences = 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;
|
||||
if (occurrences && occurrences.length > 0) {
|
||||
for (const occurrence of occurrences) {
|
||||
eventDetailsElem.appendChild(renderEventItem(occurrence));
|
||||
}
|
||||
|
||||
eventItem.appendChild(eventDescription);
|
||||
}
|
||||
|
||||
eventDetailsElem.appendChild(eventItem);
|
||||
});
|
||||
} else {
|
||||
let noEvents = document.createElement("div");
|
||||
noEvents.className = "no-events";
|
||||
|
|
@ -406,44 +352,67 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
}
|
||||
|
||||
function formatTime(icsTimeStr) {
|
||||
// Format time for display
|
||||
if (!icsTimeStr) return "";
|
||||
|
||||
if (icsTimeStr.length === 8) {
|
||||
// All-day event
|
||||
/**
|
||||
* Describe when an occurrence takes place, in Berlin time.
|
||||
*
|
||||
* @param {Object} occurrence The occurrence to describe
|
||||
* @returns {string} The description
|
||||
*/
|
||||
function formatTimeRange(occurrence) {
|
||||
if (occurrence.allDay) {
|
||||
return "Ganztägig";
|
||||
} 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}`;
|
||||
const start = timeOfDayFormat.format(occurrence.start);
|
||||
const end = timeOfDayFormat.format(occurrence.end);
|
||||
|
||||
return `Start: ${start}, End: ${end}`;
|
||||
}
|
||||
|
||||
// ICS-Datei abrufen und Events verarbeiten
|
||||
fetch('/calendars/all.ics')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
events = parseICS(data);
|
||||
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");
|
||||
|
||||
// Initialize with current date
|
||||
let today = new Date();
|
||||
document.getElementById("prev-month").addEventListener("click", function() {
|
||||
currentMonth--;
|
||||
if (currentMonth < 0) {
|
||||
currentMonth = 11;
|
||||
currentYear--;
|
||||
}
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
});
|
||||
document.getElementById("next-month").addEventListener("click", function() {
|
||||
currentMonth++;
|
||||
if (currentMonth > 11) {
|
||||
currentMonth = 0;
|
||||
currentYear++;
|
||||
}
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
});
|
||||
|
||||
// Show the grid of the current month right away, the events are filled in
|
||||
// once the calendar has been loaded.
|
||||
const today = new Date();
|
||||
currentYear = today.getFullYear();
|
||||
currentMonth = today.getMonth();
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
|
||||
// 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);
|
||||
})
|
||||
.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" }}
|
||||
|
||||
<div class="calendar-container">
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
||||
{{ end }}
|
||||
{{ with $js }}
|
||||
<script src="{{ .RelPermalink }}"></script>
|
||||
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
<div id="calendar">
|
||||
|
|
|
|||
Loading…
Reference in a new issue