xref: /plugin/calendar/calendar-main.js (revision 96df7d3e9a825dddf459ab1ee6077a9886837f17)
1/**
2 * DokuWiki Compact Calendar Plugin JavaScript
3 * Loaded independently to avoid DokuWiki concatenation issues
4 */
5
6// Ensure DOKU_BASE is defined - check multiple sources
7if (typeof DOKU_BASE === 'undefined') {
8    // Try to get from global jsinfo object (DokuWiki standard)
9    if (typeof window.jsinfo !== 'undefined' && window.jsinfo.dokubase) {
10        window.DOKU_BASE = window.jsinfo.dokubase;
11    } else {
12        // Fallback: extract from script source path
13        var scripts = document.getElementsByTagName('script');
14        var pluginScriptPath = null;
15        for (var i = 0; i < scripts.length; i++) {
16            if (scripts[i].src && scripts[i].src.indexOf('calendar/script.js') !== -1) {
17                pluginScriptPath = scripts[i].src;
18                break;
19            }
20        }
21
22        if (pluginScriptPath) {
23            // Extract base path from: .../lib/plugins/calendar/script.js
24            var match = pluginScriptPath.match(/^(.*?)lib\/plugins\//);
25            window.DOKU_BASE = match ? match[1] : '/';
26        } else {
27            // Last resort: use root
28            window.DOKU_BASE = '/';
29        }
30    }
31}
32
33// Shorthand for convenience
34var DOKU_BASE = window.DOKU_BASE || '/';
35
36// Helper: propagate CSS variables from a calendar container to a target element
37// This is needed for dialogs/popups that use position:fixed (they inherit CSS vars
38// from DOM parents per spec, but some DokuWiki templates break this inheritance)
39function propagateThemeVars(calId, targetEl) {
40    if (!targetEl) return;
41    // Find the calendar container (could be cal_, panel_, sidebar-widget-, etc.)
42    const container = document.getElementById(calId)
43        || document.getElementById('sidebar-widget-' + calId)
44        || document.querySelector('[id$="' + calId + '"]');
45    if (!container) return;
46    const cs = getComputedStyle(container);
47    const vars = [
48        '--background-site', '--background-alt', '--background-header',
49        '--text-primary', '--text-bright', '--text-dim',
50        '--border-color', '--border-main',
51        '--cell-bg', '--cell-today-bg', '--grid-bg',
52        '--shadow-color', '--header-border', '--header-shadow',
53        '--btn-text'
54    ];
55    vars.forEach(v => {
56        const val = cs.getPropertyValue(v).trim();
57        if (val) targetEl.style.setProperty(v, val);
58    });
59}
60
61// Filter calendar by namespace
62window.filterCalendarByNamespace = function(calId, namespace) {
63    // Get current year and month from calendar
64    const container = document.getElementById(calId);
65    if (!container) {
66        console.error('Calendar container not found:', calId);
67        return;
68    }
69
70    const year = parseInt(container.dataset.year) || new Date().getFullYear();
71    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
72
73    // Reload calendar with the filtered namespace
74    navCalendar(calId, year, month, namespace);
75};
76
77// Navigate to different month
78window.navCalendar = function(calId, year, month, namespace) {
79
80    const params = new URLSearchParams({
81        call: 'plugin_calendar',
82        action: 'load_month',
83        year: year,
84        month: month,
85        namespace: namespace,
86        _: new Date().getTime() // Cache buster
87    });
88
89    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
90        method: 'POST',
91        headers: {
92            'Content-Type': 'application/x-www-form-urlencoded',
93            'Cache-Control': 'no-cache, no-store, must-revalidate',
94            'Pragma': 'no-cache'
95        },
96        body: params.toString()
97    })
98    .then(r => r.json())
99    .then(data => {
100        if (data.success) {
101            rebuildCalendar(calId, data.year, data.month, data.events, namespace);
102        } else {
103            console.error('Failed to load month:', data.error);
104        }
105    })
106    .catch(err => {
107        console.error('Error loading month:', err);
108    });
109};
110
111// Jump to current month
112window.jumpToToday = function(calId, namespace) {
113    const today = new Date();
114    const year = today.getFullYear();
115    const month = today.getMonth() + 1; // JavaScript months are 0-indexed
116    navCalendar(calId, year, month, namespace);
117};
118
119// Jump to today for event panel
120window.jumpTodayPanel = function(calId, namespace) {
121    const today = new Date();
122    const year = today.getFullYear();
123    const month = today.getMonth() + 1;
124    navEventPanel(calId, year, month, namespace);
125};
126
127// Open month picker dialog
128window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) {
129
130    const overlay = document.getElementById('month-picker-overlay-' + calId);
131
132    const monthSelect = document.getElementById('month-picker-month-' + calId);
133
134    const yearSelect = document.getElementById('month-picker-year-' + calId);
135
136    if (!overlay) {
137        console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId);
138        return;
139    }
140
141    if (!monthSelect || !yearSelect) {
142        console.error('Select elements not found!');
143        return;
144    }
145
146    // Set current values
147    monthSelect.value = currentMonth;
148    yearSelect.value = currentYear;
149
150    // Show overlay
151    overlay.style.display = 'flex';
152};
153
154// Open month picker dialog for event panel
155window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) {
156    openMonthPicker(calId, currentYear, currentMonth, namespace);
157};
158
159// Close month picker dialog
160window.closeMonthPicker = function(calId) {
161    const overlay = document.getElementById('month-picker-overlay-' + calId);
162    overlay.style.display = 'none';
163};
164
165// Jump to selected month
166window.jumpToSelectedMonth = function(calId, namespace) {
167    const monthSelect = document.getElementById('month-picker-month-' + calId);
168    const yearSelect = document.getElementById('month-picker-year-' + calId);
169
170    const month = parseInt(monthSelect.value);
171    const year = parseInt(yearSelect.value);
172
173    closeMonthPicker(calId);
174
175    // Check if this is a calendar or event panel
176    const container = document.getElementById(calId);
177    if (container && container.classList.contains('event-panel-standalone')) {
178        navEventPanel(calId, year, month, namespace);
179    } else {
180        navCalendar(calId, year, month, namespace);
181    }
182};
183
184// Rebuild calendar grid after navigation
185window.rebuildCalendar = function(calId, year, month, events, namespace) {
186
187    const container = document.getElementById(calId);
188    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
189                       'July', 'August', 'September', 'October', 'November', 'December'];
190
191    // Get theme data from container
192    const theme = container.dataset.theme || 'matrix';
193    let themeStyles = {};
194    try {
195        themeStyles = JSON.parse(container.dataset.themeStyles || '{}');
196    } catch (e) {
197        console.error('Failed to parse theme styles:', e);
198        themeStyles = {};
199    }
200
201    // Preserve original namespace if not yet set
202    if (!container.dataset.originalNamespace) {
203        container.setAttribute('data-original-namespace', namespace || '');
204    }
205
206    // Update container data attributes for current month/year
207    container.setAttribute('data-year', year);
208    container.setAttribute('data-month', month);
209
210    // Update embedded events data
211    let eventsDataEl = document.getElementById('events-data-' + calId);
212    if (eventsDataEl) {
213        eventsDataEl.textContent = JSON.stringify(events);
214    } else {
215        eventsDataEl = document.createElement('script');
216        eventsDataEl.type = 'application/json';
217        eventsDataEl.id = 'events-data-' + calId;
218        eventsDataEl.textContent = JSON.stringify(events);
219        container.appendChild(eventsDataEl);
220    }
221
222    // Update header
223    const header = container.querySelector('.calendar-compact-header h3');
224    header.textContent = monthNames[month - 1] + ' ' + year;
225
226    // Update or create namespace filter indicator
227    let filterIndicator = container.querySelector('.calendar-namespace-filter');
228    const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' &&
229                            namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1;
230
231    if (shouldShowFilter) {
232        // Show/update filter indicator
233        if (!filterIndicator) {
234            // Create filter indicator if it doesn't exist
235            const headerDiv = container.querySelector('.calendar-compact-header');
236            if (headerDiv) {
237                filterIndicator = document.createElement('div');
238                filterIndicator.className = 'calendar-namespace-filter';
239                filterIndicator.id = 'namespace-filter-' + calId;
240                headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling);
241            }
242        }
243
244        if (filterIndicator) {
245            filterIndicator.innerHTML =
246                '<span class="namespace-filter-label">Filtering:</span>' +
247                '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' +
248                '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>';
249            filterIndicator.style.display = 'flex';
250        }
251    } else {
252        // Hide filter indicator
253        if (filterIndicator) {
254            filterIndicator.style.display = 'none';
255        }
256    }
257
258    // Update container's namespace attribute
259    container.setAttribute('data-namespace', namespace || '');
260
261    // Update nav buttons
262    let prevMonth = month - 1;
263    let prevYear = year;
264    if (prevMonth < 1) {
265        prevMonth = 12;
266        prevYear--;
267    }
268
269    let nextMonth = month + 1;
270    let nextYear = year;
271    if (nextMonth > 12) {
272        nextMonth = 1;
273        nextYear++;
274    }
275
276    const navBtns = container.querySelectorAll('.cal-nav-btn');
277    navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
278    navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
279
280    // Rebuild calendar grid
281    const tbody = container.querySelector('.calendar-compact-grid tbody');
282    const firstDay = new Date(year, month - 1, 1);
283    const daysInMonth = new Date(year, month, 0).getDate();
284    const dayOfWeek = firstDay.getDay();
285
286    // Calculate month boundaries
287    const monthStart = new Date(year, month - 1, 1);
288    const monthEnd = new Date(year, month - 1, daysInMonth);
289
290    // Build a map of all events with their date ranges
291    const eventRanges = {};
292    for (const [dateKey, dayEvents] of Object.entries(events)) {
293        // Defensive check: ensure dayEvents is an array
294        if (!Array.isArray(dayEvents)) {
295            console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents);
296            continue;
297        }
298
299        // Only process events that could possibly overlap with this month/year
300        const dateYear = parseInt(dateKey.split('-')[0]);
301
302        // Skip events from completely different years (unless they're very long multi-day events)
303        if (Math.abs(dateYear - year) > 1) {
304            continue;
305        }
306
307        for (const evt of dayEvents) {
308            const startDate = dateKey;
309            const endDate = evt.endDate || dateKey;
310
311            // Check if event overlaps with current month
312            const eventStart = new Date(startDate + 'T00:00:00');
313            const eventEnd = new Date(endDate + 'T00:00:00');
314
315            // Skip if event doesn't overlap with current month
316            if (eventEnd < monthStart || eventStart > monthEnd) {
317                continue;
318            }
319
320            // Create entry for each day the event spans
321            const start = new Date(startDate + 'T00:00:00');
322            const end = new Date(endDate + 'T00:00:00');
323            const current = new Date(start);
324
325            while (current <= end) {
326                const currentKey = current.toISOString().split('T')[0];
327
328                // Check if this date is in current month
329                const currentDate = new Date(currentKey + 'T00:00:00');
330                if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) {
331                    if (!eventRanges[currentKey]) {
332                        eventRanges[currentKey] = [];
333                    }
334
335                    // Add event with span information
336                    const eventCopy = {...evt};
337                    eventCopy._span_start = startDate;
338                    eventCopy._span_end = endDate;
339                    eventCopy._is_first_day = (currentKey === startDate);
340                    eventCopy._is_last_day = (currentKey === endDate);
341                    eventCopy._original_date = dateKey;
342
343                    // Check if event continues from previous month or to next month
344                    eventCopy._continues_from_prev = (eventStart < monthStart);
345                    eventCopy._continues_to_next = (eventEnd > monthEnd);
346
347                    eventRanges[currentKey].push(eventCopy);
348                }
349
350                current.setDate(current.getDate() + 1);
351            }
352        }
353    }
354
355    let html = '';
356    let currentDay = 1;
357    const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7);
358
359    for (let row = 0; row < rowCount; row++) {
360        html += '<tr>';
361        for (let col = 0; col < 7; col++) {
362            if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) {
363                html += `<td class="cal-empty"></td>`;
364            } else {
365                const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`;
366
367                // Get today's date in local timezone
368                const todayObj = new Date();
369                const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`;
370
371                const isToday = dateKey === today;
372                const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0;
373
374                let classes = 'cal-day';
375                if (isToday) classes += ' cal-today';
376                if (hasEvents) classes += ' cal-has-events';
377
378                const dayNumClass = isToday ? 'day-num day-num-today' : 'day-num';
379
380                html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`;
381                html += `<span class="${dayNumClass}">${currentDay}</span>`;
382
383                if (hasEvents) {
384                    // Sort events by time (no time first, then by time)
385                    const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => {
386                        const timeA = a.time || '';
387                        const timeB = b.time || '';
388                        if (!timeA && timeB) return -1;
389                        if (timeA && !timeB) return 1;
390                        if (!timeA && !timeB) return 0;
391                        return timeA.localeCompare(timeB);
392                    });
393
394                    // Get important namespaces
395                    let importantNamespaces = ['important'];
396                    if (container.dataset.importantNamespaces) {
397                        try {
398                            importantNamespaces = JSON.parse(container.dataset.importantNamespaces);
399                        } catch (e) {}
400                    }
401
402                    // Show colored stacked bars for each event
403                    html += '<div class="event-indicators">';
404                    for (const evt of sortedEvents) {
405                        const eventId = evt.id || '';
406                        const eventColor = evt.color || '#3498db';
407                        const eventTitle = evt.title || 'Event';
408                        const eventTime = evt.time || '';
409                        const originalDate = evt._original_date || dateKey;
410                        const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true;
411                        const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true;
412
413                        // Check if important namespace
414                        let evtNs = evt.namespace || evt._namespace || '';
415                        let isImportant = false;
416                        for (const impNs of importantNamespaces) {
417                            if (evtNs === impNs || evtNs.startsWith(impNs + ':')) {
418                                isImportant = true;
419                                break;
420                            }
421                        }
422
423                        let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed';
424                        if (!isFirstDay) barClass += ' event-bar-continues';
425                        if (!isLastDay) barClass += ' event-bar-continuing';
426                        if (isImportant) {
427                            barClass += ' event-bar-important';
428                            if (isFirstDay) {
429                                barClass += ' event-bar-has-star';
430                            }
431                        }
432
433                        html += `<span class="event-bar ${barClass}" `;
434                        html += `style="background: ${eventColor};" `;
435                        html += `title="${isImportant ? '⭐ ' : ''}${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `;
436                        html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');">`;
437                        html += '</span>';
438                    }
439                    html += '</div>';
440                }
441
442                html += '</td>';
443                currentDay++;
444            }
445        }
446        html += '</tr>';
447    }
448
449    tbody.innerHTML = html;
450
451    // Update Today button with current namespace
452    const todayBtn = container.querySelector('.cal-today-btn');
453    if (todayBtn) {
454        todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`);
455    }
456
457    // Update month picker with current namespace
458    const monthPicker = container.querySelector('.calendar-month-picker');
459    if (monthPicker) {
460        monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`);
461    }
462
463    // Rebuild event list - server already filtered to current month
464    const eventList = container.querySelector('.event-list-compact');
465    eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
466
467    // Auto-scroll to first future event (past events will be above viewport)
468    setTimeout(() => {
469        const firstFuture = eventList.querySelector('[data-first-future="true"]');
470        if (firstFuture) {
471            firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' });
472        }
473    }, 100);
474
475    // Update title
476    const title = container.querySelector('#eventlist-title-' + calId);
477    title.textContent = 'Events';
478};
479
480// Render event list from data
481window.renderEventListFromData = function(events, calId, namespace, year, month) {
482    if (!events || Object.keys(events).length === 0) {
483        return '<p class="no-events-msg">No events this month</p>';
484    }
485
486    // Get theme data from container
487    const container = document.getElementById(calId);
488    let themeStyles = {};
489    if (container && container.dataset.themeStyles) {
490        try {
491            themeStyles = JSON.parse(container.dataset.themeStyles);
492        } catch (e) {
493            console.error('Failed to parse theme styles in renderEventListFromData:', e);
494        }
495    }
496
497    // Check for time conflicts
498    events = checkTimeConflicts(events, null);
499
500    let pastHtml = '';
501    let futureHtml = '';
502    let pastCount = 0;
503
504    const sortedDates = Object.keys(events).sort();
505    const today = new Date();
506    today.setHours(0, 0, 0, 0);
507    const todayStr = today.toISOString().split('T')[0];
508
509    // Helper function to check if event is past (with 15-minute grace period)
510    const isEventPast = function(dateKey, time) {
511        // If event is on a past date, it's definitely past
512        if (dateKey < todayStr) {
513            return true;
514        }
515
516        // If event is on a future date, it's definitely not past
517        if (dateKey > todayStr) {
518            return false;
519        }
520
521        // Event is today - check time with grace period
522        if (time && time.trim() !== '') {
523            try {
524                const now = new Date();
525                const eventDateTime = new Date(dateKey + 'T' + time);
526
527                // Add 15-minute grace period
528                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
529
530                // Event is past if current time > event time + 15 minutes
531                return now > gracePeriodEnd;
532            } catch (e) {
533                // If time parsing fails, treat as future
534                return false;
535            }
536        }
537
538        // No time specified for today's event, treat as future
539        return false;
540    };
541
542    // Filter events to only current month if year/month provided
543    const monthStart = year && month ? new Date(year, month - 1, 1) : null;
544    const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null;
545
546    for (const dateKey of sortedDates) {
547        // Skip events not in current month if filtering
548        if (monthStart && monthEnd) {
549            const eventDate = new Date(dateKey + 'T00:00:00');
550
551            if (eventDate < monthStart || eventDate > monthEnd) {
552                continue;
553            }
554        }
555
556        // Sort events within this day by time (all-day events at top)
557        const dayEvents = events[dateKey];
558        dayEvents.sort((a, b) => {
559            const timeA = a.time && a.time.trim() !== '' ? a.time : null;
560            const timeB = b.time && b.time.trim() !== '' ? b.time : null;
561
562            // All-day events (no time) go to the TOP
563            if (timeA === null && timeB !== null) return -1; // A before B
564            if (timeA !== null && timeB === null) return 1;  // A after B
565            if (timeA === null && timeB === null) return 0;  // Both all-day, equal
566
567            // Both have times, sort chronologically
568            return timeA.localeCompare(timeB);
569        });
570
571        for (const event of dayEvents) {
572            const isTask = event.isTask || false;
573            const completed = event.completed || false;
574
575            // Use helper function to determine if event is past (with grace period)
576            const isPast = isEventPast(dateKey, event.time);
577            const isPastDue = isPast && isTask && !completed;
578
579            // Determine if this goes in past section
580            const isPastOrCompleted = (isPast && (!isTask || completed)) || completed;
581
582            const eventHtml = renderEventItem(event, dateKey, calId, namespace);
583
584            if (isPastOrCompleted) {
585                pastCount++;
586                pastHtml += eventHtml;
587            } else {
588                futureHtml += eventHtml;
589            }
590        }
591    }
592
593    let html = '';
594
595    // Add collapsible past events section if any exist
596    if (pastCount > 0) {
597        html += '<div class="past-events-section">';
598        html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">';
599        html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> ';
600        html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>';
601        html += '</div>';
602        html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">';
603        html += pastHtml;
604        html += '</div>';
605        html += '</div>';
606    } else {
607    }
608
609    // Add future events
610    html += futureHtml;
611
612
613    if (!html) {
614        return '<p class="no-events-msg">No events this month</p>';
615    }
616
617    return html;
618};
619
620// Show day popup with events when clicking a date
621window.showDayPopup = function(calId, date, namespace) {
622    // Get events for this calendar
623    const eventsDataEl = document.getElementById('events-data-' + calId);
624    let events = {};
625
626    if (eventsDataEl) {
627        try {
628            events = JSON.parse(eventsDataEl.textContent);
629        } catch (e) {
630            console.error('Failed to parse events data:', e);
631        }
632    }
633
634    const dayEvents = events[date] || [];
635
636    // Check for conflicts on this day
637    const dayEventsObj = {[date]: dayEvents};
638    const checkedEvents = checkTimeConflicts(dayEventsObj, null);
639    const dayEventsWithConflicts = checkedEvents[date] || dayEvents;
640
641    // Sort events: all-day at top, then chronological by time
642    dayEventsWithConflicts.sort((a, b) => {
643        const timeA = a.time && a.time.trim() !== '' ? a.time : null;
644        const timeB = b.time && b.time.trim() !== '' ? b.time : null;
645
646        // All-day events (no time) go to the TOP
647        if (timeA === null && timeB !== null) return -1; // A before B
648        if (timeA !== null && timeB === null) return 1;  // A after B
649        if (timeA === null && timeB === null) return 0;  // Both all-day, equal
650
651        // Both have times, sort chronologically
652        return timeA.localeCompare(timeB);
653    });
654
655    const dateObj = new Date(date + 'T00:00:00');
656    const displayDate = dateObj.toLocaleDateString('en-US', {
657        weekday: 'long',
658        month: 'long',
659        day: 'numeric',
660        year: 'numeric'
661    });
662
663    // Create popup
664    let popup = document.getElementById('day-popup-' + calId);
665    if (!popup) {
666        popup = document.createElement('div');
667        popup.id = 'day-popup-' + calId;
668        popup.className = 'day-popup';
669        document.body.appendChild(popup);
670    }
671
672    // Get theme styles
673    const container = document.getElementById(calId);
674    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
675    const theme = container ? container.dataset.theme : 'matrix';
676
677    let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>';
678    html += '<div class="day-popup-content">';
679    html += '<div class="day-popup-header">';
680    html += '<h4>' + displayDate + '</h4>';
681    html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>';
682    html += '</div>';
683
684    html += '<div class="day-popup-body">';
685
686    if (dayEventsWithConflicts.length === 0) {
687        html += '<p class="no-events-msg">No events on this day</p>';
688    } else {
689        html += '<div class="popup-events-list">';
690        dayEventsWithConflicts.forEach(event => {
691            const color = event.color || '#3498db';
692
693            // Use individual event namespace if available (for multi-namespace support)
694            const eventNamespace = event._namespace !== undefined ? event._namespace : namespace;
695
696            // Check if this is a continuation (event started before this date)
697            const originalStartDate = event.originalStartDate || event._dateKey || date;
698            const isContinuation = originalStartDate < date;
699
700            // Convert to 12-hour format and handle time ranges
701            let displayTime = '';
702            if (event.time) {
703                displayTime = formatTimeRange(event.time, event.endTime);
704            }
705
706            // Multi-day indicator
707            let multiDay = '';
708            if (event.endDate && event.endDate !== date) {
709                const endObj = new Date(event.endDate + 'T00:00:00');
710                multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
711                    month: 'short',
712                    day: 'numeric'
713                });
714            }
715
716            // Continuation message
717            if (isContinuation) {
718                const startObj = new Date(originalStartDate + 'T00:00:00');
719                const startDisplay = startObj.toLocaleDateString('en-US', {
720                    weekday: 'short',
721                    month: 'short',
722                    day: 'numeric'
723                });
724                html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>';
725            }
726
727            html += '<div class="popup-event-item">';
728            html += '<div class="event-color-bar" style="background: ' + color + ';"></div>';
729            html += '<div class="popup-event-content">';
730
731            // Single line with title, time, date range, namespace, and actions
732            html += '<div class="popup-event-main-row">';
733            html += '<div class="popup-event-info-inline">';
734            html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>';
735            if (displayTime) {
736                html += '<span class="popup-event-time">�� ' + displayTime + '</span>';
737            }
738            if (multiDay) {
739                html += '<span class="popup-event-multiday">' + multiDay + '</span>';
740            }
741            if (eventNamespace) {
742                html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>';
743            }
744
745            // Add conflict warning badge if event has conflicts
746            if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
747                // Build conflict list for tooltip
748                let conflictList = [];
749                event.conflictsWith.forEach(conflict => {
750                    let conflictText = conflict.title;
751                    if (conflict.time) {
752                        conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
753                    }
754                    conflictList.push(conflictText);
755                });
756
757                html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
758            }
759
760            html += '</div>';
761            html += '<div class="popup-event-actions">';
762            html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>';
763            html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">��️</button>';
764            html += '</div>';
765            html += '</div>';
766
767            // Description on separate line if present
768            if (event.description) {
769                html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>';
770            }
771
772            html += '</div></div>';
773        });
774        html += '</div>';
775    }
776
777    html += '</div>';
778
779    html += '<div class="day-popup-footer">';
780    html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>';
781    html += '</div>';
782
783    html += '</div>';
784
785    popup.innerHTML = html;
786    popup.style.display = 'flex';
787
788    // Propagate CSS vars from calendar container to popup (popup is outside container in DOM)
789    if (container) {
790        propagateThemeVars(calId, popup.querySelector('.day-popup-content'));
791    }
792
793    // Make popup draggable by header
794    const popupContent = popup.querySelector('.day-popup-content');
795    const popupHeader = popup.querySelector('.day-popup-header');
796
797    if (popupContent && popupHeader) {
798        // Reset position to center
799        popupContent.style.position = 'relative';
800        popupContent.style.left = '0';
801        popupContent.style.top = '0';
802
803        // Store drag state on the element itself
804        popupHeader._isDragging = false;
805
806        popupHeader.onmousedown = function(e) {
807            // Ignore if clicking the close button
808            if (e.target.classList.contains('popup-close')) return;
809
810            popupHeader._isDragging = true;
811            popupHeader._dragStartX = e.clientX;
812            popupHeader._dragStartY = e.clientY;
813
814            const rect = popupContent.getBoundingClientRect();
815            const parentRect = popup.getBoundingClientRect();
816            popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2);
817            popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2);
818
819            popupContent.style.transition = 'none';
820            e.preventDefault();
821        };
822
823        popup.onmousemove = function(e) {
824            if (!popupHeader._isDragging) return;
825
826            const deltaX = e.clientX - popupHeader._dragStartX;
827            const deltaY = e.clientY - popupHeader._dragStartY;
828
829            popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px';
830            popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px';
831        };
832
833        popup.onmouseup = function() {
834            if (popupHeader._isDragging) {
835                popupHeader._isDragging = false;
836                popupContent.style.transition = '';
837            }
838        };
839
840        popup.onmouseleave = function() {
841            if (popupHeader._isDragging) {
842                popupHeader._isDragging = false;
843                popupContent.style.transition = '';
844            }
845        };
846    }
847};
848
849// Close day popup
850window.closeDayPopup = function(calId) {
851    const popup = document.getElementById('day-popup-' + calId);
852    if (popup) {
853        popup.style.display = 'none';
854    }
855};
856
857// Show events for a specific day (for event list panel)
858window.showDayEvents = function(calId, date, namespace) {
859    const params = new URLSearchParams({
860        call: 'plugin_calendar',
861        action: 'load_month',
862        year: date.split('-')[0],
863        month: parseInt(date.split('-')[1]),
864        namespace: namespace,
865        _: new Date().getTime() // Cache buster
866    });
867
868    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
869        method: 'POST',
870        headers: {
871            'Content-Type': 'application/x-www-form-urlencoded',
872            'Cache-Control': 'no-cache, no-store, must-revalidate',
873            'Pragma': 'no-cache'
874        },
875        body: params.toString()
876    })
877    .then(r => r.json())
878    .then(data => {
879        if (data.success) {
880            const eventList = document.getElementById('eventlist-' + calId);
881            const events = data.events;
882            const title = document.getElementById('eventlist-title-' + calId);
883
884            const dateObj = new Date(date + 'T00:00:00');
885            const displayDate = dateObj.toLocaleDateString('en-US', {
886                weekday: 'short',
887                month: 'short',
888                day: 'numeric'
889            });
890
891            title.textContent = 'Events - ' + displayDate;
892
893            // Filter events for this day
894            const dayEvents = events[date] || [];
895
896            if (dayEvents.length === 0) {
897                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>';
898            } else {
899                let html = '';
900                dayEvents.forEach(event => {
901                    html += renderEventItem(event, date, calId, namespace);
902                });
903                eventList.innerHTML = html;
904            }
905        }
906    })
907    .catch(err => console.error('Error:', err));
908};
909
910// Render a single event item
911window.renderEventItem = function(event, date, calId, namespace) {
912    // Get theme data from container
913    const container = document.getElementById(calId);
914    let themeStyles = {};
915    let importantNamespaces = ['important']; // default
916    if (container && container.dataset.themeStyles) {
917        try {
918            themeStyles = JSON.parse(container.dataset.themeStyles);
919        } catch (e) {
920            console.error('Failed to parse theme styles:', e);
921        }
922    }
923    // Get important namespaces from container data attribute
924    if (container && container.dataset.importantNamespaces) {
925        try {
926            importantNamespaces = JSON.parse(container.dataset.importantNamespaces);
927        } catch (e) {
928            importantNamespaces = ['important'];
929        }
930    }
931
932    // Check if this event is in the past or today (with 15-minute grace period)
933    const today = new Date();
934    today.setHours(0, 0, 0, 0);
935    const todayStr = today.toISOString().split('T')[0];
936    const eventDate = new Date(date + 'T00:00:00');
937
938    // Helper to determine if event is past with grace period
939    let isPast;
940    if (date < todayStr) {
941        isPast = true; // Past date
942    } else if (date > todayStr) {
943        isPast = false; // Future date
944    } else {
945        // Today - check time with grace period
946        if (event.time && event.time.trim() !== '') {
947            try {
948                const now = new Date();
949                const eventDateTime = new Date(date + 'T' + event.time);
950                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
951                isPast = now > gracePeriodEnd;
952            } catch (e) {
953                isPast = false;
954            }
955        } else {
956            isPast = false; // No time, treat as future
957        }
958    }
959
960    const isToday = eventDate.getTime() === today.getTime();
961
962    // Check if this is an important namespace event
963    let eventNamespace = event.namespace || '';
964    if (!eventNamespace && event._namespace !== undefined) {
965        eventNamespace = event._namespace;
966    }
967    let isImportantNs = false;
968    if (eventNamespace) {
969        for (const impNs of importantNamespaces) {
970            if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) {
971                isImportantNs = true;
972                break;
973            }
974        }
975    }
976
977    // Format date display with day of week
978    const displayDateKey = event.originalStartDate || date;
979    const dateObj = new Date(displayDateKey + 'T00:00:00');
980    const displayDate = dateObj.toLocaleDateString('en-US', {
981        weekday: 'short',
982        month: 'short',
983        day: 'numeric'
984    });
985
986    // Convert to 12-hour format and handle time ranges
987    let displayTime = '';
988    if (event.time) {
989        displayTime = formatTimeRange(event.time, event.endTime);
990    }
991
992    // Multi-day indicator
993    let multiDay = '';
994    if (event.endDate && event.endDate !== displayDateKey) {
995        const endObj = new Date(event.endDate + 'T00:00:00');
996        multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
997            weekday: 'short',
998            month: 'short',
999            day: 'numeric'
1000        });
1001    }
1002
1003    const completedClass = event.completed ? ' event-completed' : '';
1004    const isTask = event.isTask || false;
1005    const completed = event.completed || false;
1006    const isPastDue = isPast && isTask && !completed;
1007    const pastClass = (isPast && !isPastDue) ? ' event-past' : '';
1008    const pastDueClass = isPastDue ? ' event-pastdue' : '';
1009    const importantClass = isImportantNs ? ' event-important' : '';
1010    const color = event.color || '#3498db';
1011
1012    // Only inline style needed: border-left-color for event color indicator
1013    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)' : '') + '">';
1014
1015    html += '<div class="event-info">';
1016    html += '<div class="event-title-row">';
1017    // Add star for important namespace events
1018    if (isImportantNs) {
1019        html += '<span class="event-important-star" title="Important">⭐</span> ';
1020    }
1021    html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>';
1022    html += '</div>';
1023
1024    // Show meta and description for non-past events AND past due tasks
1025    if (!isPast || isPastDue) {
1026        html += '<div class="event-meta-compact">';
1027        html += '<span class="event-date-time">' + displayDate + multiDay;
1028        if (displayTime) {
1029            html += ' • ' + displayTime;
1030        }
1031        // Add PAST DUE or TODAY badge
1032        if (isPastDue) {
1033            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>';
1034        } else if (isToday) {
1035            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>';
1036        }
1037        // Add namespace badge
1038        if (eventNamespace) {
1039            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>';
1040        }
1041        // Add conflict warning if event has time conflicts
1042        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
1043            let conflictList = [];
1044            event.conflictsWith.forEach(conflict => {
1045                let conflictText = conflict.title;
1046                if (conflict.time) {
1047                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
1048                }
1049                conflictList.push(conflictText);
1050            });
1051
1052            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
1053        }
1054        html += '</span>';
1055        html += '</div>';
1056
1057        if (event.description) {
1058            html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>';
1059        }
1060    } else {
1061        // For past events (not past due), store data in hidden divs for expand/collapse
1062        html += '<div class="event-meta-compact" style="display: none;">';
1063        html += '<span class="event-date-time">' + displayDate + multiDay;
1064        if (displayTime) {
1065            html += ' • ' + displayTime;
1066        }
1067        // Add namespace badge for past events too
1068        let eventNamespace = event.namespace || '';
1069        if (!eventNamespace && event._namespace !== undefined) {
1070            eventNamespace = event._namespace;
1071        }
1072        if (eventNamespace) {
1073            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>';
1074        }
1075        // Add conflict warning for past events too
1076        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
1077            let conflictList = [];
1078            event.conflictsWith.forEach(conflict => {
1079                let conflictText = conflict.title;
1080                if (conflict.time) {
1081                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
1082                }
1083                conflictList.push(conflictText);
1084            });
1085
1086            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
1087        }
1088        html += '</span>';
1089        html += '</div>';
1090
1091        if (event.description) {
1092            html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>';
1093        }
1094    }
1095
1096    html += '</div>'; // event-info
1097
1098    // Use stored namespace from event, fallback to _namespace, then passed namespace
1099    let buttonNamespace = event.namespace || '';
1100    if (!buttonNamespace && event._namespace !== undefined) {
1101        buttonNamespace = event._namespace;
1102    }
1103    if (!buttonNamespace) {
1104        buttonNamespace = namespace;
1105    }
1106
1107    html += '<div class="event-actions-compact">';
1108    html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">��️</button>';
1109    html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>';
1110    html += '</div>';
1111
1112    // Checkbox for tasks - ON THE FAR RIGHT
1113    if (isTask) {
1114        const checked = completed ? 'checked' : '';
1115        html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">';
1116    }
1117
1118    html += '</div>';
1119
1120    return html;
1121};
1122
1123// Render description with rich content support
1124window.renderDescription = function(description) {
1125    if (!description) return '';
1126
1127    // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping)
1128    // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00
1129
1130    let rendered = description;
1131    const tokens = [];
1132    let tokenIndex = 0;
1133
1134    // Convert DokuWiki image syntax {{image.jpg}} to tokens
1135    rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) {
1136        imagePath = imagePath.trim();
1137        alt = alt ? alt.trim() : '';
1138
1139        let imageHtml;
1140        // Handle external URLs
1141        if (imagePath.match(/^https?:\/\//)) {
1142            imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1143        } else {
1144            // Handle internal DokuWiki images
1145            const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath);
1146            imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1147        }
1148
1149        const token = '\x00TOKEN' + tokenIndex + '\x00';
1150        tokens[tokenIndex] = imageHtml;
1151        tokenIndex++;
1152        return token;
1153    });
1154
1155    // Convert DokuWiki link syntax [[link|text]] to tokens
1156    rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) {
1157        link = link.trim();
1158        text = text ? text.trim() : link;
1159
1160        let linkHtml;
1161        // Handle external URLs
1162        if (link.match(/^https?:\/\//)) {
1163            linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1164        } else {
1165            // Handle internal DokuWiki links with section anchors
1166            const hashIndex = link.indexOf('#');
1167            let pagePart = link;
1168            let sectionPart = '';
1169
1170            if (hashIndex !== -1) {
1171                pagePart = link.substring(0, hashIndex);
1172                sectionPart = link.substring(hashIndex); // Includes the #
1173            }
1174
1175            const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart;
1176            linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>';
1177        }
1178
1179        const token = '\x00TOKEN' + tokenIndex + '\x00';
1180        tokens[tokenIndex] = linkHtml;
1181        tokenIndex++;
1182        return token;
1183    });
1184
1185    // Convert markdown-style links [text](url) to tokens
1186    rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
1187        text = text.trim();
1188        url = url.trim();
1189
1190        let linkHtml;
1191        if (url.match(/^https?:\/\//)) {
1192            linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1193        } else {
1194            linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>';
1195        }
1196
1197        const token = '\x00TOKEN' + tokenIndex + '\x00';
1198        tokens[tokenIndex] = linkHtml;
1199        tokenIndex++;
1200        return token;
1201    });
1202
1203    // Convert plain URLs to tokens
1204    rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) {
1205        const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>';
1206        const token = '\x00TOKEN' + tokenIndex + '\x00';
1207        tokens[tokenIndex] = linkHtml;
1208        tokenIndex++;
1209        return token;
1210    });
1211
1212    // NOW escape the remaining text (tokens are protected with null bytes)
1213    rendered = escapeHtml(rendered);
1214
1215    // Convert newlines to <br>
1216    rendered = rendered.replace(/\n/g, '<br>');
1217
1218    // DokuWiki text formatting (on escaped text)
1219    // Bold: **text** or __text__
1220    rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1221    rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>');
1222
1223    // Italic: //text//
1224    rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>');
1225
1226    // Strikethrough: <del>text</del>
1227    rendered = rendered.replace(/&lt;del&gt;(.+?)&lt;\/del&gt;/g, '<del>$1</del>');
1228
1229    // Monospace: ''text''
1230    rendered = rendered.replace(/&#39;&#39;(.+?)&#39;&#39;/g, '<code>$1</code>');
1231
1232    // Subscript: <sub>text</sub>
1233    rendered = rendered.replace(/&lt;sub&gt;(.+?)&lt;\/sub&gt;/g, '<sub>$1</sub>');
1234
1235    // Superscript: <sup>text</sup>
1236    rendered = rendered.replace(/&lt;sup&gt;(.+?)&lt;\/sup&gt;/g, '<sup>$1</sup>');
1237
1238    // Restore tokens (replace with actual HTML)
1239    for (let i = 0; i < tokens.length; i++) {
1240        const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g');
1241        rendered = rendered.replace(tokenPattern, tokens[i]);
1242    }
1243
1244    return rendered;
1245}
1246
1247// Open add event dialog
1248window.openAddEvent = function(calId, namespace, date) {
1249    const dialog = document.getElementById('dialog-' + calId);
1250    const form = document.getElementById('eventform-' + calId);
1251    const title = document.getElementById('dialog-title-' + calId);
1252    const dateField = document.getElementById('event-date-' + calId);
1253
1254    if (!dateField) {
1255        console.error('Date field not found! ID: event-date-' + calId);
1256        return;
1257    }
1258
1259    // Check if there's a filtered namespace active (only for regular calendars)
1260    const calendar = document.getElementById(calId);
1261    const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null;
1262
1263    // Use filtered namespace if available, otherwise use the passed namespace
1264    const effectiveNamespace = filteredNamespace || namespace;
1265
1266
1267    // Reset form
1268    form.reset();
1269    document.getElementById('event-id-' + calId).value = '';
1270
1271    // Store the effective namespace in a hidden field or data attribute
1272    form.dataset.effectiveNamespace = effectiveNamespace;
1273
1274    // Set namespace dropdown to effective namespace
1275    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1276    if (namespaceSelect) {
1277        if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) {
1278            // Set to specific namespace if not wildcard or multi-namespace
1279            namespaceSelect.value = effectiveNamespace;
1280        } else {
1281            // Default to empty (default namespace) for wildcard/multi views
1282            namespaceSelect.value = '';
1283        }
1284    }
1285
1286    // Clear event namespace from previous edits
1287    delete form.dataset.eventNamespace;
1288
1289    // Set date - use local date, not UTC
1290    let defaultDate = date;
1291    if (!defaultDate) {
1292        // Get the currently displayed month from the calendar container
1293        const container = document.getElementById(calId);
1294        const displayedYear = parseInt(container.getAttribute('data-year'));
1295        const displayedMonth = parseInt(container.getAttribute('data-month'));
1296
1297
1298        if (displayedYear && displayedMonth) {
1299            // Use first day of the displayed month
1300            const year = displayedYear;
1301            const month = String(displayedMonth).padStart(2, '0');
1302            defaultDate = `${year}-${month}-01`;
1303        } else {
1304            // Fallback to today if attributes not found
1305            const today = new Date();
1306            const year = today.getFullYear();
1307            const month = String(today.getMonth() + 1).padStart(2, '0');
1308            const day = String(today.getDate()).padStart(2, '0');
1309            defaultDate = `${year}-${month}-${day}`;
1310        }
1311    }
1312    dateField.value = defaultDate;
1313    dateField.removeAttribute('data-original-date');
1314
1315    // Also set the end date field to the same default (user can change it)
1316    const endDateField = document.getElementById('event-end-date-' + calId);
1317    if (endDateField) {
1318        endDateField.value = ''; // Empty by default (single-day event)
1319        // Set min attribute to help the date picker open on the right month
1320        endDateField.setAttribute('min', defaultDate);
1321    }
1322
1323    // Set default color
1324    document.getElementById('event-color-' + calId).value = '#3498db';
1325
1326    // Initialize end time dropdown (disabled by default since no start time set)
1327    const endTimeField = document.getElementById('event-end-time-' + calId);
1328    if (endTimeField) {
1329        endTimeField.disabled = true;
1330        endTimeField.value = '';
1331    }
1332
1333    // Initialize namespace search
1334    initNamespaceSearch(calId);
1335
1336    // Set title
1337    title.textContent = 'Add Event';
1338
1339    // Show dialog
1340    dialog.style.display = 'flex';
1341
1342    // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates)
1343    propagateThemeVars(calId, dialog);
1344
1345    // Focus title field
1346    setTimeout(() => {
1347        const titleField = document.getElementById('event-title-' + calId);
1348        if (titleField) titleField.focus();
1349    }, 100);
1350};
1351
1352// Edit event
1353window.editEvent = function(calId, eventId, date, namespace) {
1354    const params = new URLSearchParams({
1355        call: 'plugin_calendar',
1356        action: 'get_event',
1357        namespace: namespace,
1358        date: date,
1359        eventId: eventId
1360    });
1361
1362    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1363        method: 'POST',
1364        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1365        body: params.toString()
1366    })
1367    .then(r => r.json())
1368    .then(data => {
1369        if (data.success && data.event) {
1370            const event = data.event;
1371            const dialog = document.getElementById('dialog-' + calId);
1372            const title = document.getElementById('dialog-title-' + calId);
1373            const dateField = document.getElementById('event-date-' + calId);
1374            const form = document.getElementById('eventform-' + calId);
1375
1376            if (!dateField) {
1377                console.error('Date field not found when editing!');
1378                return;
1379            }
1380
1381            // Store the event's actual namespace for saving (important for namespace=* views)
1382            if (event.namespace !== undefined) {
1383                form.dataset.eventNamespace = event.namespace;
1384            }
1385
1386            // Populate form
1387            document.getElementById('event-id-' + calId).value = event.id;
1388            dateField.value = date;
1389            dateField.setAttribute('data-original-date', date);
1390
1391            const endDateField = document.getElementById('event-end-date-' + calId);
1392            endDateField.value = event.endDate || '';
1393            // Set min attribute to help date picker open on the start date's month
1394            endDateField.setAttribute('min', date);
1395
1396            document.getElementById('event-title-' + calId).value = event.title;
1397            document.getElementById('event-time-' + calId).value = event.time || '';
1398            document.getElementById('event-end-time-' + calId).value = event.endTime || '';
1399            document.getElementById('event-color-' + calId).value = event.color || '#3498db';
1400            document.getElementById('event-desc-' + calId).value = event.description || '';
1401            document.getElementById('event-is-task-' + calId).checked = event.isTask || false;
1402
1403            // Update end time options based on start time
1404            if (event.time) {
1405                updateEndTimeOptions(calId);
1406            }
1407
1408            // Initialize namespace search
1409            initNamespaceSearch(calId);
1410
1411            // Set namespace fields if available
1412            const namespaceHidden = document.getElementById('event-namespace-' + calId);
1413            const namespaceSearch = document.getElementById('event-namespace-search-' + calId);
1414            if (namespaceHidden && event.namespace !== undefined) {
1415                // Set the hidden input (this is what gets submitted)
1416                namespaceHidden.value = event.namespace || '';
1417                // Set the search input to display the namespace
1418                if (namespaceSearch) {
1419                    namespaceSearch.value = event.namespace || '(default)';
1420                }
1421            } else {
1422                // No namespace on event, set to default
1423                if (namespaceHidden) {
1424                    namespaceHidden.value = '';
1425                }
1426                if (namespaceSearch) {
1427                    namespaceSearch.value = '(default)';
1428                }
1429            }
1430
1431            title.textContent = 'Edit Event';
1432            dialog.style.display = 'flex';
1433
1434            // Propagate CSS vars to dialog
1435            propagateThemeVars(calId, dialog);
1436        }
1437    })
1438    .catch(err => console.error('Error editing event:', err));
1439};
1440
1441// Delete event
1442window.deleteEvent = function(calId, eventId, date, namespace) {
1443    if (!confirm('Delete this event?')) return;
1444
1445    const params = new URLSearchParams({
1446        call: 'plugin_calendar',
1447        action: 'delete_event',
1448        namespace: namespace,
1449        date: date,
1450        eventId: eventId,
1451        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1452    });
1453
1454    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1455        method: 'POST',
1456        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1457        body: params.toString()
1458    })
1459    .then(r => r.json())
1460    .then(data => {
1461        if (data.success) {
1462            // Extract year and month from date
1463            const [year, month] = date.split('-').map(Number);
1464
1465            // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace)
1466            // This preserves wildcard/multi-namespace views
1467            const container = document.getElementById(calId);
1468            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
1469
1470            // Reload calendar data via AJAX with the calendar's original namespace
1471            reloadCalendarData(calId, year, month, calendarNamespace);
1472        }
1473    })
1474    .catch(err => console.error('Error:', err));
1475};
1476
1477// Save event (add or edit)
1478window.saveEventCompact = function(calId, namespace) {
1479    const form = document.getElementById('eventform-' + calId);
1480
1481    // Get namespace from dropdown - this is what the user selected
1482    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1483    const selectedNamespace = namespaceSelect ? namespaceSelect.value : '';
1484
1485    // ALWAYS use what the user selected in the dropdown
1486    // This allows changing namespace when editing
1487    const finalNamespace = selectedNamespace;
1488
1489    const eventId = document.getElementById('event-id-' + calId).value;
1490
1491    // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event)
1492    const originalNamespace = form.dataset.eventNamespace;
1493
1494
1495    const dateInput = document.getElementById('event-date-' + calId);
1496    const date = dateInput.value;
1497    const oldDate = dateInput.getAttribute('data-original-date') || date;
1498    const endDate = document.getElementById('event-end-date-' + calId).value;
1499    const title = document.getElementById('event-title-' + calId).value;
1500    const time = document.getElementById('event-time-' + calId).value;
1501    const endTime = document.getElementById('event-end-time-' + calId).value;
1502    const colorSelect = document.getElementById('event-color-' + calId);
1503    let color = colorSelect.value;
1504
1505    // Handle custom color
1506    if (color === 'custom') {
1507        color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value;
1508    }
1509
1510    const description = document.getElementById('event-desc-' + calId).value;
1511    const isTask = document.getElementById('event-is-task-' + calId).checked;
1512    const completed = false; // New tasks are not completed
1513    const isRecurring = document.getElementById('event-recurring-' + calId).checked;
1514    const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value;
1515    const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value;
1516
1517    // New recurrence options
1518    const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId);
1519    const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1;
1520
1521    // Weekly: collect selected days
1522    let weekDays = [];
1523    const weeklyOptions = document.getElementById('weekly-options-' + calId);
1524    if (weeklyOptions && recurrenceType === 'weekly') {
1525        const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked');
1526        weekDays = Array.from(checkboxes).map(cb => cb.value);
1527    }
1528
1529    // Monthly: collect day-of-month or ordinal weekday
1530    let monthDay = '';
1531    let monthlyType = 'dayOfMonth';
1532    let ordinalWeek = '';
1533    let ordinalDay = '';
1534    const monthlyOptions = document.getElementById('monthly-options-' + calId);
1535    if (monthlyOptions && recurrenceType === 'monthly') {
1536        const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked');
1537        monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth';
1538
1539        if (monthlyType === 'dayOfMonth') {
1540            const monthDayInput = document.getElementById('event-month-day-' + calId);
1541            monthDay = monthDayInput ? monthDayInput.value : '';
1542        } else {
1543            const ordinalSelect = document.getElementById('event-ordinal-' + calId);
1544            const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId);
1545            ordinalWeek = ordinalSelect ? ordinalSelect.value : '1';
1546            ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0';
1547        }
1548    }
1549
1550    if (!title) {
1551        alert('Please enter a title');
1552        return;
1553    }
1554
1555    if (!date) {
1556        alert('Please select a date');
1557        return;
1558    }
1559
1560    const params = new URLSearchParams({
1561        call: 'plugin_calendar',
1562        action: 'save_event',
1563        namespace: finalNamespace,
1564        eventId: eventId,
1565        date: date,
1566        oldDate: oldDate,
1567        endDate: endDate,
1568        title: title,
1569        time: time,
1570        endTime: endTime,
1571        color: color,
1572        description: description,
1573        isTask: isTask ? '1' : '0',
1574        completed: completed ? '1' : '0',
1575        isRecurring: isRecurring ? '1' : '0',
1576        recurrenceType: recurrenceType,
1577        recurrenceInterval: recurrenceInterval,
1578        recurrenceEnd: recurrenceEnd,
1579        weekDays: weekDays.join(','),
1580        monthlyType: monthlyType,
1581        monthDay: monthDay,
1582        ordinalWeek: ordinalWeek,
1583        ordinalDay: ordinalDay,
1584        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1585    });
1586
1587    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1588        method: 'POST',
1589        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1590        body: params.toString()
1591    })
1592    .then(r => r.json())
1593    .then(data => {
1594        if (data.success) {
1595            closeEventDialog(calId);
1596
1597            // For recurring events, do a full page reload to show all occurrences
1598            if (isRecurring) {
1599                location.reload();
1600                return;
1601            }
1602
1603            // Extract year and month from the NEW date (in case date was changed)
1604            const [year, month] = date.split('-').map(Number);
1605
1606            // Get the calendar's ORIGINAL namespace setting from the container
1607            // This preserves wildcard/multi-namespace views after editing
1608            const container = document.getElementById(calId);
1609            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
1610
1611            // Reload calendar data via AJAX to the month of the event
1612            reloadCalendarData(calId, year, month, calendarNamespace);
1613        } else {
1614            alert('Error: ' + (data.error || 'Unknown error'));
1615        }
1616    })
1617    .catch(err => {
1618        console.error('Error:', err);
1619        alert('Error saving event');
1620    });
1621};
1622
1623// Reload calendar data without page refresh
1624window.reloadCalendarData = function(calId, year, month, namespace) {
1625    const params = new URLSearchParams({
1626        call: 'plugin_calendar',
1627        action: 'load_month',
1628        year: year,
1629        month: month,
1630        namespace: namespace,
1631        _: new Date().getTime() // Cache buster
1632    });
1633
1634    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1635        method: 'POST',
1636        headers: {
1637            'Content-Type': 'application/x-www-form-urlencoded',
1638            'Cache-Control': 'no-cache, no-store, must-revalidate',
1639            'Pragma': 'no-cache'
1640        },
1641        body: params.toString()
1642    })
1643    .then(r => r.json())
1644    .then(data => {
1645        if (data.success) {
1646            const container = document.getElementById(calId);
1647
1648            // Check if this is a full calendar or just event panel
1649            if (container.classList.contains('calendar-compact-container')) {
1650                rebuildCalendar(calId, data.year, data.month, data.events, namespace);
1651            } else if (container.classList.contains('event-panel-standalone')) {
1652                rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1653            }
1654        }
1655    })
1656    .catch(err => console.error('Error:', err));
1657};
1658
1659// Close event dialog
1660window.closeEventDialog = function(calId) {
1661    const dialog = document.getElementById('dialog-' + calId);
1662    dialog.style.display = 'none';
1663};
1664
1665// Escape HTML
1666window.escapeHtml = function(text) {
1667    const div = document.createElement('div');
1668    div.textContent = text;
1669    return div.innerHTML;
1670};
1671
1672// Highlight event when clicking on bar in calendar
1673window.highlightEvent = function(calId, eventId, date) {
1674
1675    // Find the event item in the event list
1676    const eventList = document.querySelector('#' + calId + ' .event-list-compact');
1677    if (!eventList) {
1678        return;
1679    }
1680
1681    const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]');
1682    if (!eventItem) {
1683        return;
1684    }
1685
1686
1687    // Get theme
1688    const container = document.getElementById(calId);
1689    const theme = container ? container.dataset.theme : 'matrix';
1690    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
1691
1692
1693    // Theme-specific highlight colors
1694    let highlightBg, highlightShadow;
1695    if (theme === 'matrix') {
1696        highlightBg = '#1a3d1a';  // Darker green
1697        highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)';
1698    } else if (theme === 'purple') {
1699        highlightBg = '#3d2b4d';  // Darker purple
1700        highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)';
1701    } else if (theme === 'professional') {
1702        highlightBg = '#e3f2fd';  // Light blue
1703        highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)';
1704    } else if (theme === 'pink') {
1705        highlightBg = '#3d2030';  // Darker pink
1706        highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)';
1707    } else if (theme === 'wiki') {
1708        highlightBg = themeStyles.header_bg || '#e8e8e8';  // __background_alt__
1709        highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)';
1710    }
1711
1712
1713    // Store original styles
1714    const originalBg = eventItem.style.background;
1715    const originalShadow = eventItem.style.boxShadow;
1716
1717    // Remove previous highlights (restore their original styles)
1718    const previousHighlights = eventList.querySelectorAll('.event-highlighted');
1719    previousHighlights.forEach(el => {
1720        el.classList.remove('event-highlighted');
1721    });
1722
1723    // Add highlight class and apply theme-aware glow
1724    eventItem.classList.add('event-highlighted');
1725
1726    // Set CSS properties directly
1727    eventItem.style.setProperty('background', highlightBg, 'important');
1728    eventItem.style.setProperty('box-shadow', highlightShadow, 'important');
1729    eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important');
1730
1731
1732    // Scroll to event
1733    eventItem.scrollIntoView({
1734        behavior: 'smooth',
1735        block: 'nearest',
1736        inline: 'nearest'
1737    });
1738
1739    // Remove highlight after 3 seconds and restore original styles
1740    setTimeout(() => {
1741        eventItem.classList.remove('event-highlighted');
1742        eventItem.style.setProperty('background', originalBg);
1743        eventItem.style.setProperty('box-shadow', originalShadow);
1744        eventItem.style.setProperty('transition', '');
1745    }, 3000);
1746};
1747
1748// Toggle recurring event options
1749window.toggleRecurringOptions = function(calId) {
1750    const checkbox = document.getElementById('event-recurring-' + calId);
1751    const options = document.getElementById('recurring-options-' + calId);
1752
1753    if (checkbox && options) {
1754        options.style.display = checkbox.checked ? 'block' : 'none';
1755        if (checkbox.checked) {
1756            // Initialize the sub-options based on current selection
1757            updateRecurrenceOptions(calId);
1758        }
1759    }
1760};
1761
1762// Update visible recurrence options based on type (daily/weekly/monthly/yearly)
1763window.updateRecurrenceOptions = function(calId) {
1764    const typeSelect = document.getElementById('event-recurrence-type-' + calId);
1765    const weeklyOptions = document.getElementById('weekly-options-' + calId);
1766    const monthlyOptions = document.getElementById('monthly-options-' + calId);
1767
1768    if (!typeSelect) return;
1769
1770    const recurrenceType = typeSelect.value;
1771
1772    // Hide all conditional options first
1773    if (weeklyOptions) weeklyOptions.style.display = 'none';
1774    if (monthlyOptions) monthlyOptions.style.display = 'none';
1775
1776    // Show relevant options
1777    if (recurrenceType === 'weekly' && weeklyOptions) {
1778        weeklyOptions.style.display = 'block';
1779        // Auto-select today's day of week if nothing selected
1780        const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]');
1781        const anyChecked = Array.from(checkboxes).some(cb => cb.checked);
1782        if (!anyChecked) {
1783            const today = new Date().getDay();
1784            const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]');
1785            if (todayCheckbox) todayCheckbox.checked = true;
1786        }
1787    } else if (recurrenceType === 'monthly' && monthlyOptions) {
1788        monthlyOptions.style.display = 'block';
1789        // Set default day to current day of month
1790        const monthDayInput = document.getElementById('event-month-day-' + calId);
1791        if (monthDayInput && !monthDayInput.dataset.userSet) {
1792            monthDayInput.value = new Date().getDate();
1793        }
1794    }
1795};
1796
1797// Toggle between day-of-month and ordinal weekday for monthly recurrence
1798window.updateMonthlyType = function(calId) {
1799    const dayOfMonthDiv = document.getElementById('monthly-day-' + calId);
1800    const ordinalDiv = document.getElementById('monthly-ordinal-' + calId);
1801    const monthlyOptions = document.getElementById('monthly-options-' + calId);
1802
1803    if (!monthlyOptions) return;
1804
1805    const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked');
1806    if (!selectedRadio) return;
1807
1808    if (selectedRadio.value === 'dayOfMonth') {
1809        if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex';
1810        if (ordinalDiv) ordinalDiv.style.display = 'none';
1811    } else {
1812        if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none';
1813        if (ordinalDiv) ordinalDiv.style.display = 'block';
1814
1815        // Set defaults based on current date
1816        const now = new Date();
1817        const dayOfWeek = now.getDay();
1818        const weekOfMonth = Math.ceil(now.getDate() / 7);
1819
1820        const ordinalSelect = document.getElementById('event-ordinal-' + calId);
1821        const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId);
1822
1823        if (ordinalSelect && !ordinalSelect.dataset.userSet) {
1824            ordinalSelect.value = weekOfMonth;
1825        }
1826        if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) {
1827            ordinalDaySelect.value = dayOfWeek;
1828        }
1829    }
1830};
1831
1832// ============================================================
1833// Document-level event delegation (guarded - only attach once)
1834// These use event delegation so they work for AJAX-rebuilt content.
1835// ============================================================
1836if (!window._calendarDelegationInit) {
1837    window._calendarDelegationInit = true;
1838
1839    // ESC closes dialogs, popups, tooltips
1840    document.addEventListener('keydown', function(e) {
1841        if (e.key === 'Escape') {
1842            document.querySelectorAll('.event-dialog-compact').forEach(function(d) {
1843                if (d.style.display === 'flex') d.style.display = 'none';
1844            });
1845            document.querySelectorAll('.day-popup').forEach(function(p) {
1846                p.style.display = 'none';
1847            });
1848            hideConflictTooltip();
1849        }
1850    });
1851
1852    // Conflict tooltip delegation (capture phase for mouseenter/leave)
1853    document.addEventListener('mouseenter', function(e) {
1854        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1855            showConflictTooltip(e.target);
1856        }
1857    }, true);
1858
1859    document.addEventListener('mouseleave', function(e) {
1860        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1861            hideConflictTooltip();
1862        }
1863    }, true);
1864} // end delegation guard
1865
1866// Event panel navigation
1867window.navEventPanel = function(calId, year, month, namespace) {
1868    const params = new URLSearchParams({
1869        call: 'plugin_calendar',
1870        action: 'load_month',
1871        year: year,
1872        month: month,
1873        namespace: namespace,
1874        _: new Date().getTime() // Cache buster
1875    });
1876
1877    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1878        method: 'POST',
1879        headers: {
1880            'Content-Type': 'application/x-www-form-urlencoded',
1881            'Cache-Control': 'no-cache, no-store, must-revalidate',
1882            'Pragma': 'no-cache'
1883        },
1884        body: params.toString()
1885    })
1886    .then(r => r.json())
1887    .then(data => {
1888        if (data.success) {
1889            rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1890        }
1891    })
1892    .catch(err => console.error('Error:', err));
1893};
1894
1895// Rebuild event panel only
1896window.rebuildEventPanel = function(calId, year, month, events, namespace) {
1897    const container = document.getElementById(calId);
1898    const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1899                       'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1900
1901    // Update month title in new compact header
1902    const monthTitle = container.querySelector('.panel-month-title');
1903    if (monthTitle) {
1904        monthTitle.textContent = monthNames[month - 1] + ' ' + year;
1905        monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1906        monthTitle.setAttribute('title', 'Click to jump to month');
1907    }
1908
1909    // Fallback: Update old header format if exists
1910    const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker');
1911    if (oldHeader && !monthTitle) {
1912        oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events';
1913        oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1914    }
1915
1916    // Update nav buttons
1917    let prevMonth = month - 1;
1918    let prevYear = year;
1919    if (prevMonth < 1) {
1920        prevMonth = 12;
1921        prevYear--;
1922    }
1923
1924    let nextMonth = month + 1;
1925    let nextYear = year;
1926    if (nextMonth > 12) {
1927        nextMonth = 1;
1928        nextYear++;
1929    }
1930
1931    // Update new compact nav buttons
1932    const navBtns = container.querySelectorAll('.panel-nav-btn');
1933    if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1934    if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1935
1936    // Fallback for old nav buttons
1937    const oldNavBtns = container.querySelectorAll('.cal-nav-btn');
1938    if (oldNavBtns.length > 0 && navBtns.length === 0) {
1939        if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1940        if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1941    }
1942
1943    // Update Today button (works for both old and new)
1944    const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact');
1945    if (todayBtn) {
1946        todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`);
1947    }
1948
1949    // Rebuild event list
1950    const eventList = container.querySelector('.event-list-compact');
1951    if (eventList) {
1952        eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
1953    }
1954};
1955
1956// Open add event for panel
1957window.openAddEventPanel = function(calId, namespace) {
1958    const today = new Date();
1959    const year = today.getFullYear();
1960    const month = String(today.getMonth() + 1).padStart(2, '0');
1961    const day = String(today.getDate()).padStart(2, '0');
1962    const localDate = `${year}-${month}-${day}`;
1963    openAddEvent(calId, namespace, localDate);
1964};
1965
1966// Toggle task completion
1967window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) {
1968    const params = new URLSearchParams({
1969        call: 'plugin_calendar',
1970        action: 'toggle_task',
1971        namespace: namespace,
1972        date: date,
1973        eventId: eventId,
1974        completed: completed ? '1' : '0',
1975        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1976    });
1977
1978    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1979        method: 'POST',
1980        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1981        body: params.toString()
1982    })
1983    .then(r => r.json())
1984    .then(data => {
1985        if (data.success) {
1986            const [year, month] = date.split('-').map(Number);
1987
1988            // Get the calendar's ORIGINAL namespace setting from the container
1989            const container = document.getElementById(calId);
1990            const calendarNamespace = container ? (container.dataset.namespace || '') : namespace;
1991
1992            reloadCalendarData(calId, year, month, calendarNamespace);
1993        }
1994    })
1995    .catch(err => console.error('Error toggling task:', err));
1996};
1997
1998// Make dialog draggable
1999window.makeDialogDraggable = function(calId) {
2000    const dialog = document.getElementById('dialog-content-' + calId);
2001    const handle = document.getElementById('drag-handle-' + calId);
2002
2003    if (!dialog || !handle) return;
2004
2005    let isDragging = false;
2006    let currentX;
2007    let currentY;
2008    let initialX;
2009    let initialY;
2010    let xOffset = 0;
2011    let yOffset = 0;
2012
2013    handle.addEventListener('mousedown', dragStart);
2014    document.addEventListener('mousemove', drag);
2015    document.addEventListener('mouseup', dragEnd);
2016
2017    function dragStart(e) {
2018        initialX = e.clientX - xOffset;
2019        initialY = e.clientY - yOffset;
2020        isDragging = true;
2021    }
2022
2023    function drag(e) {
2024        if (isDragging) {
2025            e.preventDefault();
2026            currentX = e.clientX - initialX;
2027            currentY = e.clientY - initialY;
2028            xOffset = currentX;
2029            yOffset = currentY;
2030            setTranslate(currentX, currentY, dialog);
2031        }
2032    }
2033
2034    function dragEnd(e) {
2035        initialX = currentX;
2036        initialY = currentY;
2037        isDragging = false;
2038    }
2039
2040    function setTranslate(xPos, yPos, el) {
2041        el.style.transform = `translate(${xPos}px, ${yPos}px)`;
2042    }
2043};
2044
2045// Initialize dialog draggability when opened (avoid duplicate declaration)
2046if (!window.calendarDraggabilityPatched) {
2047    window.calendarDraggabilityPatched = true;
2048
2049    const originalOpenAddEvent = openAddEvent;
2050    openAddEvent = function(calId, namespace, date) {
2051        originalOpenAddEvent(calId, namespace, date);
2052        setTimeout(() => makeDialogDraggable(calId), 100);
2053    };
2054
2055    const originalEditEvent = editEvent;
2056    editEvent = function(calId, eventId, date, namespace) {
2057        originalEditEvent(calId, eventId, date, namespace);
2058        setTimeout(() => makeDialogDraggable(calId), 100);
2059    };
2060}
2061
2062// Toggle expand/collapse for past events
2063window.togglePastEventExpand = function(element) {
2064    // Stop propagation to prevent any parent click handlers
2065    event.stopPropagation();
2066
2067    const meta = element.querySelector(".event-meta-compact");
2068    const desc = element.querySelector(".event-desc-compact");
2069
2070    // Toggle visibility
2071    if (meta.style.display === "none") {
2072        // Expand
2073        meta.style.display = "block";
2074        if (desc) desc.style.display = "block";
2075        element.classList.add("event-past-expanded");
2076    } else {
2077        // Collapse
2078        meta.style.display = "none";
2079        if (desc) desc.style.display = "none";
2080        element.classList.remove("event-past-expanded");
2081    }
2082};
2083
2084// Filter calendar by namespace when clicking namespace badge (guarded)
2085if (!window._calendarClickDelegationInit) {
2086    window._calendarClickDelegationInit = true;
2087    document.addEventListener('click', function(e) {
2088    if (e.target.classList.contains('event-namespace-badge')) {
2089        const namespace = e.target.textContent;
2090        const calendar = e.target.closest('.calendar-compact-container');
2091
2092        if (!calendar) return;
2093
2094        const calId = calendar.id;
2095
2096        // Use AJAX reload to filter both calendar grid and event list
2097        filterCalendarByNamespace(calId, namespace);
2098    }
2099    });
2100} // end click delegation guard
2101
2102// Update the displayed filtered namespace in event list header
2103// Legacy badge removed - namespace filtering still works but badge no longer shown
2104window.updateFilteredNamespaceDisplay = function(calId, namespace) {
2105    const calendar = document.getElementById(calId);
2106    if (!calendar) return;
2107
2108    const headerContent = calendar.querySelector('.event-list-header-content');
2109    if (!headerContent) return;
2110
2111    // Remove any existing filter badge (cleanup)
2112    let filterBadge = headerContent.querySelector('.namespace-filter-badge');
2113    if (filterBadge) {
2114        filterBadge.remove();
2115    }
2116};
2117
2118// Clear namespace filter
2119window.clearNamespaceFilter = function(calId) {
2120
2121    const container = document.getElementById(calId);
2122    if (!container) {
2123        console.error('Calendar container not found:', calId);
2124        return;
2125    }
2126
2127    // Immediately hide/remove the filter badge
2128    const filterBadge = container.querySelector('.calendar-namespace-filter');
2129    if (filterBadge) {
2130        filterBadge.style.display = 'none';
2131        filterBadge.remove();
2132    }
2133
2134    // Get current year and month
2135    const year = parseInt(container.dataset.year) || new Date().getFullYear();
2136    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
2137
2138    // Get original namespace (what the calendar was initialized with)
2139    const originalNamespace = container.dataset.originalNamespace || '';
2140
2141    // Also check for sidebar widget
2142    const sidebarContainer = document.getElementById('sidebar-widget-' + calId);
2143    if (sidebarContainer) {
2144        // For sidebar widget, just reload the page without namespace filter
2145        // Remove the namespace from the URL and reload
2146        const url = new URL(window.location.href);
2147        url.searchParams.delete('namespace');
2148        window.location.href = url.toString();
2149        return;
2150    }
2151
2152    // For regular calendar, reload calendar with original namespace
2153    navCalendar(calId, year, month, originalNamespace);
2154};
2155
2156window.clearNamespaceFilterPanel = function(calId) {
2157
2158    const container = document.getElementById(calId);
2159    if (!container) {
2160        console.error('Event panel container not found:', calId);
2161        return;
2162    }
2163
2164    // Get current year and month from URL params or container
2165    const year = parseInt(container.dataset.year) || new Date().getFullYear();
2166    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
2167
2168    // Get original namespace (what the panel was initialized with)
2169    const originalNamespace = container.dataset.originalNamespace || '';
2170
2171
2172    // Reload event panel with original namespace
2173    navEventPanel(calId, year, month, originalNamespace);
2174};
2175
2176// Color picker functions
2177window.updateCustomColorPicker = function(calId) {
2178    const select = document.getElementById('event-color-' + calId);
2179    const picker = document.getElementById('event-color-custom-' + calId);
2180
2181    if (select.value === 'custom') {
2182        // Show color picker
2183        picker.style.display = 'inline-block';
2184        picker.click(); // Open color picker
2185    } else {
2186        // Hide color picker and sync value
2187        picker.style.display = 'none';
2188        picker.value = select.value;
2189    }
2190};
2191
2192function updateColorFromPicker(calId) {
2193    const select = document.getElementById('event-color-' + calId);
2194    const picker = document.getElementById('event-color-custom-' + calId);
2195
2196    // Set select to custom and update its underlying value
2197    select.value = 'custom';
2198    // Store the actual color value in a data attribute
2199    select.dataset.customColor = picker.value;
2200}
2201
2202// Toggle past events visibility
2203window.togglePastEvents = function(calId) {
2204    const content = document.getElementById('past-events-' + calId);
2205    const arrow = document.getElementById('past-arrow-' + calId);
2206
2207    if (!content || !arrow) {
2208        console.error('Past events elements not found for:', calId);
2209        return;
2210    }
2211
2212    // Check computed style instead of inline style
2213    const isHidden = window.getComputedStyle(content).display === 'none';
2214
2215    if (isHidden) {
2216        content.style.display = 'block';
2217        arrow.textContent = '▼';
2218    } else {
2219        content.style.display = 'none';
2220        arrow.textContent = '▶';
2221    }
2222};
2223
2224// Fuzzy match scoring function
2225window.fuzzyMatch = function(pattern, str) {
2226    pattern = pattern.toLowerCase();
2227    str = str.toLowerCase();
2228
2229    let patternIdx = 0;
2230    let score = 0;
2231    let consecutiveMatches = 0;
2232
2233    for (let i = 0; i < str.length; i++) {
2234        if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) {
2235            score += 1 + consecutiveMatches;
2236            consecutiveMatches++;
2237            patternIdx++;
2238        } else {
2239            consecutiveMatches = 0;
2240        }
2241    }
2242
2243    // Return null if not all characters matched
2244    if (patternIdx !== pattern.length) {
2245        return null;
2246    }
2247
2248    // Bonus for exact match
2249    if (str === pattern) {
2250        score += 100;
2251    }
2252
2253    // Bonus for starts with
2254    if (str.startsWith(pattern)) {
2255        score += 50;
2256    }
2257
2258    return score;
2259};
2260
2261// Initialize namespace search for a calendar
2262window.initNamespaceSearch = function(calId) {
2263    const searchInput = document.getElementById('event-namespace-search-' + calId);
2264    const hiddenInput = document.getElementById('event-namespace-' + calId);
2265    const dropdown = document.getElementById('event-namespace-dropdown-' + calId);
2266    const dataElement = document.getElementById('namespaces-data-' + calId);
2267
2268    if (!searchInput || !hiddenInput || !dropdown || !dataElement) {
2269        return; // Elements not found
2270    }
2271
2272    let namespaces = [];
2273    try {
2274        namespaces = JSON.parse(dataElement.textContent);
2275    } catch (e) {
2276        console.error('Failed to parse namespaces data:', e);
2277        return;
2278    }
2279
2280    let selectedIndex = -1;
2281
2282    // Filter and show dropdown
2283    function filterNamespaces(query) {
2284        if (!query || query.trim() === '') {
2285            // Show all namespaces when empty
2286            hiddenInput.value = '';
2287            const results = namespaces.slice(0, 20); // Limit to 20
2288            showDropdown(results);
2289            return;
2290        }
2291
2292        // Fuzzy match and score
2293        const matches = [];
2294        for (let i = 0; i < namespaces.length; i++) {
2295            const score = fuzzyMatch(query, namespaces[i]);
2296            if (score !== null) {
2297                matches.push({ namespace: namespaces[i], score: score });
2298            }
2299        }
2300
2301        // Sort by score (descending)
2302        matches.sort((a, b) => b.score - a.score);
2303
2304        // Take top 20 results
2305        const results = matches.slice(0, 20).map(m => m.namespace);
2306        showDropdown(results);
2307    }
2308
2309    function showDropdown(results) {
2310        dropdown.innerHTML = '';
2311        selectedIndex = -1;
2312
2313        if (results.length === 0) {
2314            dropdown.style.display = 'none';
2315            return;
2316        }
2317
2318        // Add (default) option
2319        const defaultOption = document.createElement('div');
2320        defaultOption.className = 'namespace-option';
2321        defaultOption.textContent = '(default)';
2322        defaultOption.dataset.value = '';
2323        dropdown.appendChild(defaultOption);
2324
2325        results.forEach(ns => {
2326            const option = document.createElement('div');
2327            option.className = 'namespace-option';
2328            option.textContent = ns;
2329            option.dataset.value = ns;
2330            dropdown.appendChild(option);
2331        });
2332
2333        dropdown.style.display = 'block';
2334    }
2335
2336    function hideDropdown() {
2337        dropdown.style.display = 'none';
2338        selectedIndex = -1;
2339    }
2340
2341    function selectOption(namespace) {
2342        hiddenInput.value = namespace;
2343        searchInput.value = namespace || '(default)';
2344        hideDropdown();
2345    }
2346
2347    // Event listeners
2348    searchInput.addEventListener('input', function(e) {
2349        filterNamespaces(e.target.value);
2350    });
2351
2352    searchInput.addEventListener('focus', function(e) {
2353        filterNamespaces(e.target.value);
2354    });
2355
2356    searchInput.addEventListener('blur', function(e) {
2357        // Delay to allow click on dropdown
2358        setTimeout(hideDropdown, 200);
2359    });
2360
2361    searchInput.addEventListener('keydown', function(e) {
2362        const options = dropdown.querySelectorAll('.namespace-option');
2363
2364        if (e.key === 'ArrowDown') {
2365            e.preventDefault();
2366            selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
2367            updateSelection(options);
2368        } else if (e.key === 'ArrowUp') {
2369            e.preventDefault();
2370            selectedIndex = Math.max(selectedIndex - 1, -1);
2371            updateSelection(options);
2372        } else if (e.key === 'Enter') {
2373            e.preventDefault();
2374            if (selectedIndex >= 0 && options[selectedIndex]) {
2375                selectOption(options[selectedIndex].dataset.value);
2376            }
2377        } else if (e.key === 'Escape') {
2378            hideDropdown();
2379        }
2380    });
2381
2382    function updateSelection(options) {
2383        options.forEach((opt, idx) => {
2384            if (idx === selectedIndex) {
2385                opt.classList.add('selected');
2386                opt.scrollIntoView({ block: 'nearest' });
2387            } else {
2388                opt.classList.remove('selected');
2389            }
2390        });
2391    }
2392
2393    // Click on dropdown option
2394    dropdown.addEventListener('mousedown', function(e) {
2395        if (e.target.classList.contains('namespace-option')) {
2396            selectOption(e.target.dataset.value);
2397        }
2398    });
2399};
2400
2401// Update end time options based on start time selection
2402window.updateEndTimeOptions = function(calId) {
2403    const startTimeSelect = document.getElementById('event-time-' + calId);
2404    const endTimeSelect = document.getElementById('event-end-time-' + calId);
2405
2406    if (!startTimeSelect || !endTimeSelect) return;
2407
2408    const startTime = startTimeSelect.value;
2409
2410    // If start time is empty (all day), disable end time
2411    if (!startTime) {
2412        endTimeSelect.disabled = true;
2413        endTimeSelect.value = '';
2414        return;
2415    }
2416
2417    // Enable end time select
2418    endTimeSelect.disabled = false;
2419
2420    // Convert start time to minutes
2421    const startMinutes = timeToMinutes(startTime);
2422
2423    // Get current end time value (to preserve if valid)
2424    const currentEndTime = endTimeSelect.value;
2425    const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0;
2426
2427    // Filter options - show only times after start time
2428    const options = endTimeSelect.options;
2429    let firstValidOption = null;
2430    let currentStillValid = false;
2431
2432    for (let i = 0; i < options.length; i++) {
2433        const option = options[i];
2434        const optionValue = option.value;
2435
2436        if (optionValue === '') {
2437            // Keep "Same as start" option visible
2438            option.style.display = '';
2439            continue;
2440        }
2441
2442        const optionMinutes = timeToMinutes(optionValue);
2443
2444        if (optionMinutes > startMinutes) {
2445            // Show options after start time
2446            option.style.display = '';
2447            if (!firstValidOption) {
2448                firstValidOption = optionValue;
2449            }
2450            if (optionValue === currentEndTime) {
2451                currentStillValid = true;
2452            }
2453        } else {
2454            // Hide options before or equal to start time
2455            option.style.display = 'none';
2456        }
2457    }
2458
2459    // If current end time is now invalid, set a new one
2460    if (!currentStillValid || currentEndMinutes <= startMinutes) {
2461        // Try to set to 1 hour after start
2462        const [startHour, startMinute] = startTime.split(':').map(Number);
2463        let endHour = startHour + 1;
2464        let endMinute = startMinute;
2465
2466        if (endHour >= 24) {
2467            endHour = 23;
2468            endMinute = 45;
2469        }
2470
2471        const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0');
2472
2473        // Check if suggested time is in the list
2474        const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime);
2475
2476        if (suggestedExists) {
2477            endTimeSelect.value = suggestedEndTime;
2478        } else if (firstValidOption) {
2479            // Use first valid option
2480            endTimeSelect.value = firstValidOption;
2481        } else {
2482            // No valid options (shouldn't happen, but just in case)
2483            endTimeSelect.value = '';
2484        }
2485    }
2486};
2487
2488// Check for time conflicts between events on the same date
2489window.checkTimeConflicts = function(events, currentEventId) {
2490    const conflicts = [];
2491
2492    // Group events by date
2493    const eventsByDate = {};
2494    for (const [date, dateEvents] of Object.entries(events)) {
2495        if (!Array.isArray(dateEvents)) continue;
2496
2497        dateEvents.forEach(evt => {
2498            if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event
2499
2500            if (!eventsByDate[date]) eventsByDate[date] = [];
2501            eventsByDate[date].push(evt);
2502        });
2503    }
2504
2505    // Check for overlaps on each date
2506    for (const [date, dateEvents] of Object.entries(eventsByDate)) {
2507        for (let i = 0; i < dateEvents.length; i++) {
2508            for (let j = i + 1; j < dateEvents.length; j++) {
2509                const evt1 = dateEvents[i];
2510                const evt2 = dateEvents[j];
2511
2512                if (eventsOverlap(evt1, evt2)) {
2513                    // Mark both events as conflicting
2514                    if (!evt1.hasConflict) evt1.hasConflict = true;
2515                    if (!evt2.hasConflict) evt2.hasConflict = true;
2516
2517                    // Store conflict info
2518                    if (!evt1.conflictsWith) evt1.conflictsWith = [];
2519                    if (!evt2.conflictsWith) evt2.conflictsWith = [];
2520
2521                    evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime});
2522                    evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime});
2523                }
2524            }
2525        }
2526    }
2527
2528    return events;
2529};
2530
2531// Check if two events overlap in time
2532function eventsOverlap(evt1, evt2) {
2533    if (!evt1.time || !evt2.time) return false; // All-day events don't conflict
2534
2535    const start1 = evt1.time;
2536    const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start
2537
2538    const start2 = evt2.time;
2539    const end2 = evt2.endTime || evt2.time;
2540
2541    // Convert to minutes for easier comparison
2542    const start1Mins = timeToMinutes(start1);
2543    const end1Mins = timeToMinutes(end1);
2544    const start2Mins = timeToMinutes(start2);
2545    const end2Mins = timeToMinutes(end2);
2546
2547    // Check for overlap
2548    // Events overlap if: start1 < end2 AND start2 < end1
2549    return start1Mins < end2Mins && start2Mins < end1Mins;
2550}
2551
2552// Convert HH:MM time to minutes since midnight
2553function timeToMinutes(timeStr) {
2554    const [hours, minutes] = timeStr.split(':').map(Number);
2555    return hours * 60 + minutes;
2556}
2557
2558// Format time range for display
2559window.formatTimeRange = function(startTime, endTime) {
2560    if (!startTime) return '';
2561
2562    const formatTime = (timeStr) => {
2563        const [hour24, minute] = timeStr.split(':').map(Number);
2564        const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24);
2565        const ampm = hour24 < 12 ? 'AM' : 'PM';
2566        return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
2567    };
2568
2569    if (!endTime || endTime === startTime) {
2570        return formatTime(startTime);
2571    }
2572
2573    return formatTime(startTime) + ' - ' + formatTime(endTime);
2574};
2575
2576// Track last known mouse position for tooltip positioning fallback
2577var _lastMouseX = 0, _lastMouseY = 0;
2578document.addEventListener('mousemove', function(e) {
2579    _lastMouseX = e.clientX;
2580    _lastMouseY = e.clientY;
2581});
2582
2583// Show custom conflict tooltip
2584window.showConflictTooltip = function(badgeElement) {
2585    // Remove any existing tooltip
2586    hideConflictTooltip();
2587
2588    // Get conflict data (base64-encoded JSON to avoid attribute quote issues)
2589    const conflictsRaw = badgeElement.getAttribute('data-conflicts');
2590    if (!conflictsRaw) return;
2591
2592    let conflicts;
2593    try {
2594        conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw))));
2595    } catch (e) {
2596        // Fallback: try parsing as plain JSON (for PHP-rendered badges)
2597        try {
2598            conflicts = JSON.parse(conflictsRaw);
2599        } catch (e2) {
2600            console.error('Failed to parse conflicts:', e2);
2601            return;
2602        }
2603    }
2604
2605    // Get theme from the calendar container via CSS variables
2606    // Try closest ancestor first, then fall back to any calendar on the page
2607    let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone');
2608    if (!containerEl) {
2609        // Badge might be inside a day popup (appended to body) - find any calendar container
2610        containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]');
2611    }
2612    const cs = containerEl ? getComputedStyle(containerEl) : null;
2613
2614    const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424';
2615    const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07';
2616    const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07';
2617    const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00';
2618    const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)';
2619
2620    // Create tooltip
2621    const tooltip = document.createElement('div');
2622    tooltip.id = 'conflict-tooltip';
2623    tooltip.className = 'conflict-tooltip';
2624
2625    // Apply theme styles
2626    tooltip.style.background = bg;
2627    tooltip.style.borderColor = border;
2628    tooltip.style.color = textPrimary;
2629    tooltip.style.boxShadow = '0 4px 12px ' + shadow;
2630
2631    // Build content with themed colors
2632    let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>';
2633    html += '<div class="conflict-tooltip-body">';
2634    conflicts.forEach(conflict => {
2635        html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>';
2636    });
2637    html += '</div>';
2638
2639    tooltip.innerHTML = html;
2640    document.body.appendChild(tooltip);
2641
2642    // Position tooltip
2643    const rect = badgeElement.getBoundingClientRect();
2644    const tooltipRect = tooltip.getBoundingClientRect();
2645
2646    // Position above the badge, centered
2647    let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
2648    let top = rect.top - tooltipRect.height - 8;
2649
2650    // Keep tooltip within viewport
2651    if (left < 10) left = 10;
2652    if (left + tooltipRect.width > window.innerWidth - 10) {
2653        left = window.innerWidth - tooltipRect.width - 10;
2654    }
2655    if (top < 10) {
2656        // If not enough room above, show below
2657        top = rect.bottom + 8;
2658    }
2659
2660    tooltip.style.left = left + 'px';
2661    tooltip.style.top = top + 'px';
2662    tooltip.style.opacity = '1';
2663};
2664
2665// Hide conflict tooltip
2666window.hideConflictTooltip = function() {
2667    const tooltip = document.getElementById('conflict-tooltip');
2668    if (tooltip) {
2669        tooltip.remove();
2670    }
2671};
2672
2673// Fuzzy search helper for event filtering - normalizes text for matching
2674function eventSearchNormalize(text) {
2675    if (typeof text !== 'string') {
2676        console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text);
2677        return '';
2678    }
2679    return text
2680        .toLowerCase()
2681        .trim()
2682        // Remove common punctuation that might differ
2683        .replace(/[''\u2018\u2019]/g, '')  // Remove apostrophes/quotes
2684        .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes
2685        .replace(/[-–—]/g, ' ')            // Dashes to spaces
2686        .replace(/[.,!?;:]/g, '')          // Remove punctuation
2687        .replace(/\s+/g, ' ')              // Normalize whitespace
2688        .trim();
2689}
2690
2691// Check if search term matches text for event filtering
2692function eventSearchMatch(text, searchTerm) {
2693    const normalizedText = eventSearchNormalize(text);
2694    const normalizedSearch = eventSearchNormalize(searchTerm);
2695
2696    // Direct match after normalization
2697    if (normalizedText.includes(normalizedSearch)) {
2698        return true;
2699    }
2700
2701    // Split search into words and check if all words are present
2702    const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0);
2703    if (searchWords.length > 1) {
2704        return searchWords.every(word => normalizedText.includes(word));
2705    }
2706
2707    return false;
2708}
2709
2710// Filter events by search term
2711window.filterEvents = function(calId, searchTerm) {
2712    const eventList = document.getElementById('eventlist-' + calId);
2713    const searchClear = document.getElementById('search-clear-' + calId);
2714    const searchMode = document.getElementById('search-mode-' + calId);
2715
2716    if (!eventList) return;
2717
2718    // Check if we're in "all dates" mode
2719    const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates');
2720
2721    // Show/hide clear button
2722    if (searchClear) {
2723        searchClear.style.display = searchTerm ? 'block' : 'none';
2724    }
2725
2726    searchTerm = searchTerm.trim();
2727
2728    // If all-dates mode and we have a search term, do AJAX search
2729    if (isAllDatesMode && searchTerm.length >= 2) {
2730        searchAllDates(calId, searchTerm);
2731        return;
2732    }
2733
2734    // If all-dates mode but search cleared, restore normal view
2735    if (isAllDatesMode && !searchTerm) {
2736        // Remove search results container if exists
2737        const resultsContainer = eventList.querySelector('.all-dates-results');
2738        if (resultsContainer) {
2739            resultsContainer.remove();
2740        }
2741        // Show normal event items
2742        eventList.querySelectorAll('.event-compact-item').forEach(item => {
2743            item.style.display = '';
2744        });
2745        // Show past events toggle if it exists
2746        const pastToggle = eventList.querySelector('.past-events-toggle');
2747        if (pastToggle) pastToggle.style.display = '';
2748    }
2749
2750    // Get all event items
2751    const eventItems = eventList.querySelectorAll('.event-compact-item');
2752    let visibleCount = 0;
2753    let hiddenPastCount = 0;
2754
2755    eventItems.forEach(item => {
2756        const title = item.querySelector('.event-title-compact');
2757        const description = item.querySelector('.event-desc-compact');
2758        const dateTime = item.querySelector('.event-date-time');
2759
2760        // Build searchable text
2761        let searchableText = '';
2762        if (title) searchableText += title.textContent + ' ';
2763        if (description) searchableText += description.textContent + ' ';
2764        if (dateTime) searchableText += dateTime.textContent + ' ';
2765
2766        // Check if matches search using fuzzy matching
2767        const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm);
2768
2769        if (matches) {
2770            item.style.display = '';
2771            visibleCount++;
2772        } else {
2773            item.style.display = 'none';
2774            // Check if this is a past event
2775            if (item.classList.contains('event-past') || item.classList.contains('event-completed')) {
2776                hiddenPastCount++;
2777            }
2778        }
2779    });
2780
2781    // Update past events toggle if it exists
2782    const pastToggle = eventList.querySelector('.past-events-toggle');
2783    const pastLabel = eventList.querySelector('.past-events-label');
2784    const pastContent = document.getElementById('past-events-' + calId);
2785
2786    if (pastToggle && pastLabel && pastContent) {
2787        const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])');
2788        const totalPastVisible = visiblePastEvents.length;
2789
2790        if (totalPastVisible > 0) {
2791            pastLabel.textContent = `Past Events (${totalPastVisible})`;
2792            pastToggle.style.display = '';
2793        } else {
2794            pastToggle.style.display = 'none';
2795        }
2796    }
2797
2798    // Show "no results" message if nothing visible (only for month mode, not all-dates mode)
2799    let noResultsMsg = eventList.querySelector('.no-search-results');
2800    if (visibleCount === 0 && searchTerm && !isAllDatesMode) {
2801        if (!noResultsMsg) {
2802            noResultsMsg = document.createElement('p');
2803            noResultsMsg.className = 'no-search-results no-events-msg';
2804            noResultsMsg.textContent = 'No events match your search';
2805            eventList.appendChild(noResultsMsg);
2806        }
2807        noResultsMsg.style.display = 'block';
2808    } else if (noResultsMsg) {
2809        noResultsMsg.style.display = 'none';
2810    }
2811};
2812
2813// Toggle search mode between "this month" and "all dates"
2814window.toggleSearchMode = function(calId, namespace) {
2815    const searchMode = document.getElementById('search-mode-' + calId);
2816    const searchInput = document.getElementById('event-search-' + calId);
2817
2818    if (!searchMode) return;
2819
2820    const isAllDates = searchMode.classList.toggle('all-dates');
2821
2822    // Update button icon and title
2823    if (isAllDates) {
2824        searchMode.innerHTML = '��';
2825        searchMode.title = 'Searching all dates';
2826        if (searchInput) {
2827            searchInput.placeholder = 'Search all dates...';
2828        }
2829    } else {
2830        searchMode.innerHTML = '��';
2831        searchMode.title = 'Search this month only';
2832        if (searchInput) {
2833            searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : '�� Search...';
2834        }
2835    }
2836
2837    // Re-run search with current term
2838    if (searchInput && searchInput.value) {
2839        filterEvents(calId, searchInput.value);
2840    } else {
2841        // Clear any all-dates results
2842        const eventList = document.getElementById('eventlist-' + calId);
2843        if (eventList) {
2844            const resultsContainer = eventList.querySelector('.all-dates-results');
2845            if (resultsContainer) {
2846                resultsContainer.remove();
2847            }
2848            // Show normal event items
2849            eventList.querySelectorAll('.event-compact-item').forEach(item => {
2850                item.style.display = '';
2851            });
2852            const pastToggle = eventList.querySelector('.past-events-toggle');
2853            if (pastToggle) pastToggle.style.display = '';
2854        }
2855    }
2856};
2857
2858// Search all dates via AJAX
2859window.searchAllDates = function(calId, searchTerm) {
2860    const eventList = document.getElementById('eventlist-' + calId);
2861    if (!eventList) return;
2862
2863    // Get namespace from container
2864    const container = document.getElementById(calId);
2865    const namespace = container ? (container.dataset.namespace || '') : '';
2866
2867    // Hide normal event items
2868    eventList.querySelectorAll('.event-compact-item').forEach(item => {
2869        item.style.display = 'none';
2870    });
2871    const pastToggle = eventList.querySelector('.past-events-toggle');
2872    if (pastToggle) pastToggle.style.display = 'none';
2873
2874    // Remove old results container
2875    let resultsContainer = eventList.querySelector('.all-dates-results');
2876    if (resultsContainer) {
2877        resultsContainer.remove();
2878    }
2879
2880    // Create new results container
2881    resultsContainer = document.createElement('div');
2882    resultsContainer.className = 'all-dates-results';
2883    resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);">�� Searching all dates...</p>';
2884    eventList.appendChild(resultsContainer);
2885
2886    // Make AJAX request
2887    const params = new URLSearchParams({
2888        call: 'plugin_calendar',
2889        action: 'search_all',
2890        search: searchTerm,
2891        namespace: namespace,
2892        _: new Date().getTime()
2893    });
2894
2895    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
2896        method: 'POST',
2897        headers: {
2898            'Content-Type': 'application/x-www-form-urlencoded'
2899        },
2900        body: params.toString()
2901    })
2902    .then(r => r.json())
2903    .then(data => {
2904        if (data.success && data.results) {
2905            if (data.results.length === 0) {
2906                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>';
2907            } else {
2908                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>';
2909
2910                data.results.forEach(event => {
2911                    const dateObj = new Date(event.date + 'T00:00:00');
2912                    const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
2913                    const color = event.color || 'var(--text-bright, #00cc07)';
2914
2915                    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 + '\')">';
2916                    html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>';
2917                    html += '<div style="flex:1; min-width:0;">';
2918                    html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>';
2919                    html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay;
2920                    if (event.time) {
2921                        html += ' • ' + formatTimeRange(event.time, event.endTime);
2922                    }
2923                    html += '</div>';
2924                    if (event.namespace) {
2925                        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>';
2926                    }
2927                    html += '</div></div>';
2928                });
2929
2930                resultsContainer.innerHTML = html;
2931            }
2932        } else {
2933            resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>';
2934        }
2935    })
2936    .catch(err => {
2937        console.error('Search error:', err);
2938        resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>';
2939    });
2940};
2941
2942// Jump to a specific date (used by search results)
2943window.jumpToDate = function(calId, date, namespace) {
2944    const parts = date.split('-');
2945    const year = parseInt(parts[0]);
2946    const month = parseInt(parts[1]);
2947
2948    // Get container to check current month
2949    const container = document.getElementById(calId);
2950    const currentYear = container ? parseInt(container.dataset.year) : year;
2951    const currentMonth = container ? parseInt(container.dataset.month) : month;
2952
2953    // Get search elements
2954    const searchInput = document.getElementById('event-search-' + calId);
2955    const searchMode = document.getElementById('search-mode-' + calId);
2956    const searchClear = document.getElementById('search-clear-' + calId);
2957    const eventList = document.getElementById('eventlist-' + calId);
2958
2959    // Remove the all-dates results container
2960    if (eventList) {
2961        const resultsContainer = eventList.querySelector('.all-dates-results');
2962        if (resultsContainer) {
2963            resultsContainer.remove();
2964        }
2965        // Show normal event items again
2966        eventList.querySelectorAll('.event-compact-item').forEach(item => {
2967            item.style.display = '';
2968        });
2969        const pastToggle = eventList.querySelector('.past-events-toggle');
2970        if (pastToggle) pastToggle.style.display = '';
2971
2972        // Hide any no-results message
2973        const noResults = eventList.querySelector('.no-search-results');
2974        if (noResults) noResults.style.display = 'none';
2975    }
2976
2977    // Clear search input
2978    if (searchInput) {
2979        searchInput.value = '';
2980    }
2981
2982    // Hide clear button
2983    if (searchClear) {
2984        searchClear.style.display = 'none';
2985    }
2986
2987    // Switch back to month mode
2988    if (searchMode && searchMode.classList.contains('all-dates')) {
2989        searchMode.classList.remove('all-dates');
2990        searchMode.innerHTML = '��';
2991        searchMode.title = 'Search this month only';
2992        if (searchInput) {
2993            searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : '�� Search...';
2994        }
2995    }
2996
2997    // Check if we need to navigate to a different month
2998    if (year !== currentYear || month !== currentMonth) {
2999        // Navigate to the target month, then show popup
3000        navCalendar(calId, year, month, namespace);
3001
3002        // After navigation completes, show the day popup
3003        setTimeout(() => {
3004            showDayPopup(calId, date, namespace);
3005        }, 400);
3006    } else {
3007        // Same month - just show the popup
3008        showDayPopup(calId, date, namespace);
3009    }
3010};
3011
3012// Clear event search
3013window.clearEventSearch = function(calId) {
3014    const searchInput = document.getElementById('event-search-' + calId);
3015    if (searchInput) {
3016        searchInput.value = '';
3017        filterEvents(calId, '');
3018        searchInput.focus();
3019    }
3020};
3021
3022// ============================================
3023// PINK THEME - GLOWING PARTICLE EFFECTS
3024// ============================================
3025
3026// Create glowing pink particle effects for pink theme
3027(function() {
3028    let pinkThemeActive = false;
3029    let trailTimer = null;
3030    let pixelTimer = null;
3031
3032    // Check if pink theme is active
3033    function checkPinkTheme() {
3034        const pinkCalendars = document.querySelectorAll('.calendar-theme-pink');
3035        pinkThemeActive = pinkCalendars.length > 0;
3036        return pinkThemeActive;
3037    }
3038
3039    // Create trail particle
3040    function createTrailParticle(clientX, clientY) {
3041        if (!pinkThemeActive) return;
3042
3043        const trail = document.createElement('div');
3044        trail.className = 'pink-cursor-trail';
3045        trail.style.left = clientX + 'px';
3046        trail.style.top = clientY + 'px';
3047        trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards';
3048
3049        document.body.appendChild(trail);
3050
3051        setTimeout(function() {
3052            trail.remove();
3053        }, 500);
3054    }
3055
3056    // Create pixel sparkles
3057    function createPixelSparkles(clientX, clientY) {
3058        if (!pinkThemeActive || pixelTimer) return;
3059
3060        const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels
3061
3062        for (let i = 0; i < pixelCount; i++) {
3063            const pixel = document.createElement('div');
3064            pixel.className = 'pink-pixel-sparkle';
3065
3066            // Random offset from cursor
3067            const offsetX = (Math.random() - 0.5) * 30;
3068            const offsetY = (Math.random() - 0.5) * 30;
3069
3070            pixel.style.left = (clientX + offsetX) + 'px';
3071            pixel.style.top = (clientY + offsetY) + 'px';
3072
3073            // Random color - bright neon pinks and whites
3074            const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
3075            const color = colors[Math.floor(Math.random() * colors.length)];
3076            pixel.style.background = color;
3077            pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff';
3078
3079            // Random animation
3080            if (Math.random() > 0.5) {
3081                pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards';
3082            } else {
3083                pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards';
3084            }
3085
3086            document.body.appendChild(pixel);
3087
3088            setTimeout(function() {
3089                pixel.remove();
3090            }, 800);
3091        }
3092
3093        pixelTimer = setTimeout(function() {
3094            pixelTimer = null;
3095        }, 40);
3096    }
3097
3098    // Create explosion
3099    function createExplosion(clientX, clientY) {
3100        if (!pinkThemeActive) return;
3101
3102        const particleCount = 25;
3103        const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff'];
3104
3105        // Add hearts to explosion (8-12 hearts)
3106        const heartCount = 8 + Math.floor(Math.random() * 5);
3107        for (let i = 0; i < heartCount; i++) {
3108            const heart = document.createElement('div');
3109            heart.textContent = '��';
3110            heart.style.position = 'fixed';
3111            heart.style.left = clientX + 'px';
3112            heart.style.top = clientY + 'px';
3113            heart.style.pointerEvents = 'none';
3114            heart.style.zIndex = '9999999';
3115            heart.style.fontSize = (12 + Math.random() * 16) + 'px';
3116
3117            // Random direction
3118            const angle = Math.random() * Math.PI * 2;
3119            const velocity = 60 + Math.random() * 80;
3120            const tx = Math.cos(angle) * velocity;
3121            const ty = Math.sin(angle) * velocity;
3122
3123            heart.style.setProperty('--tx', tx + 'px');
3124            heart.style.setProperty('--ty', ty + 'px');
3125
3126            const duration = 0.8 + Math.random() * 0.4;
3127            heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
3128
3129            document.body.appendChild(heart);
3130
3131            setTimeout(function() {
3132                heart.remove();
3133            }, duration * 1000);
3134        }
3135
3136        // Main explosion particles
3137        for (let i = 0; i < particleCount; i++) {
3138            const particle = document.createElement('div');
3139            particle.className = 'pink-particle';
3140
3141            const color = colors[Math.floor(Math.random() * colors.length)];
3142            particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)';
3143            particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color;
3144
3145            particle.style.left = clientX + 'px';
3146            particle.style.top = clientY + 'px';
3147
3148            const angle = (Math.PI * 2 * i) / particleCount;
3149            const velocity = 50 + Math.random() * 100;
3150            const tx = Math.cos(angle) * velocity;
3151            const ty = Math.sin(angle) * velocity;
3152
3153            particle.style.setProperty('--tx', tx + 'px');
3154            particle.style.setProperty('--ty', ty + 'px');
3155
3156            const size = 4 + Math.random() * 6;
3157            particle.style.width = size + 'px';
3158            particle.style.height = size + 'px';
3159
3160            const duration = 0.6 + Math.random() * 0.4;
3161            particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
3162
3163            document.body.appendChild(particle);
3164
3165            setTimeout(function() {
3166                particle.remove();
3167            }, duration * 1000);
3168        }
3169
3170        // Pixel sparkles
3171        const pixelSparkleCount = 40;
3172
3173        for (let i = 0; i < pixelSparkleCount; i++) {
3174            const pixel = document.createElement('div');
3175            pixel.className = 'pink-pixel-sparkle';
3176
3177            const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
3178            const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)];
3179            pixel.style.background = pixelColor;
3180            pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff';
3181
3182            const angle = Math.random() * Math.PI * 2;
3183            const distance = 30 + Math.random() * 80;
3184            const offsetX = Math.cos(angle) * distance;
3185            const offsetY = Math.sin(angle) * distance;
3186
3187            pixel.style.left = clientX + 'px';
3188            pixel.style.top = clientY + 'px';
3189            pixel.style.setProperty('--tx', offsetX + 'px');
3190            pixel.style.setProperty('--ty', offsetY + 'px');
3191
3192            const pixelSize = 1 + Math.random() * 2;
3193            pixel.style.width = pixelSize + 'px';
3194            pixel.style.height = pixelSize + 'px';
3195
3196            const duration = 0.4 + Math.random() * 0.4;
3197            if (Math.random() > 0.5) {
3198                pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards';
3199            } else {
3200                pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
3201            }
3202
3203            document.body.appendChild(pixel);
3204
3205            setTimeout(function() {
3206                pixel.remove();
3207            }, duration * 1000);
3208        }
3209
3210        // Flash
3211        const flash = document.createElement('div');
3212        flash.style.position = 'fixed';
3213        flash.style.left = clientX + 'px';
3214        flash.style.top = clientY + 'px';
3215        flash.style.width = '40px';
3216        flash.style.height = '40px';
3217        flash.style.borderRadius = '50%';
3218        flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)';
3219        flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4';
3220        flash.style.pointerEvents = 'none';
3221        flash.style.zIndex = '9999999';  // Above everything including dialogs
3222        flash.style.transform = 'translate(-50%, -50%)';
3223        flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards';
3224
3225        document.body.appendChild(flash);
3226
3227        setTimeout(function() {
3228            flash.remove();
3229        }, 300);
3230    }
3231
3232    function initPinkParticles() {
3233        if (!checkPinkTheme()) return;
3234
3235        // Use capture phase to catch events before stopPropagation
3236        document.addEventListener('mousemove', function(e) {
3237            if (!pinkThemeActive) return;
3238
3239            createTrailParticle(e.clientX, e.clientY);
3240            createPixelSparkles(e.clientX, e.clientY);
3241        }, true); // Capture phase!
3242
3243        // Throttle main trail
3244        document.addEventListener('mousemove', function(e) {
3245            if (!pinkThemeActive || trailTimer) return;
3246
3247            trailTimer = setTimeout(function() {
3248                trailTimer = null;
3249            }, 30);
3250        }, true); // Capture phase!
3251
3252        // Click explosion - use capture phase
3253        document.addEventListener('click', function(e) {
3254            if (!pinkThemeActive) return;
3255
3256            createExplosion(e.clientX, e.clientY);
3257        }, true); // Capture phase!
3258    }
3259
3260    // Initialize on load
3261    if (document.readyState === 'loading') {
3262        document.addEventListener('DOMContentLoaded', initPinkParticles);
3263    } else {
3264        initPinkParticles();
3265    }
3266
3267    // Re-check theme if calendar is dynamically added
3268    // Must wait for document.body to exist
3269    function setupMutationObserver() {
3270        if (typeof MutationObserver !== 'undefined' && document.body) {
3271            const observer = new MutationObserver(function(mutations) {
3272                mutations.forEach(function(mutation) {
3273                    if (mutation.addedNodes.length > 0) {
3274                        mutation.addedNodes.forEach(function(node) {
3275                            if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) {
3276                                checkPinkTheme();
3277                                initPinkParticles();
3278                            }
3279                        });
3280                    }
3281                });
3282            });
3283
3284            observer.observe(document.body, {
3285                childList: true,
3286                subtree: true
3287            });
3288        }
3289    }
3290
3291    // Setup observer when DOM is ready
3292    if (document.readyState === 'loading') {
3293        document.addEventListener('DOMContentLoaded', setupMutationObserver);
3294    } else {
3295        setupMutationObserver();
3296    }
3297})();
3298
3299// End of calendar plugin JavaScript
3300