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