xref: /plugin/calendar/calendar-main.js (revision 7e8ea635dd19058d6f7c428adbbe02d9702096d7)
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                    // Show colored stacked bars for each event
395                    html += '<div class="event-indicators">';
396                    for (const evt of sortedEvents) {
397                        const eventId = evt.id || '';
398                        const eventColor = evt.color || '#3498db';
399                        const eventTitle = evt.title || 'Event';
400                        const eventTime = evt.time || '';
401                        const originalDate = evt._original_date || dateKey;
402                        const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true;
403                        const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true;
404
405                        let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed';
406                        if (!isFirstDay) barClass += ' event-bar-continues';
407                        if (!isLastDay) barClass += ' event-bar-continuing';
408
409                        html += `<span class="event-bar ${barClass}" `;
410                        html += `style="background: ${eventColor};" `;
411                        html += `title="${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `;
412                        html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');"></span>`;
413                    }
414                    html += '</div>';
415                }
416
417                html += '</td>';
418                currentDay++;
419            }
420        }
421        html += '</tr>';
422    }
423
424    tbody.innerHTML = html;
425
426    // Update Today button with current namespace
427    const todayBtn = container.querySelector('.cal-today-btn');
428    if (todayBtn) {
429        todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`);
430    }
431
432    // Update month picker with current namespace
433    const monthPicker = container.querySelector('.calendar-month-picker');
434    if (monthPicker) {
435        monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`);
436    }
437
438    // Rebuild event list - server already filtered to current month
439    const eventList = container.querySelector('.event-list-compact');
440    eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
441
442    // Auto-scroll to first future event (past events will be above viewport)
443    setTimeout(() => {
444        const firstFuture = eventList.querySelector('[data-first-future="true"]');
445        if (firstFuture) {
446            firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' });
447        }
448    }, 100);
449
450    // Update title
451    const title = container.querySelector('#eventlist-title-' + calId);
452    title.textContent = 'Events';
453};
454
455// Render event list from data
456window.renderEventListFromData = function(events, calId, namespace, year, month) {
457    if (!events || Object.keys(events).length === 0) {
458        return '<p class="no-events-msg">No events this month</p>';
459    }
460
461    // Get theme data from container
462    const container = document.getElementById(calId);
463    let themeStyles = {};
464    if (container && container.dataset.themeStyles) {
465        try {
466            themeStyles = JSON.parse(container.dataset.themeStyles);
467        } catch (e) {
468            console.error('Failed to parse theme styles in renderEventListFromData:', e);
469        }
470    }
471
472    // Check for time conflicts
473    events = checkTimeConflicts(events, null);
474
475    let pastHtml = '';
476    let futureHtml = '';
477    let pastCount = 0;
478
479    const sortedDates = Object.keys(events).sort();
480    const today = new Date();
481    today.setHours(0, 0, 0, 0);
482    const todayStr = today.toISOString().split('T')[0];
483
484    // Helper function to check if event is past (with 15-minute grace period)
485    const isEventPast = function(dateKey, time) {
486        // If event is on a past date, it's definitely past
487        if (dateKey < todayStr) {
488            return true;
489        }
490
491        // If event is on a future date, it's definitely not past
492        if (dateKey > todayStr) {
493            return false;
494        }
495
496        // Event is today - check time with grace period
497        if (time && time.trim() !== '') {
498            try {
499                const now = new Date();
500                const eventDateTime = new Date(dateKey + 'T' + time);
501
502                // Add 15-minute grace period
503                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
504
505                // Event is past if current time > event time + 15 minutes
506                return now > gracePeriodEnd;
507            } catch (e) {
508                // If time parsing fails, treat as future
509                return false;
510            }
511        }
512
513        // No time specified for today's event, treat as future
514        return false;
515    };
516
517    // Filter events to only current month if year/month provided
518    const monthStart = year && month ? new Date(year, month - 1, 1) : null;
519    const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null;
520
521    for (const dateKey of sortedDates) {
522        // Skip events not in current month if filtering
523        if (monthStart && monthEnd) {
524            const eventDate = new Date(dateKey + 'T00:00:00');
525
526            if (eventDate < monthStart || eventDate > monthEnd) {
527                continue;
528            }
529        }
530
531        // Sort events within this day by time (all-day events at top)
532        const dayEvents = events[dateKey];
533        dayEvents.sort((a, b) => {
534            const timeA = a.time && a.time.trim() !== '' ? a.time : null;
535            const timeB = b.time && b.time.trim() !== '' ? b.time : null;
536
537            // All-day events (no time) go to the TOP
538            if (timeA === null && timeB !== null) return -1; // A before B
539            if (timeA !== null && timeB === null) return 1;  // A after B
540            if (timeA === null && timeB === null) return 0;  // Both all-day, equal
541
542            // Both have times, sort chronologically
543            return timeA.localeCompare(timeB);
544        });
545
546        for (const event of dayEvents) {
547            const isTask = event.isTask || false;
548            const completed = event.completed || false;
549
550            // Use helper function to determine if event is past (with grace period)
551            const isPast = isEventPast(dateKey, event.time);
552            const isPastDue = isPast && isTask && !completed;
553
554            // Determine if this goes in past section
555            const isPastOrCompleted = (isPast && (!isTask || completed)) || completed;
556
557            const eventHtml = renderEventItem(event, dateKey, calId, namespace);
558
559            if (isPastOrCompleted) {
560                pastCount++;
561                pastHtml += eventHtml;
562            } else {
563                futureHtml += eventHtml;
564            }
565        }
566    }
567
568    let html = '';
569
570    // Add collapsible past events section if any exist
571    if (pastCount > 0) {
572        html += '<div class="past-events-section">';
573        html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">';
574        html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> ';
575        html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>';
576        html += '</div>';
577        html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">';
578        html += pastHtml;
579        html += '</div>';
580        html += '</div>';
581    } else {
582    }
583
584    // Add future events
585    html += futureHtml;
586
587
588    if (!html) {
589        return '<p class="no-events-msg">No events this month</p>';
590    }
591
592    return html;
593};
594
595// Show day popup with events when clicking a date
596window.showDayPopup = function(calId, date, namespace) {
597    // Get events for this calendar
598    const eventsDataEl = document.getElementById('events-data-' + calId);
599    let events = {};
600
601    if (eventsDataEl) {
602        try {
603            events = JSON.parse(eventsDataEl.textContent);
604        } catch (e) {
605            console.error('Failed to parse events data:', e);
606        }
607    }
608
609    const dayEvents = events[date] || [];
610
611    // Check for conflicts on this day
612    const dayEventsObj = {[date]: dayEvents};
613    const checkedEvents = checkTimeConflicts(dayEventsObj, null);
614    const dayEventsWithConflicts = checkedEvents[date] || dayEvents;
615
616    // Sort events: all-day at top, then chronological by time
617    dayEventsWithConflicts.sort((a, b) => {
618        const timeA = a.time && a.time.trim() !== '' ? a.time : null;
619        const timeB = b.time && b.time.trim() !== '' ? b.time : null;
620
621        // All-day events (no time) go to the TOP
622        if (timeA === null && timeB !== null) return -1; // A before B
623        if (timeA !== null && timeB === null) return 1;  // A after B
624        if (timeA === null && timeB === null) return 0;  // Both all-day, equal
625
626        // Both have times, sort chronologically
627        return timeA.localeCompare(timeB);
628    });
629
630    const dateObj = new Date(date + 'T00:00:00');
631    const displayDate = dateObj.toLocaleDateString('en-US', {
632        weekday: 'long',
633        month: 'long',
634        day: 'numeric',
635        year: 'numeric'
636    });
637
638    // Create popup
639    let popup = document.getElementById('day-popup-' + calId);
640    if (!popup) {
641        popup = document.createElement('div');
642        popup.id = 'day-popup-' + calId;
643        popup.className = 'day-popup';
644        document.body.appendChild(popup);
645    }
646
647    // Get theme styles
648    const container = document.getElementById(calId);
649    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
650    const theme = container ? container.dataset.theme : 'matrix';
651
652    let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>';
653    html += '<div class="day-popup-content">';
654    html += '<div class="day-popup-header">';
655    html += '<h4>' + displayDate + '</h4>';
656    html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>';
657    html += '</div>';
658
659    html += '<div class="day-popup-body">';
660
661    if (dayEventsWithConflicts.length === 0) {
662        html += '<p class="no-events-msg">No events on this day</p>';
663    } else {
664        html += '<div class="popup-events-list">';
665        dayEventsWithConflicts.forEach(event => {
666            const color = event.color || '#3498db';
667
668            // Use individual event namespace if available (for multi-namespace support)
669            const eventNamespace = event._namespace !== undefined ? event._namespace : namespace;
670
671            // Check if this is a continuation (event started before this date)
672            const originalStartDate = event.originalStartDate || event._dateKey || date;
673            const isContinuation = originalStartDate < date;
674
675            // Convert to 12-hour format and handle time ranges
676            let displayTime = '';
677            if (event.time) {
678                displayTime = formatTimeRange(event.time, event.endTime);
679            }
680
681            // Multi-day indicator
682            let multiDay = '';
683            if (event.endDate && event.endDate !== date) {
684                const endObj = new Date(event.endDate + 'T00:00:00');
685                multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
686                    month: 'short',
687                    day: 'numeric'
688                });
689            }
690
691            // Continuation message
692            if (isContinuation) {
693                const startObj = new Date(originalStartDate + 'T00:00:00');
694                const startDisplay = startObj.toLocaleDateString('en-US', {
695                    weekday: 'short',
696                    month: 'short',
697                    day: 'numeric'
698                });
699                html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>';
700            }
701
702            html += '<div class="popup-event-item">';
703            html += '<div class="event-color-bar" style="background: ' + color + ';"></div>';
704            html += '<div class="popup-event-content">';
705
706            // Single line with title, time, date range, namespace, and actions
707            html += '<div class="popup-event-main-row">';
708            html += '<div class="popup-event-info-inline">';
709            html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>';
710            if (displayTime) {
711                html += '<span class="popup-event-time">�� ' + displayTime + '</span>';
712            }
713            if (multiDay) {
714                html += '<span class="popup-event-multiday">' + multiDay + '</span>';
715            }
716            if (eventNamespace) {
717                html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>';
718            }
719
720            // Add conflict warning badge if event has conflicts
721            if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
722                // Build conflict list for tooltip
723                let conflictList = [];
724                event.conflictsWith.forEach(conflict => {
725                    let conflictText = conflict.title;
726                    if (conflict.time) {
727                        conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
728                    }
729                    conflictList.push(conflictText);
730                });
731
732                html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
733            }
734
735            html += '</div>';
736            html += '<div class="popup-event-actions">';
737            html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>';
738            html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">��️</button>';
739            html += '</div>';
740            html += '</div>';
741
742            // Description on separate line if present
743            if (event.description) {
744                html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>';
745            }
746
747            html += '</div></div>';
748        });
749        html += '</div>';
750    }
751
752    html += '</div>';
753
754    html += '<div class="day-popup-footer">';
755    html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>';
756    html += '</div>';
757
758    html += '</div>';
759
760    popup.innerHTML = html;
761    popup.style.display = 'flex';
762
763    // Propagate CSS vars from calendar container to popup (popup is outside container in DOM)
764    if (container) {
765        propagateThemeVars(calId, popup.querySelector('.day-popup-content'));
766    }
767};
768
769// Close day popup
770window.closeDayPopup = function(calId) {
771    const popup = document.getElementById('day-popup-' + calId);
772    if (popup) {
773        popup.style.display = 'none';
774    }
775};
776
777// Show events for a specific day (for event list panel)
778window.showDayEvents = function(calId, date, namespace) {
779    const params = new URLSearchParams({
780        call: 'plugin_calendar',
781        action: 'load_month',
782        year: date.split('-')[0],
783        month: parseInt(date.split('-')[1]),
784        namespace: namespace,
785        _: new Date().getTime() // Cache buster
786    });
787
788    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
789        method: 'POST',
790        headers: {
791            'Content-Type': 'application/x-www-form-urlencoded',
792            'Cache-Control': 'no-cache, no-store, must-revalidate',
793            'Pragma': 'no-cache'
794        },
795        body: params.toString()
796    })
797    .then(r => r.json())
798    .then(data => {
799        if (data.success) {
800            const eventList = document.getElementById('eventlist-' + calId);
801            const events = data.events;
802            const title = document.getElementById('eventlist-title-' + calId);
803
804            const dateObj = new Date(date + 'T00:00:00');
805            const displayDate = dateObj.toLocaleDateString('en-US', {
806                weekday: 'short',
807                month: 'short',
808                day: 'numeric'
809            });
810
811            title.textContent = 'Events - ' + displayDate;
812
813            // Filter events for this day
814            const dayEvents = events[date] || [];
815
816            if (dayEvents.length === 0) {
817                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>';
818            } else {
819                let html = '';
820                dayEvents.forEach(event => {
821                    html += renderEventItem(event, date, calId, namespace);
822                });
823                eventList.innerHTML = html;
824            }
825        }
826    })
827    .catch(err => console.error('Error:', err));
828};
829
830// Render a single event item
831window.renderEventItem = function(event, date, calId, namespace) {
832    // Get theme data from container
833    const container = document.getElementById(calId);
834    let themeStyles = {};
835    if (container && container.dataset.themeStyles) {
836        try {
837            themeStyles = JSON.parse(container.dataset.themeStyles);
838        } catch (e) {
839            console.error('Failed to parse theme styles:', e);
840        }
841    }
842
843    // Check if this event is in the past or today (with 15-minute grace period)
844    const today = new Date();
845    today.setHours(0, 0, 0, 0);
846    const todayStr = today.toISOString().split('T')[0];
847    const eventDate = new Date(date + 'T00:00:00');
848
849    // Helper to determine if event is past with grace period
850    let isPast;
851    if (date < todayStr) {
852        isPast = true; // Past date
853    } else if (date > todayStr) {
854        isPast = false; // Future date
855    } else {
856        // Today - check time with grace period
857        if (event.time && event.time.trim() !== '') {
858            try {
859                const now = new Date();
860                const eventDateTime = new Date(date + 'T' + event.time);
861                const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000);
862                isPast = now > gracePeriodEnd;
863            } catch (e) {
864                isPast = false;
865            }
866        } else {
867            isPast = false; // No time, treat as future
868        }
869    }
870
871    const isToday = eventDate.getTime() === today.getTime();
872
873    // Format date display with day of week
874    const displayDateKey = event.originalStartDate || date;
875    const dateObj = new Date(displayDateKey + 'T00:00:00');
876    const displayDate = dateObj.toLocaleDateString('en-US', {
877        weekday: 'short',
878        month: 'short',
879        day: 'numeric'
880    });
881
882    // Convert to 12-hour format and handle time ranges
883    let displayTime = '';
884    if (event.time) {
885        displayTime = formatTimeRange(event.time, event.endTime);
886    }
887
888    // Multi-day indicator
889    let multiDay = '';
890    if (event.endDate && event.endDate !== displayDateKey) {
891        const endObj = new Date(event.endDate + 'T00:00:00');
892        multiDay = ' → ' + endObj.toLocaleDateString('en-US', {
893            weekday: 'short',
894            month: 'short',
895            day: 'numeric'
896        });
897    }
898
899    const completedClass = event.completed ? ' event-completed' : '';
900    const isTask = event.isTask || false;
901    const completed = event.completed || false;
902    const isPastDue = isPast && isTask && !completed;
903    const pastClass = (isPast && !isPastDue) ? ' event-past' : '';
904    const pastDueClass = isPastDue ? ' event-pastdue' : '';
905    const color = event.color || '#3498db';
906
907    // Only inline style needed: border-left-color for event color indicator
908    let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ' !important;" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">';
909
910    html += '<div class="event-info">';
911    html += '<div class="event-title-row">';
912    html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>';
913    html += '</div>';
914
915    // Show meta and description for non-past events AND past due tasks
916    if (!isPast || isPastDue) {
917        html += '<div class="event-meta-compact">';
918        html += '<span class="event-date-time">' + displayDate + multiDay;
919        if (displayTime) {
920            html += ' • ' + displayTime;
921        }
922        // Add PAST DUE or TODAY badge
923        if (isPastDue) {
924            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>';
925        } else if (isToday) {
926            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>';
927        }
928        // Add namespace badge
929        let eventNamespace = event.namespace || '';
930        if (!eventNamespace && event._namespace !== undefined) {
931            eventNamespace = event._namespace;
932        }
933        if (eventNamespace) {
934            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>';
935        }
936        // Add conflict warning if event has time conflicts
937        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
938            let conflictList = [];
939            event.conflictsWith.forEach(conflict => {
940                let conflictText = conflict.title;
941                if (conflict.time) {
942                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
943                }
944                conflictList.push(conflictText);
945            });
946
947            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
948        }
949        html += '</span>';
950        html += '</div>';
951
952        if (event.description) {
953            html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>';
954        }
955    } else {
956        // For past events (not past due), store data in hidden divs for expand/collapse
957        html += '<div class="event-meta-compact" style="display: none;">';
958        html += '<span class="event-date-time">' + displayDate + multiDay;
959        if (displayTime) {
960            html += ' • ' + displayTime;
961        }
962        // Add namespace badge for past events too
963        let eventNamespace = event.namespace || '';
964        if (!eventNamespace && event._namespace !== undefined) {
965            eventNamespace = event._namespace;
966        }
967        if (eventNamespace) {
968            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>';
969        }
970        // Add conflict warning for past events too
971        if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) {
972            let conflictList = [];
973            event.conflictsWith.forEach(conflict => {
974                let conflictText = conflict.title;
975                if (conflict.time) {
976                    conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')';
977                }
978                conflictList.push(conflictText);
979            });
980
981            html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>';
982        }
983        html += '</span>';
984        html += '</div>';
985
986        if (event.description) {
987            html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>';
988        }
989    }
990
991    html += '</div>'; // event-info
992
993    // Use stored namespace from event, fallback to _namespace, then passed namespace
994    let buttonNamespace = event.namespace || '';
995    if (!buttonNamespace && event._namespace !== undefined) {
996        buttonNamespace = event._namespace;
997    }
998    if (!buttonNamespace) {
999        buttonNamespace = namespace;
1000    }
1001
1002    html += '<div class="event-actions-compact">';
1003    html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">��️</button>';
1004    html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>';
1005    html += '</div>';
1006
1007    // Checkbox for tasks - ON THE FAR RIGHT
1008    if (isTask) {
1009        const checked = completed ? 'checked' : '';
1010        html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">';
1011    }
1012
1013    html += '</div>';
1014
1015    return html;
1016};
1017
1018// Render description with rich content support
1019window.renderDescription = function(description) {
1020    if (!description) return '';
1021
1022    // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping)
1023    // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00
1024
1025    let rendered = description;
1026    const tokens = [];
1027    let tokenIndex = 0;
1028
1029    // Convert DokuWiki image syntax {{image.jpg}} to tokens
1030    rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) {
1031        imagePath = imagePath.trim();
1032        alt = alt ? alt.trim() : '';
1033
1034        let imageHtml;
1035        // Handle external URLs
1036        if (imagePath.match(/^https?:\/\//)) {
1037            imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1038        } else {
1039            // Handle internal DokuWiki images
1040            const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath);
1041            imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />';
1042        }
1043
1044        const token = '\x00TOKEN' + tokenIndex + '\x00';
1045        tokens[tokenIndex] = imageHtml;
1046        tokenIndex++;
1047        return token;
1048    });
1049
1050    // Convert DokuWiki link syntax [[link|text]] to tokens
1051    rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) {
1052        link = link.trim();
1053        text = text ? text.trim() : link;
1054
1055        let linkHtml;
1056        // Handle external URLs
1057        if (link.match(/^https?:\/\//)) {
1058            linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1059        } else {
1060            // Handle internal DokuWiki links with section anchors
1061            const hashIndex = link.indexOf('#');
1062            let pagePart = link;
1063            let sectionPart = '';
1064
1065            if (hashIndex !== -1) {
1066                pagePart = link.substring(0, hashIndex);
1067                sectionPart = link.substring(hashIndex); // Includes the #
1068            }
1069
1070            const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart;
1071            linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>';
1072        }
1073
1074        const token = '\x00TOKEN' + tokenIndex + '\x00';
1075        tokens[tokenIndex] = linkHtml;
1076        tokenIndex++;
1077        return token;
1078    });
1079
1080    // Convert markdown-style links [text](url) to tokens
1081    rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
1082        text = text.trim();
1083        url = url.trim();
1084
1085        let linkHtml;
1086        if (url.match(/^https?:\/\//)) {
1087            linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>';
1088        } else {
1089            linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>';
1090        }
1091
1092        const token = '\x00TOKEN' + tokenIndex + '\x00';
1093        tokens[tokenIndex] = linkHtml;
1094        tokenIndex++;
1095        return token;
1096    });
1097
1098    // Convert plain URLs to tokens
1099    rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) {
1100        const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>';
1101        const token = '\x00TOKEN' + tokenIndex + '\x00';
1102        tokens[tokenIndex] = linkHtml;
1103        tokenIndex++;
1104        return token;
1105    });
1106
1107    // NOW escape the remaining text (tokens are protected with null bytes)
1108    rendered = escapeHtml(rendered);
1109
1110    // Convert newlines to <br>
1111    rendered = rendered.replace(/\n/g, '<br>');
1112
1113    // DokuWiki text formatting (on escaped text)
1114    // Bold: **text** or __text__
1115    rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1116    rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>');
1117
1118    // Italic: //text//
1119    rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>');
1120
1121    // Strikethrough: <del>text</del>
1122    rendered = rendered.replace(/&lt;del&gt;(.+?)&lt;\/del&gt;/g, '<del>$1</del>');
1123
1124    // Monospace: ''text''
1125    rendered = rendered.replace(/&#39;&#39;(.+?)&#39;&#39;/g, '<code>$1</code>');
1126
1127    // Subscript: <sub>text</sub>
1128    rendered = rendered.replace(/&lt;sub&gt;(.+?)&lt;\/sub&gt;/g, '<sub>$1</sub>');
1129
1130    // Superscript: <sup>text</sup>
1131    rendered = rendered.replace(/&lt;sup&gt;(.+?)&lt;\/sup&gt;/g, '<sup>$1</sup>');
1132
1133    // Restore tokens (replace with actual HTML)
1134    for (let i = 0; i < tokens.length; i++) {
1135        const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g');
1136        rendered = rendered.replace(tokenPattern, tokens[i]);
1137    }
1138
1139    return rendered;
1140}
1141
1142// Open add event dialog
1143window.openAddEvent = function(calId, namespace, date) {
1144    const dialog = document.getElementById('dialog-' + calId);
1145    const form = document.getElementById('eventform-' + calId);
1146    const title = document.getElementById('dialog-title-' + calId);
1147    const dateField = document.getElementById('event-date-' + calId);
1148
1149    if (!dateField) {
1150        console.error('Date field not found! ID: event-date-' + calId);
1151        return;
1152    }
1153
1154    // Check if there's a filtered namespace active (only for regular calendars)
1155    const calendar = document.getElementById(calId);
1156    const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null;
1157
1158    // Use filtered namespace if available, otherwise use the passed namespace
1159    const effectiveNamespace = filteredNamespace || namespace;
1160
1161
1162    // Reset form
1163    form.reset();
1164    document.getElementById('event-id-' + calId).value = '';
1165
1166    // Store the effective namespace in a hidden field or data attribute
1167    form.dataset.effectiveNamespace = effectiveNamespace;
1168
1169    // Set namespace dropdown to effective namespace
1170    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1171    if (namespaceSelect) {
1172        if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) {
1173            // Set to specific namespace if not wildcard or multi-namespace
1174            namespaceSelect.value = effectiveNamespace;
1175        } else {
1176            // Default to empty (default namespace) for wildcard/multi views
1177            namespaceSelect.value = '';
1178        }
1179    }
1180
1181    // Clear event namespace from previous edits
1182    delete form.dataset.eventNamespace;
1183
1184    // Set date - use local date, not UTC
1185    let defaultDate = date;
1186    if (!defaultDate) {
1187        // Get the currently displayed month from the calendar container
1188        const container = document.getElementById(calId);
1189        const displayedYear = parseInt(container.getAttribute('data-year'));
1190        const displayedMonth = parseInt(container.getAttribute('data-month'));
1191
1192
1193        if (displayedYear && displayedMonth) {
1194            // Use first day of the displayed month
1195            const year = displayedYear;
1196            const month = String(displayedMonth).padStart(2, '0');
1197            defaultDate = `${year}-${month}-01`;
1198        } else {
1199            // Fallback to today if attributes not found
1200            const today = new Date();
1201            const year = today.getFullYear();
1202            const month = String(today.getMonth() + 1).padStart(2, '0');
1203            const day = String(today.getDate()).padStart(2, '0');
1204            defaultDate = `${year}-${month}-${day}`;
1205        }
1206    }
1207    dateField.value = defaultDate;
1208    dateField.removeAttribute('data-original-date');
1209
1210    // Also set the end date field to the same default (user can change it)
1211    const endDateField = document.getElementById('event-end-date-' + calId);
1212    if (endDateField) {
1213        endDateField.value = ''; // Empty by default (single-day event)
1214        // Set min attribute to help the date picker open on the right month
1215        endDateField.setAttribute('min', defaultDate);
1216    }
1217
1218    // Set default color
1219    document.getElementById('event-color-' + calId).value = '#3498db';
1220
1221    // Initialize end time dropdown (disabled by default since no start time set)
1222    const endTimeField = document.getElementById('event-end-time-' + calId);
1223    if (endTimeField) {
1224        endTimeField.disabled = true;
1225        endTimeField.value = '';
1226    }
1227
1228    // Initialize namespace search
1229    initNamespaceSearch(calId);
1230
1231    // Set title
1232    title.textContent = 'Add Event';
1233
1234    // Show dialog
1235    dialog.style.display = 'flex';
1236
1237    // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates)
1238    propagateThemeVars(calId, dialog);
1239
1240    // Focus title field
1241    setTimeout(() => {
1242        const titleField = document.getElementById('event-title-' + calId);
1243        if (titleField) titleField.focus();
1244    }, 100);
1245};
1246
1247// Edit event
1248window.editEvent = function(calId, eventId, date, namespace) {
1249    const params = new URLSearchParams({
1250        call: 'plugin_calendar',
1251        action: 'get_event',
1252        namespace: namespace,
1253        date: date,
1254        eventId: eventId
1255    });
1256
1257    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1258        method: 'POST',
1259        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1260        body: params.toString()
1261    })
1262    .then(r => r.json())
1263    .then(data => {
1264        if (data.success && data.event) {
1265            const event = data.event;
1266            const dialog = document.getElementById('dialog-' + calId);
1267            const title = document.getElementById('dialog-title-' + calId);
1268            const dateField = document.getElementById('event-date-' + calId);
1269            const form = document.getElementById('eventform-' + calId);
1270
1271            if (!dateField) {
1272                console.error('Date field not found when editing!');
1273                return;
1274            }
1275
1276            // Store the event's actual namespace for saving (important for namespace=* views)
1277            if (event.namespace !== undefined) {
1278                form.dataset.eventNamespace = event.namespace;
1279            }
1280
1281            // Populate form
1282            document.getElementById('event-id-' + calId).value = event.id;
1283            dateField.value = date;
1284            dateField.setAttribute('data-original-date', date);
1285
1286            const endDateField = document.getElementById('event-end-date-' + calId);
1287            endDateField.value = event.endDate || '';
1288            // Set min attribute to help date picker open on the start date's month
1289            endDateField.setAttribute('min', date);
1290
1291            document.getElementById('event-title-' + calId).value = event.title;
1292            document.getElementById('event-time-' + calId).value = event.time || '';
1293            document.getElementById('event-end-time-' + calId).value = event.endTime || '';
1294            document.getElementById('event-color-' + calId).value = event.color || '#3498db';
1295            document.getElementById('event-desc-' + calId).value = event.description || '';
1296            document.getElementById('event-is-task-' + calId).checked = event.isTask || false;
1297
1298            // Update end time options based on start time
1299            if (event.time) {
1300                updateEndTimeOptions(calId);
1301            }
1302
1303            // Initialize namespace search
1304            initNamespaceSearch(calId);
1305
1306            // Set namespace fields if available
1307            const namespaceHidden = document.getElementById('event-namespace-' + calId);
1308            const namespaceSearch = document.getElementById('event-namespace-search-' + calId);
1309            if (namespaceHidden && event.namespace !== undefined) {
1310                // Set the hidden input (this is what gets submitted)
1311                namespaceHidden.value = event.namespace || '';
1312                // Set the search input to display the namespace
1313                if (namespaceSearch) {
1314                    namespaceSearch.value = event.namespace || '(default)';
1315                }
1316            } else {
1317                // No namespace on event, set to default
1318                if (namespaceHidden) {
1319                    namespaceHidden.value = '';
1320                }
1321                if (namespaceSearch) {
1322                    namespaceSearch.value = '(default)';
1323                }
1324            }
1325
1326            title.textContent = 'Edit Event';
1327            dialog.style.display = 'flex';
1328
1329            // Propagate CSS vars to dialog
1330            propagateThemeVars(calId, dialog);
1331        }
1332    })
1333    .catch(err => console.error('Error editing event:', err));
1334};
1335
1336// Delete event
1337window.deleteEvent = function(calId, eventId, date, namespace) {
1338    if (!confirm('Delete this event?')) return;
1339
1340    const params = new URLSearchParams({
1341        call: 'plugin_calendar',
1342        action: 'delete_event',
1343        namespace: namespace,
1344        date: date,
1345        eventId: eventId,
1346        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1347    });
1348
1349    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1350        method: 'POST',
1351        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1352        body: params.toString()
1353    })
1354    .then(r => r.json())
1355    .then(data => {
1356        if (data.success) {
1357            // Extract year and month from date
1358            const [year, month] = date.split('-').map(Number);
1359
1360            // Reload calendar data via AJAX
1361            reloadCalendarData(calId, year, month, namespace);
1362        }
1363    })
1364    .catch(err => console.error('Error:', err));
1365};
1366
1367// Save event (add or edit)
1368window.saveEventCompact = function(calId, namespace) {
1369    const form = document.getElementById('eventform-' + calId);
1370
1371    // Get namespace from dropdown - this is what the user selected
1372    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1373    const selectedNamespace = namespaceSelect ? namespaceSelect.value : '';
1374
1375    // ALWAYS use what the user selected in the dropdown
1376    // This allows changing namespace when editing
1377    const finalNamespace = selectedNamespace;
1378
1379    const eventId = document.getElementById('event-id-' + calId).value;
1380
1381    // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event)
1382    const originalNamespace = form.dataset.eventNamespace;
1383
1384
1385    const dateInput = document.getElementById('event-date-' + calId);
1386    const date = dateInput.value;
1387    const oldDate = dateInput.getAttribute('data-original-date') || date;
1388    const endDate = document.getElementById('event-end-date-' + calId).value;
1389    const title = document.getElementById('event-title-' + calId).value;
1390    const time = document.getElementById('event-time-' + calId).value;
1391    const endTime = document.getElementById('event-end-time-' + calId).value;
1392    const colorSelect = document.getElementById('event-color-' + calId);
1393    let color = colorSelect.value;
1394
1395    // Handle custom color
1396    if (color === 'custom') {
1397        color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value;
1398    }
1399
1400    const description = document.getElementById('event-desc-' + calId).value;
1401    const isTask = document.getElementById('event-is-task-' + calId).checked;
1402    const completed = false; // New tasks are not completed
1403    const isRecurring = document.getElementById('event-recurring-' + calId).checked;
1404    const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value;
1405    const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value;
1406
1407    if (!title) {
1408        alert('Please enter a title');
1409        return;
1410    }
1411
1412    if (!date) {
1413        alert('Please select a date');
1414        return;
1415    }
1416
1417    const params = new URLSearchParams({
1418        call: 'plugin_calendar',
1419        action: 'save_event',
1420        namespace: finalNamespace,
1421        eventId: eventId,
1422        date: date,
1423        oldDate: oldDate,
1424        endDate: endDate,
1425        title: title,
1426        time: time,
1427        endTime: endTime,
1428        color: color,
1429        description: description,
1430        isTask: isTask ? '1' : '0',
1431        completed: completed ? '1' : '0',
1432        isRecurring: isRecurring ? '1' : '0',
1433        recurrenceType: recurrenceType,
1434        recurrenceEnd: recurrenceEnd,
1435        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1436    });
1437
1438    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1439        method: 'POST',
1440        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1441        body: params.toString()
1442    })
1443    .then(r => r.json())
1444    .then(data => {
1445        if (data.success) {
1446            closeEventDialog(calId);
1447
1448            // For recurring events, do a full page reload to show all occurrences
1449            if (isRecurring) {
1450                location.reload();
1451                return;
1452            }
1453
1454            // Extract year and month from the NEW date (in case date was changed)
1455            const [year, month] = date.split('-').map(Number);
1456
1457            // Reload calendar data via AJAX to the month of the event
1458            reloadCalendarData(calId, year, month, namespace);
1459        } else {
1460            alert('Error: ' + (data.error || 'Unknown error'));
1461        }
1462    })
1463    .catch(err => {
1464        console.error('Error:', err);
1465        alert('Error saving event');
1466    });
1467};
1468
1469// Reload calendar data without page refresh
1470window.reloadCalendarData = function(calId, year, month, namespace) {
1471    const params = new URLSearchParams({
1472        call: 'plugin_calendar',
1473        action: 'load_month',
1474        year: year,
1475        month: month,
1476        namespace: namespace,
1477        _: new Date().getTime() // Cache buster
1478    });
1479
1480    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1481        method: 'POST',
1482        headers: {
1483            'Content-Type': 'application/x-www-form-urlencoded',
1484            'Cache-Control': 'no-cache, no-store, must-revalidate',
1485            'Pragma': 'no-cache'
1486        },
1487        body: params.toString()
1488    })
1489    .then(r => r.json())
1490    .then(data => {
1491        if (data.success) {
1492            const container = document.getElementById(calId);
1493
1494            // Check if this is a full calendar or just event panel
1495            if (container.classList.contains('calendar-compact-container')) {
1496                rebuildCalendar(calId, data.year, data.month, data.events, namespace);
1497            } else if (container.classList.contains('event-panel-standalone')) {
1498                rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1499            }
1500        }
1501    })
1502    .catch(err => console.error('Error:', err));
1503};
1504
1505// Close event dialog
1506window.closeEventDialog = function(calId) {
1507    const dialog = document.getElementById('dialog-' + calId);
1508    dialog.style.display = 'none';
1509};
1510
1511// Escape HTML
1512window.escapeHtml = function(text) {
1513    const div = document.createElement('div');
1514    div.textContent = text;
1515    return div.innerHTML;
1516};
1517
1518// Highlight event when clicking on bar in calendar
1519window.highlightEvent = function(calId, eventId, date) {
1520
1521    // Find the event item in the event list
1522    const eventList = document.querySelector('#' + calId + ' .event-list-compact');
1523    if (!eventList) {
1524        return;
1525    }
1526
1527    const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]');
1528    if (!eventItem) {
1529        return;
1530    }
1531
1532
1533    // Get theme
1534    const container = document.getElementById(calId);
1535    const theme = container ? container.dataset.theme : 'matrix';
1536    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
1537
1538
1539    // Theme-specific highlight colors
1540    let highlightBg, highlightShadow;
1541    if (theme === 'matrix') {
1542        highlightBg = '#1a3d1a';  // Darker green
1543        highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)';
1544    } else if (theme === 'purple') {
1545        highlightBg = '#3d2b4d';  // Darker purple
1546        highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)';
1547    } else if (theme === 'professional') {
1548        highlightBg = '#e3f2fd';  // Light blue
1549        highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)';
1550    } else if (theme === 'pink') {
1551        highlightBg = '#3d2030';  // Darker pink
1552        highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)';
1553    } else if (theme === 'wiki') {
1554        highlightBg = themeStyles.header_bg || '#e8e8e8';  // __background_alt__
1555        highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)';
1556    }
1557
1558
1559    // Store original styles
1560    const originalBg = eventItem.style.background;
1561    const originalShadow = eventItem.style.boxShadow;
1562
1563    // Remove previous highlights (restore their original styles)
1564    const previousHighlights = eventList.querySelectorAll('.event-highlighted');
1565    previousHighlights.forEach(el => {
1566        el.classList.remove('event-highlighted');
1567    });
1568
1569    // Add highlight class and apply theme-aware glow
1570    eventItem.classList.add('event-highlighted');
1571
1572    // Set CSS properties directly
1573    eventItem.style.setProperty('background', highlightBg, 'important');
1574    eventItem.style.setProperty('box-shadow', highlightShadow, 'important');
1575    eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important');
1576
1577
1578    // Scroll to event
1579    eventItem.scrollIntoView({
1580        behavior: 'smooth',
1581        block: 'nearest',
1582        inline: 'nearest'
1583    });
1584
1585    // Remove highlight after 3 seconds and restore original styles
1586    setTimeout(() => {
1587        eventItem.classList.remove('event-highlighted');
1588        eventItem.style.setProperty('background', originalBg);
1589        eventItem.style.setProperty('box-shadow', originalShadow);
1590        eventItem.style.setProperty('transition', '');
1591    }, 3000);
1592};
1593
1594// Toggle recurring event options
1595window.toggleRecurringOptions = function(calId) {
1596    const checkbox = document.getElementById('event-recurring-' + calId);
1597    const options = document.getElementById('recurring-options-' + calId);
1598
1599    if (checkbox && options) {
1600        options.style.display = checkbox.checked ? 'block' : 'none';
1601    }
1602};
1603
1604// ============================================================
1605// Document-level event delegation (guarded - only attach once)
1606// These use event delegation so they work for AJAX-rebuilt content.
1607// ============================================================
1608if (!window._calendarDelegationInit) {
1609    window._calendarDelegationInit = true;
1610
1611    // ESC closes dialogs, popups, tooltips
1612    document.addEventListener('keydown', function(e) {
1613        if (e.key === 'Escape') {
1614            document.querySelectorAll('.event-dialog-compact').forEach(function(d) {
1615                if (d.style.display === 'flex') d.style.display = 'none';
1616            });
1617            document.querySelectorAll('.day-popup').forEach(function(p) {
1618                p.style.display = 'none';
1619            });
1620            hideConflictTooltip();
1621        }
1622    });
1623
1624    // Conflict tooltip delegation (capture phase for mouseenter/leave)
1625    document.addEventListener('mouseenter', function(e) {
1626        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1627            showConflictTooltip(e.target);
1628        }
1629    }, true);
1630
1631    document.addEventListener('mouseleave', function(e) {
1632        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1633            hideConflictTooltip();
1634        }
1635    }, true);
1636} // end delegation guard
1637
1638// Event panel navigation
1639window.navEventPanel = function(calId, year, month, namespace) {
1640    const params = new URLSearchParams({
1641        call: 'plugin_calendar',
1642        action: 'load_month',
1643        year: year,
1644        month: month,
1645        namespace: namespace,
1646        _: new Date().getTime() // Cache buster
1647    });
1648
1649    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1650        method: 'POST',
1651        headers: {
1652            'Content-Type': 'application/x-www-form-urlencoded',
1653            'Cache-Control': 'no-cache, no-store, must-revalidate',
1654            'Pragma': 'no-cache'
1655        },
1656        body: params.toString()
1657    })
1658    .then(r => r.json())
1659    .then(data => {
1660        if (data.success) {
1661            rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1662        }
1663    })
1664    .catch(err => console.error('Error:', err));
1665};
1666
1667// Rebuild event panel only
1668window.rebuildEventPanel = function(calId, year, month, events, namespace) {
1669    const container = document.getElementById(calId);
1670    const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1671                       'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1672
1673    // Update month title in new compact header
1674    const monthTitle = container.querySelector('.panel-month-title');
1675    if (monthTitle) {
1676        monthTitle.textContent = monthNames[month - 1] + ' ' + year;
1677        monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1678        monthTitle.setAttribute('title', 'Click to jump to month');
1679    }
1680
1681    // Fallback: Update old header format if exists
1682    const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker');
1683    if (oldHeader && !monthTitle) {
1684        oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events';
1685        oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1686    }
1687
1688    // Update nav buttons
1689    let prevMonth = month - 1;
1690    let prevYear = year;
1691    if (prevMonth < 1) {
1692        prevMonth = 12;
1693        prevYear--;
1694    }
1695
1696    let nextMonth = month + 1;
1697    let nextYear = year;
1698    if (nextMonth > 12) {
1699        nextMonth = 1;
1700        nextYear++;
1701    }
1702
1703    // Update new compact nav buttons
1704    const navBtns = container.querySelectorAll('.panel-nav-btn');
1705    if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1706    if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1707
1708    // Fallback for old nav buttons
1709    const oldNavBtns = container.querySelectorAll('.cal-nav-btn');
1710    if (oldNavBtns.length > 0 && navBtns.length === 0) {
1711        if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1712        if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1713    }
1714
1715    // Update Today button (works for both old and new)
1716    const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact');
1717    if (todayBtn) {
1718        todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`);
1719    }
1720
1721    // Rebuild event list
1722    const eventList = container.querySelector('.event-list-compact');
1723    if (eventList) {
1724        eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
1725    }
1726};
1727
1728// Open add event for panel
1729window.openAddEventPanel = function(calId, namespace) {
1730    const today = new Date();
1731    const year = today.getFullYear();
1732    const month = String(today.getMonth() + 1).padStart(2, '0');
1733    const day = String(today.getDate()).padStart(2, '0');
1734    const localDate = `${year}-${month}-${day}`;
1735    openAddEvent(calId, namespace, localDate);
1736};
1737
1738// Toggle task completion
1739window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) {
1740    const params = new URLSearchParams({
1741        call: 'plugin_calendar',
1742        action: 'toggle_task',
1743        namespace: namespace,
1744        date: date,
1745        eventId: eventId,
1746        completed: completed ? '1' : '0',
1747        sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : ''
1748    });
1749
1750    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1751        method: 'POST',
1752        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1753        body: params.toString()
1754    })
1755    .then(r => r.json())
1756    .then(data => {
1757        if (data.success) {
1758            const [year, month] = date.split('-').map(Number);
1759            reloadCalendarData(calId, year, month, namespace);
1760        }
1761    })
1762    .catch(err => console.error('Error toggling task:', err));
1763};
1764
1765// Make dialog draggable
1766window.makeDialogDraggable = function(calId) {
1767    const dialog = document.getElementById('dialog-content-' + calId);
1768    const handle = document.getElementById('drag-handle-' + calId);
1769
1770    if (!dialog || !handle) return;
1771
1772    let isDragging = false;
1773    let currentX;
1774    let currentY;
1775    let initialX;
1776    let initialY;
1777    let xOffset = 0;
1778    let yOffset = 0;
1779
1780    handle.addEventListener('mousedown', dragStart);
1781    document.addEventListener('mousemove', drag);
1782    document.addEventListener('mouseup', dragEnd);
1783
1784    function dragStart(e) {
1785        initialX = e.clientX - xOffset;
1786        initialY = e.clientY - yOffset;
1787        isDragging = true;
1788    }
1789
1790    function drag(e) {
1791        if (isDragging) {
1792            e.preventDefault();
1793            currentX = e.clientX - initialX;
1794            currentY = e.clientY - initialY;
1795            xOffset = currentX;
1796            yOffset = currentY;
1797            setTranslate(currentX, currentY, dialog);
1798        }
1799    }
1800
1801    function dragEnd(e) {
1802        initialX = currentX;
1803        initialY = currentY;
1804        isDragging = false;
1805    }
1806
1807    function setTranslate(xPos, yPos, el) {
1808        el.style.transform = `translate(${xPos}px, ${yPos}px)`;
1809    }
1810};
1811
1812// Initialize dialog draggability when opened (avoid duplicate declaration)
1813if (!window.calendarDraggabilityPatched) {
1814    window.calendarDraggabilityPatched = true;
1815
1816    const originalOpenAddEvent = openAddEvent;
1817    openAddEvent = function(calId, namespace, date) {
1818        originalOpenAddEvent(calId, namespace, date);
1819        setTimeout(() => makeDialogDraggable(calId), 100);
1820    };
1821
1822    const originalEditEvent = editEvent;
1823    editEvent = function(calId, eventId, date, namespace) {
1824        originalEditEvent(calId, eventId, date, namespace);
1825        setTimeout(() => makeDialogDraggable(calId), 100);
1826    };
1827}
1828
1829// Toggle expand/collapse for past events
1830window.togglePastEventExpand = function(element) {
1831    // Stop propagation to prevent any parent click handlers
1832    event.stopPropagation();
1833
1834    const meta = element.querySelector(".event-meta-compact");
1835    const desc = element.querySelector(".event-desc-compact");
1836
1837    // Toggle visibility
1838    if (meta.style.display === "none") {
1839        // Expand
1840        meta.style.display = "block";
1841        if (desc) desc.style.display = "block";
1842        element.classList.add("event-past-expanded");
1843    } else {
1844        // Collapse
1845        meta.style.display = "none";
1846        if (desc) desc.style.display = "none";
1847        element.classList.remove("event-past-expanded");
1848    }
1849};
1850
1851// Filter calendar by namespace when clicking namespace badge (guarded)
1852if (!window._calendarClickDelegationInit) {
1853    window._calendarClickDelegationInit = true;
1854    document.addEventListener('click', function(e) {
1855    if (e.target.classList.contains('event-namespace-badge')) {
1856        const namespace = e.target.textContent;
1857        const calendar = e.target.closest('.calendar-compact-container');
1858
1859        if (!calendar) return;
1860
1861        const calId = calendar.id;
1862
1863        // Use AJAX reload to filter both calendar grid and event list
1864        filterCalendarByNamespace(calId, namespace);
1865    }
1866    });
1867} // end click delegation guard
1868
1869// Update the displayed filtered namespace in event list header
1870// Legacy badge removed - namespace filtering still works but badge no longer shown
1871window.updateFilteredNamespaceDisplay = function(calId, namespace) {
1872    const calendar = document.getElementById(calId);
1873    if (!calendar) return;
1874
1875    const headerContent = calendar.querySelector('.event-list-header-content');
1876    if (!headerContent) return;
1877
1878    // Remove any existing filter badge (cleanup)
1879    let filterBadge = headerContent.querySelector('.namespace-filter-badge');
1880    if (filterBadge) {
1881        filterBadge.remove();
1882    }
1883};
1884
1885// Clear namespace filter
1886window.clearNamespaceFilter = function(calId) {
1887
1888    const container = document.getElementById(calId);
1889    if (!container) {
1890        console.error('Calendar container not found:', calId);
1891        return;
1892    }
1893
1894    // Immediately hide/remove the filter badge
1895    const filterBadge = container.querySelector('.calendar-namespace-filter');
1896    if (filterBadge) {
1897        filterBadge.style.display = 'none';
1898        filterBadge.remove();
1899    }
1900
1901    // Get current year and month
1902    const year = parseInt(container.dataset.year) || new Date().getFullYear();
1903    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
1904
1905    // Get original namespace (what the calendar was initialized with)
1906    const originalNamespace = container.dataset.originalNamespace || '';
1907
1908    // Also check for sidebar widget
1909    const sidebarContainer = document.getElementById('sidebar-widget-' + calId);
1910    if (sidebarContainer) {
1911        // For sidebar widget, just reload the page without namespace filter
1912        // Remove the namespace from the URL and reload
1913        const url = new URL(window.location.href);
1914        url.searchParams.delete('namespace');
1915        window.location.href = url.toString();
1916        return;
1917    }
1918
1919    // For regular calendar, reload calendar with original namespace
1920    navCalendar(calId, year, month, originalNamespace);
1921};
1922
1923window.clearNamespaceFilterPanel = function(calId) {
1924
1925    const container = document.getElementById(calId);
1926    if (!container) {
1927        console.error('Event panel container not found:', calId);
1928        return;
1929    }
1930
1931    // Get current year and month from URL params or container
1932    const year = parseInt(container.dataset.year) || new Date().getFullYear();
1933    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
1934
1935    // Get original namespace (what the panel was initialized with)
1936    const originalNamespace = container.dataset.originalNamespace || '';
1937
1938
1939    // Reload event panel with original namespace
1940    navEventPanel(calId, year, month, originalNamespace);
1941};
1942
1943// Color picker functions
1944window.updateCustomColorPicker = function(calId) {
1945    const select = document.getElementById('event-color-' + calId);
1946    const picker = document.getElementById('event-color-custom-' + calId);
1947
1948    if (select.value === 'custom') {
1949        // Show color picker
1950        picker.style.display = 'inline-block';
1951        picker.click(); // Open color picker
1952    } else {
1953        // Hide color picker and sync value
1954        picker.style.display = 'none';
1955        picker.value = select.value;
1956    }
1957};
1958
1959function updateColorFromPicker(calId) {
1960    const select = document.getElementById('event-color-' + calId);
1961    const picker = document.getElementById('event-color-custom-' + calId);
1962
1963    // Set select to custom and update its underlying value
1964    select.value = 'custom';
1965    // Store the actual color value in a data attribute
1966    select.dataset.customColor = picker.value;
1967}
1968
1969// Toggle past events visibility
1970window.togglePastEvents = function(calId) {
1971    const content = document.getElementById('past-events-' + calId);
1972    const arrow = document.getElementById('past-arrow-' + calId);
1973
1974    if (!content || !arrow) {
1975        console.error('Past events elements not found for:', calId);
1976        return;
1977    }
1978
1979    // Check computed style instead of inline style
1980    const isHidden = window.getComputedStyle(content).display === 'none';
1981
1982    if (isHidden) {
1983        content.style.display = 'block';
1984        arrow.textContent = '▼';
1985    } else {
1986        content.style.display = 'none';
1987        arrow.textContent = '▶';
1988    }
1989};
1990
1991// Fuzzy match scoring function
1992window.fuzzyMatch = function(pattern, str) {
1993    pattern = pattern.toLowerCase();
1994    str = str.toLowerCase();
1995
1996    let patternIdx = 0;
1997    let score = 0;
1998    let consecutiveMatches = 0;
1999
2000    for (let i = 0; i < str.length; i++) {
2001        if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) {
2002            score += 1 + consecutiveMatches;
2003            consecutiveMatches++;
2004            patternIdx++;
2005        } else {
2006            consecutiveMatches = 0;
2007        }
2008    }
2009
2010    // Return null if not all characters matched
2011    if (patternIdx !== pattern.length) {
2012        return null;
2013    }
2014
2015    // Bonus for exact match
2016    if (str === pattern) {
2017        score += 100;
2018    }
2019
2020    // Bonus for starts with
2021    if (str.startsWith(pattern)) {
2022        score += 50;
2023    }
2024
2025    return score;
2026};
2027
2028// Initialize namespace search for a calendar
2029window.initNamespaceSearch = function(calId) {
2030    const searchInput = document.getElementById('event-namespace-search-' + calId);
2031    const hiddenInput = document.getElementById('event-namespace-' + calId);
2032    const dropdown = document.getElementById('event-namespace-dropdown-' + calId);
2033    const dataElement = document.getElementById('namespaces-data-' + calId);
2034
2035    if (!searchInput || !hiddenInput || !dropdown || !dataElement) {
2036        return; // Elements not found
2037    }
2038
2039    let namespaces = [];
2040    try {
2041        namespaces = JSON.parse(dataElement.textContent);
2042    } catch (e) {
2043        console.error('Failed to parse namespaces data:', e);
2044        return;
2045    }
2046
2047    let selectedIndex = -1;
2048
2049    // Filter and show dropdown
2050    function filterNamespaces(query) {
2051        if (!query || query.trim() === '') {
2052            // Show all namespaces when empty
2053            hiddenInput.value = '';
2054            const results = namespaces.slice(0, 20); // Limit to 20
2055            showDropdown(results);
2056            return;
2057        }
2058
2059        // Fuzzy match and score
2060        const matches = [];
2061        for (let i = 0; i < namespaces.length; i++) {
2062            const score = fuzzyMatch(query, namespaces[i]);
2063            if (score !== null) {
2064                matches.push({ namespace: namespaces[i], score: score });
2065            }
2066        }
2067
2068        // Sort by score (descending)
2069        matches.sort((a, b) => b.score - a.score);
2070
2071        // Take top 20 results
2072        const results = matches.slice(0, 20).map(m => m.namespace);
2073        showDropdown(results);
2074    }
2075
2076    function showDropdown(results) {
2077        dropdown.innerHTML = '';
2078        selectedIndex = -1;
2079
2080        if (results.length === 0) {
2081            dropdown.style.display = 'none';
2082            return;
2083        }
2084
2085        // Add (default) option
2086        const defaultOption = document.createElement('div');
2087        defaultOption.className = 'namespace-option';
2088        defaultOption.textContent = '(default)';
2089        defaultOption.dataset.value = '';
2090        dropdown.appendChild(defaultOption);
2091
2092        results.forEach(ns => {
2093            const option = document.createElement('div');
2094            option.className = 'namespace-option';
2095            option.textContent = ns;
2096            option.dataset.value = ns;
2097            dropdown.appendChild(option);
2098        });
2099
2100        dropdown.style.display = 'block';
2101    }
2102
2103    function hideDropdown() {
2104        dropdown.style.display = 'none';
2105        selectedIndex = -1;
2106    }
2107
2108    function selectOption(namespace) {
2109        hiddenInput.value = namespace;
2110        searchInput.value = namespace || '(default)';
2111        hideDropdown();
2112    }
2113
2114    // Event listeners
2115    searchInput.addEventListener('input', function(e) {
2116        filterNamespaces(e.target.value);
2117    });
2118
2119    searchInput.addEventListener('focus', function(e) {
2120        filterNamespaces(e.target.value);
2121    });
2122
2123    searchInput.addEventListener('blur', function(e) {
2124        // Delay to allow click on dropdown
2125        setTimeout(hideDropdown, 200);
2126    });
2127
2128    searchInput.addEventListener('keydown', function(e) {
2129        const options = dropdown.querySelectorAll('.namespace-option');
2130
2131        if (e.key === 'ArrowDown') {
2132            e.preventDefault();
2133            selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
2134            updateSelection(options);
2135        } else if (e.key === 'ArrowUp') {
2136            e.preventDefault();
2137            selectedIndex = Math.max(selectedIndex - 1, -1);
2138            updateSelection(options);
2139        } else if (e.key === 'Enter') {
2140            e.preventDefault();
2141            if (selectedIndex >= 0 && options[selectedIndex]) {
2142                selectOption(options[selectedIndex].dataset.value);
2143            }
2144        } else if (e.key === 'Escape') {
2145            hideDropdown();
2146        }
2147    });
2148
2149    function updateSelection(options) {
2150        options.forEach((opt, idx) => {
2151            if (idx === selectedIndex) {
2152                opt.classList.add('selected');
2153                opt.scrollIntoView({ block: 'nearest' });
2154            } else {
2155                opt.classList.remove('selected');
2156            }
2157        });
2158    }
2159
2160    // Click on dropdown option
2161    dropdown.addEventListener('mousedown', function(e) {
2162        if (e.target.classList.contains('namespace-option')) {
2163            selectOption(e.target.dataset.value);
2164        }
2165    });
2166};
2167
2168// Update end time options based on start time selection
2169window.updateEndTimeOptions = function(calId) {
2170    const startTimeSelect = document.getElementById('event-time-' + calId);
2171    const endTimeSelect = document.getElementById('event-end-time-' + calId);
2172
2173    if (!startTimeSelect || !endTimeSelect) return;
2174
2175    const startTime = startTimeSelect.value;
2176
2177    // If start time is empty (all day), disable end time
2178    if (!startTime) {
2179        endTimeSelect.disabled = true;
2180        endTimeSelect.value = '';
2181        return;
2182    }
2183
2184    // Enable end time select
2185    endTimeSelect.disabled = false;
2186
2187    // Convert start time to minutes
2188    const startMinutes = timeToMinutes(startTime);
2189
2190    // Get current end time value (to preserve if valid)
2191    const currentEndTime = endTimeSelect.value;
2192    const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0;
2193
2194    // Filter options - show only times after start time
2195    const options = endTimeSelect.options;
2196    let firstValidOption = null;
2197    let currentStillValid = false;
2198
2199    for (let i = 0; i < options.length; i++) {
2200        const option = options[i];
2201        const optionValue = option.value;
2202
2203        if (optionValue === '') {
2204            // Keep "Same as start" option visible
2205            option.style.display = '';
2206            continue;
2207        }
2208
2209        const optionMinutes = timeToMinutes(optionValue);
2210
2211        if (optionMinutes > startMinutes) {
2212            // Show options after start time
2213            option.style.display = '';
2214            if (!firstValidOption) {
2215                firstValidOption = optionValue;
2216            }
2217            if (optionValue === currentEndTime) {
2218                currentStillValid = true;
2219            }
2220        } else {
2221            // Hide options before or equal to start time
2222            option.style.display = 'none';
2223        }
2224    }
2225
2226    // If current end time is now invalid, set a new one
2227    if (!currentStillValid || currentEndMinutes <= startMinutes) {
2228        // Try to set to 1 hour after start
2229        const [startHour, startMinute] = startTime.split(':').map(Number);
2230        let endHour = startHour + 1;
2231        let endMinute = startMinute;
2232
2233        if (endHour >= 24) {
2234            endHour = 23;
2235            endMinute = 45;
2236        }
2237
2238        const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0');
2239
2240        // Check if suggested time is in the list
2241        const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime);
2242
2243        if (suggestedExists) {
2244            endTimeSelect.value = suggestedEndTime;
2245        } else if (firstValidOption) {
2246            // Use first valid option
2247            endTimeSelect.value = firstValidOption;
2248        } else {
2249            // No valid options (shouldn't happen, but just in case)
2250            endTimeSelect.value = '';
2251        }
2252    }
2253};
2254
2255// Check for time conflicts between events on the same date
2256window.checkTimeConflicts = function(events, currentEventId) {
2257    const conflicts = [];
2258
2259    // Group events by date
2260    const eventsByDate = {};
2261    for (const [date, dateEvents] of Object.entries(events)) {
2262        if (!Array.isArray(dateEvents)) continue;
2263
2264        dateEvents.forEach(evt => {
2265            if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event
2266
2267            if (!eventsByDate[date]) eventsByDate[date] = [];
2268            eventsByDate[date].push(evt);
2269        });
2270    }
2271
2272    // Check for overlaps on each date
2273    for (const [date, dateEvents] of Object.entries(eventsByDate)) {
2274        for (let i = 0; i < dateEvents.length; i++) {
2275            for (let j = i + 1; j < dateEvents.length; j++) {
2276                const evt1 = dateEvents[i];
2277                const evt2 = dateEvents[j];
2278
2279                if (eventsOverlap(evt1, evt2)) {
2280                    // Mark both events as conflicting
2281                    if (!evt1.hasConflict) evt1.hasConflict = true;
2282                    if (!evt2.hasConflict) evt2.hasConflict = true;
2283
2284                    // Store conflict info
2285                    if (!evt1.conflictsWith) evt1.conflictsWith = [];
2286                    if (!evt2.conflictsWith) evt2.conflictsWith = [];
2287
2288                    evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime});
2289                    evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime});
2290                }
2291            }
2292        }
2293    }
2294
2295    return events;
2296};
2297
2298// Check if two events overlap in time
2299function eventsOverlap(evt1, evt2) {
2300    if (!evt1.time || !evt2.time) return false; // All-day events don't conflict
2301
2302    const start1 = evt1.time;
2303    const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start
2304
2305    const start2 = evt2.time;
2306    const end2 = evt2.endTime || evt2.time;
2307
2308    // Convert to minutes for easier comparison
2309    const start1Mins = timeToMinutes(start1);
2310    const end1Mins = timeToMinutes(end1);
2311    const start2Mins = timeToMinutes(start2);
2312    const end2Mins = timeToMinutes(end2);
2313
2314    // Check for overlap
2315    // Events overlap if: start1 < end2 AND start2 < end1
2316    return start1Mins < end2Mins && start2Mins < end1Mins;
2317}
2318
2319// Convert HH:MM time to minutes since midnight
2320function timeToMinutes(timeStr) {
2321    const [hours, minutes] = timeStr.split(':').map(Number);
2322    return hours * 60 + minutes;
2323}
2324
2325// Format time range for display
2326window.formatTimeRange = function(startTime, endTime) {
2327    if (!startTime) return '';
2328
2329    const formatTime = (timeStr) => {
2330        const [hour24, minute] = timeStr.split(':').map(Number);
2331        const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24);
2332        const ampm = hour24 < 12 ? 'AM' : 'PM';
2333        return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
2334    };
2335
2336    if (!endTime || endTime === startTime) {
2337        return formatTime(startTime);
2338    }
2339
2340    return formatTime(startTime) + ' - ' + formatTime(endTime);
2341};
2342
2343// Track last known mouse position for tooltip positioning fallback
2344var _lastMouseX = 0, _lastMouseY = 0;
2345document.addEventListener('mousemove', function(e) {
2346    _lastMouseX = e.clientX;
2347    _lastMouseY = e.clientY;
2348});
2349
2350// Show custom conflict tooltip
2351window.showConflictTooltip = function(badgeElement) {
2352    // Remove any existing tooltip
2353    hideConflictTooltip();
2354
2355    // Get conflict data (base64-encoded JSON to avoid attribute quote issues)
2356    const conflictsRaw = badgeElement.getAttribute('data-conflicts');
2357    if (!conflictsRaw) return;
2358
2359    let conflicts;
2360    try {
2361        conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw))));
2362    } catch (e) {
2363        // Fallback: try parsing as plain JSON (for PHP-rendered badges)
2364        try {
2365            conflicts = JSON.parse(conflictsRaw);
2366        } catch (e2) {
2367            console.error('Failed to parse conflicts:', e2);
2368            return;
2369        }
2370    }
2371
2372    // Get theme from the calendar container via CSS variables
2373    // Try closest ancestor first, then fall back to any calendar on the page
2374    let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone');
2375    if (!containerEl) {
2376        // Badge might be inside a day popup (appended to body) - find any calendar container
2377        containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]');
2378    }
2379    const cs = containerEl ? getComputedStyle(containerEl) : null;
2380
2381    const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424';
2382    const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07';
2383    const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07';
2384    const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00';
2385    const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)';
2386
2387    // Create tooltip
2388    const tooltip = document.createElement('div');
2389    tooltip.id = 'conflict-tooltip';
2390    tooltip.className = 'conflict-tooltip';
2391
2392    // Apply theme styles
2393    tooltip.style.background = bg;
2394    tooltip.style.borderColor = border;
2395    tooltip.style.color = textPrimary;
2396    tooltip.style.boxShadow = '0 4px 12px ' + shadow;
2397
2398    // Build content with themed colors
2399    let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>';
2400    html += '<div class="conflict-tooltip-body">';
2401    conflicts.forEach(conflict => {
2402        html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>';
2403    });
2404    html += '</div>';
2405
2406    tooltip.innerHTML = html;
2407    document.body.appendChild(tooltip);
2408
2409    // Position tooltip
2410    const rect = badgeElement.getBoundingClientRect();
2411    const tooltipRect = tooltip.getBoundingClientRect();
2412
2413    // Position above the badge, centered
2414    let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
2415    let top = rect.top - tooltipRect.height - 8;
2416
2417    // Keep tooltip within viewport
2418    if (left < 10) left = 10;
2419    if (left + tooltipRect.width > window.innerWidth - 10) {
2420        left = window.innerWidth - tooltipRect.width - 10;
2421    }
2422    if (top < 10) {
2423        // If not enough room above, show below
2424        top = rect.bottom + 8;
2425    }
2426
2427    tooltip.style.left = left + 'px';
2428    tooltip.style.top = top + 'px';
2429    tooltip.style.opacity = '1';
2430};
2431
2432// Hide conflict tooltip
2433window.hideConflictTooltip = function() {
2434    const tooltip = document.getElementById('conflict-tooltip');
2435    if (tooltip) {
2436        tooltip.remove();
2437    }
2438};
2439
2440// Filter events by search term
2441window.filterEvents = function(calId, searchTerm) {
2442    const eventList = document.getElementById('eventlist-' + calId);
2443    const searchClear = document.getElementById('search-clear-' + calId);
2444
2445    if (!eventList) return;
2446
2447    // Show/hide clear button
2448    if (searchClear) {
2449        searchClear.style.display = searchTerm ? 'block' : 'none';
2450    }
2451
2452    searchTerm = searchTerm.toLowerCase().trim();
2453
2454    // Get all event items
2455    const eventItems = eventList.querySelectorAll('.event-compact-item');
2456    let visibleCount = 0;
2457    let hiddenPastCount = 0;
2458
2459    eventItems.forEach(item => {
2460        const title = item.querySelector('.event-title-compact');
2461        const description = item.querySelector('.event-desc-compact');
2462        const dateTime = item.querySelector('.event-date-time');
2463
2464        // Build searchable text
2465        let searchableText = '';
2466        if (title) searchableText += title.textContent.toLowerCase() + ' ';
2467        if (description) searchableText += description.textContent.toLowerCase() + ' ';
2468        if (dateTime) searchableText += dateTime.textContent.toLowerCase() + ' ';
2469
2470        // Check if matches search
2471        const matches = !searchTerm || searchableText.includes(searchTerm);
2472
2473        if (matches) {
2474            item.style.display = '';
2475            visibleCount++;
2476        } else {
2477            item.style.display = 'none';
2478            // Check if this is a past event
2479            if (item.classList.contains('event-past') || item.classList.contains('event-completed')) {
2480                hiddenPastCount++;
2481            }
2482        }
2483    });
2484
2485    // Update past events toggle if it exists
2486    const pastToggle = eventList.querySelector('.past-events-toggle');
2487    const pastLabel = eventList.querySelector('.past-events-label');
2488    const pastContent = document.getElementById('past-events-' + calId);
2489
2490    if (pastToggle && pastLabel && pastContent) {
2491        const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])');
2492        const totalPastVisible = visiblePastEvents.length;
2493
2494        if (totalPastVisible > 0) {
2495            pastLabel.textContent = `Past Events (${totalPastVisible})`;
2496            pastToggle.style.display = '';
2497        } else {
2498            pastToggle.style.display = 'none';
2499        }
2500    }
2501
2502    // Show "no results" message if nothing visible
2503    let noResultsMsg = eventList.querySelector('.no-search-results');
2504    if (visibleCount === 0 && searchTerm) {
2505        if (!noResultsMsg) {
2506            noResultsMsg = document.createElement('p');
2507            noResultsMsg.className = 'no-search-results no-events-msg';
2508            noResultsMsg.textContent = 'No events match your search';
2509            eventList.appendChild(noResultsMsg);
2510        }
2511        noResultsMsg.style.display = 'block';
2512    } else if (noResultsMsg) {
2513        noResultsMsg.style.display = 'none';
2514    }
2515};
2516
2517// Clear event search
2518window.clearEventSearch = function(calId) {
2519    const searchInput = document.getElementById('event-search-' + calId);
2520    if (searchInput) {
2521        searchInput.value = '';
2522        filterEvents(calId, '');
2523        searchInput.focus();
2524    }
2525};
2526
2527// ============================================
2528// PINK THEME - GLOWING PARTICLE EFFECTS
2529// ============================================
2530
2531// Create glowing pink particle effects for pink theme
2532(function() {
2533    let pinkThemeActive = false;
2534    let trailTimer = null;
2535    let pixelTimer = null;
2536
2537    // Check if pink theme is active
2538    function checkPinkTheme() {
2539        const pinkCalendars = document.querySelectorAll('.calendar-theme-pink');
2540        pinkThemeActive = pinkCalendars.length > 0;
2541        return pinkThemeActive;
2542    }
2543
2544    // Create trail particle
2545    function createTrailParticle(clientX, clientY) {
2546        if (!pinkThemeActive) return;
2547
2548        const trail = document.createElement('div');
2549        trail.className = 'pink-cursor-trail';
2550        trail.style.left = clientX + 'px';
2551        trail.style.top = clientY + 'px';
2552        trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards';
2553
2554        document.body.appendChild(trail);
2555
2556        setTimeout(function() {
2557            trail.remove();
2558        }, 500);
2559    }
2560
2561    // Create pixel sparkles
2562    function createPixelSparkles(clientX, clientY) {
2563        if (!pinkThemeActive || pixelTimer) return;
2564
2565        const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels
2566
2567        for (let i = 0; i < pixelCount; i++) {
2568            const pixel = document.createElement('div');
2569            pixel.className = 'pink-pixel-sparkle';
2570
2571            // Random offset from cursor
2572            const offsetX = (Math.random() - 0.5) * 30;
2573            const offsetY = (Math.random() - 0.5) * 30;
2574
2575            pixel.style.left = (clientX + offsetX) + 'px';
2576            pixel.style.top = (clientY + offsetY) + 'px';
2577
2578            // Random color - bright neon pinks and whites
2579            const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
2580            const color = colors[Math.floor(Math.random() * colors.length)];
2581            pixel.style.background = color;
2582            pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff';
2583
2584            // Random animation
2585            if (Math.random() > 0.5) {
2586                pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards';
2587            } else {
2588                pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards';
2589            }
2590
2591            document.body.appendChild(pixel);
2592
2593            setTimeout(function() {
2594                pixel.remove();
2595            }, 800);
2596        }
2597
2598        pixelTimer = setTimeout(function() {
2599            pixelTimer = null;
2600        }, 40);
2601    }
2602
2603    // Create explosion
2604    function createExplosion(clientX, clientY) {
2605        if (!pinkThemeActive) return;
2606
2607        const particleCount = 25;
2608        const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff'];
2609
2610        // Add hearts to explosion (8-12 hearts)
2611        const heartCount = 8 + Math.floor(Math.random() * 5);
2612        for (let i = 0; i < heartCount; i++) {
2613            const heart = document.createElement('div');
2614            heart.textContent = '��';
2615            heart.style.position = 'fixed';
2616            heart.style.left = clientX + 'px';
2617            heart.style.top = clientY + 'px';
2618            heart.style.pointerEvents = 'none';
2619            heart.style.zIndex = '9999999';
2620            heart.style.fontSize = (12 + Math.random() * 16) + 'px';
2621
2622            // Random direction
2623            const angle = Math.random() * Math.PI * 2;
2624            const velocity = 60 + Math.random() * 80;
2625            const tx = Math.cos(angle) * velocity;
2626            const ty = Math.sin(angle) * velocity;
2627
2628            heart.style.setProperty('--tx', tx + 'px');
2629            heart.style.setProperty('--ty', ty + 'px');
2630
2631            const duration = 0.8 + Math.random() * 0.4;
2632            heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2633
2634            document.body.appendChild(heart);
2635
2636            setTimeout(function() {
2637                heart.remove();
2638            }, duration * 1000);
2639        }
2640
2641        // Main explosion particles
2642        for (let i = 0; i < particleCount; i++) {
2643            const particle = document.createElement('div');
2644            particle.className = 'pink-particle';
2645
2646            const color = colors[Math.floor(Math.random() * colors.length)];
2647            particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)';
2648            particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color;
2649
2650            particle.style.left = clientX + 'px';
2651            particle.style.top = clientY + 'px';
2652
2653            const angle = (Math.PI * 2 * i) / particleCount;
2654            const velocity = 50 + Math.random() * 100;
2655            const tx = Math.cos(angle) * velocity;
2656            const ty = Math.sin(angle) * velocity;
2657
2658            particle.style.setProperty('--tx', tx + 'px');
2659            particle.style.setProperty('--ty', ty + 'px');
2660
2661            const size = 4 + Math.random() * 6;
2662            particle.style.width = size + 'px';
2663            particle.style.height = size + 'px';
2664
2665            const duration = 0.6 + Math.random() * 0.4;
2666            particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2667
2668            document.body.appendChild(particle);
2669
2670            setTimeout(function() {
2671                particle.remove();
2672            }, duration * 1000);
2673        }
2674
2675        // Pixel sparkles
2676        const pixelSparkleCount = 40;
2677
2678        for (let i = 0; i < pixelSparkleCount; i++) {
2679            const pixel = document.createElement('div');
2680            pixel.className = 'pink-pixel-sparkle';
2681
2682            const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
2683            const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)];
2684            pixel.style.background = pixelColor;
2685            pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff';
2686
2687            const angle = Math.random() * Math.PI * 2;
2688            const distance = 30 + Math.random() * 80;
2689            const offsetX = Math.cos(angle) * distance;
2690            const offsetY = Math.sin(angle) * distance;
2691
2692            pixel.style.left = clientX + 'px';
2693            pixel.style.top = clientY + 'px';
2694            pixel.style.setProperty('--tx', offsetX + 'px');
2695            pixel.style.setProperty('--ty', offsetY + 'px');
2696
2697            const pixelSize = 1 + Math.random() * 2;
2698            pixel.style.width = pixelSize + 'px';
2699            pixel.style.height = pixelSize + 'px';
2700
2701            const duration = 0.4 + Math.random() * 0.4;
2702            if (Math.random() > 0.5) {
2703                pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards';
2704            } else {
2705                pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2706            }
2707
2708            document.body.appendChild(pixel);
2709
2710            setTimeout(function() {
2711                pixel.remove();
2712            }, duration * 1000);
2713        }
2714
2715        // Flash
2716        const flash = document.createElement('div');
2717        flash.style.position = 'fixed';
2718        flash.style.left = clientX + 'px';
2719        flash.style.top = clientY + 'px';
2720        flash.style.width = '40px';
2721        flash.style.height = '40px';
2722        flash.style.borderRadius = '50%';
2723        flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)';
2724        flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4';
2725        flash.style.pointerEvents = 'none';
2726        flash.style.zIndex = '9999999';  // Above everything including dialogs
2727        flash.style.transform = 'translate(-50%, -50%)';
2728        flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards';
2729
2730        document.body.appendChild(flash);
2731
2732        setTimeout(function() {
2733            flash.remove();
2734        }, 300);
2735    }
2736
2737    function initPinkParticles() {
2738        if (!checkPinkTheme()) return;
2739
2740        // Use capture phase to catch events before stopPropagation
2741        document.addEventListener('mousemove', function(e) {
2742            if (!pinkThemeActive) return;
2743
2744            createTrailParticle(e.clientX, e.clientY);
2745            createPixelSparkles(e.clientX, e.clientY);
2746        }, true); // Capture phase!
2747
2748        // Throttle main trail
2749        document.addEventListener('mousemove', function(e) {
2750            if (!pinkThemeActive || trailTimer) return;
2751
2752            trailTimer = setTimeout(function() {
2753                trailTimer = null;
2754            }, 30);
2755        }, true); // Capture phase!
2756
2757        // Click explosion - use capture phase
2758        document.addEventListener('click', function(e) {
2759            if (!pinkThemeActive) return;
2760
2761            createExplosion(e.clientX, e.clientY);
2762        }, true); // Capture phase!
2763    }
2764
2765    // Initialize on load
2766    if (document.readyState === 'loading') {
2767        document.addEventListener('DOMContentLoaded', initPinkParticles);
2768    } else {
2769        initPinkParticles();
2770    }
2771
2772    // Re-check theme if calendar is dynamically added
2773    if (typeof MutationObserver !== 'undefined') {
2774        const observer = new MutationObserver(function(mutations) {
2775            mutations.forEach(function(mutation) {
2776                if (mutation.addedNodes.length > 0) {
2777                    mutation.addedNodes.forEach(function(node) {
2778                        if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) {
2779                            checkPinkTheme();
2780                            initPinkParticles();
2781                        }
2782                    });
2783                }
2784            });
2785        });
2786
2787        observer.observe(document.body, {
2788            childList: true,
2789            subtree: true
2790        });
2791    }
2792})();
2793
2794// End of calendar plugin JavaScript
2795