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