Add calendar next to newsletter — month view fed by schedule webhook

- Calendar card beside newsletter signup (2-column grid, stacks on mobile)
- Month navigation (prev/next), Monday-first week, today highlighted
- Event dots on days with entries + event list below the grid
- Normalizes schedule webhook response ({data:[]} or [{data:[]}])
- Bilingual DE/EN month names + weekday headers, re-renders on language toggle
This commit is contained in:
Kato
2026-09-05 18:19:00 +00:00
parent c8e53d907d
commit 5f66682f1d
3 changed files with 277 additions and 4 deletions
+130 -4
View File
@@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentLang = document.body.dataset.lang || 'de';
checkSession();
loadCommunityStats();
initCalendar();
// Smooth scroll nav links
document.querySelectorAll('.nav-link').forEach(link => {
@@ -179,6 +180,7 @@ function initScrollReveal() {
'.member-card',
'.date-card',
'.newsletter-card',
'.calendar-card',
'.btn-cta',
'.hero-title',
'.hero-subtitle'
@@ -218,7 +220,7 @@ function initScrollReveal() {
// initScrollReveal() ran, so they were never observed -> stuck at opacity 0.
// ========================================
function observeRevealCards(container) {
const els = (container || document).querySelectorAll('.wiki-card-new, .member-card, .date-card, .newsletter-card');
const els = (container || document).querySelectorAll('.wiki-card-new, .member-card, .date-card, .newsletter-card, .calendar-card');
if (!revealObserver) {
// Reduced-motion (observer never created) or init missed: show immediately
els.forEach(el => el.classList.add('visible'));
@@ -267,6 +269,7 @@ function setLanguage(lang) {
document.querySelectorAll('.lang-btn').forEach(b => {
b.classList.toggle('active', b.dataset.lang === lang);
});
renderCalendar();
}
function isDE() {
@@ -906,21 +909,39 @@ function escapeHtml(text) {
// ========================================
// DATES / EVENTS
// ========================================
let scheduleData = [];
// Schedule webhook may return { data: [...] } or [{ data: [...] }] — normalize
function normalizeScheduleResult(result) {
if (!result) return [];
if (Array.isArray(result)) {
for (const item of result) {
if (item && Array.isArray(item.data)) return item.data;
}
return [];
}
if (Array.isArray(result.data)) return result.data;
return [];
}
async function loadDates() {
try {
const response = await fetch(SCHEDULE_URL);
if (!response.ok) throw new Error('Failed to fetch');
const result = await response.json();
if (result.data && Array.isArray(result.data) && result.data.length > 0) {
renderDates(result.data);
scheduleData = normalizeScheduleResult(result);
if (scheduleData.length > 0) {
renderDates(scheduleData);
} else {
document.getElementById('dates-list').innerHTML =
'<p class="no-events">' + (isDE() ? 'Keine Termine geplant' : 'No events scheduled') + '</p>';
}
renderCalendar();
} catch (error) {
document.getElementById('dates-list').innerHTML =
'<p class="no-events">' + (isDE() ? 'Fehler beim Laden' : 'Error loading events') + '</p>';
renderCalendar();
}
}
@@ -973,6 +994,111 @@ function renderDates(events) {
observeRevealCards(container);
}
// ========================================
// CALENDAR (next to newsletter) — month view from schedule webhook
// ========================================
const MONTH_NAMES_DE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const MONTH_NAMES_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
let calendarDate = new Date();
function isoDay(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function eventsByDay() {
const map = {};
scheduleData.forEach(ev => {
if (!ev || !ev.start) return;
const key = isoDay(new Date(ev.start));
(map[key] = map[key] || []).push(ev);
});
return map;
}
function initCalendar() {
const prev = document.getElementById('calendar-prev');
const next = document.getElementById('calendar-next');
if (!prev || !next) return;
prev.addEventListener('click', () => {
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1);
renderCalendar();
});
next.addEventListener('click', () => {
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1);
renderCalendar();
});
renderCalendar();
}
function renderCalendar() {
const grid = document.getElementById('calendar-grid');
const title = document.getElementById('calendar-title');
if (!grid || !title) return;
const lang = isDE() ? MONTH_NAMES_DE : MONTH_NAMES_EN;
const year = calendarDate.getFullYear();
const month = calendarDate.getMonth();
title.innerHTML = '<span>' + escapeHtml(lang[month]) + ' ' + year + '</span>';
const firstDay = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Monday as first weekday: JS getDay() -> 0=Sun..6=Sat
let startOffset = (firstDay.getDay() + 6) % 7;
const todayKey = isoDay(new Date());
const eventMap = eventsByDay();
let html = '';
for (let i = 0; i < startOffset; i++) {
html += '<div class="calendar-day empty"></div>';
}
for (let day = 1; day <= daysInMonth; day++) {
const key = isoDay(new Date(year, month, day));
const dayEvents = eventMap[key] || [];
const classes = ['calendar-day'];
if (key === todayKey) classes.push('today');
if (dayEvents.length > 0) classes.push('has-event');
let cell = '<div class="' + classes.join(' ') + '"' +
(dayEvents.length ? ' title="' + escapeHtml(dayEvents.map(e => e.headline || 'Event').join(', ')) + '"' : '') + '>';
cell += '<span class="calendar-day-number">' + day + '</span>';
if (dayEvents.length > 0) {
cell += '<span class="calendar-dots">' + '<i></i>'.repeat(Math.min(dayEvents.length, 3)) + '</span>';
}
cell += '</div>';
html += cell;
}
grid.innerHTML = html;
// Event list below: upcoming events from this month (or hint)
const eventsEl = document.getElementById('calendar-events');
if (!eventsEl) return;
const monthEvents = scheduleData
.filter(ev => {
const d = new Date(ev.start);
return d.getFullYear() === year && d.getMonth() === month;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
if (monthEvents.length === 0) {
eventsEl.innerHTML = '<p class="calendar-events-hint">' +
(isDE() ? 'Keine Termine in diesem Monat.' : 'No events this month.') + '</p>';
return;
}
let list = '<ul class="calendar-event-list">';
monthEvents.forEach(ev => {
const sd = new Date(ev.start);
const ed = ev.end ? new Date(ev.end) : null;
const timeStr = String(sd.getHours()).padStart(2, '0') + ':' + String(sd.getMinutes()).padStart(2, '0') +
(ed ? '' + String(ed.getHours()).padStart(2, '0') + ':' + String(ed.getMinutes()).padStart(2, '0') : '');
list += '<li><span class="calendar-event-date">' + String(sd.getDate()).padStart(2, '0') + '. ' + escapeHtml(lang[month].slice(0, 2)) + '</span>' +
'<span class="calendar-event-headline">' + escapeHtml(ev.headline || 'Event') + '</span>' +
'<span class="calendar-event-time">' + timeStr + '</span></li>';
});
list += '</ul>';
eventsEl.innerHTML = list;
}
function setCookie(name, value, days) {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);