xref: /plugin/calendar/script.js (revision e3a9f44ce79ec1754946340aa2b4e60f3e5583ec)
1/**
2 * DokuWiki Compact Calendar Plugin JavaScript
3 */
4
5// Navigate to different month
6function navCalendar(calId, year, month, namespace) {
7    const params = new URLSearchParams({
8        call: 'plugin_calendar',
9        action: 'load_month',
10        year: year,
11        month: month,
12        namespace: namespace,
13        _: new Date().getTime() // Cache buster
14    });
15
16    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
17        method: 'POST',
18        headers: {
19            'Content-Type': 'application/x-www-form-urlencoded',
20            'Cache-Control': 'no-cache, no-store, must-revalidate',
21            'Pragma': 'no-cache'
22        },
23        body: params.toString()
24    })
25    .then(r => r.json())
26    .then(data => {
27        console.log('=== navCalendar AJAX Response ===');
28        console.log('Requested year:', year, 'month:', month);
29        console.log('Response:', data);
30        console.log('Response year:', data.year, 'month:', data.month);
31        console.log('Event date keys:', Object.keys(data.events || {}));
32        if (data.success) {
33            console.log('Rebuilding calendar for', year, month, 'with', Object.keys(data.events || {}).length, 'date entries');
34            rebuildCalendar(calId, data.year, data.month, data.events, namespace);
35        } else {
36            console.error('Failed to load month:', data.error);
37        }
38    })
39    .catch(err => {
40        console.error('Error loading month:', err);
41    });
42}
43
44// Jump to current month
45function jumpToToday(calId, namespace) {
46    const today = new Date();
47    const year = today.getFullYear();
48    const month = today.getMonth() + 1; // JavaScript months are 0-indexed
49    navCalendar(calId, year, month, namespace);
50}
51
52// Jump to today for event panel
53function jumpTodayPanel(calId, namespace) {
54    const today = new Date();
55    const year = today.getFullYear();
56    const month = today.getMonth() + 1;
57    navEventPanel(calId, year, month, namespace);
58}
59
60// Open month picker dialog
61function openMonthPicker(calId, currentYear, currentMonth, namespace) {
62    console.log('openMonthPicker called:', calId, currentYear, currentMonth, namespace);
63
64    const overlay = document.getElementById('month-picker-overlay-' + calId);
65    console.log('Overlay element:', overlay);
66
67    const monthSelect = document.getElementById('month-picker-month-' + calId);
68    console.log('Month select:', monthSelect);
69
70    const yearSelect = document.getElementById('month-picker-year-' + calId);
71    console.log('Year select:', yearSelect);
72
73    if (!overlay) {
74        console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId);
75        return;
76    }
77
78    if (!monthSelect || !yearSelect) {
79        console.error('Select elements not found!');
80        return;
81    }
82
83    // Set current values
84    monthSelect.value = currentMonth;
85    yearSelect.value = currentYear;
86
87    // Show overlay
88    overlay.style.display = 'flex';
89    console.log('Overlay display set to flex');
90}
91
92// Open month picker dialog for event panel
93function openMonthPickerPanel(calId, currentYear, currentMonth, namespace) {
94    openMonthPicker(calId, currentYear, currentMonth, namespace);
95}
96
97// Close month picker dialog
98function closeMonthPicker(calId) {
99    const overlay = document.getElementById('month-picker-overlay-' + calId);
100    overlay.style.display = 'none';
101}
102
103// Jump to selected month
104function jumpToSelectedMonth(calId, namespace) {
105    const monthSelect = document.getElementById('month-picker-month-' + calId);
106    const yearSelect = document.getElementById('month-picker-year-' + calId);
107
108    const month = parseInt(monthSelect.value);
109    const year = parseInt(yearSelect.value);
110
111    closeMonthPicker(calId);
112
113    // Check if this is a calendar or event panel
114    const container = document.getElementById(calId);
115    if (container && container.classList.contains('event-panel-standalone')) {
116        navEventPanel(calId, year, month, namespace);
117    } else {
118        navCalendar(calId, year, month, namespace);
119    }
120}
121
122// Rebuild calendar grid after navigation
123function rebuildCalendar(calId, year, month, events, namespace) {
124    console.log('=== rebuildCalendar DEBUG ===');
125    console.log('Requested:', {year, month, namespace});
126    console.log('Event date keys received:', Object.keys(events));
127    console.log('Events object:', events);
128
129    const container = document.getElementById(calId);
130    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
131                       'July', 'August', 'September', 'October', 'November', 'December'];
132
133    // Update container data attributes for current month/year
134    container.setAttribute('data-year', year);
135    container.setAttribute('data-month', month);
136
137    // Update embedded events data
138    let eventsDataEl = document.getElementById('events-data-' + calId);
139    if (eventsDataEl) {
140        eventsDataEl.textContent = JSON.stringify(events);
141    } else {
142        eventsDataEl = document.createElement('script');
143        eventsDataEl.type = 'application/json';
144        eventsDataEl.id = 'events-data-' + calId;
145        eventsDataEl.textContent = JSON.stringify(events);
146        container.appendChild(eventsDataEl);
147    }
148
149    // Update header
150    const header = container.querySelector('.calendar-compact-header h3');
151    header.textContent = monthNames[month - 1] + ' ' + year;
152
153    // Update nav buttons
154    let prevMonth = month - 1;
155    let prevYear = year;
156    if (prevMonth < 1) {
157        prevMonth = 12;
158        prevYear--;
159    }
160
161    let nextMonth = month + 1;
162    let nextYear = year;
163    if (nextMonth > 12) {
164        nextMonth = 1;
165        nextYear++;
166    }
167
168    const navBtns = container.querySelectorAll('.cal-nav-btn');
169    navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
170    navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
171
172    // Rebuild calendar grid
173    const tbody = container.querySelector('.calendar-compact-grid tbody');
174    const firstDay = new Date(year, month - 1, 1);
175    const daysInMonth = new Date(year, month, 0).getDate();
176    const dayOfWeek = firstDay.getDay();
177
178    // Calculate month boundaries
179    const monthStart = new Date(year, month - 1, 1);
180    const monthEnd = new Date(year, month - 1, daysInMonth);
181
182    // Build a map of all events with their date ranges
183    const eventRanges = {};
184    for (const [dateKey, dayEvents] of Object.entries(events)) {
185        // Defensive check: ensure dayEvents is an array
186        if (!Array.isArray(dayEvents)) {
187            console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents);
188            continue;
189        }
190
191        // Only process events that could possibly overlap with this month/year
192        const dateYear = parseInt(dateKey.split('-')[0]);
193        const dateMonth = parseInt(dateKey.split('-')[1]);
194
195        // Skip events from completely different years (unless they're very long multi-day events)
196        if (Math.abs(dateYear - year) > 1) {
197            continue;
198        }
199
200        for (const evt of dayEvents) {
201            const startDate = dateKey;
202            const endDate = evt.endDate || dateKey;
203
204            // Check if event overlaps with current month
205            const eventStart = new Date(startDate + 'T00:00:00');
206            const eventEnd = new Date(endDate + 'T00:00:00');
207
208            // Skip if event doesn't overlap with current month
209            if (eventEnd < monthStart || eventStart > monthEnd) {
210                continue;
211            }
212
213            // Create entry for each day the event spans
214            const start = new Date(startDate + 'T00:00:00');
215            const end = new Date(endDate + 'T00:00:00');
216            const current = new Date(start);
217
218            while (current <= end) {
219                const currentKey = current.toISOString().split('T')[0];
220
221                // Check if this date is in current month
222                const currentDate = new Date(currentKey + 'T00:00:00');
223                if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) {
224                    if (!eventRanges[currentKey]) {
225                        eventRanges[currentKey] = [];
226                    }
227
228                    // Add event with span information
229                    const eventCopy = {...evt};
230                    eventCopy._span_start = startDate;
231                    eventCopy._span_end = endDate;
232                    eventCopy._is_first_day = (currentKey === startDate);
233                    eventCopy._is_last_day = (currentKey === endDate);
234                    eventCopy._original_date = dateKey;
235
236                    // Check if event continues from previous month or to next month
237                    eventCopy._continues_from_prev = (eventStart < monthStart);
238                    eventCopy._continues_to_next = (eventEnd > monthEnd);
239
240                    eventRanges[currentKey].push(eventCopy);
241                }
242
243                current.setDate(current.getDate() + 1);
244            }
245        }
246    }
247
248    let html = '';
249    let currentDay = 1;
250    const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7);
251
252    for (let row = 0; row < rowCount; row++) {
253        html += '<tr>';
254        for (let col = 0; col < 7; col++) {
255            if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) {
256                html += '<td class="cal-empty"></td>';
257            } else {
258                const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`;
259
260                // Get today's date in local timezone
261                const todayObj = new Date();
262                const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`;
263
264                const isToday = dateKey === today;
265                const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0;
266
267                let classes = 'cal-day';
268                if (isToday) classes += ' cal-today';
269                if (hasEvents) classes += ' cal-has-events';
270
271                html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`;
272                html += `<span class="day-num">${currentDay}</span>`;
273
274                if (hasEvents) {
275                    // Sort events by time (no time first, then by time)
276                    const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => {
277                        const timeA = a.time || '';
278                        const timeB = b.time || '';
279
280                        // Events without time go first
281                        if (!timeA && timeB) return -1;
282                        if (timeA && !timeB) return 1;
283                        if (!timeA && !timeB) return 0;
284
285                        // Sort by time
286                        return timeA.localeCompare(timeB);
287                    });
288
289                    // Show colored stacked bars for each event
290                    html += '<div class="event-indicators">';
291                    for (const evt of sortedEvents) {
292                        const eventId = evt.id || '';
293                        const eventColor = evt.color || '#3498db';
294                        const eventTime = evt.time || '';
295                        const eventTitle = evt.title || 'Event';
296                        const originalDate = evt._original_date || dateKey;
297                        const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true;
298                        const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true;
299
300                        let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed';
301
302                        // Add classes for multi-day spanning
303                        if (!isFirstDay) barClass += ' event-bar-continues';
304                        if (!isLastDay) barClass += ' event-bar-continuing';
305
306                        html += `<span class="event-bar ${barClass}" `;
307                        html += `style="background: ${eventColor};" `;
308                        html += `title="${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `;
309                        html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');"></span>`;
310                    }
311                    html += '</div>';
312                }
313
314                html += '</td>';
315                currentDay++;
316            }
317        }
318        html += '</tr>';
319    }
320
321    tbody.innerHTML = html;
322
323    // Rebuild event list - server already filtered to current month
324    const eventList = container.querySelector('.event-list-compact');
325    eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
326
327    // Auto-scroll to first future event (past events will be above viewport)
328    setTimeout(() => {
329        const firstFuture = eventList.querySelector('[data-first-future="true"]');
330        if (firstFuture) {
331            firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' });
332        }
333    }, 100);
334
335    // Update title
336    const title = container.querySelector('#eventlist-title-' + calId);
337    title.textContent = 'Events';
338}
339
340// Render event list from data
341function renderEventListFromData(events, calId, namespace, year, month) {
342    if (!events || Object.keys(events).length === 0) {
343        return '<p class="no-events-msg">No events this month</p>';
344    }
345
346    let html = '';
347    const sortedDates = Object.keys(events).sort();
348
349    // Filter events to only current month if year/month provided
350    const monthStart = year && month ? new Date(year, month - 1, 1) : null;
351    const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null;
352
353    for (const dateKey of sortedDates) {
354        // Skip events not in current month if filtering
355        if (monthStart && monthEnd) {
356            const eventDate = new Date(dateKey + 'T00:00:00');
357
358            if (eventDate < monthStart || eventDate > monthEnd) {
359                continue;
360            }
361        }
362
363        // Sort events within this day by time
364        const dayEvents = events[dateKey];
365        dayEvents.sort((a, b) => {
366            const timeA = a.time || '00:00';
367            const timeB = b.time || '00:00';
368            return timeA.localeCompare(timeB);
369        });
370
371        for (const event of dayEvents) {
372            html += renderEventItem(event, dateKey, calId, namespace);
373        }
374    }
375
376    if (!html) {
377        return '<p class="no-events-msg">No events this month</p>';
378    }
379
380    return html;
381}
382
383// Show day popup with events when clicking a date
384function showDayPopup(calId, date, namespace) {
385    // Get events for this calendar
386    const eventsDataEl = document.getElementById('events-data-' + calId);
387    let events = {};
388
389    if (eventsDataEl) {
390        try {
391            events = JSON.parse(eventsDataEl.textContent);
392        } catch (e) {
393            console.error('Failed to parse events data:', e);
394        }
395    }
396
397    const dayEvents = events[date] || [];
398    const dateObj = new Date(date + 'T00:00:00');
399    const displayDate = dateObj.toLocaleDateString('en-US', {
400        weekday: 'long',
401        month: 'long',
402        day: 'numeric',
403        year: 'numeric'
404    });
405
406    // Create popup
407    let popup = document.getElementById('day-popup-' + calId);
408    if (!popup) {
409        popup = document.createElement('div');
410        popup.id = 'day-popup-' + calId;
411        popup.className = 'day-popup';
412        document.body.appendChild(popup);
413    }
414
415    let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>';
416    html += '<div class="day-popup-content">';
417    html += '<div class="day-popup-header">';
418    html += '<h4>' + displayDate + '</h4>';
419    html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>';
420    html += '</div>';
421
422    html += '<div class="day-popup-body">';
423
424    if (dayEvents.length === 0) {
425        html += '<p class="no-events-msg">No events on this day</p>';
426    } else {
427        html += '<div class="popup-events-list">';
428        dayEvents.forEach(event => {
429            const color = event.color || '#3498db';
430
431            // Use individual event namespace if available (for multi-namespace support)
432            const eventNamespace = event._namespace !== undefined ? event._namespace : namespace;
433
434            // Convert to 12-hour format
435            let displayTime = '';
436            if (event.time) {
437                const timeParts = event.time.split(':');
438                if (timeParts.length === 2) {
439                    let hour = parseInt(timeParts[0]);
440                    const minute = timeParts[1];
441                    const ampm = hour >= 12 ? 'PM' : 'AM';
442                    hour = hour % 12 || 12;
443                    displayTime = hour + ':' + minute + ' ' + ampm;
444                } else {
445                    displayTime = event.time;
446                }
447            }
448
449            // Multi-day indicator
450            let multiDay = '';
451            if (event.endDate && event.endDate !== date) {
452                const endObj = new Date(event.endDate + 'T00:00:00');
453                multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
454                    month: 'short',
455                    day: 'numeric'
456                });
457            }
458
459            html += '<div class="popup-event-item">';
460            html += '<div class="event-color-bar" style="background: ' + color + ';"></div>';
461            html += '<div class="popup-event-content">';
462
463            // Single line with title, time, date range, namespace, and actions
464            html += '<div class="popup-event-main-row">';
465            html += '<div class="popup-event-info-inline">';
466            html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>';
467            if (displayTime) {
468                html += '<span class="popup-event-time">�� ' + displayTime + '</span>';
469            }
470            if (multiDay) {
471                html += '<span class="popup-event-multiday">' + multiDay + '</span>';
472            }
473            if (eventNamespace) {
474                html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>';
475            }
476            html += '</div>';
477            html += '<div class="popup-event-actions">';
478            html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>';
479            html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">��️</button>';
480            html += '</div>';
481            html += '</div>';
482
483            // Description on separate line if present
484            if (event.description) {
485                html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>';
486            }
487
488            html += '</div></div>';
489        });
490        html += '</div>';
491    }
492
493    html += '</div>';
494
495    html += '<div class="day-popup-footer">';
496    html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>';
497    html += '</div>';
498
499    html += '</div>';
500
501    popup.innerHTML = html;
502    popup.style.display = 'flex';
503}
504
505// Close day popup
506function closeDayPopup(calId) {
507    const popup = document.getElementById('day-popup-' + calId);
508    if (popup) {
509        popup.style.display = 'none';
510    }
511}
512
513// Show events for a specific day (for event list panel)
514function showDayEvents(calId, date, namespace) {
515    const params = new URLSearchParams({
516        call: 'plugin_calendar',
517        action: 'load_month',
518        year: date.split('-')[0],
519        month: parseInt(date.split('-')[1]),
520        namespace: namespace,
521        _: new Date().getTime() // Cache buster
522    });
523
524    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
525        method: 'POST',
526        headers: {
527            'Content-Type': 'application/x-www-form-urlencoded',
528            'Cache-Control': 'no-cache, no-store, must-revalidate',
529            'Pragma': 'no-cache'
530        },
531        body: params.toString()
532    })
533    .then(r => r.json())
534    .then(data => {
535        if (data.success) {
536            const eventList = document.getElementById('eventlist-' + calId);
537            const events = data.events;
538            const title = document.getElementById('eventlist-title-' + calId);
539
540            const dateObj = new Date(date + 'T00:00:00');
541            const displayDate = dateObj.toLocaleDateString('en-US', {
542                weekday: 'short',
543                month: 'short',
544                day: 'numeric'
545            });
546
547            title.textContent = 'Events - ' + displayDate;
548
549            // Filter events for this day
550            const dayEvents = events[date] || [];
551
552            if (dayEvents.length === 0) {
553                eventList.innerHTML = '<p class="no-events-msg">No events on this day<br><button class="add-event-compact" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\')">+ Add Event</button></p>';
554            } else {
555                let html = '';
556                dayEvents.forEach(event => {
557                    html += renderEventItem(event, date, calId, namespace);
558                });
559                eventList.innerHTML = html;
560            }
561        }
562    })
563    .catch(err => console.error('Error:', err));
564}
565
566// Render a single event item
567function renderEventItem(event, date, calId, namespace) {
568    // Check if this event is in the past or today
569    const today = new Date();
570    today.setHours(0, 0, 0, 0);
571    const eventDate = new Date(date + 'T00:00:00');
572    const isPast = eventDate < today;
573    const isToday = eventDate.getTime() === today.getTime();
574
575    // Format date display with day of week
576    // Use originalStartDate if this is a multi-month event continuation
577    const displayDateKey = event.originalStartDate || date;
578    const dateObj = new Date(displayDateKey + 'T00:00:00');
579    const displayDate = dateObj.toLocaleDateString('en-US', {
580        weekday: 'short',
581        month: 'short',
582        day: 'numeric'
583    });
584
585    // Convert to 12-hour format
586    let displayTime = '';
587    if (event.time) {
588        const timeParts = event.time.split(':');
589        if (timeParts.length === 2) {
590            let hour = parseInt(timeParts[0]);
591            const minute = timeParts[1];
592            const ampm = hour >= 12 ? 'PM' : 'AM';
593            hour = hour % 12 || 12;
594            displayTime = hour + ':' + minute + ' ' + ampm;
595        } else {
596            displayTime = event.time;
597        }
598    }
599
600    // Multi-day indicator
601    let multiDay = '';
602    if (event.endDate && event.endDate !== displayDateKey) {
603        const endObj = new Date(event.endDate + 'T00:00:00');
604        multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
605            weekday: 'short',
606            month: 'short',
607            day: 'numeric'
608        });
609    }
610
611    const completedClass = event.completed ? ' event-completed' : '';
612    const pastClass = isPast ? ' event-past' : '';
613    const color = event.color || '#3498db';
614    const isTask = event.isTask || false;
615    const completed = event.completed || false;
616
617    let html = '<div class="event-compact-item' + completedClass + pastClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ';" onclick="' + (isPast ? 'togglePastEventExpand(this)' : '') + '">';
618
619    html += '<div class="event-info">';
620    html += '<div class="event-title-row">';
621    html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>';
622    html += '</div>';
623
624    // Only show meta and description for non-past events (collapsed for past)
625    if (!isPast) {
626        html += '<div class="event-meta-compact">';
627        html += '<span class="event-date-time">' + displayDate + multiDay;
628        if (displayTime) {
629            html += ' • ' + displayTime;
630        }
631        // Add TODAY badge for today's events
632        if (isToday) {
633            html += ' <span class="event-today-badge">TODAY</span>';
634        }
635        // Add namespace badge (stored namespace or _namespace for multi-namespace)
636        let eventNamespace = event.namespace || '';
637        if (!eventNamespace && event._namespace !== undefined) {
638            eventNamespace = event._namespace; // Fallback to _namespace for multi-namespace loading
639        }
640        if (eventNamespace) {
641            html += ' <span class="event-namespace-badge">' + escapeHtml(eventNamespace) + '</span>';
642        }
643        html += '</span>';
644        html += '</div>';
645
646        if (event.description) {
647            html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>';
648        }
649    } else {
650        // For past events, store data in hidden divs for expand/collapse
651        html += '<div class="event-meta-compact" style="display: none;">';
652        html += '<span class="event-date-time">' + displayDate + multiDay;
653        if (displayTime) {
654            html += ' • ' + displayTime;
655        }
656        html += '</span>';
657        html += '</div>';
658
659        if (event.description) {
660            html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>';
661        }
662    }
663
664    html += '</div>'; // event-info
665
666    // Use stored namespace from event, fallback to _namespace, then passed namespace
667    let buttonNamespace = event.namespace || '';
668    if (!buttonNamespace && event._namespace !== undefined) {
669        buttonNamespace = event._namespace;
670    }
671    if (!buttonNamespace) {
672        buttonNamespace = namespace;
673    }
674
675    html += '<div class="event-actions-compact">';
676    html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">��️</button>';
677    html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>';
678    html += '</div>';
679
680    // Checkbox for tasks - ON THE FAR RIGHT
681    if (isTask) {
682        const checked = completed ? 'checked' : '';
683        html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">';
684    }
685
686    html += '</div>';
687
688    return html;
689}
690
691// Render description with rich content support
692function renderDescription(description) {
693    if (!description) return '';
694
695    // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping)
696    // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00
697
698    let rendered = description;
699    const tokens = [];
700    let tokenIndex = 0;
701
702    // Convert DokuWiki image syntax {{image.jpg}} to tokens
703    rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) {
704        imagePath = imagePath.trim();
705        alt = alt ? alt.trim() : '';
706
707        let imageHtml;
708        // Handle external URLs
709        if (imagePath.match(/^https?:\/\//)) {
710            imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
711        } else {
712            // Handle internal DokuWiki images
713            const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath);
714            imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
715        }
716
717        const token = '\x00TOKEN' + tokenIndex + '\x00';
718        tokens[tokenIndex] = imageHtml;
719        tokenIndex++;
720        return token;
721    });
722
723    // Convert DokuWiki link syntax [[link|text]] to tokens
724    rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) {
725        link = link.trim();
726        text = text ? text.trim() : link;
727
728        let linkHtml;
729        // Handle external URLs
730        if (link.match(/^https?:\/\//)) {
731            linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
732        } else {
733            // Handle internal DokuWiki links with section anchors
734            const hashIndex = link.indexOf('#');
735            let pagePart = link;
736            let sectionPart = '';
737
738            if (hashIndex !== -1) {
739                pagePart = link.substring(0, hashIndex);
740                sectionPart = link.substring(hashIndex); // Includes the #
741            }
742
743            const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart;
744            linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>';
745        }
746
747        const token = '\x00TOKEN' + tokenIndex + '\x00';
748        tokens[tokenIndex] = linkHtml;
749        tokenIndex++;
750        return token;
751    });
752
753    // Convert markdown-style links [text](url) to tokens
754    rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
755        text = text.trim();
756        url = url.trim();
757
758        let linkHtml;
759        if (url.match(/^https?:\/\//)) {
760            linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
761        } else {
762            linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>';
763        }
764
765        const token = '\x00TOKEN' + tokenIndex + '\x00';
766        tokens[tokenIndex] = linkHtml;
767        tokenIndex++;
768        return token;
769    });
770
771    // Convert plain URLs to tokens
772    rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) {
773        const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>';
774        const token = '\x00TOKEN' + tokenIndex + '\x00';
775        tokens[tokenIndex] = linkHtml;
776        tokenIndex++;
777        return token;
778    });
779
780    // NOW escape the remaining text (tokens are protected with null bytes)
781    rendered = escapeHtml(rendered);
782
783    // Convert newlines to <br>
784    rendered = rendered.replace(/\n/g, '<br>');
785
786    // DokuWiki text formatting (on escaped text)
787    // Bold: **text** or __text__
788    rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
789    rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>');
790
791    // Italic: //text//
792    rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>');
793
794    // Strikethrough: <del>text</del>
795    rendered = rendered.replace(/&lt;del&gt;(.+?)&lt;\/del&gt;/g, '<del>$1</del>');
796
797    // Monospace: ''text''
798    rendered = rendered.replace(/&#39;&#39;(.+?)&#39;&#39;/g, '<code>$1</code>');
799
800    // Subscript: <sub>text</sub>
801    rendered = rendered.replace(/&lt;sub&gt;(.+?)&lt;\/sub&gt;/g, '<sub>$1</sub>');
802
803    // Superscript: <sup>text</sup>
804    rendered = rendered.replace(/&lt;sup&gt;(.+?)&lt;\/sup&gt;/g, '<sup>$1</sup>');
805
806    // Restore tokens (replace with actual HTML)
807    for (let i = 0; i < tokens.length; i++) {
808        const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g');
809        rendered = rendered.replace(tokenPattern, tokens[i]);
810    }
811
812    return rendered;
813}
814
815// Open add event dialog
816function openAddEvent(calId, namespace, date) {
817    const dialog = document.getElementById('dialog-' + calId);
818    const form = document.getElementById('eventform-' + calId);
819    const title = document.getElementById('dialog-title-' + calId);
820    const dateField = document.getElementById('event-date-' + calId);
821
822    if (!dateField) {
823        console.error('Date field not found! ID: event-date-' + calId);
824        return;
825    }
826
827    // Check if there's a filtered namespace active
828    const calendar = document.getElementById(calId);
829    const filteredNamespace = calendar.dataset.filteredNamespace;
830
831    // Use filtered namespace if available, otherwise use the passed namespace
832    const effectiveNamespace = filteredNamespace || namespace;
833
834    console.log('Opening add event: filtered=' + filteredNamespace + ', passed=' + namespace + ', using=' + effectiveNamespace);
835
836    // Reset form
837    form.reset();
838    document.getElementById('event-id-' + calId).value = '';
839
840    // Store the effective namespace in a hidden field or data attribute
841    form.dataset.effectiveNamespace = effectiveNamespace;
842
843    // Set date - use local date, not UTC
844    let defaultDate = date;
845    if (!defaultDate) {
846        // Get the currently displayed month from the calendar container
847        const container = document.getElementById(calId);
848        const displayedYear = parseInt(container.getAttribute('data-year'));
849        const displayedMonth = parseInt(container.getAttribute('data-month'));
850
851        console.log('Setting default date: year=' + displayedYear + ', month=' + displayedMonth);
852
853        if (displayedYear && displayedMonth) {
854            // Use first day of the displayed month
855            const year = displayedYear;
856            const month = String(displayedMonth).padStart(2, '0');
857            defaultDate = `${year}-${month}-01`;
858            console.log('Using displayed month:', defaultDate);
859        } else {
860            // Fallback to today if attributes not found
861            const today = new Date();
862            const year = today.getFullYear();
863            const month = String(today.getMonth() + 1).padStart(2, '0');
864            const day = String(today.getDate()).padStart(2, '0');
865            defaultDate = `${year}-${month}-${day}`;
866            console.log('Fallback to today:', defaultDate);
867        }
868    }
869    dateField.value = defaultDate;
870    dateField.removeAttribute('data-original-date');
871
872    // Also set the end date field to the same default (user can change it)
873    const endDateField = document.getElementById('event-end-date-' + calId);
874    if (endDateField) {
875        endDateField.value = ''; // Empty by default (single-day event)
876        // Set min attribute to help the date picker open on the right month
877        endDateField.setAttribute('min', defaultDate);
878    }
879
880    // Set default color
881    document.getElementById('event-color-' + calId).value = '#3498db';
882
883    // Set title
884    title.textContent = 'Add Event';
885
886    // Show dialog
887    dialog.style.display = 'flex';
888
889    // Focus title field
890    setTimeout(() => {
891        const titleField = document.getElementById('event-title-' + calId);
892        if (titleField) titleField.focus();
893    }, 100);
894}
895
896// Edit event
897function editEvent(calId, eventId, date, namespace) {
898    const params = new URLSearchParams({
899        call: 'plugin_calendar',
900        action: 'get_event',
901        namespace: namespace,
902        date: date,
903        eventId: eventId
904    });
905
906    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
907        method: 'POST',
908        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
909        body: params.toString()
910    })
911    .then(r => r.json())
912    .then(data => {
913        if (data.success && data.event) {
914            const event = data.event;
915            const dialog = document.getElementById('dialog-' + calId);
916            const title = document.getElementById('dialog-title-' + calId);
917            const dateField = document.getElementById('event-date-' + calId);
918
919            if (!dateField) {
920                console.error('Date field not found when editing!');
921                return;
922            }
923
924            // Populate form
925            document.getElementById('event-id-' + calId).value = event.id;
926            dateField.value = date;
927            dateField.setAttribute('data-original-date', date);
928
929            const endDateField = document.getElementById('event-end-date-' + calId);
930            endDateField.value = event.endDate || '';
931            // Set min attribute to help date picker open on the start date's month
932            endDateField.setAttribute('min', date);
933
934            document.getElementById('event-title-' + calId).value = event.title;
935            document.getElementById('event-time-' + calId).value = event.time || '';
936            document.getElementById('event-color-' + calId).value = event.color || '#3498db';
937            document.getElementById('event-desc-' + calId).value = event.description || '';
938            document.getElementById('event-is-task-' + calId).checked = event.isTask || false;
939
940            title.textContent = 'Edit Event';
941            dialog.style.display = 'flex';
942        }
943    })
944    .catch(err => console.error('Error editing event:', err));
945}
946
947// Delete event
948function deleteEvent(calId, eventId, date, namespace) {
949    if (!confirm('Delete this event?')) return;
950
951    const params = new URLSearchParams({
952        call: 'plugin_calendar',
953        action: 'delete_event',
954        namespace: namespace,
955        date: date,
956        eventId: eventId
957    });
958
959    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
960        method: 'POST',
961        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
962        body: params.toString()
963    })
964    .then(r => r.json())
965    .then(data => {
966        if (data.success) {
967            // Extract year and month from date
968            const [year, month] = date.split('-').map(Number);
969
970            // Reload calendar data via AJAX
971            reloadCalendarData(calId, year, month, namespace);
972        }
973    })
974    .catch(err => console.error('Error:', err));
975}
976
977// Save event (add or edit)
978function saveEventCompact(calId, namespace) {
979    const form = document.getElementById('eventform-' + calId);
980
981    // Use the effective namespace (filtered namespace if active, otherwise passed namespace)
982    const effectiveNamespace = form.dataset.effectiveNamespace || namespace;
983
984    console.log('Saving event: passed namespace=' + namespace + ', effective=' + effectiveNamespace);
985
986    const eventId = document.getElementById('event-id-' + calId).value;
987    const dateInput = document.getElementById('event-date-' + calId);
988    const date = dateInput.value;
989    const oldDate = dateInput.getAttribute('data-original-date') || date;
990    const endDate = document.getElementById('event-end-date-' + calId).value;
991    const title = document.getElementById('event-title-' + calId).value;
992    const time = document.getElementById('event-time-' + calId).value;
993    const color = document.getElementById('event-color-' + calId).value;
994    const description = document.getElementById('event-desc-' + calId).value;
995    const isTask = document.getElementById('event-is-task-' + calId).checked;
996    const completed = false; // New tasks are not completed
997    const isRecurring = document.getElementById('event-recurring-' + calId).checked;
998    const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value;
999    const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value;
1000
1001    if (!title) {
1002        alert('Please enter a title');
1003        return;
1004    }
1005
1006    if (!date) {
1007        alert('Please select a date');
1008        return;
1009    }
1010
1011    const params = new URLSearchParams({
1012        call: 'plugin_calendar',
1013        action: 'save_event',
1014        namespace: effectiveNamespace,
1015        eventId: eventId,
1016        date: date,
1017        oldDate: oldDate,
1018        endDate: endDate,
1019        title: title,
1020        time: time,
1021        color: color,
1022        description: description,
1023        isTask: isTask ? '1' : '0',
1024        completed: completed ? '1' : '0',
1025        isRecurring: isRecurring ? '1' : '0',
1026        recurrenceType: recurrenceType,
1027        recurrenceEnd: recurrenceEnd
1028    });
1029
1030    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1031        method: 'POST',
1032        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1033        body: params.toString()
1034    })
1035    .then(r => r.json())
1036    .then(data => {
1037        if (data.success) {
1038            closeEventDialog(calId);
1039
1040            // For recurring events, do a full page reload to show all occurrences
1041            if (isRecurring) {
1042                location.reload();
1043                return;
1044            }
1045
1046            // Extract year and month from the NEW date (in case date was changed)
1047            const [year, month] = date.split('-').map(Number);
1048
1049            // Reload calendar data via AJAX to the month of the event
1050            reloadCalendarData(calId, year, month, namespace);
1051        } else {
1052            alert('Error: ' + (data.error || 'Unknown error'));
1053        }
1054    })
1055    .catch(err => {
1056        console.error('Error:', err);
1057        alert('Error saving event');
1058    });
1059}
1060
1061// Reload calendar data without page refresh
1062function reloadCalendarData(calId, year, month, namespace) {
1063    const params = new URLSearchParams({
1064        call: 'plugin_calendar',
1065        action: 'load_month',
1066        year: year,
1067        month: month,
1068        namespace: namespace,
1069        _: new Date().getTime() // Cache buster
1070    });
1071
1072    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1073        method: 'POST',
1074        headers: {
1075            'Content-Type': 'application/x-www-form-urlencoded',
1076            'Cache-Control': 'no-cache, no-store, must-revalidate',
1077            'Pragma': 'no-cache'
1078        },
1079        body: params.toString()
1080    })
1081    .then(r => r.json())
1082    .then(data => {
1083        if (data.success) {
1084            const container = document.getElementById(calId);
1085
1086            // Check if this is a full calendar or just event panel
1087            if (container.classList.contains('calendar-compact-container')) {
1088                rebuildCalendar(calId, data.year, data.month, data.events, namespace);
1089            } else if (container.classList.contains('event-panel-standalone')) {
1090                rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1091            }
1092        }
1093    })
1094    .catch(err => console.error('Error:', err));
1095}
1096
1097// Close event dialog
1098function closeEventDialog(calId) {
1099    const dialog = document.getElementById('dialog-' + calId);
1100    dialog.style.display = 'none';
1101}
1102
1103// Escape HTML
1104function escapeHtml(text) {
1105    const div = document.createElement('div');
1106    div.textContent = text;
1107    return div.innerHTML;
1108}
1109
1110// Highlight event when clicking on bar in calendar
1111function highlightEvent(calId, eventId, date) {
1112    // Find the event item in the event list
1113    const eventList = document.querySelector('#' + calId + ' .event-list-compact');
1114    if (!eventList) return;
1115
1116    const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]');
1117    if (!eventItem) return;
1118
1119    // Remove previous highlights
1120    const previousHighlights = eventList.querySelectorAll('.event-highlighted');
1121    previousHighlights.forEach(el => el.classList.remove('event-highlighted'));
1122
1123    // Add highlight
1124    eventItem.classList.add('event-highlighted');
1125
1126    // Scroll to event
1127    eventItem.scrollIntoView({
1128        behavior: 'smooth',
1129        block: 'nearest',
1130        inline: 'nearest'
1131    });
1132
1133    // Remove highlight after 3 seconds
1134    setTimeout(() => {
1135        eventItem.classList.remove('event-highlighted');
1136    }, 3000);
1137}
1138
1139// Toggle recurring event options
1140function toggleRecurringOptions(calId) {
1141    const checkbox = document.getElementById('event-recurring-' + calId);
1142    const options = document.getElementById('recurring-options-' + calId);
1143
1144    if (checkbox && options) {
1145        options.style.display = checkbox.checked ? 'block' : 'none';
1146    }
1147}
1148
1149// Close dialog on escape key
1150document.addEventListener('keydown', function(e) {
1151    if (e.key === 'Escape') {
1152        const dialogs = document.querySelectorAll('.event-dialog-compact');
1153        dialogs.forEach(dialog => {
1154            if (dialog.style.display === 'flex') {
1155                dialog.style.display = 'none';
1156            }
1157        });
1158    }
1159});
1160
1161// Event panel navigation
1162function navEventPanel(calId, year, month, namespace) {
1163    const params = new URLSearchParams({
1164        call: 'plugin_calendar',
1165        action: 'load_month',
1166        year: year,
1167        month: month,
1168        namespace: namespace,
1169        _: new Date().getTime() // Cache buster
1170    });
1171
1172    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1173        method: 'POST',
1174        headers: {
1175            'Content-Type': 'application/x-www-form-urlencoded',
1176            'Cache-Control': 'no-cache, no-store, must-revalidate',
1177            'Pragma': 'no-cache'
1178        },
1179        body: params.toString()
1180    })
1181    .then(r => r.json())
1182    .then(data => {
1183        if (data.success) {
1184            rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1185        }
1186    })
1187    .catch(err => console.error('Error:', err));
1188}
1189
1190// Rebuild event panel only
1191function rebuildEventPanel(calId, year, month, events, namespace) {
1192    const container = document.getElementById(calId);
1193    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
1194                       'July', 'August', 'September', 'October', 'November', 'December'];
1195
1196    // Update header - preserve the onclick and classes
1197    const headerContent = container.querySelector('.panel-header-content');
1198    const header = container.querySelector('.panel-standalone-header h3');
1199    if (header) {
1200        header.textContent = monthNames[month - 1] + ' ' + year + ' Events';
1201        header.className = 'calendar-month-picker';
1202        header.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1203        header.setAttribute('title', 'Click to jump to month');
1204    }
1205
1206    // Update namespace badge if needed (preserve existing one)
1207    // The namespace badge should already exist and doesn't need updating
1208
1209    // Update nav buttons
1210    let prevMonth = month - 1;
1211    let prevYear = year;
1212    if (prevMonth < 1) {
1213        prevMonth = 12;
1214        prevYear--;
1215    }
1216
1217    let nextMonth = month + 1;
1218    let nextYear = year;
1219    if (nextMonth > 12) {
1220        nextMonth = 1;
1221        nextYear++;
1222    }
1223
1224    const navBtns = container.querySelectorAll('.cal-nav-btn');
1225    if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1226    if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1227
1228    // Update Today button
1229    const todayBtn = container.querySelector('.cal-today-btn');
1230    if (todayBtn) {
1231        todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`);
1232    }
1233
1234    // Rebuild event list
1235    const eventList = container.querySelector('.event-list-compact');
1236    if (eventList) {
1237        eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
1238    }
1239}
1240
1241// Open add event for panel
1242function openAddEventPanel(calId, namespace) {
1243    const today = new Date();
1244    const year = today.getFullYear();
1245    const month = String(today.getMonth() + 1).padStart(2, '0');
1246    const day = String(today.getDate()).padStart(2, '0');
1247    const localDate = `${year}-${month}-${day}`;
1248    openAddEvent(calId, namespace, localDate);
1249}
1250
1251// Toggle task completion
1252function toggleTaskComplete(calId, eventId, date, namespace, completed) {
1253    const params = new URLSearchParams({
1254        call: 'plugin_calendar',
1255        action: 'toggle_task',
1256        namespace: namespace,
1257        date: date,
1258        eventId: eventId,
1259        completed: completed ? '1' : '0'
1260    });
1261
1262    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1263        method: 'POST',
1264        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1265        body: params.toString()
1266    })
1267    .then(r => r.json())
1268    .then(data => {
1269        if (data.success) {
1270            const [year, month] = date.split('-').map(Number);
1271            reloadCalendarData(calId, year, month, namespace);
1272        }
1273    })
1274    .catch(err => console.error('Error toggling task:', err));
1275}
1276
1277// Make dialog draggable
1278function makeDialogDraggable(calId) {
1279    const dialog = document.getElementById('dialog-content-' + calId);
1280    const handle = document.getElementById('drag-handle-' + calId);
1281
1282    if (!dialog || !handle) return;
1283
1284    let isDragging = false;
1285    let currentX;
1286    let currentY;
1287    let initialX;
1288    let initialY;
1289    let xOffset = 0;
1290    let yOffset = 0;
1291
1292    handle.addEventListener('mousedown', dragStart);
1293    document.addEventListener('mousemove', drag);
1294    document.addEventListener('mouseup', dragEnd);
1295
1296    function dragStart(e) {
1297        initialX = e.clientX - xOffset;
1298        initialY = e.clientY - yOffset;
1299        isDragging = true;
1300    }
1301
1302    function drag(e) {
1303        if (isDragging) {
1304            e.preventDefault();
1305            currentX = e.clientX - initialX;
1306            currentY = e.clientY - initialY;
1307            xOffset = currentX;
1308            yOffset = currentY;
1309            setTranslate(currentX, currentY, dialog);
1310        }
1311    }
1312
1313    function dragEnd(e) {
1314        initialX = currentX;
1315        initialY = currentY;
1316        isDragging = false;
1317    }
1318
1319    function setTranslate(xPos, yPos, el) {
1320        el.style.transform = `translate(${xPos}px, ${yPos}px)`;
1321    }
1322}
1323
1324// Initialize dialog draggability when opened
1325const originalOpenAddEvent = openAddEvent;
1326openAddEvent = function(calId, namespace, date) {
1327    originalOpenAddEvent(calId, namespace, date);
1328    setTimeout(() => makeDialogDraggable(calId), 100);
1329};
1330
1331const originalEditEvent = editEvent;
1332editEvent = function(calId, eventId, date, namespace) {
1333    originalEditEvent(calId, eventId, date, namespace);
1334    setTimeout(() => makeDialogDraggable(calId), 100);
1335};
1336
1337// Toggle expand/collapse for past events
1338function togglePastEventExpand(element) {
1339    // Stop propagation to prevent any parent click handlers
1340    event.stopPropagation();
1341
1342    const meta = element.querySelector(".event-meta-compact");
1343    const desc = element.querySelector(".event-desc-compact");
1344
1345    // Toggle visibility
1346    if (meta.style.display === "none") {
1347        // Expand
1348        meta.style.display = "block";
1349        if (desc) desc.style.display = "block";
1350        element.classList.add("event-past-expanded");
1351    } else {
1352        // Collapse
1353        meta.style.display = "none";
1354        if (desc) desc.style.display = "none";
1355        element.classList.remove("event-past-expanded");
1356    }
1357}
1358
1359// Filter calendar by namespace when clicking namespace badge
1360document.addEventListener('click', function(e) {
1361    if (e.target.classList.contains('event-namespace-badge')) {
1362        const namespace = e.target.textContent;
1363        const eventItem = e.target.closest('.event-compact-item');
1364        const eventList = e.target.closest('.event-list-compact');
1365        const calendar = e.target.closest('.calendar-compact-container');
1366
1367        if (!eventList || !calendar) return;
1368
1369        const calId = calendar.id;
1370
1371        // Check if already filtered
1372        const isFiltered = eventList.classList.contains('namespace-filtered');
1373
1374        if (isFiltered && eventList.dataset.filterNamespace === namespace) {
1375            // Unfilter - show all
1376            eventList.classList.remove('namespace-filtered');
1377            delete eventList.dataset.filterNamespace;
1378            delete calendar.dataset.filteredNamespace;
1379            eventList.querySelectorAll('.event-compact-item').forEach(item => {
1380                item.style.display = '';
1381            });
1382
1383            // Update header to show "all namespaces"
1384            updateFilteredNamespaceDisplay(calId, null);
1385        } else {
1386            // Filter by this namespace
1387            eventList.classList.add('namespace-filtered');
1388            eventList.dataset.filterNamespace = namespace;
1389            calendar.dataset.filteredNamespace = namespace;
1390            eventList.querySelectorAll('.event-compact-item').forEach(item => {
1391                const itemBadge = item.querySelector('.event-namespace-badge');
1392                if (itemBadge && itemBadge.textContent === namespace) {
1393                    item.style.display = '';
1394                } else {
1395                    item.style.display = 'none';
1396                }
1397            });
1398
1399            // Update header to show filtered namespace
1400            updateFilteredNamespaceDisplay(calId, namespace);
1401        }
1402    }
1403});
1404
1405// Update the displayed filtered namespace in event list header
1406function updateFilteredNamespaceDisplay(calId, namespace) {
1407    const calendar = document.getElementById(calId);
1408    if (!calendar) return;
1409
1410    const headerContent = calendar.querySelector('.event-list-header-content');
1411    if (!headerContent) return;
1412
1413    // Remove existing filter badge
1414    let filterBadge = headerContent.querySelector('.namespace-filter-badge');
1415    if (filterBadge) {
1416        filterBadge.remove();
1417    }
1418
1419    // Add new filter badge if filtering
1420    if (namespace) {
1421        filterBadge = document.createElement('span');
1422        filterBadge.className = 'namespace-badge namespace-filter-badge';
1423        filterBadge.innerHTML = escapeHtml(namespace) + ' <button class="filter-clear-inline" onclick="clearNamespaceFilter(\'' + calId + '\'); event.stopPropagation();">✕</button>';
1424        headerContent.appendChild(filterBadge);
1425    }
1426}
1427
1428// Clear namespace filter
1429function clearNamespaceFilter(calId) {
1430    const calendar = document.getElementById(calId);
1431    if (!calendar) return;
1432
1433    const eventList = calendar.querySelector('.event-list-compact');
1434    if (!eventList) return;
1435
1436    // Clear filter
1437    eventList.classList.remove('namespace-filtered');
1438    delete eventList.dataset.filterNamespace;
1439    delete calendar.dataset.filteredNamespace;
1440    eventList.querySelectorAll('.event-compact-item').forEach(item => {
1441        item.style.display = '';
1442    });
1443
1444    // Update header
1445    updateFilteredNamespaceDisplay(calId, null);
1446}
1447