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