xref: /plugin/calendar/calendar-main.js (revision 8e9c470bd61cc12efe7c339cb5cc6fdb71945c2a)
1/**
2 * DokuWiki Compact Calendar Plugin JavaScript
3 * Loaded independently to avoid DokuWiki concatenation issues
4 * @version 7.0.8
5 */
6
7// Debug mode - set to true for console logging
8var CALENDAR_DEBUG = false;
9
10// Debug logging helper
11function calendarLog() {
12    if (CALENDAR_DEBUG && console && console.log) {
13        console.log.apply(console, ['[Calendar]'].concat(Array.prototype.slice.call(arguments)));
14    }
15}
16
17function calendarError() {
18    if (console && console.error) {
19        console.error.apply(console, ['[Calendar]'].concat(Array.prototype.slice.call(arguments)));
20    }
21}
22
23/**
24 * Format a Date object as YYYY-MM-DD in LOCAL time (not UTC)
25 * This avoids timezone issues where toISOString() shifts dates
26 * For example: In Prague (UTC+1), midnight local = 23:00 UTC previous day
27 * @param {Date} date - Date object to format
28 * @returns {string} Date string in YYYY-MM-DD format
29 */
30function formatLocalDate(date) {
31    var year = date.getFullYear();
32    var month = String(date.getMonth() + 1).padStart(2, '0');
33    var day = String(date.getDate()).padStart(2, '0');
34    return year + '-' + month + '-' + day;
35}
36
37// Ensure DOKU_BASE is defined - check multiple sources
38if (typeof DOKU_BASE === 'undefined') {
39    // Try to get from global jsinfo object (DokuWiki standard)
40    if (typeof window.jsinfo !== 'undefined' && window.jsinfo.dokubase) {
41        window.DOKU_BASE = window.jsinfo.dokubase;
42    } else {
43        // Fallback: extract from script source path
44        var scripts = document.getElementsByTagName('script');
45        var pluginScriptPath = null;
46        for (var i = 0; i < scripts.length; i++) {
47            if (scripts[i].src && scripts[i].src.indexOf('calendar/script.js') !== -1) {
48                pluginScriptPath = scripts[i].src;
49                break;
50            }
51        }
52
53        if (pluginScriptPath) {
54            // Extract base path from: .../lib/plugins/calendar/script.js
55            var match = pluginScriptPath.match(/^(.*?)lib\/plugins\//);
56            window.DOKU_BASE = match ? match[1] : '/';
57        } else {
58            // Last resort: use root
59            window.DOKU_BASE = '/';
60        }
61    }
62}
63
64// Shorthand for convenience
65var DOKU_BASE = window.DOKU_BASE || '/';
66
67/**
68 * Get DokuWiki security token from multiple possible sources
69 * DokuWiki stores this in different places depending on version/config
70 */
71function getSecurityToken() {
72    // Try JSINFO.sectok (standard location)
73    if (typeof JSINFO !== 'undefined' && JSINFO.sectok) {
74        return JSINFO.sectok;
75    }
76    // Try window.JSINFO
77    if (typeof window.JSINFO !== 'undefined' && window.JSINFO.sectok) {
78        return window.JSINFO.sectok;
79    }
80    // Try finding it in a hidden form field (some templates/plugins add this)
81    var sectokInput = document.querySelector('input[name="sectok"]');
82    if (sectokInput && sectokInput.value) {
83        return sectokInput.value;
84    }
85    // Try meta tag (some DokuWiki setups)
86    var sectokMeta = document.querySelector('meta[name="sectok"]');
87    if (sectokMeta && sectokMeta.content) {
88        return sectokMeta.content;
89    }
90    // Return empty string if not found
91    console.warn('Calendar plugin: Security token not found');
92    return '';
93}
94
95// Helper: propagate CSS variables from a calendar container to a target element
96// This is needed for dialogs/popups that use position:fixed (they inherit CSS vars
97// from DOM parents per spec, but some DokuWiki templates break this inheritance)
98function propagateThemeVars(calId, targetEl) {
99    if (!targetEl) return;
100    // Find the calendar container (could be cal_, panel_, sidebar-widget-, etc.)
101    const container = document.getElementById(calId)
102        || document.getElementById('sidebar-widget-' + calId)
103        || document.querySelector('[id$="' + calId + '"]');
104    if (!container) return;
105    const cs = getComputedStyle(container);
106    const vars = [
107        '--background-site', '--background-alt', '--background-header',
108        '--text-primary', '--text-bright', '--text-dim',
109        '--border-color', '--border-main',
110        '--cell-bg', '--cell-today-bg', '--grid-bg',
111        '--shadow-color', '--header-border', '--header-shadow',
112        '--btn-text'
113    ];
114    vars.forEach(v => {
115        const val = cs.getPropertyValue(v).trim();
116        if (val) targetEl.style.setProperty(v, val);
117    });
118}
119
120// Filter calendar by namespace
121window.filterCalendarByNamespace = function(calId, namespace) {
122    // Get current year and month from calendar
123    const container = document.getElementById(calId);
124    if (!container) {
125        console.error('Calendar container not found:', calId);
126        return;
127    }
128
129    const year = parseInt(container.dataset.year) || new Date().getFullYear();
130    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
131
132    // Reload calendar with the filtered namespace
133    navCalendar(calId, year, month, namespace);
134};
135
136// Navigate to different month
137window.navCalendar = function(calId, year, month, namespace) {
138
139    // Read exclude list from container data attribute
140    const container = document.getElementById(calId);
141    const exclude = container ? (container.dataset.exclude || '') : '';
142
143    const params = new URLSearchParams({
144        call: 'plugin_calendar',
145        action: 'load_month',
146        year: year,
147        month: month,
148        namespace: namespace,
149        exclude: exclude,
150        _: new Date().getTime() // Cache buster
151    });
152
153    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
154        method: 'POST',
155        headers: {
156            'Content-Type': 'application/x-www-form-urlencoded',
157            'Cache-Control': 'no-cache, no-store, must-revalidate',
158            'Pragma': 'no-cache'
159        },
160        body: params.toString()
161    })
162    .then(r => r.json())
163    .then(data => {
164        if (data.success) {
165            rebuildCalendar(calId, data.year, data.month, data.events, namespace);
166        } else {
167            console.error('Failed to load month:', data.error);
168        }
169    })
170    .catch(err => {
171        console.error('Error loading month:', err);
172    });
173};
174
175// Jump to current month
176window.jumpToToday = function(calId, namespace) {
177    const today = new Date();
178    const year = today.getFullYear();
179    const month = today.getMonth() + 1; // JavaScript months are 0-indexed
180    navCalendar(calId, year, month, namespace);
181};
182
183// Jump to today for event panel
184window.jumpTodayPanel = function(calId, namespace) {
185    const today = new Date();
186    const year = today.getFullYear();
187    const month = today.getMonth() + 1;
188    navEventPanel(calId, year, month, namespace);
189};
190
191// Open month picker dialog
192window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) {
193
194    const overlay = document.getElementById('month-picker-overlay-' + calId);
195
196    const monthSelect = document.getElementById('month-picker-month-' + calId);
197
198    const yearSelect = document.getElementById('month-picker-year-' + calId);
199
200    if (!overlay) {
201        console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId);
202        return;
203    }
204
205    if (!monthSelect || !yearSelect) {
206        console.error('Select elements not found!');
207        return;
208    }
209
210    // Set current values
211    monthSelect.value = currentMonth;
212    yearSelect.value = currentYear;
213
214    // Show overlay
215    overlay.style.display = 'flex';
216};
217
218// Open month picker dialog for event panel
219window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) {
220    openMonthPicker(calId, currentYear, currentMonth, namespace);
221};
222
223// Close month picker dialog
224window.closeMonthPicker = function(calId) {
225    const overlay = document.getElementById('month-picker-overlay-' + calId);
226    overlay.style.display = 'none';
227};
228
229// Jump to selected month
230window.jumpToSelectedMonth = function(calId, namespace) {
231    const monthSelect = document.getElementById('month-picker-month-' + calId);
232    const yearSelect = document.getElementById('month-picker-year-' + calId);
233
234    const month = parseInt(monthSelect.value);
235    const year = parseInt(yearSelect.value);
236
237    closeMonthPicker(calId);
238
239    // Check if this is a calendar or event panel
240    const container = document.getElementById(calId);
241    if (container && container.classList.contains('event-panel-standalone')) {
242        navEventPanel(calId, year, month, namespace);
243    } else {
244        navCalendar(calId, year, month, namespace);
245    }
246};
247
248// Rebuild calendar grid after navigation
249window.rebuildCalendar = function(calId, year, month, events, namespace) {
250
251    const container = document.getElementById(calId);
252    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
253                       'July', 'August', 'September', 'October', 'November', 'December'];
254
255    // Get theme data from container
256    const theme = container.dataset.theme || 'matrix';
257    let themeStyles = {};
258    try {
259        themeStyles = JSON.parse(container.dataset.themeStyles || '{}');
260    } catch (e) {
261        console.error('Failed to parse theme styles:', e);
262        themeStyles = {};
263    }
264
265    // Preserve original namespace if not yet set
266    if (!container.dataset.originalNamespace) {
267        container.setAttribute('data-original-namespace', namespace || '');
268    }
269
270    // Update container data attributes for current month/year
271    container.setAttribute('data-year', year);
272    container.setAttribute('data-month', month);
273
274    // Update embedded events data
275    let eventsDataEl = document.getElementById('events-data-' + calId);
276    if (eventsDataEl) {
277        eventsDataEl.textContent = JSON.stringify(events);
278    } else {
279        eventsDataEl = document.createElement('script');
280        eventsDataEl.type = 'application/json';
281        eventsDataEl.id = 'events-data-' + calId;
282        eventsDataEl.textContent = JSON.stringify(events);
283        container.appendChild(eventsDataEl);
284    }
285
286    // Update header
287    const header = container.querySelector('.calendar-compact-header h3');
288    header.textContent = monthNames[month - 1] + ' ' + year;
289
290    // Update or create namespace filter indicator
291    let filterIndicator = container.querySelector('.calendar-namespace-filter');
292    const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' &&
293                            namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1;
294
295    if (shouldShowFilter) {
296        // Show/update filter indicator
297        if (!filterIndicator) {
298            // Create filter indicator if it doesn't exist
299            const headerDiv = container.querySelector('.calendar-compact-header');
300            if (headerDiv) {
301                filterIndicator = document.createElement('div');
302                filterIndicator.className = 'calendar-namespace-filter';
303                filterIndicator.id = 'namespace-filter-' + calId;
304                headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling);
305            }
306        }
307
308        if (filterIndicator) {
309            filterIndicator.innerHTML =
310                '<span class="namespace-filter-label">Filtering:</span>' +
311                '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' +
312                '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>';
313            filterIndicator.style.display = 'flex';
314        }
315    } else {
316        // Hide filter indicator
317        if (filterIndicator) {
318            filterIndicator.style.display = 'none';
319        }
320    }
321
322    // Update container's namespace attribute
323    container.setAttribute('data-namespace', namespace || '');
324
325    // Update nav buttons
326    let prevMonth = month - 1;
327    let prevYear = year;
328    if (prevMonth < 1) {
329        prevMonth = 12;
330        prevYear--;
331    }
332
333    let nextMonth = month + 1;
334    let nextYear = year;
335    if (nextMonth > 12) {
336        nextMonth = 1;
337        nextYear++;
338    }
339
340    const navBtns = container.querySelectorAll('.cal-nav-btn');
341    navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
342    navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
343
344    // Rebuild calendar grid
345    const tbody = container.querySelector('.calendar-compact-grid tbody');
346    const firstDay = new Date(year, month - 1, 1);
347    const daysInMonth = new Date(year, month, 0).getDate();
348    const dayOfWeek = firstDay.getDay();
349
350    // Calculate month boundaries
351    const monthStart = new Date(year, month - 1, 1);
352    const monthEnd = new Date(year, month - 1, daysInMonth);
353
354    // Build a map of all events with their date ranges
355    const eventRanges = {};
356    for (const [dateKey, dayEvents] of Object.entries(events)) {
357        // Defensive check: ensure dayEvents is an array
358        if (!Array.isArray(dayEvents)) {
359            console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents);
360            continue;
361        }
362
363        // Only process events that could possibly overlap with this month/year
364        const dateYear = parseInt(dateKey.split('-')[0]);
365
366        // Skip events from completely different years (unless they're very long multi-day events)
367        if (Math.abs(dateYear - year) > 1) {
368            continue;
369        }
370
371        for (const evt of dayEvents) {
372            const startDate = dateKey;
373            const endDate = evt.endDate || dateKey;
374
375            // Check if event overlaps with current month
376            const eventStart = new Date(startDate + 'T00:00:00');
377            const eventEnd = new Date(endDate + 'T00:00:00');
378
379            // Skip if event doesn't overlap with current month
380            if (eventEnd < monthStart || eventStart > monthEnd) {
381                continue;
382            }
383
384            // Create entry for each day the event spans
385            const start = new Date(startDate + 'T00:00:00');
386            const end = new Date(endDate + 'T00:00:00');
387            const current = new Date(start);
388
389            while (current <= end) {
390                // Use formatLocalDate to avoid timezone shift issues
391                const currentKey = formatLocalDate(current);
392
393                // Check if this date is in current month (use current Date object directly)
394                if (current.getFullYear() === year && current.getMonth() === month - 1) {
395                    if (!eventRanges[currentKey]) {
396                        eventRanges[currentKey] = [];
397                    }
398
399                    // Add event with span information
400                    const eventCopy = {...evt};
401                    eventCopy._span_start = startDate;
402                    eventCopy._span_end = endDate;
403                    eventCopy._is_first_day = (currentKey === startDate);
404                    eventCopy._is_last_day = (currentKey === endDate);
405                    eventCopy._original_date = dateKey;
406
407                    // Check if event continues from previous month or to next month
408                    eventCopy._continues_from_prev = (eventStart < monthStart);
409                    eventCopy._continues_to_next = (eventEnd > monthEnd);
410
411                    eventRanges[currentKey].push(eventCopy);
412                }
413
414                current.setDate(current.getDate() + 1);
415            }
416        }
417    }
418
419    // Assign stable row slots to multi-day events so bars line up across days
420    // Each multi-day event gets a consistent slot index across all days it spans
421    const slotAssignments = {}; // dateKey -> array of {event, slot}
422    const eventSlots = {};      // eventId -> assigned slot number
423
424    // First pass: identify all multi-day events and assign them stable slots
425    // Process dates in order so slots are assigned left-to-right
426    const allDates = Object.keys(eventRanges).sort();
427
428    for (const dateKey of allDates) {
429        const dayEvents = eventRanges[dateKey];
430        if (!dayEvents) continue;
431
432        for (const evt of dayEvents) {
433            const eid = evt.id || evt.title;
434            const isMultiDay = evt._span_start !== evt._span_end;
435
436            if (isMultiDay && !(eid in eventSlots)) {
437                // Find the lowest available slot across ALL days this event spans
438                let slot = 0;
439                let slotFree = false;
440                while (!slotFree) {
441                    slotFree = true;
442                    // Check every day this event spans to see if this slot is taken
443                    const checkStart = new Date(evt._span_start + 'T00:00:00');
444                    const checkEnd = new Date(evt._span_end + 'T00:00:00');
445                    const checkCurrent = new Date(checkStart);
446                    while (checkCurrent <= checkEnd) {
447                        const checkKey = formatLocalDate(checkCurrent);
448                        if (slotAssignments[checkKey]) {
449                            for (const assigned of slotAssignments[checkKey]) {
450                                if (assigned.slot === slot) {
451                                    slotFree = false;
452                                    break;
453                                }
454                            }
455                        }
456                        if (!slotFree) break;
457                        checkCurrent.setDate(checkCurrent.getDate() + 1);
458                    }
459                    if (!slotFree) slot++;
460                }
461                eventSlots[eid] = slot;
462
463                // Reserve this slot on all days the event spans
464                const resStart = new Date(evt._span_start + 'T00:00:00');
465                const resEnd = new Date(evt._span_end + 'T00:00:00');
466                const resCurrent = new Date(resStart);
467                while (resCurrent <= resEnd) {
468                    const resKey = formatLocalDate(resCurrent);
469                    if (!slotAssignments[resKey]) slotAssignments[resKey] = [];
470                    slotAssignments[resKey].push({ id: eid, slot: slot });
471                    resCurrent.setDate(resCurrent.getDate() + 1);
472                }
473            }
474        }
475    }
476
477    // Second pass: assign slots to single-day events
478    for (const dateKey of allDates) {
479        const dayEvents = eventRanges[dateKey];
480        if (!dayEvents) continue;
481
482        // Sort single-day events by time
483        const singleDay = dayEvents.filter(e => {
484            const eid = e.id || e.title;
485            return !(eid in eventSlots);
486        }).sort((a, b) => {
487            const timeA = a.time || '';
488            const timeB = b.time || '';
489            if (!timeA && timeB) return -1;
490            if (timeA && !timeB) return 1;
491            return timeA.localeCompare(timeB);
492        });
493
494        for (const evt of singleDay) {
495            const eid = evt.id || evt.title;
496            let slot = 0;
497            while (true) {
498                const taken = (slotAssignments[dateKey] || []).some(a => a.slot === slot);
499                if (!taken) break;
500                slot++;
501            }
502            eventSlots[eid] = slot;
503            if (!slotAssignments[dateKey]) slotAssignments[dateKey] = [];
504            slotAssignments[dateKey].push({ id: eid, slot: slot });
505        }
506    }
507
508    let html = '';
509    let currentDay = 1;
510    const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7);
511
512    for (let row = 0; row < rowCount; row++) {
513        html += '<tr>';
514        for (let col = 0; col < 7; col++) {
515            if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) {
516                html += `<td class="cal-empty"></td>`;
517            } else {
518                const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`;
519
520                // Get today's date in local timezone
521                const todayObj = new Date();
522                const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`;
523
524                const isToday = dateKey === today;
525                const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0;
526
527                let classes = 'cal-day';
528                if (isToday) classes += ' cal-today';
529                if (hasEvents) classes += ' cal-has-events';
530
531                const dayNumClass = isToday ? 'day-num day-num-today' : 'day-num';
532
533                html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`;
534                html += `<span class="${dayNumClass}">${currentDay}</span>`;
535
536                if (hasEvents) {
537                    // Get important namespaces
538                    let importantNamespaces = ['important'];
539                    if (container.dataset.importantNamespaces) {
540                        try {
541                            importantNamespaces = JSON.parse(container.dataset.importantNamespaces);
542                        } catch (e) {}
543                    }
544
545                    // Build slot-ordered event list with placeholders for gaps
546                    const daySlots = (slotAssignments[dateKey] || []).slice().sort((a, b) => a.slot - b.slot);
547                    const maxSlot = daySlots.length > 0 ? daySlots[daySlots.length - 1].slot : -1;
548
549                    // Create a map from slot -> event
550                    const slotMap = {};
551                    for (const evt of eventRanges[dateKey]) {
552                        const eid = evt.id || evt.title;
553                        const slot = eventSlots[eid];
554                        if (slot !== undefined) slotMap[slot] = evt;
555                    }
556
557                    // Show colored stacked bars for each slot (with spacers for empty slots)
558                    html += '<div class="event-indicators">';
559                    for (let s = 0; s <= maxSlot; s++) {
560                        const evt = slotMap[s];
561                        if (!evt) {
562                            // Empty spacer to maintain alignment
563                            html += '<span style="display:block;width:100%;height:6px;min-height:6px;flex-shrink:0;"></span>';
564                            continue;
565                        }
566
567                        const eventId = evt.id || '';
568                        const eventColor = evt.color || '#3498db';
569                        const eventTitle = evt.title || 'Event';
570                        const eventTime = evt.time || '';
571                        const originalDate = evt._original_date || dateKey;
572                        const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true;
573                        const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true;
574
575                        // Check if important namespace
576                        let evtNs = evt.namespace || evt._namespace || '';
577                        let isImportant = false;
578                        for (const impNs of importantNamespaces) {
579                            if (evtNs === impNs || evtNs.startsWith(impNs + ':')) {
580                                isImportant = true;
581                                break;
582                            }
583                        }
584
585                        let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed';
586                        if (!isFirstDay) barClass += ' event-bar-continues';
587                        if (!isLastDay) barClass += ' event-bar-continuing';
588                        if (isImportant) {
589                            barClass += ' event-bar-important';
590                            if (isFirstDay) {
591                                barClass += ' event-bar-has-star';
592                            }
593                        }
594
595                        html += `<span class="event-bar ${barClass}" `;
596                        html += `style="background: ${eventColor};" `;
597                        html += `title="${isImportant ? '⭐ ' : ''}${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `;
598                        html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');">`;
599                        html += '</span>';
600                    }
601                    html += '</div>';
602                }
603
604                html += '</td>';
605                currentDay++;
606            }
607        }
608        html += '</tr>';
609    }
610
611    tbody.innerHTML = html;
612
613    // Update Today button with current namespace
614    const todayBtn = container.querySelector('.cal-today-btn');
615    if (todayBtn) {
616        todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`);
617    }
618
619    // Update month picker with current namespace
620    const monthPicker = container.querySelector('.calendar-month-picker');
621    if (monthPicker) {
622        monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`);
623    }
624
625    // Rebuild event list - server already filtered to current month
626    const eventList = container.querySelector('.event-list-compact');
627    eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
628
629    // Auto-scroll to first future event (past events will be above viewport)
630    setTimeout(() => {
631        const firstFuture = eventList.querySelector('[data-first-future="true"]');
632        if (firstFuture) {
633            firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' });
634        }
635    }, 100);
636
637    // Update title
638    const title = container.querySelector('#eventlist-title-' + calId);
639    title.textContent = 'Events';
640};
641
642// Render event list from data
643window.renderEventListFromData = function(events, calId, namespace, year, month) {
644    if (!events || Object.keys(events).length === 0) {
645        return '<p class="no-events-msg">No events this month</p>';
646    }
647
648    // Get theme data from container
649    const container = document.getElementById(calId);
650    let themeStyles = {};
651    if (container && container.dataset.themeStyles) {
652        try {
653            themeStyles = JSON.parse(container.dataset.themeStyles);
654        } catch (e) {
655            console.error('Failed to parse theme styles in renderEventListFromData:', e);
656        }
657    }
658
659    // Check for time conflicts
660    events = checkTimeConflicts(events, null);
661
662    let pastHtml = '';
663    let futureHtml = '';
664    let pastCount = 0;
665
666    const sortedDates = Object.keys(events).sort();
667    const today = new Date();
668    today.setHours(0, 0, 0, 0);
669    const todayStr = formatLocalDate(today);
670
671    // Helper function to check if event is past (with 15-minute grace period)
672    // For multi-day events, uses the end date instead of start date
673    const isEventPast = function(dateKey, time, endDate) {
674        // For multi-day events, use the end date to determine if past
675        const effectiveDate = endDate || dateKey;
676
677        // If the effective end date is past, the event is past
678        if (effectiveDate < todayStr) {
679            return true;
680        }
681
682        // If the effective end date is in the future, event is still ongoing
683        if (effectiveDate > todayStr) {
684            return false;
685        }
686
687        // Event ends today - check time with grace period
688        if (time && time.trim() !== '') {
689            try {
690                const now = new Date();
691                const eventDateTime = new Date(effectiveDate + 'T' + time);
692
693                // Add 15-minute grace period
694                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
695
696                // Event is past if current time > event time + 15 minutes
697                return now > gracePeriodEnd;
698            } catch (e) {
699                // If time parsing fails, treat as future
700                return false;
701            }
702        }
703
704        // No time specified for today's event, treat as future
705        return false;
706    };
707
708    // Filter events to only current month if year/month provided
709    const monthStart = year && month ? new Date(year, month - 1, 1) : null;
710    const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null;
711
712    for (const dateKey of sortedDates) {
713        // Skip events not in current month if filtering
714        if (monthStart && monthEnd) {
715            const eventDate = new Date(dateKey + 'T00:00:00');
716
717            if (eventDate < monthStart || eventDate > monthEnd) {
718                continue;
719            }
720        }
721
722        // Sort events within this day by time (all-day events at top)
723        const dayEvents = events[dateKey];
724        dayEvents.sort((a, b) => {
725            const timeA = a.time && a.time.trim() !== '' ? a.time : null;
726            const timeB = b.time && b.time.trim() !== '' ? b.time : null;
727
728            // All-day events (no time) go to the TOP
729            if (timeA === null && timeB !== null) return -1; // A before B
730            if (timeA !== null && timeB === null) return 1;  // A after B
731            if (timeA === null && timeB === null) return 0;  // Both all-day, equal
732
733            // Both have times, sort chronologically
734            return timeA.localeCompare(timeB);
735        });
736
737        for (const event of dayEvents) {
738            const isTask = event.isTask || false;
739            const completed = event.completed || false;
740
741            // Use helper function to determine if event is past (with grace period)
742            const isPast = isEventPast(dateKey, event.time, event.endDate);
743            const isPastDue = isPast && isTask && !completed;
744
745            // Determine if this goes in past section
746            const isPastOrCompleted = (isPast && (!isTask || completed)) || completed;
747
748            const eventHtml = renderEventItem(event, dateKey, calId, namespace);
749
750            if (isPastOrCompleted) {
751                pastCount++;
752                pastHtml += eventHtml;
753            } else {
754                futureHtml += eventHtml;
755            }
756        }
757    }
758
759    let html = '';
760
761    // Add collapsible past events section if any exist
762    if (pastCount > 0) {
763        html += '<div class="past-events-section">';
764        html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">';
765        html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> ';
766        html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>';
767        html += '</div>';
768        html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">';
769        html += pastHtml;
770        html += '</div>';
771        html += '</div>';
772    } else {
773    }
774
775    // Add future events
776    html += futureHtml;
777
778
779    if (!html) {
780        return '<p class="no-events-msg">No events this month</p>';
781    }
782
783    return html;
784};
785
786// Show day popup with events when clicking a date
787window.showDayPopup = function(calId, date, namespace) {
788    // Get events for this calendar
789    const eventsDataEl = document.getElementById('events-data-' + calId);
790    let events = {};
791
792    if (eventsDataEl) {
793        try {
794            events = JSON.parse(eventsDataEl.textContent);
795        } catch (e) {
796            console.error('Failed to parse events data:', e);
797        }
798    }
799
800    const dayEvents = events[date] || [];
801
802    // Check for conflicts on this day
803    const dayEventsObj = {[date]: dayEvents};
804    const checkedEvents = checkTimeConflicts(dayEventsObj, null);
805    const dayEventsWithConflicts = checkedEvents[date] || dayEvents;
806
807    // Sort events: all-day at top, then chronological by time
808    dayEventsWithConflicts.sort((a, b) => {
809        const timeA = a.time && a.time.trim() !== '' ? a.time : null;
810        const timeB = b.time && b.time.trim() !== '' ? b.time : null;
811
812        // All-day events (no time) go to the TOP
813        if (timeA === null && timeB !== null) return -1; // A before B
814        if (timeA !== null && timeB === null) return 1;  // A after B
815        if (timeA === null && timeB === null) return 0;  // Both all-day, equal
816
817        // Both have times, sort chronologically
818        return timeA.localeCompare(timeB);
819    });
820
821    const dateObj = new Date(date + 'T00:00:00');
822    const displayDate = dateObj.toLocaleDateString('en-US', {
823        weekday: 'long',
824        month: 'long',
825        day: 'numeric',
826        year: 'numeric'
827    });
828
829    // Create popup
830    let popup = document.getElementById('day-popup-' + calId);
831    if (!popup) {
832        popup = document.createElement('div');
833        popup.id = 'day-popup-' + calId;
834        popup.className = 'day-popup';
835        document.body.appendChild(popup);
836    }
837
838    // Get theme styles and important namespaces
839    const container = document.getElementById(calId);
840    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
841    const theme = container ? container.dataset.theme : 'matrix';
842
843    // Get important namespaces
844    let importantNamespaces = ['important'];
845    if (container && container.dataset.importantNamespaces) {
846        try {
847            importantNamespaces = JSON.parse(container.dataset.importantNamespaces);
848        } catch (e) {
849            importantNamespaces = ['important'];
850        }
851    }
852
853    let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>';
854    html += '<div class="day-popup-content">';
855    html += '<div class="day-popup-header">';
856    html += '<h4>' + displayDate + '</h4>';
857    html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>';
858    html += '</div>';
859
860    html += '<div class="day-popup-body">';
861
862    if (dayEventsWithConflicts.length === 0) {
863        html += '<p class="no-events-msg">No events on this day</p>';
864    } else {
865        html += '<div class="popup-events-list">';
866        dayEventsWithConflicts.forEach(event => {
867            const color = event.color || '#3498db';
868
869            // Use individual event namespace if available (for multi-namespace support)
870            const eventNamespace = event._namespace !== undefined ? event._namespace : namespace;
871
872            // Check if this is an important namespace event
873            let isImportant = false;
874            if (eventNamespace) {
875                for (const impNs of importantNamespaces) {
876                    if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) {
877                        isImportant = true;
878                        break;
879                    }
880                }
881            }
882
883            // Check if this is a continuation (event started before this date)
884            const originalStartDate = event.originalStartDate || event._dateKey || date;
885            const isContinuation = originalStartDate < date;
886
887            // Convert to 12-hour format and handle time ranges
888            let displayTime = '';
889            if (event.time) {
890                displayTime = formatTimeRange(event.time, event.endTime);
891            }
892
893            // Multi-day indicator
894            let multiDay = '';
895            if (event.endDate && event.endDate !== date) {
896                const endObj = new Date(event.endDate + 'T00:00:00');
897                multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
898                    month: 'short',
899                    day: 'numeric'
900                });
901            }
902
903            // Continuation message
904            if (isContinuation) {
905                const startObj = new Date(originalStartDate + 'T00:00:00');
906                const startDisplay = startObj.toLocaleDateString('en-US', {
907                    weekday: 'short',
908                    month: 'short',
909                    day: 'numeric'
910                });
911                html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>';
912            }
913
914            const importantClass = isImportant ? ' popup-event-important' : '';
915            html += '<div class="popup-event-item' + importantClass + '" tabindex="0" role="listitem" aria-label="' + escapeHtml(event.title) + (displayTime ? ', ' + displayTime : '') + '">';
916            html += '<div class="event-color-bar" style="background: ' + color + ';"></div>';
917            html += '<div class="popup-event-content">';
918
919            // Single line with title, time, date range, namespace, and actions
920            html += '<div class="popup-event-main-row">';
921            html += '<div class="popup-event-info-inline">';
922
923            // Add star for important events
924            if (isImportant) {
925                html += '<span class="popup-event-star">⭐</span>';
926            }
927
928            html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>';
929            if (displayTime) {
930                html += '<span class="popup-event-time">�� ' + displayTime + '</span>';
931            }
932            if (multiDay) {
933                html += '<span class="popup-event-multiday">' + multiDay + '</span>';
934            }
935            if (eventNamespace) {
936                html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>';
937            }
938
939            // Add conflict warning badge if event has conflicts
940            if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
941                // Build conflict list for tooltip
942                let conflictList = [];
943                event.conflictsWith.forEach(conflict => {
944                    let conflictText = conflict.title;
945                    if (conflict.time) {
946                        conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
947                    }
948                    conflictList.push(conflictText);
949                });
950
951                html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
952            }
953
954            html += '</div>';
955            html += '<div class="popup-event-actions">';
956            html += '<button type="button" class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>';
957            html += '<button type="button" class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">��️</button>';
958            html += '</div>';
959            html += '</div>';
960
961            // Description on separate line if present
962            if (event.description) {
963                html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>';
964            }
965
966            html += '</div></div>';
967        });
968        html += '</div>';
969    }
970
971    html += '</div>';
972
973    html += '<div class="day-popup-footer">';
974    html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>';
975    html += '</div>';
976
977    html += '</div>';
978
979    popup.innerHTML = html;
980    popup.style.display = 'flex';
981
982    // Propagate CSS vars from calendar container to popup (popup is outside container in DOM)
983    if (container) {
984        propagateThemeVars(calId, popup.querySelector('.day-popup-content'));
985    }
986
987    // Make popup draggable by header
988    const popupContent = popup.querySelector('.day-popup-content');
989    const popupHeader = popup.querySelector('.day-popup-header');
990
991    if (popupContent && popupHeader) {
992        // Reset position to center
993        popupContent.style.position = 'relative';
994        popupContent.style.left = '0';
995        popupContent.style.top = '0';
996
997        // Store drag state on the element itself
998        popupHeader._isDragging = false;
999
1000        popupHeader.onmousedown = function(e) {
1001            // Ignore if clicking the close button
1002            if (e.target.classList.contains('popup-close')) return;
1003
1004            popupHeader._isDragging = true;
1005            popupHeader._dragStartX = e.clientX;
1006            popupHeader._dragStartY = e.clientY;
1007
1008            const rect = popupContent.getBoundingClientRect();
1009            const parentRect = popup.getBoundingClientRect();
1010            popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2);
1011            popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2);
1012
1013            popupContent.style.transition = 'none';
1014            e.preventDefault();
1015        };
1016
1017        popup.onmousemove = function(e) {
1018            if (!popupHeader._isDragging) return;
1019
1020            const deltaX = e.clientX - popupHeader._dragStartX;
1021            const deltaY = e.clientY - popupHeader._dragStartY;
1022
1023            popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px';
1024            popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px';
1025        };
1026
1027        popup.onmouseup = function() {
1028            if (popupHeader._isDragging) {
1029                popupHeader._isDragging = false;
1030                popupContent.style.transition = '';
1031            }
1032        };
1033
1034        popup.onmouseleave = function() {
1035            if (popupHeader._isDragging) {
1036                popupHeader._isDragging = false;
1037                popupContent.style.transition = '';
1038            }
1039        };
1040    }
1041};
1042
1043// Close day popup
1044window.closeDayPopup = function(calId) {
1045    const popup = document.getElementById('day-popup-' + calId);
1046    if (popup) {
1047        popup.style.display = 'none';
1048    }
1049};
1050
1051// Show events for a specific day (for event list panel)
1052window.showDayEvents = function(calId, date, namespace) {
1053    const params = new URLSearchParams({
1054        call: 'plugin_calendar',
1055        action: 'load_month',
1056        year: date.split('-')[0],
1057        month: parseInt(date.split('-')[1]),
1058        namespace: namespace,
1059        _: new Date().getTime() // Cache buster
1060    });
1061
1062    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1063        method: 'POST',
1064        headers: {
1065            'Content-Type': 'application/x-www-form-urlencoded',
1066            'Cache-Control': 'no-cache, no-store, must-revalidate',
1067            'Pragma': 'no-cache'
1068        },
1069        body: params.toString()
1070    })
1071    .then(r => r.json())
1072    .then(data => {
1073        if (data.success) {
1074            const eventList = document.getElementById('eventlist-' + calId);
1075            const events = data.events;
1076            const title = document.getElementById('eventlist-title-' + calId);
1077
1078            const dateObj = new Date(date + 'T00:00:00');
1079            const displayDate = dateObj.toLocaleDateString('en-US', {
1080                weekday: 'short',
1081                month: 'short',
1082                day: 'numeric'
1083            });
1084
1085            title.textContent = 'Events - ' + displayDate;
1086
1087            // Filter events for this day
1088            const dayEvents = events[date] || [];
1089
1090            if (dayEvents.length === 0) {
1091                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>';
1092            } else {
1093                let html = '';
1094                dayEvents.forEach(event => {
1095                    html += renderEventItem(event, date, calId, namespace);
1096                });
1097                eventList.innerHTML = html;
1098            }
1099        }
1100    })
1101    .catch(err => console.error('Error:', err));
1102};
1103
1104// Render a single event item
1105window.renderEventItem = function(event, date, calId, namespace) {
1106    // Get theme data from container
1107    const container = document.getElementById(calId);
1108    let themeStyles = {};
1109    let importantNamespaces = ['important']; // default
1110    if (container && container.dataset.themeStyles) {
1111        try {
1112            themeStyles = JSON.parse(container.dataset.themeStyles);
1113        } catch (e) {
1114            console.error('Failed to parse theme styles:', e);
1115        }
1116    }
1117    // Get important namespaces from container data attribute
1118    if (container && container.dataset.importantNamespaces) {
1119        try {
1120            importantNamespaces = JSON.parse(container.dataset.importantNamespaces);
1121        } catch (e) {
1122            importantNamespaces = ['important'];
1123        }
1124    }
1125
1126    // Check if this event is in the past or today (with 15-minute grace period)
1127    // For multi-day events, use the end date instead of start date
1128    const today = new Date();
1129    today.setHours(0, 0, 0, 0);
1130    const todayStr = formatLocalDate(today);
1131    const effectiveEndDate = event.endDate || date;
1132    const eventDate = new Date(date + 'T00:00:00');
1133
1134    // Helper to determine if event is past with grace period
1135    let isPast;
1136    if (effectiveEndDate < todayStr) {
1137        isPast = true; // End date is past
1138    } else if (effectiveEndDate > todayStr) {
1139        isPast = false; // End date is future, event still ongoing
1140    } else {
1141        // Event ends today - check time with grace period
1142        if (event.time && event.time.trim() !== '') {
1143            try {
1144                const now = new Date();
1145                const eventDateTime = new Date(effectiveEndDate + 'T' + event.time);
1146                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
1147                isPast = now > gracePeriodEnd;
1148            } catch (e) {
1149                isPast = false;
1150            }
1151        } else {
1152            isPast = false; // No time, treat as future
1153        }
1154    }
1155
1156    const isToday = eventDate.getTime() === today.getTime();
1157
1158    // Check if this is an important namespace event
1159    let eventNamespace = event.namespace || '';
1160    if (!eventNamespace && event._namespace !== undefined) {
1161        eventNamespace = event._namespace;
1162    }
1163    let isImportantNs = false;
1164    if (eventNamespace) {
1165        for (const impNs of importantNamespaces) {
1166            if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) {
1167                isImportantNs = true;
1168                break;
1169            }
1170        }
1171    }
1172
1173    // Format date display with day of week
1174    const displayDateKey = event.originalStartDate || date;
1175    const dateObj = new Date(displayDateKey + 'T00:00:00');
1176    const displayDate = dateObj.toLocaleDateString('en-US', {
1177        weekday: 'short',
1178        month: 'short',
1179        day: 'numeric'
1180    });
1181
1182    // Convert to 12-hour format and handle time ranges
1183    let displayTime = '';
1184    if (event.time) {
1185        displayTime = formatTimeRange(event.time, event.endTime);
1186    }
1187
1188    // Multi-day indicator
1189    let multiDay = '';
1190    if (event.endDate && event.endDate !== displayDateKey) {
1191        const endObj = new Date(event.endDate + 'T00:00:00');
1192        multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
1193            weekday: 'short',
1194            month: 'short',
1195            day: 'numeric'
1196        });
1197    }
1198
1199    const completedClass = event.completed ? ' event-completed' : '';
1200    const isTask = event.isTask || false;
1201    const completed = event.completed || false;
1202    const isPastDue = isPast && isTask && !completed;
1203    const pastClass = (isPast && !isPastDue) ? ' event-past' : '';
1204    const pastDueClass = isPastDue ? ' event-pastdue' : '';
1205    const importantClass = isImportantNs ? ' event-important' : '';
1206    const color = event.color || '#3498db';
1207
1208    // Only inline style needed: border-left-color for event color indicator
1209    let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + importantClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ' !important;" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">';
1210
1211    html += '<div class="event-info">';
1212    html += '<div class="event-title-row">';
1213    // Add star for important namespace events
1214    if (isImportantNs) {
1215        html += '<span class="event-important-star" title="Important">⭐</span> ';
1216    }
1217    html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>';
1218    html += '</div>';
1219
1220    // Show meta and description for non-past events AND past due tasks
1221    if (!isPast || isPastDue) {
1222        html += '<div class="event-meta-compact">';
1223        html += '<span class="event-date-time">' + displayDate + multiDay;
1224        if (displayTime) {
1225            html += ' • ' + displayTime;
1226        }
1227        // Add PAST DUE or TODAY badge
1228        if (isPastDue) {
1229            html += ' <span class="event-pastdue-badge" style="background:var(--pastdue-color, #e74c3c) !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>';
1230        } else if (isToday) {
1231            html += ' <span class="event-today-badge" style="background:var(--border-main, #9b59b6) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;">TODAY</span>';
1232        }
1233        // Add namespace badge
1234        if (eventNamespace) {
1235            html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>';
1236        }
1237        // Add conflict warning if event has time conflicts
1238        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
1239            let conflictList = [];
1240            event.conflictsWith.forEach(conflict => {
1241                let conflictText = conflict.title;
1242                if (conflict.time) {
1243                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
1244                }
1245                conflictList.push(conflictText);
1246            });
1247
1248            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
1249        }
1250        html += '</span>';
1251        html += '</div>';
1252
1253        if (event.description) {
1254            html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>';
1255        }
1256    } else {
1257        // For past events (not past due), store data in hidden divs for expand/collapse
1258        html += '<div class="event-meta-compact" style="display: none;">';
1259        html += '<span class="event-date-time">' + displayDate + multiDay;
1260        if (displayTime) {
1261            html += ' • ' + displayTime;
1262        }
1263        // Add namespace badge for past events too
1264        let eventNamespace = event.namespace || '';
1265        if (!eventNamespace && event._namespace !== undefined) {
1266            eventNamespace = event._namespace;
1267        }
1268        if (eventNamespace) {
1269            html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>';
1270        }
1271        // Add conflict warning for past events too
1272        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
1273            let conflictList = [];
1274            event.conflictsWith.forEach(conflict => {
1275                let conflictText = conflict.title;
1276                if (conflict.time) {
1277                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
1278                }
1279                conflictList.push(conflictText);
1280            });
1281
1282            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
1283        }
1284        html += '</span>';
1285        html += '</div>';
1286
1287        if (event.description) {
1288            html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>';
1289        }
1290    }
1291
1292    html += '</div>'; // event-info
1293
1294    // Use stored namespace from event, fallback to _namespace, then passed namespace
1295    let buttonNamespace = event.namespace || '';
1296    if (!buttonNamespace && event._namespace !== undefined) {
1297        buttonNamespace = event._namespace;
1298    }
1299    if (!buttonNamespace) {
1300        buttonNamespace = namespace;
1301    }
1302
1303    html += '<div class="event-actions-compact">';
1304    html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">��️</button>';
1305    html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>';
1306    html += '</div>';
1307
1308    // Checkbox for tasks - ON THE FAR RIGHT
1309    if (isTask) {
1310        const checked = completed ? 'checked' : '';
1311        html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">';
1312    }
1313
1314    html += '</div>';
1315
1316    return html;
1317};
1318
1319// Render description with rich content support
1320window.renderDescription = function(description) {
1321    if (!description) return '';
1322
1323    // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping)
1324    // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00
1325
1326    let rendered = description;
1327    const tokens = [];
1328    let tokenIndex = 0;
1329
1330    // Convert DokuWiki image syntax {{image.jpg}} to tokens
1331    rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) {
1332        imagePath = imagePath.trim();
1333        alt = alt ? alt.trim() : '';
1334
1335        let imageHtml;
1336        // Handle external URLs
1337        if (imagePath.match(/^https?:\/\//)) {
1338            imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1339        } else {
1340            // Handle internal DokuWiki images
1341            const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath);
1342            imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1343        }
1344
1345        const token = '\x00TOKEN' + tokenIndex + '\x00';
1346        tokens[tokenIndex] = imageHtml;
1347        tokenIndex++;
1348        return token;
1349    });
1350
1351    // Convert DokuWiki link syntax [[link|text]] to tokens
1352    rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) {
1353        link = link.trim();
1354        text = text ? text.trim() : link;
1355
1356        let linkHtml;
1357        // Handle external URLs
1358        if (link.match(/^https?:\/\//)) {
1359            linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1360        } else {
1361            // Handle internal DokuWiki links with section anchors
1362            const hashIndex = link.indexOf('#');
1363            let pagePart = link;
1364            let sectionPart = '';
1365
1366            if (hashIndex !== -1) {
1367                pagePart = link.substring(0, hashIndex);
1368                sectionPart = link.substring(hashIndex); // Includes the #
1369            }
1370
1371            const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart;
1372            linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>';
1373        }
1374
1375        const token = '\x00TOKEN' + tokenIndex + '\x00';
1376        tokens[tokenIndex] = linkHtml;
1377        tokenIndex++;
1378        return token;
1379    });
1380
1381    // Convert markdown-style links [text](url) to tokens
1382    rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
1383        text = text.trim();
1384        url = url.trim();
1385
1386        let linkHtml;
1387        if (url.match(/^https?:\/\//)) {
1388            linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1389        } else {
1390            linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>';
1391        }
1392
1393        const token = '\x00TOKEN' + tokenIndex + '\x00';
1394        tokens[tokenIndex] = linkHtml;
1395        tokenIndex++;
1396        return token;
1397    });
1398
1399    // Convert plain URLs to tokens
1400    rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) {
1401        const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>';
1402        const token = '\x00TOKEN' + tokenIndex + '\x00';
1403        tokens[tokenIndex] = linkHtml;
1404        tokenIndex++;
1405        return token;
1406    });
1407
1408    // NOW escape the remaining text (tokens are protected with null bytes)
1409    rendered = escapeHtml(rendered);
1410
1411    // Convert newlines to <br>
1412    rendered = rendered.replace(/\n/g, '<br>');
1413
1414    // DokuWiki text formatting (on escaped text)
1415    // Bold: **text** or __text__
1416    rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1417    rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>');
1418
1419    // Italic: //text//
1420    rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>');
1421
1422    // Strikethrough: <del>text</del>
1423    rendered = rendered.replace(/&lt;del&gt;(.+?)&lt;\/del&gt;/g, '<del>$1</del>');
1424
1425    // Monospace: ''text''
1426    rendered = rendered.replace(/&#39;&#39;(.+?)&#39;&#39;/g, '<code>$1</code>');
1427
1428    // Subscript: <sub>text</sub>
1429    rendered = rendered.replace(/&lt;sub&gt;(.+?)&lt;\/sub&gt;/g, '<sub>$1</sub>');
1430
1431    // Superscript: <sup>text</sup>
1432    rendered = rendered.replace(/&lt;sup&gt;(.+?)&lt;\/sup&gt;/g, '<sup>$1</sup>');
1433
1434    // Restore tokens (replace with actual HTML)
1435    for (let i = 0; i < tokens.length; i++) {
1436        const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g');
1437        rendered = rendered.replace(tokenPattern, tokens[i]);
1438    }
1439
1440    return rendered;
1441}
1442
1443// Open add event dialog
1444window.openAddEvent = function(calId, namespace, date) {
1445    const dialog = document.getElementById('dialog-' + calId);
1446    const form = document.getElementById('eventform-' + calId);
1447    const title = document.getElementById('dialog-title-' + calId);
1448    const dateField = document.getElementById('event-date-' + calId);
1449
1450    if (!dateField) {
1451        console.error('Date field not found! ID: event-date-' + calId);
1452        return;
1453    }
1454
1455    // Check if there's a filtered namespace active (only for regular calendars)
1456    const calendar = document.getElementById(calId);
1457    const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null;
1458
1459    // Use filtered namespace if available, otherwise use the passed namespace
1460    const effectiveNamespace = filteredNamespace || namespace;
1461
1462
1463    // Reset form
1464    form.reset();
1465    document.getElementById('event-id-' + calId).value = '';
1466
1467    // Store the effective namespace in a hidden field or data attribute
1468    form.dataset.effectiveNamespace = effectiveNamespace;
1469
1470    // Set namespace dropdown to effective namespace
1471    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1472    if (namespaceSelect) {
1473        if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) {
1474            // Set to specific namespace if not wildcard or multi-namespace
1475            namespaceSelect.value = effectiveNamespace;
1476        } else {
1477            // Default to empty (default namespace) for wildcard/multi views
1478            namespaceSelect.value = '';
1479        }
1480    }
1481
1482    // Clear event namespace from previous edits
1483    delete form.dataset.eventNamespace;
1484
1485    // Set date - use local date, not UTC
1486    let defaultDate = date;
1487    if (!defaultDate) {
1488        // Get the currently displayed month from the calendar container
1489        const container = document.getElementById(calId);
1490        const displayedYear = parseInt(container.getAttribute('data-year'));
1491        const displayedMonth = parseInt(container.getAttribute('data-month'));
1492
1493
1494        if (displayedYear && displayedMonth) {
1495            // Use first day of the displayed month
1496            const year = displayedYear;
1497            const month = String(displayedMonth).padStart(2, '0');
1498            defaultDate = `${year}-${month}-01`;
1499        } else {
1500            // Fallback to today if attributes not found
1501            const today = new Date();
1502            const year = today.getFullYear();
1503            const month = String(today.getMonth() + 1).padStart(2, '0');
1504            const day = String(today.getDate()).padStart(2, '0');
1505            defaultDate = `${year}-${month}-${day}`;
1506        }
1507    }
1508    dateField.value = defaultDate;
1509    dateField.removeAttribute('data-original-date');
1510
1511    // Also set the end date field to the same default (user can change it)
1512    const endDateField = document.getElementById('event-end-date-' + calId);
1513    if (endDateField) {
1514        endDateField.value = ''; // Empty by default (single-day event)
1515    }
1516
1517    // Set default color
1518    document.getElementById('event-color-' + calId).value = '#3498db';
1519
1520    // Reset time pickers to default state
1521    setTimePicker(calId, false, ''); // Start time = All day
1522    setTimePicker(calId, true, '');  // End time = Same as start
1523
1524    // Set date pickers
1525    setDatePicker(calId, false, defaultDate); // Start date
1526    setDatePicker(calId, true, '');  // End date = Optional
1527
1528    // Initialize namespace search
1529    initNamespaceSearch(calId);
1530
1531    // Set title
1532    title.textContent = 'Add Event';
1533
1534    // Show dialog
1535    dialog.style.display = 'flex';
1536
1537    // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates)
1538    propagateThemeVars(calId, dialog);
1539
1540    // Initialize custom pickers
1541    initCustomTimePickers(calId);
1542    initCustomDatePickers(calId);
1543
1544    // Make dialog draggable
1545    setTimeout(() => makeDialogDraggable(calId), 50);
1546
1547    // Focus title field
1548    setTimeout(() => {
1549        const titleField = document.getElementById('event-title-' + calId);
1550        if (titleField) titleField.focus();
1551    }, 100);
1552};
1553
1554// Edit event
1555window.editEvent = function(calId, eventId, date, namespace) {
1556    const params = new URLSearchParams({
1557        call: 'plugin_calendar',
1558        action: 'get_event',
1559        namespace: namespace,
1560        date: date,
1561        eventId: eventId
1562    });
1563
1564    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1565        method: 'POST',
1566        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1567        body: params.toString()
1568    })
1569    .then(r => r.json())
1570    .then(data => {
1571        if (data.success && data.event) {
1572            const event = data.event;
1573            const dialog = document.getElementById('dialog-' + calId);
1574            const title = document.getElementById('dialog-title-' + calId);
1575            const dateField = document.getElementById('event-date-' + calId);
1576            const form = document.getElementById('eventform-' + calId);
1577
1578            if (!dateField) {
1579                console.error('Date field not found when editing!');
1580                return;
1581            }
1582
1583            // Store the event's actual namespace for saving (important for namespace=* views)
1584            if (event.namespace !== undefined) {
1585                form.dataset.eventNamespace = event.namespace;
1586            }
1587
1588            // Populate form
1589            document.getElementById('event-id-' + calId).value = event.id;
1590            dateField.value = date;
1591            dateField.setAttribute('data-original-date', date);
1592
1593            const endDateField = document.getElementById('event-end-date-' + calId);
1594            endDateField.value = event.endDate || '';
1595
1596            document.getElementById('event-title-' + calId).value = event.title;
1597            document.getElementById('event-color-' + calId).value = event.color || '#3498db';
1598            document.getElementById('event-desc-' + calId).value = event.description || '';
1599            document.getElementById('event-is-task-' + calId).checked = event.isTask || false;
1600
1601            // Set time picker values using custom picker API
1602            setTimePicker(calId, false, event.time || '');
1603            setTimePicker(calId, true, event.endTime || '');
1604
1605            // Set date picker values
1606            setDatePicker(calId, false, date);
1607            setDatePicker(calId, true, event.endDate || '');
1608
1609            // Initialize namespace search
1610            initNamespaceSearch(calId);
1611
1612            // Set namespace fields if available
1613            const namespaceHidden = document.getElementById('event-namespace-' + calId);
1614            const namespaceSearch = document.getElementById('event-namespace-search-' + calId);
1615            if (namespaceHidden && event.namespace !== undefined) {
1616                // Set the hidden input (this is what gets submitted)
1617                namespaceHidden.value = event.namespace || '';
1618                // Set the search input to display the namespace
1619                if (namespaceSearch) {
1620                    namespaceSearch.value = event.namespace || '(default)';
1621                }
1622            } else {
1623                // No namespace on event, set to default
1624                if (namespaceHidden) {
1625                    namespaceHidden.value = '';
1626                }
1627                if (namespaceSearch) {
1628                    namespaceSearch.value = '(default)';
1629                }
1630            }
1631
1632            title.textContent = 'Edit Event';
1633            dialog.style.display = 'flex';
1634
1635            // Propagate CSS vars to dialog
1636            propagateThemeVars(calId, dialog);
1637
1638            // Initialize custom pickers
1639            initCustomTimePickers(calId);
1640            initCustomDatePickers(calId);
1641
1642            // Make dialog draggable
1643            setTimeout(() => makeDialogDraggable(calId), 50);
1644        }
1645    })
1646    .catch(err => console.error('Error editing event:', err));
1647};
1648
1649// Delete event
1650window.deleteEvent = function(calId, eventId, date, namespace) {
1651    if (!confirm('Delete this event?')) return;
1652
1653    const params = new URLSearchParams({
1654        call: 'plugin_calendar',
1655        action: 'delete_event',
1656        namespace: namespace,
1657        date: date,
1658        eventId: eventId,
1659        sectok: getSecurityToken()
1660    });
1661
1662    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1663        method: 'POST',
1664        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1665        body: params.toString()
1666    })
1667    .then(r => r.json())
1668    .then(data => {
1669        if (data.success) {
1670            // Announce to screen readers
1671            announceToScreenReader('Event deleted');
1672
1673            // Extract year and month from date
1674            const [year, month] = date.split('-').map(Number);
1675
1676            // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace)
1677            // This preserves wildcard/multi-namespace views
1678            const container = document.getElementById(calId);
1679            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
1680
1681            // Reload calendar data via AJAX with the calendar's original namespace
1682            reloadCalendarData(calId, year, month, calendarNamespace);
1683        }
1684    })
1685    .catch(err => console.error('Error:', err));
1686};
1687
1688// Save event (add or edit)
1689window.saveEventCompact = function(calId, namespace) {
1690    const form = document.getElementById('eventform-' + calId);
1691
1692    // Get namespace from dropdown - this is what the user selected
1693    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1694    const selectedNamespace = namespaceSelect ? namespaceSelect.value : '';
1695
1696    // ALWAYS use what the user selected in the dropdown
1697    // This allows changing namespace when editing
1698    const finalNamespace = selectedNamespace;
1699
1700    const eventId = document.getElementById('event-id-' + calId).value;
1701
1702    // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event)
1703    const originalNamespace = form.dataset.eventNamespace;
1704
1705
1706    const dateInput = document.getElementById('event-date-' + calId);
1707    const date = dateInput.value;
1708    const oldDate = dateInput.getAttribute('data-original-date') || date;
1709    const endDate = document.getElementById('event-end-date-' + calId).value;
1710    const title = document.getElementById('event-title-' + calId).value;
1711    const time = document.getElementById('event-time-' + calId).value;
1712    const endTime = document.getElementById('event-end-time-' + calId).value;
1713    const colorSelect = document.getElementById('event-color-' + calId);
1714    let color = colorSelect.value;
1715
1716    // Handle custom color
1717    if (color === 'custom') {
1718        color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value;
1719    }
1720
1721    const description = document.getElementById('event-desc-' + calId).value;
1722    const isTask = document.getElementById('event-is-task-' + calId).checked;
1723    const completed = false; // New tasks are not completed
1724    const isRecurring = document.getElementById('event-recurring-' + calId).checked;
1725    const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value;
1726    const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value;
1727
1728    // New recurrence options
1729    const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId);
1730    const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1;
1731
1732    // Weekly: collect selected days
1733    let weekDays = [];
1734    const weeklyOptions = document.getElementById('weekly-options-' + calId);
1735    if (weeklyOptions && recurrenceType === 'weekly') {
1736        const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked');
1737        weekDays = Array.from(checkboxes).map(cb => cb.value);
1738    }
1739
1740    // Monthly: collect day-of-month or ordinal weekday
1741    let monthDay = '';
1742    let monthlyType = 'dayOfMonth';
1743    let ordinalWeek = '';
1744    let ordinalDay = '';
1745    const monthlyOptions = document.getElementById('monthly-options-' + calId);
1746    if (monthlyOptions && recurrenceType === 'monthly') {
1747        const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked');
1748        monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth';
1749
1750        if (monthlyType === 'dayOfMonth') {
1751            const monthDayInput = document.getElementById('event-month-day-' + calId);
1752            monthDay = monthDayInput ? monthDayInput.value : '';
1753        } else {
1754            const ordinalSelect = document.getElementById('event-ordinal-' + calId);
1755            const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId);
1756            ordinalWeek = ordinalSelect ? ordinalSelect.value : '1';
1757            ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0';
1758        }
1759    }
1760
1761    if (!title) {
1762        alert('Please enter a title');
1763        return;
1764    }
1765
1766    if (!date) {
1767        alert('Please select a date');
1768        return;
1769    }
1770
1771    const params = new URLSearchParams({
1772        call: 'plugin_calendar',
1773        action: 'save_event',
1774        namespace: finalNamespace,
1775        eventId: eventId,
1776        date: date,
1777        oldDate: oldDate,
1778        endDate: endDate,
1779        title: title,
1780        time: time,
1781        endTime: endTime,
1782        color: color,
1783        description: description,
1784        isTask: isTask ? '1' : '0',
1785        completed: completed ? '1' : '0',
1786        isRecurring: isRecurring ? '1' : '0',
1787        recurrenceType: recurrenceType,
1788        recurrenceInterval: recurrenceInterval,
1789        recurrenceEnd: recurrenceEnd,
1790        weekDays: weekDays.join(','),
1791        monthlyType: monthlyType,
1792        monthDay: monthDay,
1793        ordinalWeek: ordinalWeek,
1794        ordinalDay: ordinalDay,
1795        sectok: getSecurityToken()
1796    });
1797
1798    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1799        method: 'POST',
1800        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1801        body: params.toString()
1802    })
1803    .then(r => r.json())
1804    .then(data => {
1805        if (data.success) {
1806            // Announce to screen readers
1807            announceToScreenReader(eventId ? 'Event updated' : 'Event created');
1808
1809            closeEventDialog(calId);
1810
1811            // For recurring events, do a full page reload to show all occurrences
1812            if (isRecurring) {
1813                location.reload();
1814                return;
1815            }
1816
1817            // Extract year and month from the NEW date (in case date was changed)
1818            const [year, month] = date.split('-').map(Number);
1819
1820            // Get the calendar's ORIGINAL namespace setting from the container
1821            // This preserves wildcard/multi-namespace views after editing
1822            const container = document.getElementById(calId);
1823            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
1824
1825            // Reload calendar data via AJAX to the month of the event
1826            reloadCalendarData(calId, year, month, calendarNamespace);
1827        } else {
1828            alert('Error: ' + (data.error || 'Unknown error'));
1829        }
1830    })
1831    .catch(err => {
1832        console.error('Error:', err);
1833        alert('Error saving event');
1834    });
1835};
1836
1837// Reload calendar data without page refresh
1838window.reloadCalendarData = function(calId, year, month, namespace) {
1839    // Read exclude list from container data attribute
1840    const container = document.getElementById(calId);
1841    const exclude = container ? (container.dataset.exclude || '') : '';
1842
1843    const params = new URLSearchParams({
1844        call: 'plugin_calendar',
1845        action: 'load_month',
1846        year: year,
1847        month: month,
1848        namespace: namespace,
1849        exclude: exclude,
1850        _: new Date().getTime() // Cache buster
1851    });
1852
1853    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1854        method: 'POST',
1855        headers: {
1856            'Content-Type': 'application/x-www-form-urlencoded',
1857            'Cache-Control': 'no-cache, no-store, must-revalidate',
1858            'Pragma': 'no-cache'
1859        },
1860        body: params.toString()
1861    })
1862    .then(r => r.json())
1863    .then(data => {
1864        if (data.success) {
1865            const container = document.getElementById(calId);
1866
1867            // Check if this is a full calendar or just event panel
1868            if (container.classList.contains('calendar-compact-container')) {
1869                rebuildCalendar(calId, data.year, data.month, data.events, namespace);
1870            } else if (container.classList.contains('event-panel-standalone')) {
1871                rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1872            }
1873        }
1874    })
1875    .catch(err => console.error('Error:', err));
1876};
1877
1878// Close event dialog
1879window.closeEventDialog = function(calId) {
1880    const dialog = document.getElementById('dialog-' + calId);
1881    dialog.style.display = 'none';
1882};
1883
1884// Escape HTML
1885window.escapeHtml = function(text) {
1886    const div = document.createElement('div');
1887    div.textContent = text;
1888    return div.innerHTML;
1889};
1890
1891// Highlight event when clicking on bar in calendar
1892window.highlightEvent = function(calId, eventId, date) {
1893
1894    // Find the event item in the event list
1895    const eventList = document.querySelector('#' + calId + ' .event-list-compact');
1896    if (!eventList) {
1897        return;
1898    }
1899
1900    const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]');
1901    if (!eventItem) {
1902        return;
1903    }
1904
1905
1906    // Get theme
1907    const container = document.getElementById(calId);
1908    const theme = container ? container.dataset.theme : 'matrix';
1909    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
1910
1911
1912    // Theme-specific highlight colors
1913    let highlightBg, highlightShadow;
1914    if (theme === 'matrix') {
1915        highlightBg = '#1a3d1a';  // Darker green
1916        highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)';
1917    } else if (theme === 'purple') {
1918        highlightBg = '#3d2b4d';  // Darker purple
1919        highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)';
1920    } else if (theme === 'professional') {
1921        highlightBg = '#e3f2fd';  // Light blue
1922        highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)';
1923    } else if (theme === 'pink') {
1924        highlightBg = '#3d2030';  // Darker pink
1925        highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)';
1926    } else if (theme === 'wiki') {
1927        highlightBg = themeStyles.header_bg || '#e8e8e8';  // __background_alt__
1928        highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)';
1929    }
1930
1931
1932    // Store original styles
1933    const originalBg = eventItem.style.background;
1934    const originalShadow = eventItem.style.boxShadow;
1935
1936    // Remove previous highlights (restore their original styles)
1937    const previousHighlights = eventList.querySelectorAll('.event-highlighted');
1938    previousHighlights.forEach(el => {
1939        el.classList.remove('event-highlighted');
1940    });
1941
1942    // Add highlight class and apply theme-aware glow
1943    eventItem.classList.add('event-highlighted');
1944
1945    // Set CSS properties directly
1946    eventItem.style.setProperty('background', highlightBg, 'important');
1947    eventItem.style.setProperty('box-shadow', highlightShadow, 'important');
1948    eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important');
1949
1950
1951    // Scroll to event
1952    eventItem.scrollIntoView({
1953        behavior: 'smooth',
1954        block: 'nearest',
1955        inline: 'nearest'
1956    });
1957
1958    // Remove highlight after 3 seconds and restore original styles
1959    setTimeout(() => {
1960        eventItem.classList.remove('event-highlighted');
1961        eventItem.style.setProperty('background', originalBg);
1962        eventItem.style.setProperty('box-shadow', originalShadow);
1963        eventItem.style.setProperty('transition', '');
1964    }, 3000);
1965};
1966
1967// Toggle recurring event options
1968window.toggleRecurringOptions = function(calId) {
1969    const checkbox = document.getElementById('event-recurring-' + calId);
1970    const options = document.getElementById('recurring-options-' + calId);
1971
1972    if (checkbox && options) {
1973        options.style.display = checkbox.checked ? 'block' : 'none';
1974        if (checkbox.checked) {
1975            // Initialize the sub-options based on current selection
1976            updateRecurrenceOptions(calId);
1977        }
1978    }
1979};
1980
1981// Update visible recurrence options based on type (daily/weekly/monthly/yearly)
1982window.updateRecurrenceOptions = function(calId) {
1983    const typeSelect = document.getElementById('event-recurrence-type-' + calId);
1984    const weeklyOptions = document.getElementById('weekly-options-' + calId);
1985    const monthlyOptions = document.getElementById('monthly-options-' + calId);
1986
1987    if (!typeSelect) return;
1988
1989    const recurrenceType = typeSelect.value;
1990
1991    // Hide all conditional options first
1992    if (weeklyOptions) weeklyOptions.style.display = 'none';
1993    if (monthlyOptions) monthlyOptions.style.display = 'none';
1994
1995    // Show relevant options
1996    if (recurrenceType === 'weekly' && weeklyOptions) {
1997        weeklyOptions.style.display = 'block';
1998        // Auto-select today's day of week if nothing selected
1999        const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]');
2000        const anyChecked = Array.from(checkboxes).some(cb => cb.checked);
2001        if (!anyChecked) {
2002            const today = new Date().getDay();
2003            const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]');
2004            if (todayCheckbox) todayCheckbox.checked = true;
2005        }
2006    } else if (recurrenceType === 'monthly' && monthlyOptions) {
2007        monthlyOptions.style.display = 'block';
2008        // Set default day to current day of month
2009        const monthDayInput = document.getElementById('event-month-day-' + calId);
2010        if (monthDayInput && !monthDayInput.dataset.userSet) {
2011            monthDayInput.value = new Date().getDate();
2012        }
2013    }
2014};
2015
2016// Toggle between day-of-month and ordinal weekday for monthly recurrence
2017window.updateMonthlyType = function(calId) {
2018    const dayOfMonthDiv = document.getElementById('monthly-day-' + calId);
2019    const ordinalDiv = document.getElementById('monthly-ordinal-' + calId);
2020    const monthlyOptions = document.getElementById('monthly-options-' + calId);
2021
2022    if (!monthlyOptions) return;
2023
2024    const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked');
2025    if (!selectedRadio) return;
2026
2027    if (selectedRadio.value === 'dayOfMonth') {
2028        if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex';
2029        if (ordinalDiv) ordinalDiv.style.display = 'none';
2030    } else {
2031        if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none';
2032        if (ordinalDiv) ordinalDiv.style.display = 'block';
2033
2034        // Set defaults based on current date
2035        const now = new Date();
2036        const dayOfWeek = now.getDay();
2037        const weekOfMonth = Math.ceil(now.getDate() / 7);
2038
2039        const ordinalSelect = document.getElementById('event-ordinal-' + calId);
2040        const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId);
2041
2042        if (ordinalSelect && !ordinalSelect.dataset.userSet) {
2043            ordinalSelect.value = weekOfMonth;
2044        }
2045        if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) {
2046            ordinalDaySelect.value = dayOfWeek;
2047        }
2048    }
2049};
2050
2051// ============================================================
2052// Document-level event delegation (guarded - only attach once)
2053// These use event delegation so they work for AJAX-rebuilt content.
2054// ============================================================
2055if (!window._calendarDelegationInit) {
2056    window._calendarDelegationInit = true;
2057
2058    // Keyboard navigation for accessibility
2059    document.addEventListener('keydown', function(e) {
2060        // ESC closes dialogs, popups, tooltips, dropdowns
2061        if (e.key === 'Escape') {
2062            // Close dialogs
2063            document.querySelectorAll('.event-dialog-compact').forEach(function(d) {
2064                if (d.style.display === 'flex') d.style.display = 'none';
2065            });
2066            // Close day popups
2067            document.querySelectorAll('.day-popup').forEach(function(p) {
2068                p.style.display = 'none';
2069            });
2070            // Close custom pickers
2071            document.querySelectorAll('.time-dropdown.open, .date-dropdown.open').forEach(function(d) {
2072                d.classList.remove('open');
2073                d.innerHTML = '';
2074            });
2075            document.querySelectorAll('.custom-time-picker.open, .custom-date-picker.open').forEach(function(b) {
2076                b.classList.remove('open');
2077            });
2078            hideConflictTooltip();
2079            return;
2080        }
2081
2082        // Calendar grid navigation with arrow keys
2083        var focusedDay = document.activeElement;
2084        if (focusedDay && focusedDay.classList.contains('calendar-day')) {
2085            var calGrid = focusedDay.closest('.calendar-grid');
2086            if (!calGrid) return;
2087
2088            var days = Array.from(calGrid.querySelectorAll('.calendar-day:not(.empty)'));
2089            var currentIndex = days.indexOf(focusedDay);
2090            if (currentIndex === -1) return;
2091
2092            var newIndex = currentIndex;
2093
2094            if (e.key === 'ArrowRight') {
2095                newIndex = Math.min(currentIndex + 1, days.length - 1);
2096                e.preventDefault();
2097            } else if (e.key === 'ArrowLeft') {
2098                newIndex = Math.max(currentIndex - 1, 0);
2099                e.preventDefault();
2100            } else if (e.key === 'ArrowDown') {
2101                newIndex = Math.min(currentIndex + 7, days.length - 1);
2102                e.preventDefault();
2103            } else if (e.key === 'ArrowUp') {
2104                newIndex = Math.max(currentIndex - 7, 0);
2105                e.preventDefault();
2106            } else if (e.key === 'Enter' || e.key === ' ') {
2107                // Activate the day (click it)
2108                focusedDay.click();
2109                e.preventDefault();
2110                return;
2111            }
2112
2113            if (newIndex !== currentIndex && days[newIndex]) {
2114                days[newIndex].focus();
2115            }
2116        }
2117
2118        // Event item navigation with arrow keys
2119        var focusedEvent = document.activeElement;
2120        if (focusedEvent && focusedEvent.classList.contains('event-item')) {
2121            var eventList = focusedEvent.closest('.event-list-items, .day-popup-events');
2122            if (!eventList) return;
2123
2124            var events = Array.from(eventList.querySelectorAll('.event-item'));
2125            var currentIdx = events.indexOf(focusedEvent);
2126            if (currentIdx === -1) return;
2127
2128            if (e.key === 'ArrowDown') {
2129                var nextIdx = Math.min(currentIdx + 1, events.length - 1);
2130                events[nextIdx].focus();
2131                e.preventDefault();
2132            } else if (e.key === 'ArrowUp') {
2133                var prevIdx = Math.max(currentIdx - 1, 0);
2134                events[prevIdx].focus();
2135                e.preventDefault();
2136            } else if (e.key === 'Enter') {
2137                // Find and click the edit button
2138                var editBtn = focusedEvent.querySelector('.event-action-edit');
2139                if (editBtn) editBtn.click();
2140                e.preventDefault();
2141            } else if (e.key === 'Delete' || e.key === 'Backspace') {
2142                // Find and click the delete button
2143                var deleteBtn = focusedEvent.querySelector('.event-action-delete');
2144                if (deleteBtn) deleteBtn.click();
2145                e.preventDefault();
2146            }
2147        }
2148    });
2149
2150    // Conflict tooltip delegation (capture phase for mouseenter/leave)
2151    document.addEventListener('mouseenter', function(e) {
2152        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
2153            showConflictTooltip(e.target);
2154        }
2155    }, true);
2156
2157    document.addEventListener('mouseleave', function(e) {
2158        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
2159            hideConflictTooltip();
2160        }
2161    }, true);
2162} // end delegation guard
2163
2164// Event panel navigation
2165window.navEventPanel = function(calId, year, month, namespace) {
2166    // Read exclude list from container data attribute
2167    const container = document.getElementById(calId);
2168    const exclude = container ? (container.dataset.exclude || '') : '';
2169
2170    const params = new URLSearchParams({
2171        call: 'plugin_calendar',
2172        action: 'load_month',
2173        year: year,
2174        month: month,
2175        namespace: namespace,
2176        exclude: exclude,
2177        _: new Date().getTime() // Cache buster
2178    });
2179
2180    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
2181        method: 'POST',
2182        headers: {
2183            'Content-Type': 'application/x-www-form-urlencoded',
2184            'Cache-Control': 'no-cache, no-store, must-revalidate',
2185            'Pragma': 'no-cache'
2186        },
2187        body: params.toString()
2188    })
2189    .then(r => r.json())
2190    .then(data => {
2191        if (data.success) {
2192            rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
2193        }
2194    })
2195    .catch(err => console.error('Error:', err));
2196};
2197
2198// Rebuild event panel only
2199window.rebuildEventPanel = function(calId, year, month, events, namespace) {
2200    const container = document.getElementById(calId);
2201    const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
2202                       'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2203
2204    // Update month title in new compact header
2205    const monthTitle = container.querySelector('.panel-month-title');
2206    if (monthTitle) {
2207        monthTitle.textContent = monthNames[month - 1] + ' ' + year;
2208        monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
2209        monthTitle.setAttribute('title', 'Click to jump to month');
2210    }
2211
2212    // Fallback: Update old header format if exists
2213    const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker');
2214    if (oldHeader && !monthTitle) {
2215        oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events';
2216        oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
2217    }
2218
2219    // Update nav buttons
2220    let prevMonth = month - 1;
2221    let prevYear = year;
2222    if (prevMonth < 1) {
2223        prevMonth = 12;
2224        prevYear--;
2225    }
2226
2227    let nextMonth = month + 1;
2228    let nextYear = year;
2229    if (nextMonth > 12) {
2230        nextMonth = 1;
2231        nextYear++;
2232    }
2233
2234    // Update new compact nav buttons
2235    const navBtns = container.querySelectorAll('.panel-nav-btn');
2236    if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
2237    if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
2238
2239    // Fallback for old nav buttons
2240    const oldNavBtns = container.querySelectorAll('.cal-nav-btn');
2241    if (oldNavBtns.length > 0 && navBtns.length === 0) {
2242        if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
2243        if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
2244    }
2245
2246    // Update Today button (works for both old and new)
2247    const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact');
2248    if (todayBtn) {
2249        todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`);
2250    }
2251
2252    // Rebuild event list
2253    const eventList = container.querySelector('.event-list-compact');
2254    if (eventList) {
2255        eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
2256    }
2257};
2258
2259// Open add event for panel
2260window.openAddEventPanel = function(calId, namespace) {
2261    const today = new Date();
2262    const year = today.getFullYear();
2263    const month = String(today.getMonth() + 1).padStart(2, '0');
2264    const day = String(today.getDate()).padStart(2, '0');
2265    const localDate = `${year}-${month}-${day}`;
2266    openAddEvent(calId, namespace, localDate);
2267};
2268
2269// Toggle task completion
2270window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) {
2271    const params = new URLSearchParams({
2272        call: 'plugin_calendar',
2273        action: 'toggle_task',
2274        namespace: namespace,
2275        date: date,
2276        eventId: eventId,
2277        completed: completed ? '1' : '0',
2278        sectok: getSecurityToken()
2279    });
2280
2281    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
2282        method: 'POST',
2283        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
2284        body: params.toString()
2285    })
2286    .then(r => r.json())
2287    .then(data => {
2288        if (data.success) {
2289            // Announce to screen readers
2290            announceToScreenReader(completed ? 'Task marked complete' : 'Task marked incomplete');
2291
2292            const [year, month] = date.split('-').map(Number);
2293
2294            // Get the calendar's ORIGINAL namespace setting from the container
2295            const container = document.getElementById(calId);
2296            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
2297
2298            reloadCalendarData(calId, year, month, calendarNamespace);
2299        }
2300    })
2301    .catch(err => console.error('Error toggling task:', err));
2302};
2303
2304// Make dialog draggable
2305window.makeDialogDraggable = function(calId) {
2306    const dialog = document.getElementById('dialog-content-' + calId);
2307    const handle = document.getElementById('drag-handle-' + calId);
2308
2309    if (!dialog || !handle) return;
2310
2311    // Remove any existing drag setup to prevent duplicate listeners
2312    if (handle._dragCleanup) {
2313        handle._dragCleanup();
2314    }
2315
2316    // Reset position when dialog opens
2317    dialog.style.transform = '';
2318
2319    let isDragging = false;
2320    let currentX = 0;
2321    let currentY = 0;
2322    let initialX;
2323    let initialY;
2324    let xOffset = 0;
2325    let yOffset = 0;
2326
2327    function dragStart(e) {
2328        // Only start drag if clicking on the handle itself, not buttons inside it
2329        if (e.target.tagName === 'BUTTON') return;
2330
2331        initialX = e.clientX - xOffset;
2332        initialY = e.clientY - yOffset;
2333        isDragging = true;
2334        handle.style.cursor = 'grabbing';
2335    }
2336
2337    function drag(e) {
2338        if (isDragging) {
2339            e.preventDefault();
2340            currentX = e.clientX - initialX;
2341            currentY = e.clientY - initialY;
2342            xOffset = currentX;
2343            yOffset = currentY;
2344            dialog.style.transform = `translate(${currentX}px, ${currentY}px)`;
2345        }
2346    }
2347
2348    function dragEnd(e) {
2349        if (isDragging) {
2350            initialX = currentX;
2351            initialY = currentY;
2352            isDragging = false;
2353            handle.style.cursor = 'move';
2354        }
2355    }
2356
2357    // Add listeners
2358    handle.addEventListener('mousedown', dragStart);
2359    document.addEventListener('mousemove', drag);
2360    document.addEventListener('mouseup', dragEnd);
2361
2362    // Store cleanup function to remove listeners later
2363    handle._dragCleanup = function() {
2364        handle.removeEventListener('mousedown', dragStart);
2365        document.removeEventListener('mousemove', drag);
2366        document.removeEventListener('mouseup', dragEnd);
2367    };
2368};
2369
2370// Toggle expand/collapse for past events
2371window.togglePastEventExpand = function(element) {
2372    // Stop propagation to prevent any parent click handlers
2373    event.stopPropagation();
2374
2375    const meta = element.querySelector(".event-meta-compact");
2376    const desc = element.querySelector(".event-desc-compact");
2377
2378    // Toggle visibility
2379    if (meta.style.display === "none") {
2380        // Expand
2381        meta.style.display = "block";
2382        if (desc) desc.style.display = "block";
2383        element.classList.add("event-past-expanded");
2384    } else {
2385        // Collapse
2386        meta.style.display = "none";
2387        if (desc) desc.style.display = "none";
2388        element.classList.remove("event-past-expanded");
2389    }
2390};
2391
2392// Filter calendar by namespace when clicking namespace badge (guarded)
2393if (!window._calendarClickDelegationInit) {
2394    window._calendarClickDelegationInit = true;
2395    document.addEventListener('click', function(e) {
2396    if (e.target.classList.contains('event-namespace-badge')) {
2397        const namespace = e.target.textContent;
2398        const calendar = e.target.closest('.calendar-compact-container');
2399
2400        if (!calendar) return;
2401
2402        const calId = calendar.id;
2403
2404        // Use AJAX reload to filter both calendar grid and event list
2405        filterCalendarByNamespace(calId, namespace);
2406    }
2407    });
2408} // end click delegation guard
2409
2410// Update the displayed filtered namespace in event list header
2411// Legacy badge removed - namespace filtering still works but badge no longer shown
2412window.updateFilteredNamespaceDisplay = function(calId, namespace) {
2413    const calendar = document.getElementById(calId);
2414    if (!calendar) return;
2415
2416    const headerContent = calendar.querySelector('.event-list-header-content');
2417    if (!headerContent) return;
2418
2419    // Remove any existing filter badge (cleanup)
2420    let filterBadge = headerContent.querySelector('.namespace-filter-badge');
2421    if (filterBadge) {
2422        filterBadge.remove();
2423    }
2424};
2425
2426// Clear namespace filter
2427window.clearNamespaceFilter = function(calId) {
2428
2429    const container = document.getElementById(calId);
2430    if (!container) {
2431        console.error('Calendar container not found:', calId);
2432        return;
2433    }
2434
2435    // Immediately hide/remove the filter badge
2436    const filterBadge = container.querySelector('.calendar-namespace-filter');
2437    if (filterBadge) {
2438        filterBadge.style.display = 'none';
2439        filterBadge.remove();
2440    }
2441
2442    // Get current year and month
2443    const year = parseInt(container.dataset.year) || new Date().getFullYear();
2444    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
2445
2446    // Get original namespace (what the calendar was initialized with)
2447    const originalNamespace = container.dataset.originalNamespace || '';
2448
2449    // Also check for sidebar widget
2450    const sidebarContainer = document.getElementById('sidebar-widget-' + calId);
2451    if (sidebarContainer) {
2452        // For sidebar widget, just reload the page without namespace filter
2453        // Remove the namespace from the URL and reload
2454        const url = new URL(window.location.href);
2455        url.searchParams.delete('namespace');
2456        window.location.href = url.toString();
2457        return;
2458    }
2459
2460    // For regular calendar, reload calendar with original namespace
2461    navCalendar(calId, year, month, originalNamespace);
2462};
2463
2464window.clearNamespaceFilterPanel = function(calId) {
2465
2466    const container = document.getElementById(calId);
2467    if (!container) {
2468        console.error('Event panel container not found:', calId);
2469        return;
2470    }
2471
2472    // Get current year and month from URL params or container
2473    const year = parseInt(container.dataset.year) || new Date().getFullYear();
2474    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
2475
2476    // Get original namespace (what the panel was initialized with)
2477    const originalNamespace = container.dataset.originalNamespace || '';
2478
2479
2480    // Reload event panel with original namespace
2481    navEventPanel(calId, year, month, originalNamespace);
2482};
2483
2484// Color picker functions
2485window.updateCustomColorPicker = function(calId) {
2486    const select = document.getElementById('event-color-' + calId);
2487    const picker = document.getElementById('event-color-custom-' + calId);
2488
2489    if (select.value === 'custom') {
2490        // Show color picker
2491        picker.style.display = 'inline-block';
2492        picker.click(); // Open color picker
2493    } else {
2494        // Hide color picker and sync value
2495        picker.style.display = 'none';
2496        picker.value = select.value;
2497    }
2498};
2499
2500function updateColorFromPicker(calId) {
2501    const select = document.getElementById('event-color-' + calId);
2502    const picker = document.getElementById('event-color-custom-' + calId);
2503
2504    // Set select to custom and update its underlying value
2505    select.value = 'custom';
2506    // Store the actual color value in a data attribute
2507    select.dataset.customColor = picker.value;
2508}
2509
2510// Toggle past events visibility
2511window.togglePastEvents = function(calId) {
2512    const content = document.getElementById('past-events-' + calId);
2513    const arrow = document.getElementById('past-arrow-' + calId);
2514
2515    if (!content || !arrow) {
2516        console.error('Past events elements not found for:', calId);
2517        return;
2518    }
2519
2520    // Check computed style instead of inline style
2521    const isHidden = window.getComputedStyle(content).display === 'none';
2522
2523    if (isHidden) {
2524        content.style.display = 'block';
2525        arrow.textContent = '▼';
2526    } else {
2527        content.style.display = 'none';
2528        arrow.textContent = '▶';
2529    }
2530};
2531
2532// Fuzzy match scoring function
2533window.fuzzyMatch = function(pattern, str) {
2534    pattern = pattern.toLowerCase();
2535    str = str.toLowerCase();
2536
2537    let patternIdx = 0;
2538    let score = 0;
2539    let consecutiveMatches = 0;
2540
2541    for (let i = 0; i < str.length; i++) {
2542        if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) {
2543            score += 1 + consecutiveMatches;
2544            consecutiveMatches++;
2545            patternIdx++;
2546        } else {
2547            consecutiveMatches = 0;
2548        }
2549    }
2550
2551    // Return null if not all characters matched
2552    if (patternIdx !== pattern.length) {
2553        return null;
2554    }
2555
2556    // Bonus for exact match
2557    if (str === pattern) {
2558        score += 100;
2559    }
2560
2561    // Bonus for starts with
2562    if (str.startsWith(pattern)) {
2563        score += 50;
2564    }
2565
2566    return score;
2567};
2568
2569// Initialize namespace search for a calendar
2570window.initNamespaceSearch = function(calId) {
2571    const searchInput = document.getElementById('event-namespace-search-' + calId);
2572    const hiddenInput = document.getElementById('event-namespace-' + calId);
2573    const dropdown = document.getElementById('event-namespace-dropdown-' + calId);
2574    const dataElement = document.getElementById('namespaces-data-' + calId);
2575
2576    if (!searchInput || !hiddenInput || !dropdown || !dataElement) {
2577        return; // Elements not found
2578    }
2579
2580    // PERFORMANCE FIX: Prevent re-binding event listeners on each dialog open
2581    if (searchInput.dataset.initialized === 'true') {
2582        return;
2583    }
2584    searchInput.dataset.initialized = 'true';
2585
2586    let namespaces = [];
2587    try {
2588        namespaces = JSON.parse(dataElement.textContent);
2589    } catch (e) {
2590        console.error('Failed to parse namespaces data:', e);
2591        return;
2592    }
2593
2594    let selectedIndex = -1;
2595
2596    // Filter and show dropdown
2597    function filterNamespaces(query) {
2598        if (!query || query.trim() === '') {
2599            // Show all namespaces when empty
2600            hiddenInput.value = '';
2601            const results = namespaces.slice(0, 20); // Limit to 20
2602            showDropdown(results);
2603            return;
2604        }
2605
2606        // Fuzzy match and score
2607        const matches = [];
2608        for (let i = 0; i < namespaces.length; i++) {
2609            const score = fuzzyMatch(query, namespaces[i]);
2610            if (score !== null) {
2611                matches.push({ namespace: namespaces[i], score: score });
2612            }
2613        }
2614
2615        // Sort by score (descending)
2616        matches.sort((a, b) => b.score - a.score);
2617
2618        // Take top 20 results
2619        const results = matches.slice(0, 20).map(m => m.namespace);
2620        showDropdown(results);
2621    }
2622
2623    function showDropdown(results) {
2624        dropdown.innerHTML = '';
2625        selectedIndex = -1;
2626
2627        if (results.length === 0) {
2628            dropdown.style.display = 'none';
2629            return;
2630        }
2631
2632        // Add (default) option
2633        const defaultOption = document.createElement('div');
2634        defaultOption.className = 'namespace-option';
2635        defaultOption.textContent = '(default)';
2636        defaultOption.dataset.value = '';
2637        dropdown.appendChild(defaultOption);
2638
2639        results.forEach(ns => {
2640            const option = document.createElement('div');
2641            option.className = 'namespace-option';
2642            option.textContent = ns;
2643            option.dataset.value = ns;
2644            dropdown.appendChild(option);
2645        });
2646
2647        dropdown.style.display = 'block';
2648    }
2649
2650    function hideDropdown() {
2651        dropdown.style.display = 'none';
2652        selectedIndex = -1;
2653    }
2654
2655    function selectOption(namespace) {
2656        hiddenInput.value = namespace;
2657        searchInput.value = namespace || '(default)';
2658        hideDropdown();
2659    }
2660
2661    // Event listeners - only bound once now
2662    searchInput.addEventListener('input', function(e) {
2663        filterNamespaces(e.target.value);
2664    });
2665
2666    searchInput.addEventListener('focus', function(e) {
2667        filterNamespaces(e.target.value);
2668    });
2669
2670    searchInput.addEventListener('blur', function(e) {
2671        // Delay to allow click on dropdown
2672        setTimeout(hideDropdown, 200);
2673    });
2674
2675    searchInput.addEventListener('keydown', function(e) {
2676        const options = dropdown.querySelectorAll('.namespace-option');
2677
2678        if (e.key === 'ArrowDown') {
2679            e.preventDefault();
2680            selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
2681            updateSelection(options);
2682        } else if (e.key === 'ArrowUp') {
2683            e.preventDefault();
2684            selectedIndex = Math.max(selectedIndex - 1, -1);
2685            updateSelection(options);
2686        } else if (e.key === 'Enter') {
2687            e.preventDefault();
2688            if (selectedIndex >= 0 && options[selectedIndex]) {
2689                selectOption(options[selectedIndex].dataset.value);
2690            }
2691        } else if (e.key === 'Escape') {
2692            hideDropdown();
2693        }
2694    });
2695
2696    function updateSelection(options) {
2697        options.forEach((opt, idx) => {
2698            if (idx === selectedIndex) {
2699                opt.classList.add('selected');
2700                opt.scrollIntoView({ block: 'nearest' });
2701            } else {
2702                opt.classList.remove('selected');
2703            }
2704        });
2705    }
2706
2707    // Click on dropdown option
2708    dropdown.addEventListener('mousedown', function(e) {
2709        if (e.target.classList.contains('namespace-option')) {
2710            selectOption(e.target.dataset.value);
2711        }
2712    });
2713};
2714
2715// Legacy function - kept for compatibility, now handled by custom pickers
2716window.updateEndTimeOptions = function(calId) {
2717    updateEndTimeButtonState(calId);
2718};
2719
2720// ============================================================================
2721// CUSTOM TIME PICKER - Fast, lightweight time selection
2722// ============================================================================
2723
2724// Time data - generated once, reused for all pickers
2725window._calendarTimeData = null;
2726window.getTimeData = function() {
2727    if (window._calendarTimeData) return window._calendarTimeData;
2728
2729    const periods = [
2730        { name: 'Morning', hours: [6, 7, 8, 9, 10, 11] },
2731        { name: 'Afternoon', hours: [12, 13, 14, 15, 16, 17] },
2732        { name: 'Evening', hours: [18, 19, 20, 21, 22, 23] },
2733        { name: 'Night', hours: [0, 1, 2, 3, 4, 5] }
2734    ];
2735
2736    const data = [];
2737    periods.forEach(period => {
2738        const times = [];
2739        period.hours.forEach(hour => {
2740            for (let minute = 0; minute < 60; minute += 15) {
2741                const value = String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
2742                const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour);
2743                const ampm = hour < 12 ? 'AM' : 'PM';
2744                const display = displayHour + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
2745                const minutes = hour * 60 + minute;
2746                times.push({ value, display, minutes });
2747            }
2748        });
2749        data.push({ name: period.name, times });
2750    });
2751
2752    window._calendarTimeData = data;
2753    return data;
2754};
2755
2756// Format time value to display string
2757window.formatTimeDisplay = function(value) {
2758    if (!value) return '';
2759    const [hour, minute] = value.split(':').map(Number);
2760    const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour);
2761    const ampm = hour < 12 ? 'AM' : 'PM';
2762    return displayHour + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
2763};
2764
2765// Build dropdown HTML - called only when opening
2766window.buildTimeDropdown = function(calId, isEndTime, startTimeValue, isMultiDay) {
2767    const data = getTimeData();
2768    let html = '';
2769
2770    // Calculate start time minutes for filtering end time options
2771    let startMinutes = -1;
2772    if (isEndTime && startTimeValue && !isMultiDay) {
2773        const [h, m] = startTimeValue.split(':').map(Number);
2774        startMinutes = h * 60 + m;
2775    }
2776
2777    // Add "All day" / "Same as start" option
2778    const defaultText = isEndTime ? 'Same as start' : 'All day';
2779    html += '<div class="time-option" data-value="">' + defaultText + '</div>';
2780
2781    data.forEach(period => {
2782        html += '<div class="time-dropdown-section">';
2783        html += '<div class="time-dropdown-header">' + period.name + '</div>';
2784        period.times.forEach(time => {
2785            const disabled = (isEndTime && !isMultiDay && startMinutes >= 0 && time.minutes <= startMinutes);
2786            const disabledClass = disabled ? ' disabled' : '';
2787            html += '<div class="time-option' + disabledClass + '" data-value="' + time.value + '" data-minutes="' + time.minutes + '">' + time.display + '</div>';
2788        });
2789        html += '</div>';
2790    });
2791
2792    return html;
2793};
2794
2795// Open time dropdown
2796window.openTimeDropdown = function(calId, isEndTime) {
2797    const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId;
2798    const dropdownId = isEndTime ? 'end-time-dropdown-' + calId : 'time-dropdown-' + calId;
2799    const btn = document.getElementById(btnId);
2800    const dropdown = document.getElementById(dropdownId);
2801
2802    if (!btn || !dropdown) return;
2803
2804    // Close any other open dropdowns first
2805    document.querySelectorAll('.time-dropdown.open').forEach(d => {
2806        if (d.id !== dropdownId) {
2807            d.classList.remove('open');
2808            d.innerHTML = '';
2809        }
2810    });
2811    document.querySelectorAll('.custom-time-picker.open').forEach(b => {
2812        if (b.id !== btnId) b.classList.remove('open');
2813    });
2814
2815    // Toggle this dropdown
2816    if (dropdown.classList.contains('open')) {
2817        dropdown.classList.remove('open');
2818        btn.classList.remove('open');
2819        dropdown.innerHTML = '';
2820        return;
2821    }
2822
2823    // Get current state
2824    const startTimeInput = document.getElementById('event-time-' + calId);
2825    const startDateInput = document.getElementById('event-date-' + calId);
2826    const endDateInput = document.getElementById('event-end-date-' + calId);
2827
2828    const startTime = startTimeInput ? startTimeInput.value : '';
2829    const startDate = startDateInput ? startDateInput.value : '';
2830    const endDate = endDateInput ? endDateInput.value : '';
2831    const isMultiDay = endDate && endDate !== startDate;
2832
2833    // Build and show dropdown
2834    dropdown.innerHTML = buildTimeDropdown(calId, isEndTime, startTime, isMultiDay);
2835    dropdown.classList.add('open');
2836    btn.classList.add('open');
2837
2838    // Scroll to appropriate option
2839    const currentValue = isEndTime ?
2840        document.getElementById('event-end-time-' + calId).value :
2841        document.getElementById('event-time-' + calId).value;
2842
2843    if (currentValue) {
2844        // Scroll to selected option
2845        const selected = dropdown.querySelector('[data-value="' + currentValue + '"]');
2846        if (selected) {
2847            selected.classList.add('selected');
2848            selected.scrollIntoView({ block: 'center', behavior: 'instant' });
2849        }
2850    } else if (isEndTime && startTime) {
2851        // For end time with no selection, scroll to first available option after start time
2852        const firstAvailable = dropdown.querySelector('.time-option:not(.disabled):not([data-value=""])');
2853        if (firstAvailable) {
2854            firstAvailable.scrollIntoView({ block: 'center', behavior: 'instant' });
2855        }
2856    }
2857};
2858
2859// Select time option
2860window.selectTimeOption = function(calId, isEndTime, value) {
2861    const inputId = isEndTime ? 'event-end-time-' + calId : 'event-time-' + calId;
2862    const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId;
2863    const dropdownId = isEndTime ? 'end-time-dropdown-' + calId : 'time-dropdown-' + calId;
2864
2865    const input = document.getElementById(inputId);
2866    const btn = document.getElementById(btnId);
2867    const dropdown = document.getElementById(dropdownId);
2868
2869    if (input) {
2870        input.value = value;
2871    }
2872
2873    if (btn) {
2874        const display = btn.querySelector('.time-display');
2875        if (display) {
2876            if (value) {
2877                display.textContent = formatTimeDisplay(value);
2878            } else {
2879                display.textContent = isEndTime ? 'Same as start' : 'All day';
2880            }
2881        }
2882        btn.classList.remove('open');
2883    }
2884
2885    if (dropdown) {
2886        dropdown.classList.remove('open');
2887        dropdown.innerHTML = '';
2888    }
2889
2890    // If start time changed, update end time button state
2891    if (!isEndTime) {
2892        updateEndTimeButtonState(calId);
2893    }
2894};
2895
2896// Update end time button enabled/disabled state
2897window.updateEndTimeButtonState = function(calId) {
2898    const startTimeInput = document.getElementById('event-time-' + calId);
2899    const endTimeBtn = document.getElementById('end-time-picker-btn-' + calId);
2900    const endTimeInput = document.getElementById('event-end-time-' + calId);
2901
2902    if (!startTimeInput || !endTimeBtn) return;
2903
2904    const startTime = startTimeInput.value;
2905
2906    if (!startTime) {
2907        // All day - disable end time
2908        endTimeBtn.disabled = true;
2909        if (endTimeInput) endTimeInput.value = '';
2910        const display = endTimeBtn.querySelector('.time-display');
2911        if (display) display.textContent = 'Same as start';
2912    } else {
2913        endTimeBtn.disabled = false;
2914    }
2915};
2916
2917// Initialize custom time pickers for a dialog
2918window.initCustomTimePickers = function(calId) {
2919    const startBtn = document.getElementById('time-picker-btn-' + calId);
2920    const endBtn = document.getElementById('end-time-picker-btn-' + calId);
2921    const startDropdown = document.getElementById('time-dropdown-' + calId);
2922    const endDropdown = document.getElementById('end-time-dropdown-' + calId);
2923
2924    // Prevent re-initialization
2925    if (startBtn && startBtn.dataset.initialized) return;
2926
2927    if (startBtn) {
2928        startBtn.dataset.initialized = 'true';
2929        startBtn.addEventListener('click', function(e) {
2930            e.preventDefault();
2931            e.stopPropagation();
2932            openTimeDropdown(calId, false);
2933        });
2934    }
2935
2936    if (endBtn) {
2937        endBtn.addEventListener('click', function(e) {
2938            e.preventDefault();
2939            e.stopPropagation();
2940            if (!endBtn.disabled) {
2941                openTimeDropdown(calId, true);
2942            }
2943        });
2944    }
2945
2946    // Handle clicks on time options
2947    if (startDropdown) {
2948        startDropdown.addEventListener('click', function(e) {
2949            const option = e.target.closest('.time-option');
2950            if (option && !option.classList.contains('disabled')) {
2951                e.stopPropagation();
2952                selectTimeOption(calId, false, option.dataset.value);
2953            }
2954        });
2955    }
2956
2957    if (endDropdown) {
2958        endDropdown.addEventListener('click', function(e) {
2959            const option = e.target.closest('.time-option');
2960            if (option && !option.classList.contains('disabled')) {
2961                e.stopPropagation();
2962                selectTimeOption(calId, true, option.dataset.value);
2963            }
2964        });
2965    }
2966
2967    // Handle date changes - update end time options when dates change
2968    const startDateInput = document.getElementById('event-date-' + calId);
2969    const endDateInput = document.getElementById('event-end-date-' + calId);
2970
2971    if (startDateInput && !startDateInput.dataset.initialized) {
2972        startDateInput.dataset.initialized = 'true';
2973        startDateInput.addEventListener('change', function() {
2974            // Just close any open dropdowns - they'll rebuild with correct state when reopened
2975            const dropdown = document.getElementById('end-time-dropdown-' + calId);
2976            if (dropdown && dropdown.classList.contains('open')) {
2977                dropdown.classList.remove('open');
2978                dropdown.innerHTML = '';
2979            }
2980        });
2981    }
2982
2983    if (endDateInput && !endDateInput.dataset.initialized) {
2984        endDateInput.dataset.initialized = 'true';
2985        endDateInput.addEventListener('change', function() {
2986            const dropdown = document.getElementById('end-time-dropdown-' + calId);
2987            if (dropdown && dropdown.classList.contains('open')) {
2988                dropdown.classList.remove('open');
2989                dropdown.innerHTML = '';
2990            }
2991        });
2992    }
2993};
2994
2995// Close dropdowns when clicking outside
2996if (!window._calendarDropdownCloseInit) {
2997    window._calendarDropdownCloseInit = true;
2998    document.addEventListener('click', function(e) {
2999        // Don't close if clicking inside a picker button or dropdown
3000        if (e.target.closest('.custom-time-picker') || e.target.closest('.time-dropdown') ||
3001            e.target.closest('.custom-date-picker') || e.target.closest('.date-dropdown')) {
3002            return;
3003        }
3004
3005        // Close all open time dropdowns
3006        document.querySelectorAll('.time-dropdown.open').forEach(d => {
3007            d.classList.remove('open');
3008            d.innerHTML = '';
3009        });
3010        document.querySelectorAll('.custom-time-picker.open').forEach(b => {
3011            b.classList.remove('open');
3012        });
3013
3014        // Close all open date dropdowns
3015        document.querySelectorAll('.date-dropdown.open').forEach(d => {
3016            d.classList.remove('open');
3017            d.innerHTML = '';
3018        });
3019        document.querySelectorAll('.custom-date-picker.open').forEach(b => {
3020            b.classList.remove('open');
3021        });
3022    });
3023}
3024
3025// Set time picker value programmatically (for edit mode)
3026window.setTimePicker = function(calId, isEndTime, value) {
3027    const inputId = isEndTime ? 'event-end-time-' + calId : 'event-time-' + calId;
3028    const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId;
3029
3030    const input = document.getElementById(inputId);
3031    const btn = document.getElementById(btnId);
3032
3033    if (input) {
3034        input.value = value || '';
3035    }
3036
3037    if (btn) {
3038        const display = btn.querySelector('.time-display');
3039        if (display) {
3040            if (value) {
3041                display.textContent = formatTimeDisplay(value);
3042            } else {
3043                display.textContent = isEndTime ? 'Same as start' : 'All day';
3044            }
3045        }
3046
3047        // Update disabled state for end time
3048        if (isEndTime) {
3049            const startTimeInput = document.getElementById('event-time-' + calId);
3050            btn.disabled = !startTimeInput || !startTimeInput.value;
3051        }
3052    }
3053};
3054
3055// ============================================================================
3056// CUSTOM DATE PICKER - Fast, lightweight date selection
3057// ============================================================================
3058
3059// Format date for display
3060window.formatDateDisplay = function(dateStr) {
3061    if (!dateStr) return '';
3062    const date = new Date(dateStr + 'T00:00:00');
3063    return date.toLocaleDateString('en-US', {
3064        weekday: 'short',
3065        month: 'short',
3066        day: 'numeric',
3067        year: 'numeric'
3068    });
3069};
3070
3071// Build date picker calendar HTML
3072window.buildDateCalendar = function(calId, isEndDate, year, month, selectedDate, minDate) {
3073    const today = new Date();
3074    today.setHours(0, 0, 0, 0);
3075
3076    const firstDay = new Date(year, month, 1);
3077    const lastDay = new Date(year, month + 1, 0);
3078    const startDayOfWeek = firstDay.getDay();
3079    const daysInMonth = lastDay.getDate();
3080
3081    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
3082                        'July', 'August', 'September', 'October', 'November', 'December'];
3083
3084    let html = '<div class="date-picker-calendar">';
3085
3086    // Header with navigation
3087    html += '<div class="date-picker-header">';
3088    html += '<button type="button" class="date-picker-nav" data-action="prev">◀</button>';
3089    html += '<span class="date-picker-title">' + monthNames[month] + ' ' + year + '</span>';
3090    html += '<button type="button" class="date-picker-nav" data-action="next">▶</button>';
3091    html += '</div>';
3092
3093    // Weekday headers
3094    html += '<div class="date-picker-weekdays">';
3095    ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].forEach(d => {
3096        html += '<div class="date-picker-weekday">' + d + '</div>';
3097    });
3098    html += '</div>';
3099
3100    // Days grid
3101    html += '<div class="date-picker-days">';
3102
3103    // Previous month days
3104    const prevMonth = new Date(year, month, 0);
3105    const prevMonthDays = prevMonth.getDate();
3106    for (let i = startDayOfWeek - 1; i >= 0; i--) {
3107        const day = prevMonthDays - i;
3108        const dateStr = formatDateValue(year, month - 1, day);
3109        html += '<button type="button" class="date-picker-day other-month" data-date="' + dateStr + '">' + day + '</button>';
3110    }
3111
3112    // Current month days
3113    for (let day = 1; day <= daysInMonth; day++) {
3114        const dateStr = formatDateValue(year, month, day);
3115        const dateObj = new Date(year, month, day);
3116        dateObj.setHours(0, 0, 0, 0);
3117
3118        let classes = 'date-picker-day';
3119        if (dateObj.getTime() === today.getTime()) classes += ' today';
3120        if (dateStr === selectedDate) classes += ' selected';
3121
3122        // For end date, disable dates before start date
3123        if (isEndDate && minDate) {
3124            const minDateObj = new Date(minDate + 'T00:00:00');
3125            if (dateObj < minDateObj) classes += ' disabled';
3126        }
3127
3128        html += '<button type="button" class="' + classes + '" data-date="' + dateStr + '">' + day + '</button>';
3129    }
3130
3131    // Next month days to fill grid
3132    const totalCells = startDayOfWeek + daysInMonth;
3133    const remainingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
3134    for (let i = 1; i <= remainingCells; i++) {
3135        const dateStr = formatDateValue(year, month + 1, i);
3136        html += '<button type="button" class="date-picker-day other-month" data-date="' + dateStr + '">' + i + '</button>';
3137    }
3138
3139    html += '</div>';
3140
3141    // Clear button for end date
3142    if (isEndDate) {
3143        html += '<button type="button" class="date-picker-clear" data-action="clear">Clear End Date</button>';
3144    }
3145
3146    html += '</div>';
3147    return html;
3148};
3149
3150// Format date value as YYYY-MM-DD
3151window.formatDateValue = function(year, month, day) {
3152    // Handle month overflow
3153    const date = new Date(year, month, day);
3154    const y = date.getFullYear();
3155    const m = String(date.getMonth() + 1).padStart(2, '0');
3156    const d = String(date.getDate()).padStart(2, '0');
3157    return y + '-' + m + '-' + d;
3158};
3159
3160// Open date dropdown
3161window.openDateDropdown = function(calId, isEndDate) {
3162    const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId;
3163    const dropdownId = isEndDate ? 'end-date-dropdown-' + calId : 'date-dropdown-' + calId;
3164    const btn = document.getElementById(btnId);
3165    const dropdown = document.getElementById(dropdownId);
3166
3167    if (!btn || !dropdown) return;
3168
3169    // Close any other open dropdowns first
3170    document.querySelectorAll('.date-dropdown.open, .time-dropdown.open').forEach(d => {
3171        if (d.id !== dropdownId) {
3172            d.classList.remove('open');
3173            d.innerHTML = '';
3174        }
3175    });
3176    document.querySelectorAll('.custom-date-picker.open, .custom-time-picker.open').forEach(b => {
3177        if (b.id !== btnId) b.classList.remove('open');
3178    });
3179
3180    // Toggle this dropdown
3181    if (dropdown.classList.contains('open')) {
3182        dropdown.classList.remove('open');
3183        btn.classList.remove('open');
3184        dropdown.innerHTML = '';
3185        return;
3186    }
3187
3188    // Get current value and min date
3189    const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId;
3190    const input = document.getElementById(inputId);
3191    const selectedDate = input ? input.value : '';
3192
3193    let minDate = null;
3194    if (isEndDate) {
3195        const startInput = document.getElementById('event-date-' + calId);
3196        minDate = startInput ? startInput.value : null;
3197    }
3198
3199    // Determine which month to show
3200    let year, month;
3201    if (selectedDate) {
3202        // If there's a selected date, show that month
3203        const d = new Date(selectedDate + 'T00:00:00');
3204        year = d.getFullYear();
3205        month = d.getMonth();
3206    } else if (isEndDate && minDate) {
3207        // For end date with no value, start on the start date's month
3208        const d = new Date(minDate + 'T00:00:00');
3209        year = d.getFullYear();
3210        month = d.getMonth();
3211    } else {
3212        // Fallback to current month
3213        const now = new Date();
3214        year = now.getFullYear();
3215        month = now.getMonth();
3216    }
3217
3218    // Store current view state
3219    dropdown.dataset.year = year;
3220    dropdown.dataset.month = month;
3221    dropdown.dataset.isEnd = isEndDate ? '1' : '0';
3222    dropdown.dataset.calId = calId;
3223
3224    // Build and show
3225    dropdown.innerHTML = buildDateCalendar(calId, isEndDate, year, month, selectedDate, minDate);
3226    dropdown.classList.add('open');
3227    btn.classList.add('open');
3228};
3229
3230// Select date
3231window.selectDate = function(calId, isEndDate, dateStr) {
3232    const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId;
3233    const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId;
3234    const dropdownId = isEndDate ? 'end-date-dropdown-' + calId : 'date-dropdown-' + calId;
3235
3236    const input = document.getElementById(inputId);
3237    const btn = document.getElementById(btnId);
3238    const dropdown = document.getElementById(dropdownId);
3239
3240    if (input) {
3241        input.value = dateStr || '';
3242    }
3243
3244    if (btn) {
3245        const display = btn.querySelector('.date-display');
3246        if (display) {
3247            display.textContent = dateStr ? formatDateDisplay(dateStr) : (isEndDate ? 'Optional' : 'Select date');
3248        }
3249        btn.classList.remove('open');
3250    }
3251
3252    if (dropdown) {
3253        dropdown.classList.remove('open');
3254        dropdown.innerHTML = '';
3255    }
3256};
3257
3258// Navigate date picker month
3259window.navigateDatePicker = function(dropdown, direction) {
3260    let year = parseInt(dropdown.dataset.year);
3261    let month = parseInt(dropdown.dataset.month);
3262    const isEndDate = dropdown.dataset.isEnd === '1';
3263    const calId = dropdown.dataset.calId;
3264
3265    month += direction;
3266    if (month < 0) { month = 11; year--; }
3267    if (month > 11) { month = 0; year++; }
3268
3269    dropdown.dataset.year = year;
3270    dropdown.dataset.month = month;
3271
3272    const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId;
3273    const input = document.getElementById(inputId);
3274    const selectedDate = input ? input.value : '';
3275
3276    let minDate = null;
3277    if (isEndDate) {
3278        const startInput = document.getElementById('event-date-' + calId);
3279        minDate = startInput ? startInput.value : null;
3280    }
3281
3282    dropdown.innerHTML = buildDateCalendar(calId, isEndDate, year, month, selectedDate, minDate);
3283};
3284
3285// Initialize custom date pickers for a dialog
3286window.initCustomDatePickers = function(calId) {
3287    const startBtn = document.getElementById('date-picker-btn-' + calId);
3288    const endBtn = document.getElementById('end-date-picker-btn-' + calId);
3289    const startDropdown = document.getElementById('date-dropdown-' + calId);
3290    const endDropdown = document.getElementById('end-date-dropdown-' + calId);
3291
3292    // Prevent re-initialization
3293    if (startBtn && startBtn.dataset.initialized) return;
3294
3295    if (startBtn) {
3296        startBtn.dataset.initialized = 'true';
3297        startBtn.addEventListener('click', function(e) {
3298            e.preventDefault();
3299            e.stopPropagation();
3300            openDateDropdown(calId, false);
3301        });
3302    }
3303
3304    if (endBtn) {
3305        endBtn.addEventListener('click', function(e) {
3306            e.preventDefault();
3307            e.stopPropagation();
3308            openDateDropdown(calId, true);
3309        });
3310    }
3311
3312    // Handle clicks inside date dropdowns
3313    [startDropdown, endDropdown].forEach((dropdown, idx) => {
3314        if (!dropdown) return;
3315        const isEnd = idx === 1;
3316
3317        dropdown.addEventListener('click', function(e) {
3318            e.stopPropagation();
3319
3320            const nav = e.target.closest('.date-picker-nav');
3321            if (nav) {
3322                const direction = nav.dataset.action === 'prev' ? -1 : 1;
3323                navigateDatePicker(dropdown, direction);
3324                return;
3325            }
3326
3327            const clear = e.target.closest('.date-picker-clear');
3328            if (clear) {
3329                selectDate(calId, true, '');
3330                return;
3331            }
3332
3333            const day = e.target.closest('.date-picker-day');
3334            if (day && !day.classList.contains('disabled')) {
3335                selectDate(calId, isEnd, day.dataset.date);
3336            }
3337        });
3338    });
3339};
3340
3341// Set date picker value programmatically
3342window.setDatePicker = function(calId, isEndDate, value) {
3343    const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId;
3344    const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId;
3345
3346    const input = document.getElementById(inputId);
3347    const btn = document.getElementById(btnId);
3348
3349    if (input) {
3350        input.value = value || '';
3351    }
3352
3353    if (btn) {
3354        const display = btn.querySelector('.date-display');
3355        if (display) {
3356            display.textContent = value ? formatDateDisplay(value) : (isEndDate ? 'Optional' : 'Select date');
3357        }
3358    }
3359};
3360
3361// Check for time conflicts between events on the same date
3362window.checkTimeConflicts = function(events, currentEventId) {
3363    const conflicts = [];
3364
3365    // Group events by date
3366    const eventsByDate = {};
3367    for (const [date, dateEvents] of Object.entries(events)) {
3368        if (!Array.isArray(dateEvents)) continue;
3369
3370        dateEvents.forEach(evt => {
3371            if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event
3372
3373            if (!eventsByDate[date]) eventsByDate[date] = [];
3374            eventsByDate[date].push(evt);
3375        });
3376    }
3377
3378    // Check for overlaps on each date
3379    for (const [date, dateEvents] of Object.entries(eventsByDate)) {
3380        for (let i = 0; i < dateEvents.length; i++) {
3381            for (let j = i + 1; j < dateEvents.length; j++) {
3382                const evt1 = dateEvents[i];
3383                const evt2 = dateEvents[j];
3384
3385                if (eventsOverlap(evt1, evt2)) {
3386                    // Mark both events as conflicting
3387                    if (!evt1.hasConflict) evt1.hasConflict = true;
3388                    if (!evt2.hasConflict) evt2.hasConflict = true;
3389
3390                    // Store conflict info
3391                    if (!evt1.conflictsWith) evt1.conflictsWith = [];
3392                    if (!evt2.conflictsWith) evt2.conflictsWith = [];
3393
3394                    evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime});
3395                    evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime});
3396                }
3397            }
3398        }
3399    }
3400
3401    return events;
3402};
3403
3404// Check if two events overlap in time
3405function eventsOverlap(evt1, evt2) {
3406    if (!evt1.time || !evt2.time) return false; // All-day events don't conflict
3407
3408    const start1 = evt1.time;
3409    const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start
3410
3411    const start2 = evt2.time;
3412    const end2 = evt2.endTime || evt2.time;
3413
3414    // Convert to minutes for easier comparison
3415    const start1Mins = timeToMinutes(start1);
3416    const end1Mins = timeToMinutes(end1);
3417    const start2Mins = timeToMinutes(start2);
3418    const end2Mins = timeToMinutes(end2);
3419
3420    // Check for overlap
3421    // Events overlap if: start1 < end2 AND start2 < end1
3422    return start1Mins < end2Mins && start2Mins < end1Mins;
3423}
3424
3425// Convert HH:MM time to minutes since midnight
3426function timeToMinutes(timeStr) {
3427    const [hours, minutes] = timeStr.split(':').map(Number);
3428    return hours * 60 + minutes;
3429}
3430
3431// Format time range for display
3432window.formatTimeRange = function(startTime, endTime) {
3433    if (!startTime) return '';
3434
3435    const formatTime = (timeStr) => {
3436        const [hour24, minute] = timeStr.split(':').map(Number);
3437        const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24);
3438        const ampm = hour24 < 12 ? 'AM' : 'PM';
3439        return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
3440    };
3441
3442    if (!endTime || endTime === startTime) {
3443        return formatTime(startTime);
3444    }
3445
3446    return formatTime(startTime) + ' - ' + formatTime(endTime);
3447};
3448
3449// Track last known mouse position for tooltip positioning fallback
3450var _lastMouseX = 0, _lastMouseY = 0;
3451document.addEventListener('mousemove', function(e) {
3452    _lastMouseX = e.clientX;
3453    _lastMouseY = e.clientY;
3454});
3455
3456// Show custom conflict tooltip
3457window.showConflictTooltip = function(badgeElement) {
3458    // Remove any existing tooltip
3459    hideConflictTooltip();
3460
3461    // Get conflict data (base64-encoded JSON to avoid attribute quote issues)
3462    const conflictsRaw = badgeElement.getAttribute('data-conflicts');
3463    if (!conflictsRaw) return;
3464
3465    let conflicts;
3466    try {
3467        conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw))));
3468    } catch (e) {
3469        // Fallback: try parsing as plain JSON (for PHP-rendered badges)
3470        try {
3471            conflicts = JSON.parse(conflictsRaw);
3472        } catch (e2) {
3473            console.error('Failed to parse conflicts:', e2);
3474            return;
3475        }
3476    }
3477
3478    // Get theme from the calendar container via CSS variables
3479    // Try closest ancestor first, then fall back to any calendar on the page
3480    let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone');
3481    if (!containerEl) {
3482        // Badge might be inside a day popup (appended to body) - find any calendar container
3483        containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]');
3484    }
3485    const cs = containerEl ? getComputedStyle(containerEl) : null;
3486
3487    const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424';
3488    const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07';
3489    const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07';
3490    const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00';
3491    const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)';
3492
3493    // Create tooltip
3494    const tooltip = document.createElement('div');
3495    tooltip.id = 'conflict-tooltip';
3496    tooltip.className = 'conflict-tooltip';
3497
3498    // Apply theme styles
3499    tooltip.style.background = bg;
3500    tooltip.style.borderColor = border;
3501    tooltip.style.color = textPrimary;
3502    tooltip.style.boxShadow = '0 4px 12px ' + shadow;
3503
3504    // Build content with themed colors
3505    let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>';
3506    html += '<div class="conflict-tooltip-body">';
3507    conflicts.forEach(conflict => {
3508        html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>';
3509    });
3510    html += '</div>';
3511
3512    tooltip.innerHTML = html;
3513    document.body.appendChild(tooltip);
3514
3515    // Position tooltip
3516    const rect = badgeElement.getBoundingClientRect();
3517    const tooltipRect = tooltip.getBoundingClientRect();
3518
3519    // Position above the badge, centered
3520    let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
3521    let top = rect.top - tooltipRect.height - 8;
3522
3523    // Keep tooltip within viewport
3524    if (left < 10) left = 10;
3525    if (left + tooltipRect.width > window.innerWidth - 10) {
3526        left = window.innerWidth - tooltipRect.width - 10;
3527    }
3528    if (top < 10) {
3529        // If not enough room above, show below
3530        top = rect.bottom + 8;
3531    }
3532
3533    tooltip.style.left = left + 'px';
3534    tooltip.style.top = top + 'px';
3535    tooltip.style.opacity = '1';
3536};
3537
3538// Hide conflict tooltip
3539window.hideConflictTooltip = function() {
3540    const tooltip = document.getElementById('conflict-tooltip');
3541    if (tooltip) {
3542        tooltip.remove();
3543    }
3544};
3545
3546// Fuzzy search helper for event filtering - normalizes text for matching
3547function eventSearchNormalize(text) {
3548    if (typeof text !== 'string') {
3549        console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text);
3550        return '';
3551    }
3552    return text
3553        .toLowerCase()
3554        .trim()
3555        // Remove common punctuation that might differ
3556        .replace(/[''\u2018\u2019]/g, '')  // Remove apostrophes/quotes
3557        .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes
3558        .replace(/[-–—]/g, ' ')            // Dashes to spaces
3559        .replace(/[.,!?;:]/g, '')          // Remove punctuation
3560        .replace(/\s+/g, ' ')              // Normalize whitespace
3561        .trim();
3562}
3563
3564// Check if search term matches text for event filtering
3565function eventSearchMatch(text, searchTerm) {
3566    const normalizedText = eventSearchNormalize(text);
3567    const normalizedSearch = eventSearchNormalize(searchTerm);
3568
3569    // Direct match after normalization
3570    if (normalizedText.includes(normalizedSearch)) {
3571        return true;
3572    }
3573
3574    // Split search into words and check if all words are present
3575    const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0);
3576    if (searchWords.length > 1) {
3577        return searchWords.every(word => normalizedText.includes(word));
3578    }
3579
3580    return false;
3581}
3582
3583// Filter events by search term
3584window.filterEvents = function(calId, searchTerm) {
3585    const eventList = document.getElementById('eventlist-' + calId);
3586    const searchClear = document.getElementById('search-clear-' + calId);
3587    const searchMode = document.getElementById('search-mode-' + calId);
3588
3589    if (!eventList) return;
3590
3591    // Check if we're in "all dates" mode
3592    const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates');
3593
3594    // Show/hide clear button
3595    if (searchClear) {
3596        searchClear.style.display = searchTerm ? 'block' : 'none';
3597    }
3598
3599    searchTerm = searchTerm.trim();
3600
3601    // If all-dates mode and we have a search term, do AJAX search
3602    if (isAllDatesMode && searchTerm.length >= 2) {
3603        searchAllDates(calId, searchTerm);
3604        return;
3605    }
3606
3607    // If all-dates mode but search cleared, restore normal view
3608    if (isAllDatesMode && !searchTerm) {
3609        // Remove search results container if exists
3610        const resultsContainer = eventList.querySelector('.all-dates-results');
3611        if (resultsContainer) {
3612            resultsContainer.remove();
3613        }
3614        // Show normal event items
3615        eventList.querySelectorAll('.event-compact-item').forEach(item => {
3616            item.style.display = '';
3617        });
3618        // Show past events toggle if it exists
3619        const pastToggle = eventList.querySelector('.past-events-toggle');
3620        if (pastToggle) pastToggle.style.display = '';
3621    }
3622
3623    // Get all event items
3624    const eventItems = eventList.querySelectorAll('.event-compact-item');
3625    let visibleCount = 0;
3626    let hiddenPastCount = 0;
3627
3628    eventItems.forEach(item => {
3629        const title = item.querySelector('.event-title-compact');
3630        const description = item.querySelector('.event-desc-compact');
3631        const dateTime = item.querySelector('.event-date-time');
3632
3633        // Build searchable text
3634        let searchableText = '';
3635        if (title) searchableText += title.textContent + ' ';
3636        if (description) searchableText += description.textContent + ' ';
3637        if (dateTime) searchableText += dateTime.textContent + ' ';
3638
3639        // Check if matches search using fuzzy matching
3640        const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm);
3641
3642        if (matches) {
3643            item.style.display = '';
3644            visibleCount++;
3645        } else {
3646            item.style.display = 'none';
3647            // Check if this is a past event
3648            if (item.classList.contains('event-past') || item.classList.contains('event-completed')) {
3649                hiddenPastCount++;
3650            }
3651        }
3652    });
3653
3654    // Update past events toggle if it exists
3655    const pastToggle = eventList.querySelector('.past-events-toggle');
3656    const pastLabel = eventList.querySelector('.past-events-label');
3657    const pastContent = document.getElementById('past-events-' + calId);
3658
3659    if (pastToggle && pastLabel && pastContent) {
3660        const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])');
3661        const totalPastVisible = visiblePastEvents.length;
3662
3663        if (totalPastVisible > 0) {
3664            pastLabel.textContent = `Past Events (${totalPastVisible})`;
3665            pastToggle.style.display = '';
3666        } else {
3667            pastToggle.style.display = 'none';
3668        }
3669    }
3670
3671    // Show "no results" message if nothing visible (only for month mode, not all-dates mode)
3672    let noResultsMsg = eventList.querySelector('.no-search-results');
3673    if (visibleCount === 0 && searchTerm && !isAllDatesMode) {
3674        if (!noResultsMsg) {
3675            noResultsMsg = document.createElement('p');
3676            noResultsMsg.className = 'no-search-results no-events-msg';
3677            noResultsMsg.textContent = 'No events match your search';
3678            eventList.appendChild(noResultsMsg);
3679        }
3680        noResultsMsg.style.display = 'block';
3681    } else if (noResultsMsg) {
3682        noResultsMsg.style.display = 'none';
3683    }
3684};
3685
3686// Toggle search mode between "this month" and "all dates"
3687window.toggleSearchMode = function(calId, namespace) {
3688    const searchMode = document.getElementById('search-mode-' + calId);
3689    const searchInput = document.getElementById('event-search-' + calId);
3690
3691    if (!searchMode) return;
3692
3693    const isAllDates = searchMode.classList.toggle('all-dates');
3694
3695    // Update button icon and title
3696    if (isAllDates) {
3697        searchMode.innerHTML = '��';
3698        searchMode.title = 'Searching all dates';
3699        if (searchInput) {
3700            searchInput.placeholder = 'Search all dates...';
3701        }
3702    } else {
3703        searchMode.innerHTML = '��';
3704        searchMode.title = 'Search this month only';
3705        if (searchInput) {
3706            searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : '�� Search...';
3707        }
3708    }
3709
3710    // Re-run search with current term
3711    if (searchInput && searchInput.value) {
3712        filterEvents(calId, searchInput.value);
3713    } else {
3714        // Clear any all-dates results
3715        const eventList = document.getElementById('eventlist-' + calId);
3716        if (eventList) {
3717            const resultsContainer = eventList.querySelector('.all-dates-results');
3718            if (resultsContainer) {
3719                resultsContainer.remove();
3720            }
3721            // Show normal event items
3722            eventList.querySelectorAll('.event-compact-item').forEach(item => {
3723                item.style.display = '';
3724            });
3725            const pastToggle = eventList.querySelector('.past-events-toggle');
3726            if (pastToggle) pastToggle.style.display = '';
3727        }
3728    }
3729};
3730
3731// Search all dates via AJAX
3732window.searchAllDates = function(calId, searchTerm) {
3733    const eventList = document.getElementById('eventlist-' + calId);
3734    if (!eventList) return;
3735
3736    // Get namespace from container
3737    const container = document.getElementById(calId);
3738    const namespace = container ? (container.dataset.namespace || '') : '';
3739    const exclude = container ? (container.dataset.exclude || '') : '';
3740
3741    // Hide normal event items
3742    eventList.querySelectorAll('.event-compact-item').forEach(item => {
3743        item.style.display = 'none';
3744    });
3745    const pastToggle = eventList.querySelector('.past-events-toggle');
3746    if (pastToggle) pastToggle.style.display = 'none';
3747
3748    // Remove old results container
3749    let resultsContainer = eventList.querySelector('.all-dates-results');
3750    if (resultsContainer) {
3751        resultsContainer.remove();
3752    }
3753
3754    // Create new results container
3755    resultsContainer = document.createElement('div');
3756    resultsContainer.className = 'all-dates-results';
3757    resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);">�� Searching all dates...</p>';
3758    eventList.appendChild(resultsContainer);
3759
3760    // Make AJAX request
3761    const params = new URLSearchParams({
3762        call: 'plugin_calendar',
3763        action: 'search_all',
3764        search: searchTerm,
3765        namespace: namespace,
3766        exclude: exclude,
3767        _: new Date().getTime()
3768    });
3769
3770    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
3771        method: 'POST',
3772        headers: {
3773            'Content-Type': 'application/x-www-form-urlencoded'
3774        },
3775        body: params.toString()
3776    })
3777    .then(r => r.json())
3778    .then(data => {
3779        if (data.success && data.results) {
3780            if (data.results.length === 0) {
3781                resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim); font-style:italic;">No events found matching "' + escapeHtml(searchTerm) + '"</p>';
3782            } else {
3783                let html = '<div class="all-dates-header" style="padding:4px 8px; background:var(--cell-today-bg, #e8f5e9); font-size:10px; font-weight:600; color:var(--text-bright, #00cc07); border-bottom:1px solid var(--border-color);">Found ' + data.results.length + ' event(s) across all dates</div>';
3784
3785                data.results.forEach(event => {
3786                    const dateObj = new Date(event.date + 'T00:00:00');
3787                    const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
3788                    const color = event.color || 'var(--text-bright, #00cc07)';
3789
3790                    html += '<div class="event-compact-item search-result-item" style="display:flex; border-bottom:1px solid var(--border-color, #e0e0e0); padding:6px 8px; gap:6px; cursor:pointer;" onclick="jumpToDate(\'' + calId + '\', \'' + event.date + '\', \'' + namespace + '\')">';
3791                    html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>';
3792                    html += '<div style="flex:1; min-width:0;">';
3793                    html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>';
3794                    html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay;
3795                    if (event.time) {
3796                        html += ' • ' + formatTimeRange(event.time, event.endTime);
3797                    }
3798                    html += '</div>';
3799                    if (event.namespace) {
3800                        html += '<span style="font-size:9px; background:var(--text-bright); color:var(--background-site); padding:1px 4px; border-radius:2px; margin-top:2px; display:inline-block;">' + escapeHtml(event.namespace) + '</span>';
3801                    }
3802                    html += '</div></div>';
3803                });
3804
3805                resultsContainer.innerHTML = html;
3806            }
3807        } else {
3808            resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>';
3809        }
3810    })
3811    .catch(err => {
3812        console.error('Search error:', err);
3813        resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>';
3814    });
3815};
3816
3817// Jump to a specific date (used by search results)
3818window.jumpToDate = function(calId, date, namespace) {
3819    const parts = date.split('-');
3820    const year = parseInt(parts[0]);
3821    const month = parseInt(parts[1]);
3822
3823    // Get container to check current month
3824    const container = document.getElementById(calId);
3825    const currentYear = container ? parseInt(container.dataset.year) : year;
3826    const currentMonth = container ? parseInt(container.dataset.month) : month;
3827
3828    // Get search elements
3829    const searchInput = document.getElementById('event-search-' + calId);
3830    const searchMode = document.getElementById('search-mode-' + calId);
3831    const searchClear = document.getElementById('search-clear-' + calId);
3832    const eventList = document.getElementById('eventlist-' + calId);
3833
3834    // Remove the all-dates results container
3835    if (eventList) {
3836        const resultsContainer = eventList.querySelector('.all-dates-results');
3837        if (resultsContainer) {
3838            resultsContainer.remove();
3839        }
3840        // Show normal event items again
3841        eventList.querySelectorAll('.event-compact-item').forEach(item => {
3842            item.style.display = '';
3843        });
3844        const pastToggle = eventList.querySelector('.past-events-toggle');
3845        if (pastToggle) pastToggle.style.display = '';
3846
3847        // Hide any no-results message
3848        const noResults = eventList.querySelector('.no-search-results');
3849        if (noResults) noResults.style.display = 'none';
3850    }
3851
3852    // Clear search input
3853    if (searchInput) {
3854        searchInput.value = '';
3855    }
3856
3857    // Hide clear button
3858    if (searchClear) {
3859        searchClear.style.display = 'none';
3860    }
3861
3862    // Switch back to month mode
3863    if (searchMode && searchMode.classList.contains('all-dates')) {
3864        searchMode.classList.remove('all-dates');
3865        searchMode.innerHTML = '��';
3866        searchMode.title = 'Search this month only';
3867        if (searchInput) {
3868            searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : '�� Search...';
3869        }
3870    }
3871
3872    // Check if we need to navigate to a different month
3873    if (year !== currentYear || month !== currentMonth) {
3874        // Navigate to the target month, then show popup
3875        navCalendar(calId, year, month, namespace);
3876
3877        // After navigation completes, show the day popup
3878        setTimeout(() => {
3879            showDayPopup(calId, date, namespace);
3880        }, 400);
3881    } else {
3882        // Same month - just show the popup
3883        showDayPopup(calId, date, namespace);
3884    }
3885};
3886
3887// Clear event search
3888window.clearEventSearch = function(calId) {
3889    const searchInput = document.getElementById('event-search-' + calId);
3890    if (searchInput) {
3891        searchInput.value = '';
3892        filterEvents(calId, '');
3893        searchInput.focus();
3894    }
3895};
3896
3897// ============================================
3898// PINK THEME - GLOWING PARTICLE EFFECTS
3899// ============================================
3900
3901// Create glowing pink particle effects for pink theme
3902(function() {
3903    let pinkThemeActive = false;
3904    let trailTimer = null;
3905    let pixelTimer = null;
3906
3907    // Check if pink theme is active
3908    function checkPinkTheme() {
3909        const pinkCalendars = document.querySelectorAll('.calendar-theme-pink');
3910        pinkThemeActive = pinkCalendars.length > 0;
3911        return pinkThemeActive;
3912    }
3913
3914    // Create trail particle
3915    function createTrailParticle(clientX, clientY) {
3916        if (!pinkThemeActive) return;
3917
3918        const trail = document.createElement('div');
3919        trail.className = 'pink-cursor-trail';
3920        trail.style.left = clientX + 'px';
3921        trail.style.top = clientY + 'px';
3922        trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards';
3923
3924        document.body.appendChild(trail);
3925
3926        setTimeout(function() {
3927            trail.remove();
3928        }, 500);
3929    }
3930
3931    // Create pixel sparkles
3932    function createPixelSparkles(clientX, clientY) {
3933        if (!pinkThemeActive || pixelTimer) return;
3934
3935        const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels
3936
3937        for (let i = 0; i < pixelCount; i++) {
3938            const pixel = document.createElement('div');
3939            pixel.className = 'pink-pixel-sparkle';
3940
3941            // Random offset from cursor
3942            const offsetX = (Math.random() - 0.5) * 30;
3943            const offsetY = (Math.random() - 0.5) * 30;
3944
3945            pixel.style.left = (clientX + offsetX) + 'px';
3946            pixel.style.top = (clientY + offsetY) + 'px';
3947
3948            // Random color - bright neon pinks and whites
3949            const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
3950            const color = colors[Math.floor(Math.random() * colors.length)];
3951            pixel.style.background = color;
3952            pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff';
3953
3954            // Random animation
3955            if (Math.random() > 0.5) {
3956                pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards';
3957            } else {
3958                pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards';
3959            }
3960
3961            document.body.appendChild(pixel);
3962
3963            setTimeout(function() {
3964                pixel.remove();
3965            }, 800);
3966        }
3967
3968        pixelTimer = setTimeout(function() {
3969            pixelTimer = null;
3970        }, 40);
3971    }
3972
3973    // Create explosion
3974    function createExplosion(clientX, clientY) {
3975        if (!pinkThemeActive) return;
3976
3977        const particleCount = 25;
3978        const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff'];
3979
3980        // Add hearts to explosion (8-12 hearts)
3981        const heartCount = 8 + Math.floor(Math.random() * 5);
3982        for (let i = 0; i < heartCount; i++) {
3983            const heart = document.createElement('div');
3984            heart.textContent = '��';
3985            heart.style.position = 'fixed';
3986            heart.style.left = clientX + 'px';
3987            heart.style.top = clientY + 'px';
3988            heart.style.pointerEvents = 'none';
3989            heart.style.zIndex = '9999999';
3990            heart.style.fontSize = (12 + Math.random() * 16) + 'px';
3991
3992            // Random direction
3993            const angle = Math.random() * Math.PI * 2;
3994            const velocity = 60 + Math.random() * 80;
3995            const tx = Math.cos(angle) * velocity;
3996            const ty = Math.sin(angle) * velocity;
3997
3998            heart.style.setProperty('--tx', tx + 'px');
3999            heart.style.setProperty('--ty', ty + 'px');
4000
4001            const duration = 0.8 + Math.random() * 0.4;
4002            heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
4003
4004            document.body.appendChild(heart);
4005
4006            setTimeout(function() {
4007                heart.remove();
4008            }, duration * 1000);
4009        }
4010
4011        // Main explosion particles
4012        for (let i = 0; i < particleCount; i++) {
4013            const particle = document.createElement('div');
4014            particle.className = 'pink-particle';
4015
4016            const color = colors[Math.floor(Math.random() * colors.length)];
4017            particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)';
4018            particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color;
4019
4020            particle.style.left = clientX + 'px';
4021            particle.style.top = clientY + 'px';
4022
4023            const angle = (Math.PI * 2 * i) / particleCount;
4024            const velocity = 50 + Math.random() * 100;
4025            const tx = Math.cos(angle) * velocity;
4026            const ty = Math.sin(angle) * velocity;
4027
4028            particle.style.setProperty('--tx', tx + 'px');
4029            particle.style.setProperty('--ty', ty + 'px');
4030
4031            const size = 4 + Math.random() * 6;
4032            particle.style.width = size + 'px';
4033            particle.style.height = size + 'px';
4034
4035            const duration = 0.6 + Math.random() * 0.4;
4036            particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
4037
4038            document.body.appendChild(particle);
4039
4040            setTimeout(function() {
4041                particle.remove();
4042            }, duration * 1000);
4043        }
4044
4045        // Pixel sparkles
4046        const pixelSparkleCount = 40;
4047
4048        for (let i = 0; i < pixelSparkleCount; i++) {
4049            const pixel = document.createElement('div');
4050            pixel.className = 'pink-pixel-sparkle';
4051
4052            const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
4053            const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)];
4054            pixel.style.background = pixelColor;
4055            pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff';
4056
4057            const angle = Math.random() * Math.PI * 2;
4058            const distance = 30 + Math.random() * 80;
4059            const offsetX = Math.cos(angle) * distance;
4060            const offsetY = Math.sin(angle) * distance;
4061
4062            pixel.style.left = clientX + 'px';
4063            pixel.style.top = clientY + 'px';
4064            pixel.style.setProperty('--tx', offsetX + 'px');
4065            pixel.style.setProperty('--ty', offsetY + 'px');
4066
4067            const pixelSize = 1 + Math.random() * 2;
4068            pixel.style.width = pixelSize + 'px';
4069            pixel.style.height = pixelSize + 'px';
4070
4071            const duration = 0.4 + Math.random() * 0.4;
4072            if (Math.random() > 0.5) {
4073                pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards';
4074            } else {
4075                pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
4076            }
4077
4078            document.body.appendChild(pixel);
4079
4080            setTimeout(function() {
4081                pixel.remove();
4082            }, duration * 1000);
4083        }
4084
4085        // Flash
4086        const flash = document.createElement('div');
4087        flash.style.position = 'fixed';
4088        flash.style.left = clientX + 'px';
4089        flash.style.top = clientY + 'px';
4090        flash.style.width = '40px';
4091        flash.style.height = '40px';
4092        flash.style.borderRadius = '50%';
4093        flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)';
4094        flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4';
4095        flash.style.pointerEvents = 'none';
4096        flash.style.zIndex = '9999999';  // Above everything including dialogs
4097        flash.style.transform = 'translate(-50%, -50%)';
4098        flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards';
4099
4100        document.body.appendChild(flash);
4101
4102        setTimeout(function() {
4103            flash.remove();
4104        }, 300);
4105    }
4106
4107    function initPinkParticles() {
4108        if (!checkPinkTheme()) return;
4109
4110        // Use capture phase to catch events before stopPropagation
4111        document.addEventListener('mousemove', function(e) {
4112            if (!pinkThemeActive) return;
4113
4114            createTrailParticle(e.clientX, e.clientY);
4115            createPixelSparkles(e.clientX, e.clientY);
4116        }, true); // Capture phase!
4117
4118        // Throttle main trail
4119        document.addEventListener('mousemove', function(e) {
4120            if (!pinkThemeActive || trailTimer) return;
4121
4122            trailTimer = setTimeout(function() {
4123                trailTimer = null;
4124            }, 30);
4125        }, true); // Capture phase!
4126
4127        // Click explosion - use capture phase
4128        document.addEventListener('click', function(e) {
4129            if (!pinkThemeActive) return;
4130
4131            createExplosion(e.clientX, e.clientY);
4132        }, true); // Capture phase!
4133    }
4134
4135    // Initialize on load
4136    if (document.readyState === 'loading') {
4137        document.addEventListener('DOMContentLoaded', initPinkParticles);
4138    } else {
4139        initPinkParticles();
4140    }
4141
4142    // Re-check theme if calendar is dynamically added
4143    // Must wait for document.body to exist
4144    function setupMutationObserver() {
4145        if (typeof MutationObserver !== 'undefined' && document.body) {
4146            const observer = new MutationObserver(function(mutations) {
4147                mutations.forEach(function(mutation) {
4148                    if (mutation.addedNodes.length > 0) {
4149                        mutation.addedNodes.forEach(function(node) {
4150                            if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) {
4151                                checkPinkTheme();
4152                                initPinkParticles();
4153                            }
4154                        });
4155                    }
4156                });
4157            });
4158
4159            observer.observe(document.body, {
4160                childList: true,
4161                subtree: true
4162            });
4163        }
4164    }
4165
4166    // Setup observer when DOM is ready
4167    if (document.readyState === 'loading') {
4168        document.addEventListener('DOMContentLoaded', setupMutationObserver);
4169    } else {
4170        setupMutationObserver();
4171    }
4172})();
4173
4174// Mobile touch event delegation for edit/delete buttons
4175// This ensures buttons work on mobile where onclick may not fire reliably
4176(function() {
4177    function handleButtonTouch(e) {
4178        const btn = e.target.closest('.event-edit-btn, .event-delete-btn, .event-action-btn');
4179        if (!btn) return;
4180
4181        // Prevent double-firing with onclick
4182        e.preventDefault();
4183
4184        // Small delay to show visual feedback
4185        setTimeout(function() {
4186            btn.click();
4187        }, 10);
4188    }
4189
4190    // Use touchend for more reliable mobile handling
4191    document.addEventListener('touchend', handleButtonTouch, { passive: false });
4192})();
4193
4194// Static calendar navigation
4195window.navStaticCalendar = function(calId, direction) {
4196    const container = document.getElementById(calId);
4197    if (!container) return;
4198
4199    let year = parseInt(container.dataset.year);
4200    let month = parseInt(container.dataset.month);
4201    const namespace = container.dataset.namespace || '';
4202
4203    // Calculate new month
4204    month += direction;
4205    if (month < 1) {
4206        month = 12;
4207        year--;
4208    } else if (month > 12) {
4209        month = 1;
4210        year++;
4211    }
4212
4213    // Fetch new calendar content via AJAX
4214    const params = new URLSearchParams({
4215        call: 'plugin_calendar',
4216        action: 'get_static_calendar',
4217        year: year,
4218        month: month,
4219        namespace: namespace
4220    });
4221
4222    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
4223        method: 'POST',
4224        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
4225        body: params.toString()
4226    })
4227    .then(r => r.json())
4228    .then(data => {
4229        if (data.success && data.html) {
4230            // Replace the container content
4231            container.outerHTML = data.html;
4232        }
4233    })
4234    .catch(err => console.error('Static calendar navigation error:', err));
4235};
4236
4237// Print static calendar - opens print dialog with only calendar content
4238window.printStaticCalendar = function(calId) {
4239    const container = document.getElementById(calId);
4240    if (!container) return;
4241
4242    // Get the print view content
4243    const printView = container.querySelector('.static-print-view');
4244    if (!printView) return;
4245
4246    // Create a new window for printing
4247    const printWindow = window.open('', '_blank', 'width=800,height=600');
4248
4249    // Build print document with inline margins for maximum compatibility
4250    const printContent = `
4251<!DOCTYPE html>
4252<html>
4253<head>
4254    <title>Calendar - ${container.dataset.year}-${String(container.dataset.month).padStart(2, '0')}</title>
4255    <style>
4256        * { margin: 0; padding: 0; box-sizing: border-box; }
4257        body { font-family: Arial, sans-serif; color: #333; background: white; }
4258        table { border-collapse: collapse; font-size: 12px; }
4259        th { background: #2c3e50; color: white; padding: 8px; text-align: left; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
4260        td { padding: 6px 8px; border-bottom: 1px solid #ccc; vertical-align: top; }
4261        tr:nth-child(even) { background: #f0f0f0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
4262        .static-itinerary-important { background: #fffde7 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
4263        .static-itinerary-date { font-weight: bold; white-space: nowrap; }
4264        .static-itinerary-time { white-space: nowrap; color: #555; }
4265        .static-itinerary-title { font-weight: 500; }
4266        .static-itinerary-desc { color: #555; font-size: 11px; }
4267        thead { display: table-header-group; }
4268        tr { page-break-inside: avoid; }
4269        h2 { font-size: 16px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid #333; }
4270        p { font-size: 12px; color: #666; margin-bottom: 15px; }
4271    </style>
4272</head>
4273<body style="margin: 0; padding: 0;">
4274    <div style="padding: 50px 60px; margin: 0 auto; max-width: 800px;">
4275        ${printView.innerHTML}
4276    </div>
4277    <script>
4278        window.onload = function() {
4279            setTimeout(function() {
4280                window.print();
4281            }, 300);
4282            window.onafterprint = function() {
4283                window.close();
4284            };
4285        };
4286    </script>
4287</body>
4288</html>`;
4289
4290    printWindow.document.write(printContent);
4291    printWindow.document.close();
4292};
4293
4294// ============================================================================
4295// ACCESSIBILITY - Screen reader announcements
4296// ============================================================================
4297
4298// Create ARIA live region for announcements
4299(function() {
4300    function createAriaLive() {
4301        if (document.getElementById('calendar-aria-live')) return;
4302        if (!document.body) {
4303            // Body not ready yet (script loaded in <head>), wait for it
4304            document.addEventListener('DOMContentLoaded', createAriaLive);
4305            return;
4306        }
4307        var ariaLive = document.createElement('div');
4308        ariaLive.id = 'calendar-aria-live';
4309        ariaLive.setAttribute('role', 'status');
4310        ariaLive.setAttribute('aria-live', 'polite');
4311        ariaLive.setAttribute('aria-atomic', 'true');
4312        ariaLive.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
4313        document.body.appendChild(ariaLive);
4314    }
4315    createAriaLive();
4316})();
4317
4318// Announce message to screen readers
4319window.announceToScreenReader = function(message) {
4320    var ariaLive = document.getElementById('calendar-aria-live');
4321    if (ariaLive) {
4322        ariaLive.textContent = '';
4323        // Small delay to ensure screen reader picks up the change
4324        setTimeout(function() {
4325            ariaLive.textContent = message;
4326        }, 100);
4327    }
4328};
4329
4330// End of calendar plugin JavaScript
4331