xref: /plugin/calendar/calendar-main.js (revision 9ccd446ecbe25932c2e89f7608c11495a1f1dbac)
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 + ';" 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">PAST DUE</span>';
925        } else if (isToday) {
926            html += ' <span class="event-today-badge">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) + '\')" 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) + '\')" 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                console.log('Set namespace for editing:', event.namespace, 'Hidden value:', namespaceHidden.value);
1317            } else {
1318                // No namespace on event, set to default
1319                if (namespaceHidden) {
1320                    namespaceHidden.value = '';
1321                }
1322                if (namespaceSearch) {
1323                    namespaceSearch.value = '(default)';
1324                }
1325                console.log('No namespace on event, using default');
1326            }
1327
1328            title.textContent = 'Edit Event';
1329            dialog.style.display = 'flex';
1330
1331            // Propagate CSS vars to dialog
1332            propagateThemeVars(calId, dialog);
1333        }
1334    })
1335    .catch(err => console.error('Error editing event:', err));
1336};
1337
1338// Delete event
1339window.deleteEvent = function(calId, eventId, date, namespace) {
1340    if (!confirm('Delete this event?')) return;
1341
1342    const params = new URLSearchParams({
1343        call: 'plugin_calendar',
1344        action: 'delete_event',
1345        namespace: namespace,
1346        date: date,
1347        eventId: eventId
1348    });
1349
1350    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1351        method: 'POST',
1352        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1353        body: params.toString()
1354    })
1355    .then(r => r.json())
1356    .then(data => {
1357        if (data.success) {
1358            // Extract year and month from date
1359            const [year, month] = date.split('-').map(Number);
1360
1361            // Reload calendar data via AJAX
1362            reloadCalendarData(calId, year, month, namespace);
1363        }
1364    })
1365    .catch(err => console.error('Error:', err));
1366};
1367
1368// Save event (add or edit)
1369window.saveEventCompact = function(calId, namespace) {
1370    const form = document.getElementById('eventform-' + calId);
1371
1372    // Get namespace from dropdown - this is what the user selected
1373    const namespaceSelect = document.getElementById('event-namespace-' + calId);
1374    const selectedNamespace = namespaceSelect ? namespaceSelect.value : '';
1375
1376    // ALWAYS use what the user selected in the dropdown
1377    // This allows changing namespace when editing
1378    const finalNamespace = selectedNamespace;
1379
1380    const eventId = document.getElementById('event-id-' + calId).value;
1381
1382    // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event)
1383    const originalNamespace = form.dataset.eventNamespace;
1384
1385
1386    const dateInput = document.getElementById('event-date-' + calId);
1387    const date = dateInput.value;
1388    const oldDate = dateInput.getAttribute('data-original-date') || date;
1389    const endDate = document.getElementById('event-end-date-' + calId).value;
1390    const title = document.getElementById('event-title-' + calId).value;
1391    const time = document.getElementById('event-time-' + calId).value;
1392    const endTime = document.getElementById('event-end-time-' + calId).value;
1393    const colorSelect = document.getElementById('event-color-' + calId);
1394    let color = colorSelect.value;
1395
1396    // Handle custom color
1397    if (color === 'custom') {
1398        color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value;
1399    }
1400
1401    const description = document.getElementById('event-desc-' + calId).value;
1402    const isTask = document.getElementById('event-is-task-' + calId).checked;
1403    const completed = false; // New tasks are not completed
1404    const isRecurring = document.getElementById('event-recurring-' + calId).checked;
1405    const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value;
1406    const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value;
1407
1408    if (!title) {
1409        alert('Please enter a title');
1410        return;
1411    }
1412
1413    if (!date) {
1414        alert('Please select a date');
1415        return;
1416    }
1417
1418    const params = new URLSearchParams({
1419        call: 'plugin_calendar',
1420        action: 'save_event',
1421        namespace: finalNamespace,
1422        eventId: eventId,
1423        date: date,
1424        oldDate: oldDate,
1425        endDate: endDate,
1426        title: title,
1427        time: time,
1428        endTime: endTime,
1429        color: color,
1430        description: description,
1431        isTask: isTask ? '1' : '0',
1432        completed: completed ? '1' : '0',
1433        isRecurring: isRecurring ? '1' : '0',
1434        recurrenceType: recurrenceType,
1435        recurrenceEnd: recurrenceEnd
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    console.log('Highlighting event:', calId, eventId, date);
1521
1522    // Find the event item in the event list
1523    const eventList = document.querySelector('#' + calId + ' .event-list-compact');
1524    if (!eventList) {
1525        console.log('Event list not found');
1526        return;
1527    }
1528
1529    const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]');
1530    if (!eventItem) {
1531        console.log('Event item not found');
1532        return;
1533    }
1534
1535    console.log('Found event item:', eventItem);
1536
1537    // Get theme
1538    const container = document.getElementById(calId);
1539    const theme = container ? container.dataset.theme : 'matrix';
1540    const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {};
1541
1542    console.log('Theme:', theme);
1543
1544    // Theme-specific highlight colors
1545    let highlightBg, highlightShadow;
1546    if (theme === 'matrix') {
1547        highlightBg = '#1a3d1a';  // Darker green
1548        highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)';
1549    } else if (theme === 'purple') {
1550        highlightBg = '#3d2b4d';  // Darker purple
1551        highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)';
1552    } else if (theme === 'professional') {
1553        highlightBg = '#e3f2fd';  // Light blue
1554        highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)';
1555    } else if (theme === 'pink') {
1556        highlightBg = '#3d2030';  // Darker pink
1557        highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)';
1558    } else if (theme === 'wiki') {
1559        highlightBg = '#dce9f5';  // Light blue highlight
1560        highlightShadow = '0 0 20px rgba(43, 115, 183, 0.4)';
1561    }
1562
1563    console.log('Highlight colors:', highlightBg, highlightShadow);
1564
1565    // Store original styles
1566    const originalBg = eventItem.style.background;
1567    const originalShadow = eventItem.style.boxShadow;
1568
1569    // Remove previous highlights (restore their original styles)
1570    const previousHighlights = eventList.querySelectorAll('.event-highlighted');
1571    previousHighlights.forEach(el => {
1572        el.classList.remove('event-highlighted');
1573    });
1574
1575    // Add highlight class and apply theme-aware glow
1576    eventItem.classList.add('event-highlighted');
1577
1578    // Set CSS properties directly
1579    eventItem.style.setProperty('background', highlightBg, 'important');
1580    eventItem.style.setProperty('box-shadow', highlightShadow, 'important');
1581    eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important');
1582
1583    console.log('Applied highlight styles');
1584
1585    // Scroll to event
1586    eventItem.scrollIntoView({
1587        behavior: 'smooth',
1588        block: 'nearest',
1589        inline: 'nearest'
1590    });
1591
1592    // Remove highlight after 3 seconds and restore original styles
1593    setTimeout(() => {
1594        console.log('Removing highlight');
1595        eventItem.classList.remove('event-highlighted');
1596        eventItem.style.setProperty('background', originalBg);
1597        eventItem.style.setProperty('box-shadow', originalShadow);
1598        eventItem.style.setProperty('transition', '');
1599    }, 3000);
1600};
1601
1602// Toggle recurring event options
1603window.toggleRecurringOptions = function(calId) {
1604    const checkbox = document.getElementById('event-recurring-' + calId);
1605    const options = document.getElementById('recurring-options-' + calId);
1606
1607    if (checkbox && options) {
1608        options.style.display = checkbox.checked ? 'block' : 'none';
1609    }
1610};
1611
1612// ============================================================
1613// Document-level event delegation (guarded - only attach once)
1614// These use event delegation so they work for AJAX-rebuilt content.
1615// ============================================================
1616if (!window._calendarDelegationInit) {
1617    window._calendarDelegationInit = true;
1618
1619    // ESC closes dialogs, popups, tooltips
1620    document.addEventListener('keydown', function(e) {
1621        if (e.key === 'Escape') {
1622            document.querySelectorAll('.event-dialog-compact').forEach(function(d) {
1623                if (d.style.display === 'flex') d.style.display = 'none';
1624            });
1625            document.querySelectorAll('.day-popup').forEach(function(p) {
1626                p.style.display = 'none';
1627            });
1628            hideConflictTooltip();
1629        }
1630    });
1631
1632    // Conflict tooltip delegation (capture phase for mouseenter/leave)
1633    document.addEventListener('mouseenter', function(e) {
1634        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1635            showConflictTooltip(e.target);
1636        }
1637    }, true);
1638
1639    document.addEventListener('mouseleave', function(e) {
1640        if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) {
1641            hideConflictTooltip();
1642        }
1643    }, true);
1644} // end delegation guard
1645
1646// Event panel navigation
1647window.navEventPanel = function(calId, year, month, namespace) {
1648    const params = new URLSearchParams({
1649        call: 'plugin_calendar',
1650        action: 'load_month',
1651        year: year,
1652        month: month,
1653        namespace: namespace,
1654        _: new Date().getTime() // Cache buster
1655    });
1656
1657    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1658        method: 'POST',
1659        headers: {
1660            'Content-Type': 'application/x-www-form-urlencoded',
1661            'Cache-Control': 'no-cache, no-store, must-revalidate',
1662            'Pragma': 'no-cache'
1663        },
1664        body: params.toString()
1665    })
1666    .then(r => r.json())
1667    .then(data => {
1668        if (data.success) {
1669            rebuildEventPanel(calId, data.year, data.month, data.events, namespace);
1670        }
1671    })
1672    .catch(err => console.error('Error:', err));
1673};
1674
1675// Rebuild event panel only
1676window.rebuildEventPanel = function(calId, year, month, events, namespace) {
1677    const container = document.getElementById(calId);
1678    const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1679                       'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1680
1681    // Update month title in new compact header
1682    const monthTitle = container.querySelector('.panel-month-title');
1683    if (monthTitle) {
1684        monthTitle.textContent = monthNames[month - 1] + ' ' + year;
1685        monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1686        monthTitle.setAttribute('title', 'Click to jump to month');
1687    }
1688
1689    // Fallback: Update old header format if exists
1690    const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker');
1691    if (oldHeader && !monthTitle) {
1692        oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events';
1693        oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`);
1694    }
1695
1696    // Update nav buttons
1697    let prevMonth = month - 1;
1698    let prevYear = year;
1699    if (prevMonth < 1) {
1700        prevMonth = 12;
1701        prevYear--;
1702    }
1703
1704    let nextMonth = month + 1;
1705    let nextYear = year;
1706    if (nextMonth > 12) {
1707        nextMonth = 1;
1708        nextYear++;
1709    }
1710
1711    // Update new compact nav buttons
1712    const navBtns = container.querySelectorAll('.panel-nav-btn');
1713    if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1714    if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1715
1716    // Fallback for old nav buttons
1717    const oldNavBtns = container.querySelectorAll('.cal-nav-btn');
1718    if (oldNavBtns.length > 0 && navBtns.length === 0) {
1719        if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`);
1720        if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`);
1721    }
1722
1723    // Update Today button (works for both old and new)
1724    const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact');
1725    if (todayBtn) {
1726        todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`);
1727    }
1728
1729    // Rebuild event list
1730    const eventList = container.querySelector('.event-list-compact');
1731    if (eventList) {
1732        eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month);
1733    }
1734};
1735
1736// Open add event for panel
1737window.openAddEventPanel = function(calId, namespace) {
1738    const today = new Date();
1739    const year = today.getFullYear();
1740    const month = String(today.getMonth() + 1).padStart(2, '0');
1741    const day = String(today.getDate()).padStart(2, '0');
1742    const localDate = `${year}-${month}-${day}`;
1743    openAddEvent(calId, namespace, localDate);
1744};
1745
1746// Toggle task completion
1747window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) {
1748    const params = new URLSearchParams({
1749        call: 'plugin_calendar',
1750        action: 'toggle_task',
1751        namespace: namespace,
1752        date: date,
1753        eventId: eventId,
1754        completed: completed ? '1' : '0'
1755    });
1756
1757    fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1758        method: 'POST',
1759        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1760        body: params.toString()
1761    })
1762    .then(r => r.json())
1763    .then(data => {
1764        if (data.success) {
1765            const [year, month] = date.split('-').map(Number);
1766            reloadCalendarData(calId, year, month, namespace);
1767        }
1768    })
1769    .catch(err => console.error('Error toggling task:', err));
1770};
1771
1772// Make dialog draggable
1773window.makeDialogDraggable = function(calId) {
1774    const dialog = document.getElementById('dialog-content-' + calId);
1775    const handle = document.getElementById('drag-handle-' + calId);
1776
1777    if (!dialog || !handle) return;
1778
1779    let isDragging = false;
1780    let currentX;
1781    let currentY;
1782    let initialX;
1783    let initialY;
1784    let xOffset = 0;
1785    let yOffset = 0;
1786
1787    handle.addEventListener('mousedown', dragStart);
1788    document.addEventListener('mousemove', drag);
1789    document.addEventListener('mouseup', dragEnd);
1790
1791    function dragStart(e) {
1792        initialX = e.clientX - xOffset;
1793        initialY = e.clientY - yOffset;
1794        isDragging = true;
1795    }
1796
1797    function drag(e) {
1798        if (isDragging) {
1799            e.preventDefault();
1800            currentX = e.clientX - initialX;
1801            currentY = e.clientY - initialY;
1802            xOffset = currentX;
1803            yOffset = currentY;
1804            setTranslate(currentX, currentY, dialog);
1805        }
1806    }
1807
1808    function dragEnd(e) {
1809        initialX = currentX;
1810        initialY = currentY;
1811        isDragging = false;
1812    }
1813
1814    function setTranslate(xPos, yPos, el) {
1815        el.style.transform = `translate(${xPos}px, ${yPos}px)`;
1816    }
1817};
1818
1819// Initialize dialog draggability when opened (avoid duplicate declaration)
1820if (!window.calendarDraggabilityPatched) {
1821    window.calendarDraggabilityPatched = true;
1822
1823    const originalOpenAddEvent = openAddEvent;
1824    openAddEvent = function(calId, namespace, date) {
1825        originalOpenAddEvent(calId, namespace, date);
1826        setTimeout(() => makeDialogDraggable(calId), 100);
1827    };
1828
1829    const originalEditEvent = editEvent;
1830    editEvent = function(calId, eventId, date, namespace) {
1831        originalEditEvent(calId, eventId, date, namespace);
1832        setTimeout(() => makeDialogDraggable(calId), 100);
1833    };
1834}
1835
1836// Toggle expand/collapse for past events
1837window.togglePastEventExpand = function(element) {
1838    // Stop propagation to prevent any parent click handlers
1839    event.stopPropagation();
1840
1841    const meta = element.querySelector(".event-meta-compact");
1842    const desc = element.querySelector(".event-desc-compact");
1843
1844    // Toggle visibility
1845    if (meta.style.display === "none") {
1846        // Expand
1847        meta.style.display = "block";
1848        if (desc) desc.style.display = "block";
1849        element.classList.add("event-past-expanded");
1850    } else {
1851        // Collapse
1852        meta.style.display = "none";
1853        if (desc) desc.style.display = "none";
1854        element.classList.remove("event-past-expanded");
1855    }
1856};
1857
1858// Filter calendar by namespace when clicking namespace badge (guarded)
1859if (!window._calendarClickDelegationInit) {
1860    window._calendarClickDelegationInit = true;
1861    document.addEventListener('click', function(e) {
1862    if (e.target.classList.contains('event-namespace-badge')) {
1863        const namespace = e.target.textContent;
1864        const eventItem = e.target.closest('.event-compact-item');
1865        const eventList = e.target.closest('.event-list-compact');
1866        const calendar = e.target.closest('.calendar-compact-container');
1867
1868        if (!eventList || !calendar) return;
1869
1870        const calId = calendar.id;
1871
1872        // Check if already filtered
1873        const isFiltered = eventList.classList.contains('namespace-filtered');
1874
1875        if (isFiltered && eventList.dataset.filterNamespace === namespace) {
1876            // Unfilter - show all
1877            eventList.classList.remove('namespace-filtered');
1878            delete eventList.dataset.filterNamespace;
1879            delete calendar.dataset.filteredNamespace;
1880            eventList.querySelectorAll('.event-compact-item').forEach(item => {
1881                item.style.display = '';
1882            });
1883
1884            // Update header to show "all namespaces"
1885            updateFilteredNamespaceDisplay(calId, null);
1886        } else {
1887            // Filter by this namespace
1888            eventList.classList.add('namespace-filtered');
1889            eventList.dataset.filterNamespace = namespace;
1890            calendar.dataset.filteredNamespace = namespace;
1891            eventList.querySelectorAll('.event-compact-item').forEach(item => {
1892                const itemBadge = item.querySelector('.event-namespace-badge');
1893                if (itemBadge && itemBadge.textContent === namespace) {
1894                    item.style.display = '';
1895                } else {
1896                    item.style.display = 'none';
1897                }
1898            });
1899
1900            // Update header to show filtered namespace
1901            updateFilteredNamespaceDisplay(calId, namespace);
1902        }
1903    }
1904    });
1905} // end click delegation guard
1906
1907// Update the displayed filtered namespace in event list header
1908window.updateFilteredNamespaceDisplay = function(calId, namespace) {
1909    const calendar = document.getElementById(calId);
1910    if (!calendar) return;
1911
1912    const headerContent = calendar.querySelector('.event-list-header-content');
1913    if (!headerContent) return;
1914
1915    // Remove existing filter badge
1916    let filterBadge = headerContent.querySelector('.namespace-filter-badge');
1917    if (filterBadge) {
1918        filterBadge.remove();
1919    }
1920
1921    // Add new filter badge if filtering
1922    if (namespace) {
1923        filterBadge = document.createElement('span');
1924        filterBadge.className = 'namespace-badge namespace-filter-badge';
1925        filterBadge.innerHTML = escapeHtml(namespace) + ' <button class="filter-clear-inline" onclick="clearNamespaceFilter(\'' + calId + '\'); event.stopPropagation();">✕</button>';
1926        headerContent.appendChild(filterBadge);
1927    }
1928};
1929
1930// Clear namespace filter
1931window.clearNamespaceFilter = function(calId) {
1932
1933    const container = document.getElementById(calId);
1934    if (!container) {
1935        console.error('Calendar container not found:', calId);
1936        return;
1937    }
1938
1939    // Immediately hide/remove the filter badge
1940    const filterBadge = container.querySelector('.calendar-namespace-filter');
1941    if (filterBadge) {
1942        filterBadge.style.display = 'none';
1943        filterBadge.remove();
1944    }
1945
1946    // Get current year and month
1947    const year = parseInt(container.dataset.year) || new Date().getFullYear();
1948    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
1949
1950    // Get original namespace (what the calendar was initialized with)
1951    const originalNamespace = container.dataset.originalNamespace || '';
1952
1953    // Also check for sidebar widget
1954    const sidebarContainer = document.getElementById('sidebar-widget-' + calId);
1955    if (sidebarContainer) {
1956        // For sidebar widget, just reload the page without namespace filter
1957        // Remove the namespace from the URL and reload
1958        const url = new URL(window.location.href);
1959        url.searchParams.delete('namespace');
1960        window.location.href = url.toString();
1961        return;
1962    }
1963
1964    // For regular calendar, reload calendar with original namespace
1965    navCalendar(calId, year, month, originalNamespace);
1966};
1967
1968window.clearNamespaceFilterPanel = function(calId) {
1969
1970    const container = document.getElementById(calId);
1971    if (!container) {
1972        console.error('Event panel container not found:', calId);
1973        return;
1974    }
1975
1976    // Get current year and month from URL params or container
1977    const year = parseInt(container.dataset.year) || new Date().getFullYear();
1978    const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1);
1979
1980    // Get original namespace (what the panel was initialized with)
1981    const originalNamespace = container.dataset.originalNamespace || '';
1982
1983
1984    // Reload event panel with original namespace
1985    navEventPanel(calId, year, month, originalNamespace);
1986};
1987
1988// Color picker functions
1989window.updateCustomColorPicker = function(calId) {
1990    const select = document.getElementById('event-color-' + calId);
1991    const picker = document.getElementById('event-color-custom-' + calId);
1992
1993    if (select.value === 'custom') {
1994        // Show color picker
1995        picker.style.display = 'inline-block';
1996        picker.click(); // Open color picker
1997    } else {
1998        // Hide color picker and sync value
1999        picker.style.display = 'none';
2000        picker.value = select.value;
2001    }
2002};
2003
2004function updateColorFromPicker(calId) {
2005    const select = document.getElementById('event-color-' + calId);
2006    const picker = document.getElementById('event-color-custom-' + calId);
2007
2008    // Set select to custom and update its underlying value
2009    select.value = 'custom';
2010    // Store the actual color value in a data attribute
2011    select.dataset.customColor = picker.value;
2012}
2013
2014// Toggle past events visibility
2015window.togglePastEvents = function(calId) {
2016    const content = document.getElementById('past-events-' + calId);
2017    const arrow = document.getElementById('past-arrow-' + calId);
2018
2019    if (!content || !arrow) {
2020        console.error('Past events elements not found for:', calId);
2021        return;
2022    }
2023
2024    // Check computed style instead of inline style
2025    const isHidden = window.getComputedStyle(content).display === 'none';
2026
2027    if (isHidden) {
2028        content.style.display = 'block';
2029        arrow.textContent = '▼';
2030    } else {
2031        content.style.display = 'none';
2032        arrow.textContent = '▶';
2033    }
2034};
2035
2036// Fuzzy match scoring function
2037window.fuzzyMatch = function(pattern, str) {
2038    pattern = pattern.toLowerCase();
2039    str = str.toLowerCase();
2040
2041    let patternIdx = 0;
2042    let score = 0;
2043    let consecutiveMatches = 0;
2044
2045    for (let i = 0; i < str.length; i++) {
2046        if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) {
2047            score += 1 + consecutiveMatches;
2048            consecutiveMatches++;
2049            patternIdx++;
2050        } else {
2051            consecutiveMatches = 0;
2052        }
2053    }
2054
2055    // Return null if not all characters matched
2056    if (patternIdx !== pattern.length) {
2057        return null;
2058    }
2059
2060    // Bonus for exact match
2061    if (str === pattern) {
2062        score += 100;
2063    }
2064
2065    // Bonus for starts with
2066    if (str.startsWith(pattern)) {
2067        score += 50;
2068    }
2069
2070    return score;
2071};
2072
2073// Initialize namespace search for a calendar
2074window.initNamespaceSearch = function(calId) {
2075    const searchInput = document.getElementById('event-namespace-search-' + calId);
2076    const hiddenInput = document.getElementById('event-namespace-' + calId);
2077    const dropdown = document.getElementById('event-namespace-dropdown-' + calId);
2078    const dataElement = document.getElementById('namespaces-data-' + calId);
2079
2080    if (!searchInput || !hiddenInput || !dropdown || !dataElement) {
2081        return; // Elements not found
2082    }
2083
2084    let namespaces = [];
2085    try {
2086        namespaces = JSON.parse(dataElement.textContent);
2087    } catch (e) {
2088        console.error('Failed to parse namespaces data:', e);
2089        return;
2090    }
2091
2092    let selectedIndex = -1;
2093
2094    // Filter and show dropdown
2095    function filterNamespaces(query) {
2096        if (!query || query.trim() === '') {
2097            // Show all namespaces when empty
2098            hiddenInput.value = '';
2099            const results = namespaces.slice(0, 20); // Limit to 20
2100            showDropdown(results);
2101            return;
2102        }
2103
2104        // Fuzzy match and score
2105        const matches = [];
2106        for (let i = 0; i < namespaces.length; i++) {
2107            const score = fuzzyMatch(query, namespaces[i]);
2108            if (score !== null) {
2109                matches.push({ namespace: namespaces[i], score: score });
2110            }
2111        }
2112
2113        // Sort by score (descending)
2114        matches.sort((a, b) => b.score - a.score);
2115
2116        // Take top 20 results
2117        const results = matches.slice(0, 20).map(m => m.namespace);
2118        showDropdown(results);
2119    }
2120
2121    function showDropdown(results) {
2122        dropdown.innerHTML = '';
2123        selectedIndex = -1;
2124
2125        if (results.length === 0) {
2126            dropdown.style.display = 'none';
2127            return;
2128        }
2129
2130        // Add (default) option
2131        const defaultOption = document.createElement('div');
2132        defaultOption.className = 'namespace-option';
2133        defaultOption.textContent = '(default)';
2134        defaultOption.dataset.value = '';
2135        dropdown.appendChild(defaultOption);
2136
2137        results.forEach(ns => {
2138            const option = document.createElement('div');
2139            option.className = 'namespace-option';
2140            option.textContent = ns;
2141            option.dataset.value = ns;
2142            dropdown.appendChild(option);
2143        });
2144
2145        dropdown.style.display = 'block';
2146    }
2147
2148    function hideDropdown() {
2149        dropdown.style.display = 'none';
2150        selectedIndex = -1;
2151    }
2152
2153    function selectOption(namespace) {
2154        hiddenInput.value = namespace;
2155        searchInput.value = namespace || '(default)';
2156        hideDropdown();
2157    }
2158
2159    // Event listeners
2160    searchInput.addEventListener('input', function(e) {
2161        filterNamespaces(e.target.value);
2162    });
2163
2164    searchInput.addEventListener('focus', function(e) {
2165        filterNamespaces(e.target.value);
2166    });
2167
2168    searchInput.addEventListener('blur', function(e) {
2169        // Delay to allow click on dropdown
2170        setTimeout(hideDropdown, 200);
2171    });
2172
2173    searchInput.addEventListener('keydown', function(e) {
2174        const options = dropdown.querySelectorAll('.namespace-option');
2175
2176        if (e.key === 'ArrowDown') {
2177            e.preventDefault();
2178            selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
2179            updateSelection(options);
2180        } else if (e.key === 'ArrowUp') {
2181            e.preventDefault();
2182            selectedIndex = Math.max(selectedIndex - 1, -1);
2183            updateSelection(options);
2184        } else if (e.key === 'Enter') {
2185            e.preventDefault();
2186            if (selectedIndex >= 0 && options[selectedIndex]) {
2187                selectOption(options[selectedIndex].dataset.value);
2188            }
2189        } else if (e.key === 'Escape') {
2190            hideDropdown();
2191        }
2192    });
2193
2194    function updateSelection(options) {
2195        options.forEach((opt, idx) => {
2196            if (idx === selectedIndex) {
2197                opt.classList.add('selected');
2198                opt.scrollIntoView({ block: 'nearest' });
2199            } else {
2200                opt.classList.remove('selected');
2201            }
2202        });
2203    }
2204
2205    // Click on dropdown option
2206    dropdown.addEventListener('mousedown', function(e) {
2207        if (e.target.classList.contains('namespace-option')) {
2208            selectOption(e.target.dataset.value);
2209        }
2210    });
2211};
2212
2213// Update end time options based on start time selection
2214window.updateEndTimeOptions = function(calId) {
2215    const startTimeSelect = document.getElementById('event-time-' + calId);
2216    const endTimeSelect = document.getElementById('event-end-time-' + calId);
2217
2218    if (!startTimeSelect || !endTimeSelect) return;
2219
2220    const startTime = startTimeSelect.value;
2221
2222    // If start time is empty (all day), disable end time
2223    if (!startTime) {
2224        endTimeSelect.disabled = true;
2225        endTimeSelect.value = '';
2226        return;
2227    }
2228
2229    // Enable end time select
2230    endTimeSelect.disabled = false;
2231
2232    // Convert start time to minutes
2233    const startMinutes = timeToMinutes(startTime);
2234
2235    // Get current end time value (to preserve if valid)
2236    const currentEndTime = endTimeSelect.value;
2237    const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0;
2238
2239    // Filter options - show only times after start time
2240    const options = endTimeSelect.options;
2241    let firstValidOption = null;
2242    let currentStillValid = false;
2243
2244    for (let i = 0; i < options.length; i++) {
2245        const option = options[i];
2246        const optionValue = option.value;
2247
2248        if (optionValue === '') {
2249            // Keep "Same as start" option visible
2250            option.style.display = '';
2251            continue;
2252        }
2253
2254        const optionMinutes = timeToMinutes(optionValue);
2255
2256        if (optionMinutes > startMinutes) {
2257            // Show options after start time
2258            option.style.display = '';
2259            if (!firstValidOption) {
2260                firstValidOption = optionValue;
2261            }
2262            if (optionValue === currentEndTime) {
2263                currentStillValid = true;
2264            }
2265        } else {
2266            // Hide options before or equal to start time
2267            option.style.display = 'none';
2268        }
2269    }
2270
2271    // If current end time is now invalid, set a new one
2272    if (!currentStillValid || currentEndMinutes <= startMinutes) {
2273        // Try to set to 1 hour after start
2274        const [startHour, startMinute] = startTime.split(':').map(Number);
2275        let endHour = startHour + 1;
2276        let endMinute = startMinute;
2277
2278        if (endHour >= 24) {
2279            endHour = 23;
2280            endMinute = 45;
2281        }
2282
2283        const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0');
2284
2285        // Check if suggested time is in the list
2286        const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime);
2287
2288        if (suggestedExists) {
2289            endTimeSelect.value = suggestedEndTime;
2290        } else if (firstValidOption) {
2291            // Use first valid option
2292            endTimeSelect.value = firstValidOption;
2293        } else {
2294            // No valid options (shouldn't happen, but just in case)
2295            endTimeSelect.value = '';
2296        }
2297    }
2298};
2299
2300// Check for time conflicts between events on the same date
2301window.checkTimeConflicts = function(events, currentEventId) {
2302    const conflicts = [];
2303
2304    // Group events by date
2305    const eventsByDate = {};
2306    for (const [date, dateEvents] of Object.entries(events)) {
2307        if (!Array.isArray(dateEvents)) continue;
2308
2309        dateEvents.forEach(evt => {
2310            if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event
2311
2312            if (!eventsByDate[date]) eventsByDate[date] = [];
2313            eventsByDate[date].push(evt);
2314        });
2315    }
2316
2317    // Check for overlaps on each date
2318    for (const [date, dateEvents] of Object.entries(eventsByDate)) {
2319        for (let i = 0; i < dateEvents.length; i++) {
2320            for (let j = i + 1; j < dateEvents.length; j++) {
2321                const evt1 = dateEvents[i];
2322                const evt2 = dateEvents[j];
2323
2324                if (eventsOverlap(evt1, evt2)) {
2325                    // Mark both events as conflicting
2326                    if (!evt1.hasConflict) evt1.hasConflict = true;
2327                    if (!evt2.hasConflict) evt2.hasConflict = true;
2328
2329                    // Store conflict info
2330                    if (!evt1.conflictsWith) evt1.conflictsWith = [];
2331                    if (!evt2.conflictsWith) evt2.conflictsWith = [];
2332
2333                    evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime});
2334                    evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime});
2335                }
2336            }
2337        }
2338    }
2339
2340    return events;
2341};
2342
2343// Check if two events overlap in time
2344function eventsOverlap(evt1, evt2) {
2345    if (!evt1.time || !evt2.time) return false; // All-day events don't conflict
2346
2347    const start1 = evt1.time;
2348    const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start
2349
2350    const start2 = evt2.time;
2351    const end2 = evt2.endTime || evt2.time;
2352
2353    // Convert to minutes for easier comparison
2354    const start1Mins = timeToMinutes(start1);
2355    const end1Mins = timeToMinutes(end1);
2356    const start2Mins = timeToMinutes(start2);
2357    const end2Mins = timeToMinutes(end2);
2358
2359    // Check for overlap
2360    // Events overlap if: start1 < end2 AND start2 < end1
2361    return start1Mins < end2Mins && start2Mins < end1Mins;
2362}
2363
2364// Convert HH:MM time to minutes since midnight
2365function timeToMinutes(timeStr) {
2366    const [hours, minutes] = timeStr.split(':').map(Number);
2367    return hours * 60 + minutes;
2368}
2369
2370// Format time range for display
2371window.formatTimeRange = function(startTime, endTime) {
2372    if (!startTime) return '';
2373
2374    const formatTime = (timeStr) => {
2375        const [hour24, minute] = timeStr.split(':').map(Number);
2376        const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24);
2377        const ampm = hour24 < 12 ? 'AM' : 'PM';
2378        return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm;
2379    };
2380
2381    if (!endTime || endTime === startTime) {
2382        return formatTime(startTime);
2383    }
2384
2385    return formatTime(startTime) + ' - ' + formatTime(endTime);
2386};
2387
2388// Track last known mouse position for tooltip positioning fallback
2389var _lastMouseX = 0, _lastMouseY = 0;
2390document.addEventListener('mousemove', function(e) {
2391    _lastMouseX = e.clientX;
2392    _lastMouseY = e.clientY;
2393});
2394
2395// Show custom conflict tooltip
2396window.showConflictTooltip = function(badgeElement) {
2397    // Remove any existing tooltip
2398    hideConflictTooltip();
2399
2400    // Get conflict data (base64-encoded JSON to avoid attribute quote issues)
2401    const conflictsRaw = badgeElement.getAttribute('data-conflicts');
2402    if (!conflictsRaw) return;
2403
2404    let conflicts;
2405    try {
2406        conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw))));
2407    } catch (e) {
2408        // Fallback: try parsing as plain JSON (for PHP-rendered badges)
2409        try {
2410            conflicts = JSON.parse(conflictsRaw);
2411        } catch (e2) {
2412            console.error('Failed to parse conflicts:', e2);
2413            return;
2414        }
2415    }
2416
2417    // Get theme from the calendar container via CSS variables
2418    // Try closest ancestor first, then fall back to any calendar on the page
2419    let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone');
2420    if (!containerEl) {
2421        // Badge might be inside a day popup (appended to body) - find any calendar container
2422        containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]');
2423    }
2424    const cs = containerEl ? getComputedStyle(containerEl) : null;
2425
2426    const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424';
2427    const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07';
2428    const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07';
2429    const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00';
2430    const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)';
2431
2432    // Create tooltip
2433    const tooltip = document.createElement('div');
2434    tooltip.id = 'conflict-tooltip';
2435    tooltip.className = 'conflict-tooltip';
2436
2437    // Apply theme styles
2438    tooltip.style.background = bg;
2439    tooltip.style.borderColor = border;
2440    tooltip.style.color = textPrimary;
2441    tooltip.style.boxShadow = '0 4px 12px ' + shadow;
2442
2443    // Build content with themed colors
2444    let html = '<div class="conflict-tooltip-header" style="color: ' + textPrimary + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>';
2445    html += '<div class="conflict-tooltip-body">';
2446    conflicts.forEach(conflict => {
2447        html += '<div class="conflict-item" style="color: ' + textDim + ';">• ' + escapeHtml(conflict) + '</div>';
2448    });
2449    html += '</div>';
2450
2451    tooltip.innerHTML = html;
2452    document.body.appendChild(tooltip);
2453
2454    // Position tooltip
2455    const rect = badgeElement.getBoundingClientRect();
2456    const tooltipRect = tooltip.getBoundingClientRect();
2457
2458    // Position above the badge, centered
2459    let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
2460    let top = rect.top - tooltipRect.height - 8;
2461
2462    // Keep tooltip within viewport
2463    if (left < 10) left = 10;
2464    if (left + tooltipRect.width > window.innerWidth - 10) {
2465        left = window.innerWidth - tooltipRect.width - 10;
2466    }
2467    if (top < 10) {
2468        // If not enough room above, show below
2469        top = rect.bottom + 8;
2470    }
2471
2472    tooltip.style.left = left + 'px';
2473    tooltip.style.top = top + 'px';
2474    tooltip.style.opacity = '1';
2475};
2476
2477// Hide conflict tooltip
2478window.hideConflictTooltip = function() {
2479    const tooltip = document.getElementById('conflict-tooltip');
2480    if (tooltip) {
2481        tooltip.remove();
2482    }
2483};
2484
2485// Filter events by search term
2486window.filterEvents = function(calId, searchTerm) {
2487    const eventList = document.getElementById('eventlist-' + calId);
2488    const searchClear = document.getElementById('search-clear-' + calId);
2489
2490    if (!eventList) return;
2491
2492    // Show/hide clear button
2493    if (searchClear) {
2494        searchClear.style.display = searchTerm ? 'block' : 'none';
2495    }
2496
2497    searchTerm = searchTerm.toLowerCase().trim();
2498
2499    // Get all event items
2500    const eventItems = eventList.querySelectorAll('.event-compact-item');
2501    let visibleCount = 0;
2502    let hiddenPastCount = 0;
2503
2504    eventItems.forEach(item => {
2505        const title = item.querySelector('.event-title-compact');
2506        const description = item.querySelector('.event-desc-compact');
2507        const dateTime = item.querySelector('.event-date-time');
2508
2509        // Build searchable text
2510        let searchableText = '';
2511        if (title) searchableText += title.textContent.toLowerCase() + ' ';
2512        if (description) searchableText += description.textContent.toLowerCase() + ' ';
2513        if (dateTime) searchableText += dateTime.textContent.toLowerCase() + ' ';
2514
2515        // Check if matches search
2516        const matches = !searchTerm || searchableText.includes(searchTerm);
2517
2518        if (matches) {
2519            item.style.display = '';
2520            visibleCount++;
2521        } else {
2522            item.style.display = 'none';
2523            // Check if this is a past event
2524            if (item.classList.contains('event-past') || item.classList.contains('event-completed')) {
2525                hiddenPastCount++;
2526            }
2527        }
2528    });
2529
2530    // Update past events toggle if it exists
2531    const pastToggle = eventList.querySelector('.past-events-toggle');
2532    const pastLabel = eventList.querySelector('.past-events-label');
2533    const pastContent = document.getElementById('past-events-' + calId);
2534
2535    if (pastToggle && pastLabel && pastContent) {
2536        const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])');
2537        const totalPastVisible = visiblePastEvents.length;
2538
2539        if (totalPastVisible > 0) {
2540            pastLabel.textContent = `Past Events (${totalPastVisible})`;
2541            pastToggle.style.display = '';
2542        } else {
2543            pastToggle.style.display = 'none';
2544        }
2545    }
2546
2547    // Show "no results" message if nothing visible
2548    let noResultsMsg = eventList.querySelector('.no-search-results');
2549    if (visibleCount === 0 && searchTerm) {
2550        if (!noResultsMsg) {
2551            noResultsMsg = document.createElement('p');
2552            noResultsMsg.className = 'no-search-results no-events-msg';
2553            noResultsMsg.textContent = 'No events match your search';
2554            eventList.appendChild(noResultsMsg);
2555        }
2556        noResultsMsg.style.display = 'block';
2557    } else if (noResultsMsg) {
2558        noResultsMsg.style.display = 'none';
2559    }
2560};
2561
2562// Clear event search
2563window.clearEventSearch = function(calId) {
2564    const searchInput = document.getElementById('event-search-' + calId);
2565    if (searchInput) {
2566        searchInput.value = '';
2567        filterEvents(calId, '');
2568        searchInput.focus();
2569    }
2570};
2571
2572// ============================================
2573// PINK THEME - GLOWING PARTICLE EFFECTS
2574// ============================================
2575
2576// Create glowing pink particle effects for pink theme
2577(function() {
2578    let pinkThemeActive = false;
2579    let trailTimer = null;
2580    let pixelTimer = null;
2581
2582    // Check if pink theme is active
2583    function checkPinkTheme() {
2584        const pinkCalendars = document.querySelectorAll('.calendar-theme-pink');
2585        pinkThemeActive = pinkCalendars.length > 0;
2586        return pinkThemeActive;
2587    }
2588
2589    // Create trail particle
2590    function createTrailParticle(clientX, clientY) {
2591        if (!pinkThemeActive) return;
2592
2593        const trail = document.createElement('div');
2594        trail.className = 'pink-cursor-trail';
2595        trail.style.left = clientX + 'px';
2596        trail.style.top = clientY + 'px';
2597        trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards';
2598
2599        document.body.appendChild(trail);
2600
2601        setTimeout(function() {
2602            trail.remove();
2603        }, 500);
2604    }
2605
2606    // Create pixel sparkles
2607    function createPixelSparkles(clientX, clientY) {
2608        if (!pinkThemeActive || pixelTimer) return;
2609
2610        const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels
2611
2612        for (let i = 0; i < pixelCount; i++) {
2613            const pixel = document.createElement('div');
2614            pixel.className = 'pink-pixel-sparkle';
2615
2616            // Random offset from cursor
2617            const offsetX = (Math.random() - 0.5) * 30;
2618            const offsetY = (Math.random() - 0.5) * 30;
2619
2620            pixel.style.left = (clientX + offsetX) + 'px';
2621            pixel.style.top = (clientY + offsetY) + 'px';
2622
2623            // Random color - bright neon pinks and whites
2624            const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
2625            const color = colors[Math.floor(Math.random() * colors.length)];
2626            pixel.style.background = color;
2627            pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff';
2628
2629            // Random animation
2630            if (Math.random() > 0.5) {
2631                pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards';
2632            } else {
2633                pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards';
2634            }
2635
2636            document.body.appendChild(pixel);
2637
2638            setTimeout(function() {
2639                pixel.remove();
2640            }, 800);
2641        }
2642
2643        pixelTimer = setTimeout(function() {
2644            pixelTimer = null;
2645        }, 40);
2646    }
2647
2648    // Create explosion
2649    function createExplosion(clientX, clientY) {
2650        if (!pinkThemeActive) return;
2651
2652        const particleCount = 25;
2653        const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff'];
2654
2655        // Add hearts to explosion (8-12 hearts)
2656        const heartCount = 8 + Math.floor(Math.random() * 5);
2657        for (let i = 0; i < heartCount; i++) {
2658            const heart = document.createElement('div');
2659            heart.textContent = '��';
2660            heart.style.position = 'fixed';
2661            heart.style.left = clientX + 'px';
2662            heart.style.top = clientY + 'px';
2663            heart.style.pointerEvents = 'none';
2664            heart.style.zIndex = '9999999';
2665            heart.style.fontSize = (12 + Math.random() * 16) + 'px';
2666
2667            // Random direction
2668            const angle = Math.random() * Math.PI * 2;
2669            const velocity = 60 + Math.random() * 80;
2670            const tx = Math.cos(angle) * velocity;
2671            const ty = Math.sin(angle) * velocity;
2672
2673            heart.style.setProperty('--tx', tx + 'px');
2674            heart.style.setProperty('--ty', ty + 'px');
2675
2676            const duration = 0.8 + Math.random() * 0.4;
2677            heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2678
2679            document.body.appendChild(heart);
2680
2681            setTimeout(function() {
2682                heart.remove();
2683            }, duration * 1000);
2684        }
2685
2686        // Main explosion particles
2687        for (let i = 0; i < particleCount; i++) {
2688            const particle = document.createElement('div');
2689            particle.className = 'pink-particle';
2690
2691            const color = colors[Math.floor(Math.random() * colors.length)];
2692            particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)';
2693            particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color;
2694
2695            particle.style.left = clientX + 'px';
2696            particle.style.top = clientY + 'px';
2697
2698            const angle = (Math.PI * 2 * i) / particleCount;
2699            const velocity = 50 + Math.random() * 100;
2700            const tx = Math.cos(angle) * velocity;
2701            const ty = Math.sin(angle) * velocity;
2702
2703            particle.style.setProperty('--tx', tx + 'px');
2704            particle.style.setProperty('--ty', ty + 'px');
2705
2706            const size = 4 + Math.random() * 6;
2707            particle.style.width = size + 'px';
2708            particle.style.height = size + 'px';
2709
2710            const duration = 0.6 + Math.random() * 0.4;
2711            particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2712
2713            document.body.appendChild(particle);
2714
2715            setTimeout(function() {
2716                particle.remove();
2717            }, duration * 1000);
2718        }
2719
2720        // Pixel sparkles
2721        const pixelSparkleCount = 40;
2722
2723        for (let i = 0; i < pixelSparkleCount; i++) {
2724            const pixel = document.createElement('div');
2725            pixel.className = 'pink-pixel-sparkle';
2726
2727            const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1'];
2728            const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)];
2729            pixel.style.background = pixelColor;
2730            pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff';
2731
2732            const angle = Math.random() * Math.PI * 2;
2733            const distance = 30 + Math.random() * 80;
2734            const offsetX = Math.cos(angle) * distance;
2735            const offsetY = Math.sin(angle) * distance;
2736
2737            pixel.style.left = clientX + 'px';
2738            pixel.style.top = clientY + 'px';
2739            pixel.style.setProperty('--tx', offsetX + 'px');
2740            pixel.style.setProperty('--ty', offsetY + 'px');
2741
2742            const pixelSize = 1 + Math.random() * 2;
2743            pixel.style.width = pixelSize + 'px';
2744            pixel.style.height = pixelSize + 'px';
2745
2746            const duration = 0.4 + Math.random() * 0.4;
2747            if (Math.random() > 0.5) {
2748                pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards';
2749            } else {
2750                pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards';
2751            }
2752
2753            document.body.appendChild(pixel);
2754
2755            setTimeout(function() {
2756                pixel.remove();
2757            }, duration * 1000);
2758        }
2759
2760        // Flash
2761        const flash = document.createElement('div');
2762        flash.style.position = 'fixed';
2763        flash.style.left = clientX + 'px';
2764        flash.style.top = clientY + 'px';
2765        flash.style.width = '40px';
2766        flash.style.height = '40px';
2767        flash.style.borderRadius = '50%';
2768        flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)';
2769        flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4';
2770        flash.style.pointerEvents = 'none';
2771        flash.style.zIndex = '9999999';  // Above everything including dialogs
2772        flash.style.transform = 'translate(-50%, -50%)';
2773        flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards';
2774
2775        document.body.appendChild(flash);
2776
2777        setTimeout(function() {
2778            flash.remove();
2779        }, 300);
2780    }
2781
2782    function initPinkParticles() {
2783        if (!checkPinkTheme()) return;
2784
2785        // Use capture phase to catch events before stopPropagation
2786        document.addEventListener('mousemove', function(e) {
2787            if (!pinkThemeActive) return;
2788
2789            createTrailParticle(e.clientX, e.clientY);
2790            createPixelSparkles(e.clientX, e.clientY);
2791        }, true); // Capture phase!
2792
2793        // Throttle main trail
2794        document.addEventListener('mousemove', function(e) {
2795            if (!pinkThemeActive || trailTimer) return;
2796
2797            trailTimer = setTimeout(function() {
2798                trailTimer = null;
2799            }, 30);
2800        }, true); // Capture phase!
2801
2802        // Click explosion - use capture phase
2803        document.addEventListener('click', function(e) {
2804            if (!pinkThemeActive) return;
2805
2806            createExplosion(e.clientX, e.clientY);
2807        }, true); // Capture phase!
2808    }
2809
2810    // Initialize on load
2811    if (document.readyState === 'loading') {
2812        document.addEventListener('DOMContentLoaded', initPinkParticles);
2813    } else {
2814        initPinkParticles();
2815    }
2816
2817    // Re-check theme if calendar is dynamically added
2818    if (typeof MutationObserver !== 'undefined') {
2819        const observer = new MutationObserver(function(mutations) {
2820            mutations.forEach(function(mutation) {
2821                if (mutation.addedNodes.length > 0) {
2822                    mutation.addedNodes.forEach(function(node) {
2823                        if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) {
2824                            checkPinkTheme();
2825                            initPinkParticles();
2826                        }
2827                    });
2828                }
2829            });
2830        });
2831
2832        observer.observe(document.body, {
2833            childList: true,
2834            subtree: true
2835        });
2836    }
2837})();
2838
2839// End of calendar plugin JavaScript
2840