xref: /plugin/calendar/syntax.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
1<?php
2/**
3 * DokuWiki Plugin calendar (Syntax Component)
4 * Compact design with integrated event list
5 *
6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @author  DokuWiki Community
8 */
9
10if (!defined('DOKU_INC')) die();
11
12class syntax_plugin_calendar extends DokuWiki_Syntax_Plugin {
13
14    /**
15     * Get the meta directory path (farm-safe)
16     * Uses $conf['metadir'] instead of hardcoded DOKU_INC . 'data/meta/'
17     */
18    private function metaDir() {
19        global $conf;
20        return rtrim($conf['metadir'], '/') . '/';
21    }
22
23    /**
24     * Check if the current user has read access to a namespace
25     *
26     * @param string $namespace Namespace to check (empty = root)
27     * @return bool True if user has at least AUTH_READ
28     */
29    private function checkNamespaceRead($namespace) {
30        if (empty($namespace) || $namespace === '*') return true;
31        $ns = str_replace(['*', ';'], '', $namespace);
32        if (empty($ns)) return true;
33        $perm = auth_quickaclcheck($ns . ':*');
34        return ($perm >= AUTH_READ);
35    }
36
37    /**
38     * Get sync config file path (farm-safe)
39     * Checks per-animal metadir first, falls back to shared plugin dir
40     */
41    private function syncConfigPath() {
42        $perAnimal = $this->metaDir() . 'calendar/sync_config.php';
43        if (file_exists($perAnimal)) return $perAnimal;
44        return DOKU_PLUGIN . 'calendar/sync_config.php';
45    }
46
47    public function getType() {
48        return 'substition';
49    }
50
51    public function getPType() {
52        return 'block';
53    }
54
55    public function getSort() {
56        return 155;
57    }
58
59    public function connectTo($mode) {
60        $this->Lexer->addSpecialPattern('\{\{calendar(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
61        $this->Lexer->addSpecialPattern('\{\{eventlist(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
62        $this->Lexer->addSpecialPattern('\{\{eventpanel(?:[^\}]*)\}\}', $mode, 'plugin_calendar');
63    }
64
65    public function handle($match, $state, $pos, Doku_Handler $handler) {
66        $isEventList = (strpos($match, '{{eventlist') === 0);
67        $isEventPanel = (strpos($match, '{{eventpanel') === 0);
68
69        if ($isEventList) {
70            $match = substr($match, 12, -2);
71        } elseif ($isEventPanel) {
72            $match = substr($match, 13, -2);
73        } else {
74            $match = substr($match, 10, -2);
75        }
76
77        $params = array(
78            'type' => $isEventPanel ? 'eventpanel' : ($isEventList ? 'eventlist' : 'calendar'),
79            'year' => date('Y'),
80            'month' => date('n'),
81            'namespace' => '',
82            'daterange' => '',
83            'date' => '',
84            'range' => '',
85            'static' => false,
86            'title' => '',
87            'noprint' => false,
88            'theme' => '',
89            'locked' => false  // Will be set true if month/year specified
90        );
91
92        // Track if user explicitly set month or year
93        $userSetMonth = false;
94        $userSetYear = false;
95
96        if (trim($match)) {
97            // Parse parameters, handling quoted strings properly
98            // Match: key="value with spaces" OR key=value OR standalone_flag
99            preg_match_all('/(\w+)=["\']([^"\']+)["\']|(\w+)=(\S+)|(\w+)/', trim($match), $matches, PREG_SET_ORDER);
100
101            foreach ($matches as $m) {
102                if (!empty($m[1]) && isset($m[2])) {
103                    // key="quoted value"
104                    $key = $m[1];
105                    $value = $m[2];
106                    $params[$key] = $value;
107                    if ($key === 'month') $userSetMonth = true;
108                    if ($key === 'year') $userSetYear = true;
109                } elseif (!empty($m[3]) && isset($m[4])) {
110                    // key=unquoted_value
111                    $key = $m[3];
112                    $value = $m[4];
113                    $params[$key] = $value;
114                    if ($key === 'month') $userSetMonth = true;
115                    if ($key === 'year') $userSetYear = true;
116                } elseif (!empty($m[5])) {
117                    // standalone flag
118                    $params[$m[5]] = true;
119                }
120            }
121        }
122
123        // If user explicitly set month or year, lock navigation
124        if ($userSetMonth || $userSetYear) {
125            $params['locked'] = true;
126        }
127
128        return $params;
129    }
130
131    public function render($mode, Doku_Renderer $renderer, $data) {
132        if ($mode !== 'xhtml') return false;
133
134        // Disable caching - theme can change via admin without page edit
135        $renderer->nocache();
136
137        if ($data['type'] === 'eventlist') {
138            $html = $this->renderStandaloneEventList($data);
139        } elseif ($data['type'] === 'eventpanel') {
140            $html = $this->renderEventPanelOnly($data);
141        } elseif ($data['static']) {
142            $html = $this->renderStaticCalendar($data);
143        } else {
144            $html = $this->renderCompactCalendar($data);
145        }
146
147        $renderer->doc .= $html;
148        return true;
149    }
150
151    private function renderCompactCalendar($data) {
152        $year = (int)$data['year'];
153        $month = (int)$data['month'];
154        $namespace = $data['namespace'];
155        $exclude = isset($data['exclude']) ? $data['exclude'] : '';
156        $excludeList = $this->parseExcludeList($exclude);
157
158        // Get theme - prefer inline theme= parameter, fall back to admin default
159        $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme();
160        $themeStyles = $this->getSidebarThemeStyles($theme);
161        $themeClass = 'calendar-theme-' . $theme;
162
163        // Determine button text color: professional uses white, others use bg color
164        $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg'];
165
166        // Check if multiple namespaces or wildcard specified
167        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
168
169        if ($isMultiNamespace) {
170            $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
171        } else {
172            $events = $this->loadEvents($namespace, $year, $month);
173        }
174        $calId = 'cal_' . md5(serialize($data) . microtime());
175
176        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
177
178        $prevMonth = $month - 1;
179        $prevYear = $year;
180        if ($prevMonth < 1) {
181            $prevMonth = 12;
182            $prevYear--;
183        }
184
185        $nextMonth = $month + 1;
186        $nextYear = $year;
187        if ($nextMonth > 12) {
188            $nextMonth = 1;
189            $nextYear++;
190        }
191
192        // Get important namespaces from config for highlighting
193        $configFile = $this->syncConfigPath();
194        $importantNsList = ['important']; // default
195        if (file_exists($configFile)) {
196            $config = include $configFile;
197            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
198                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
199            }
200        }
201
202        // Container - all styling via CSS variables
203        $html = '<div class="calendar-compact-container ' . $themeClass . '" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-exclude="' . htmlspecialchars($exclude) . '" data-year="' . $year . '" data-month="' . $month . '" data-theme="' . $theme . '" data-theme-styles="' . htmlspecialchars(json_encode($themeStyles)) . '" data-important-namespaces="' . htmlspecialchars(json_encode($importantNsList)) . '">';
204
205        // Inject CSS variables for this calendar instance - all theming flows from here
206        $html .= '<style>
207        #' . $calId . ' {
208            --background-site: ' . $themeStyles['bg'] . ';
209            --background-alt: ' . $themeStyles['cell_bg'] . ';
210            --background-header: ' . $themeStyles['header_bg'] . ';
211            --text-primary: ' . $themeStyles['text_primary'] . ';
212            --text-dim: ' . $themeStyles['text_dim'] . ';
213            --text-bright: ' . $themeStyles['text_bright'] . ';
214            --border-color: ' . $themeStyles['grid_border'] . ';
215            --border-main: ' . $themeStyles['border'] . ';
216            --cell-bg: ' . $themeStyles['cell_bg'] . ';
217            --cell-today-bg: ' . $themeStyles['cell_today_bg'] . ';
218            --shadow-color: ' . $themeStyles['shadow'] . ';
219            --header-border: ' . $themeStyles['header_border'] . ';
220            --header-shadow: ' . $themeStyles['header_shadow'] . ';
221            --grid-bg: ' . $themeStyles['grid_bg'] . ';
222            --btn-text: ' . $btnTextColor . ';
223            --pastdue-color: ' . $themeStyles['pastdue_color'] . ';
224            --pastdue-bg: ' . $themeStyles['pastdue_bg'] . ';
225            --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . ';
226            --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . ';
227            --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . ';
228            --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . ';
229            --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . ';
230        }
231        #event-search-' . $calId . '::placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; }
232        #event-search-' . $calId . '::-webkit-input-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; }
233        #event-search-' . $calId . '::-moz-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; }
234        #event-search-' . $calId . ':-ms-input-placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; }
235        </style>';
236
237        // Load calendar JavaScript manually (not through DokuWiki concatenation)
238        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
239
240        // Initialize DOKU_BASE for JavaScript
241        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
242
243        // Embed events data as JSON for JavaScript access
244        $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>';
245
246        // Left side: Calendar
247        $html .= '<div class="calendar-compact-left">';
248
249        // Header with navigation
250        $html .= '<div class="calendar-compact-header">';
251        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
252        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>';
253        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
254        $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
255        $html .= '</div>';
256
257        // Calendar grid - day name headers as a separate div (avoids Firefox th height issues)
258        $html .= '<div class="calendar-day-headers">';
259        $html .= '<span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span>';
260        $html .= '</div>';
261        $html .= '<table class="calendar-compact-grid">';
262        $html .= '<tbody>';
263
264        $firstDay = mktime(0, 0, 0, $month, 1, $year);
265        $daysInMonth = date('t', $firstDay);
266        $dayOfWeek = date('w', $firstDay);
267
268        // Build a map of all events with their date ranges for the calendar grid
269        $eventRanges = array();
270        foreach ($events as $dateKey => $dayEvents) {
271            foreach ($dayEvents as $evt) {
272                $eventId = isset($evt['id']) ? $evt['id'] : '';
273                $startDate = $dateKey;
274                $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey;
275
276                // Only process events that touch this month
277                $eventStart = new DateTime($startDate);
278                $eventEnd = new DateTime($endDate);
279                $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month));
280                $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth));
281
282                // Skip if event doesn't overlap with current month
283                if ($eventEnd < $monthStart || $eventStart > $monthEnd) {
284                    continue;
285                }
286
287                // Create entry for each day the event spans
288                $current = clone $eventStart;
289                while ($current <= $eventEnd) {
290                    $currentKey = $current->format('Y-m-d');
291
292                    // Check if this date is in current month
293                    $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey);
294                    if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) {
295                        if (!isset($eventRanges[$currentKey])) {
296                            $eventRanges[$currentKey] = array();
297                        }
298
299                        // Add event with span information
300                        $evt['_span_start'] = $startDate;
301                        $evt['_span_end'] = $endDate;
302                        $evt['_is_first_day'] = ($currentKey === $startDate);
303                        $evt['_is_last_day'] = ($currentKey === $endDate);
304                        $evt['_original_date'] = $dateKey; // Keep track of original date
305
306                        // Check if event continues from previous month or to next month
307                        $evt['_continues_from_prev'] = ($eventStart < $monthStart);
308                        $evt['_continues_to_next'] = ($eventEnd > $monthEnd);
309
310                        $eventRanges[$currentKey][] = $evt;
311                    }
312
313                    $current->modify('+1 day');
314                }
315            }
316        }
317
318        $currentDay = 1;
319        $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7);
320
321        for ($row = 0; $row < $rowCount; $row++) {
322            $html .= '<tr>';
323            for ($col = 0; $col < 7; $col++) {
324                if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) {
325                    $html .= '<td class="cal-empty"></td>';
326                } else {
327                    $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay);
328                    $isToday = ($dateKey === date('Y-m-d'));
329                    $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]);
330
331                    $classes = 'cal-day';
332                    if ($isToday) $classes .= ' cal-today';
333                    if ($hasEvents) $classes .= ' cal-has-events';
334
335                    $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" tabindex="0" role="gridcell" aria-label="' . date('F j, Y', strtotime($dateKey)) . ($hasEvents ? ', has events' : '') . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">';
336
337                    $dayNumClass = $isToday ? 'day-num day-num-today' : 'day-num';
338                    $html .= '<span class="' . $dayNumClass . '">' . $currentDay . '</span>';
339
340                    if ($hasEvents) {
341                        // Sort events by time (no time first, then by time)
342                        $sortedEvents = $eventRanges[$dateKey];
343                        usort($sortedEvents, function($a, $b) {
344                            $timeA = isset($a['time']) ? $a['time'] : '';
345                            $timeB = isset($b['time']) ? $b['time'] : '';
346
347                            // Events without time go first
348                            if (empty($timeA) && !empty($timeB)) return -1;
349                            if (!empty($timeA) && empty($timeB)) return 1;
350                            if (empty($timeA) && empty($timeB)) return 0;
351
352                            // Sort by time
353                            return strcmp($timeA, $timeB);
354                        });
355
356                        // Show colored stacked bars for each event
357                        $html .= '<div class="event-indicators">';
358                        foreach ($sortedEvents as $evt) {
359                            $eventId = isset($evt['id']) ? $evt['id'] : '';
360                            $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db';
361                            $eventTime = isset($evt['time']) ? $evt['time'] : '';
362                            $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event';
363                            $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey;
364                            $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true;
365                            $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true;
366
367                            // Check if this event is from an important namespace
368                            $evtNs = isset($evt['namespace']) ? $evt['namespace'] : '';
369                            if (!$evtNs && isset($evt['_namespace'])) {
370                                $evtNs = $evt['_namespace'];
371                            }
372                            $isImportantEvent = false;
373                            foreach ($importantNsList as $impNs) {
374                                if ($evtNs === $impNs || strpos($evtNs, $impNs . ':') === 0) {
375                                    $isImportantEvent = true;
376                                    break;
377                                }
378                            }
379
380                            $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed';
381
382                            // Add classes for multi-day spanning
383                            if (!$isFirstDay) $barClass .= ' event-bar-continues';
384                            if (!$isLastDay) $barClass .= ' event-bar-continuing';
385                            if ($isImportantEvent) {
386                                $barClass .= ' event-bar-important';
387                                if ($isFirstDay) {
388                                    $barClass .= ' event-bar-has-star';
389                                }
390                            }
391
392                            $titlePrefix = $isImportantEvent ? '⭐ ' : '';
393
394                            $html .= '<span class="event-bar ' . $barClass . '" ';
395                            $html .= 'style="background: ' . $eventColor . ';" ';
396                            $html .= 'title="' . $titlePrefix . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" ';
397                            $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">';
398                            $html .= '</span>';
399                        }
400                        $html .= '</div>';
401                    }
402
403                    $html .= '</td>';
404                    $currentDay++;
405                }
406            }
407            $html .= '</tr>';
408        }
409
410        $html .= '</tbody></table>';
411        $html .= '</div>'; // End calendar-left
412
413        // Right side: Event list
414        $html .= '<div class="calendar-compact-right">';
415        $html .= '<div class="event-list-header">';
416        $html .= '<div class="event-list-header-content">';
417        $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>';
418        if ($namespace) {
419            $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>';
420        }
421        $html .= '</div>';
422
423        // Search bar in header
424        $searchDefault = $this->getSearchDefault();
425        $searchAllClass = $searchDefault === 'all' ? ' all-dates' : '';
426        $searchIcon = $searchDefault === 'all' ? '��' : '��';
427        $searchTitle = $searchDefault === 'all' ? 'Searching all dates' : 'Search this month only';
428        $searchPlaceholder = $searchDefault === 'all' ? 'Search all dates...' : '�� Search...';
429        $html .= '<div class="event-search-container-inline">';
430        $html .= '<input type="text" class="event-search-input-inline" id="event-search-' . $calId . '" placeholder="' . htmlspecialchars($searchPlaceholder) . '" oninput="filterEvents(\'' . $calId . '\', this.value)">';
431        $html .= '<button class="event-search-clear-inline" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>';
432        $html .= '<button class="event-search-mode-inline' . $searchAllClass . '" id="search-mode-' . $calId . '" onclick="toggleSearchMode(\'' . $calId . '\', \'' . $namespace . '\')" title="' . htmlspecialchars($searchTitle) . '">' . $searchIcon . '</button>';
433        $html .= '</div>';
434
435        $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
436        $html .= '</div>';
437
438        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">';
439        $html .= $this->renderEventListContent($events, $calId, $namespace, $themeStyles);
440        $html .= '</div>';
441
442        $html .= '</div>'; // End calendar-right
443
444        // Event dialog
445        $html .= $this->renderEventDialog($calId, $namespace, $theme);
446
447        // Month/Year picker dialog (at container level for proper overlay)
448        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles);
449
450        $html .= '</div>'; // End container
451
452        return $html;
453    }
454
455    /**
456     * Render a static/read-only calendar for presentation and printing
457     * No edit buttons, clean layout, print-friendly itinerary
458     */
459    private function renderStaticCalendar($data) {
460        $year = (int)$data['year'];
461        $month = (int)$data['month'];
462        $namespace = isset($data['namespace']) ? $data['namespace'] : '';
463        $exclude = isset($data['exclude']) ? $data['exclude'] : '';
464        $excludeList = $this->parseExcludeList($exclude);
465        $customTitle = isset($data['title']) ? $data['title'] : '';
466        $noprint = isset($data['noprint']) && $data['noprint'];
467        $locked = isset($data['locked']) && $data['locked'];
468        $themeOverride = isset($data['theme']) ? $data['theme'] : '';
469
470        // Generate unique ID for this static calendar
471        $calId = 'static-cal-' . substr(md5($namespace . $year . $month . uniqid()), 0, 8);
472
473        // Get theme settings
474        if ($themeOverride && in_array($themeOverride, ['matrix', 'pink', 'purple', 'professional', 'wiki', 'dark', 'light'])) {
475            $theme = $themeOverride;
476        } else {
477            $theme = $this->getSidebarTheme();
478        }
479        $themeStyles = $this->getSidebarThemeStyles($theme);
480
481        // Get important namespaces
482        $importantNsList = $this->getImportantNamespaces();
483
484        // Load events - check for multi-namespace or wildcard
485        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
486        if ($isMultiNamespace) {
487            $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
488        } else {
489            $events = $this->loadEvents($namespace, $year, $month);
490        }
491
492        // Month info
493        $firstDay = mktime(0, 0, 0, $month, 1, $year);
494        $daysInMonth = date('t', $firstDay);
495        $startDayOfWeek = (int)date('w', $firstDay);
496        $monthName = date('F', $firstDay);
497
498        // Display title - custom or default month/year
499        $displayTitle = $customTitle ? $customTitle : $monthName . ' ' . $year;
500
501        // Theme class for styling
502        $themeClass = 'static-theme-' . $theme;
503
504        // Build HTML
505        $html = '<div class="calendar-static ' . $themeClass . '" id="' . $calId . '" data-year="' . $year . '" data-month="' . $month . '" data-namespace="' . hsc($namespace) . '" data-exclude="' . hsc($exclude) . '" data-locked="' . ($locked ? '1' : '0') . '">';
506
507        // Screen view: Calendar Grid
508        $html .= '<div class="static-screen-view">';
509
510        // Header with navigation (hide nav buttons if locked)
511        $html .= '<div class="static-header">';
512        if (!$locked) {
513            $html .= '<button class="static-nav-btn" onclick="navStaticCalendar(\'' . $calId . '\', -1)" title="' . $this->getLang('previous_month') . '">◀</button>';
514        }
515        $html .= '<h2 class="static-month-title">' . hsc($displayTitle) . '</h2>';
516        if (!$locked) {
517            $html .= '<button class="static-nav-btn" onclick="navStaticCalendar(\'' . $calId . '\', 1)" title="' . $this->getLang('next_month') . '">▶</button>';
518        }
519        if (!$noprint) {
520            $html .= '<button class="static-print-btn" onclick="printStaticCalendar(\'' . $calId . '\')" title="' . $this->getLang('print_calendar') . '">��️</button>';
521        }
522        $html .= '</div>';
523
524        // Calendar grid
525        $html .= '<table class="static-calendar-grid">';
526        $html .= '<thead><tr>';
527        $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
528        foreach ($dayNames as $day) {
529            $html .= '<th>' . $day . '</th>';
530        }
531        $html .= '</tr></thead>';
532        $html .= '<tbody>';
533
534        $dayCount = 1;
535        $totalCells = $startDayOfWeek + $daysInMonth;
536        $rows = ceil($totalCells / 7);
537
538        for ($row = 0; $row < $rows; $row++) {
539            $html .= '<tr>';
540            for ($col = 0; $col < 7; $col++) {
541                $cellNum = $row * 7 + $col;
542
543                if ($cellNum < $startDayOfWeek || $dayCount > $daysInMonth) {
544                    $html .= '<td class="static-day-empty"></td>';
545                } else {
546                    $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $dayCount);
547                    $dayEvents = isset($events[$dateKey]) ? $events[$dateKey] : [];
548                    $isToday = ($dateKey === date('Y-m-d'));
549                    $isWeekend = ($col === 0 || $col === 6);
550
551                    $cellClass = 'static-day';
552                    if ($isToday) $cellClass .= ' static-day-today';
553                    if ($isWeekend) $cellClass .= ' static-day-weekend';
554                    if (!empty($dayEvents)) $cellClass .= ' static-day-has-events';
555
556                    $html .= '<td class="' . $cellClass . '">';
557                    $html .= '<div class="static-day-number">' . $dayCount . '</div>';
558
559                    if (!empty($dayEvents)) {
560                        $html .= '<div class="static-day-events">';
561                        foreach ($dayEvents as $event) {
562                            $color = isset($event['color']) ? $event['color'] : '#3498db';
563                            $title = hsc($event['title']);
564                            $time = isset($event['time']) && $event['time'] ? $event['time'] : '';
565                            $desc = isset($event['description']) ? $event['description'] : '';
566                            $eventNs = isset($event['namespace']) ? $event['namespace'] : $namespace;
567
568                            // Check if important
569                            $isImportant = false;
570                            foreach ($importantNsList as $impNs) {
571                                if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
572                                    $isImportant = true;
573                                    break;
574                                }
575                            }
576
577                            // Build tooltip - plain text with basic formatting indicators
578                            $tooltipText = $event['title'];
579                            if ($time) {
580                                $tooltipText .= "\n�� " . $this->formatTime12Hour($time);
581                                if (isset($event['endTime']) && $event['endTime']) {
582                                    $tooltipText .= ' - ' . $this->formatTime12Hour($event['endTime']);
583                                }
584                            }
585                            if ($desc) {
586                                // Convert formatting to plain text equivalents
587                                $plainDesc = $desc;
588                                $plainDesc = preg_replace('/\*\*(.+?)\*\*/', '*$1*', $plainDesc);
589                                $plainDesc = preg_replace('/__(.+?)__/', '*$1*', $plainDesc);
590                                $plainDesc = preg_replace('/\/\/(.+?)\/\//', '_$1_', $plainDesc);
591                                $plainDesc = preg_replace('/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/', '$2 ($1)', $plainDesc);
592                                $plainDesc = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1 ($2)', $plainDesc);
593                                $tooltipText .= "\n\n" . $plainDesc;
594                            }
595
596                            $eventClass = 'static-event';
597                            if ($isImportant) $eventClass .= ' static-event-important';
598
599                            $html .= '<div class="' . $eventClass . '" style="border-left-color: ' . $color . ';" title="' . hsc($tooltipText) . '">';
600                            if ($isImportant) {
601                                $html .= '<span class="static-event-star">⭐</span>';
602                            }
603                            if ($time) {
604                                $html .= '<span class="static-event-time">' . $this->formatTime12Hour($time) . '</span> ';
605                            }
606                            $html .= '<span class="static-event-title">' . $title . '</span>';
607                            $html .= '</div>';
608                        }
609                        $html .= '</div>';
610                    }
611
612                    $html .= '</td>';
613                    $dayCount++;
614                }
615            }
616            $html .= '</tr>';
617        }
618
619        $html .= '</tbody></table>';
620        $html .= '</div>'; // End screen view
621
622        // Print view: Itinerary format (skip if noprint)
623        if (!$noprint) {
624            $html .= '<div class="static-print-view">';
625            $html .= '<h2 class="static-print-title">' . hsc($displayTitle) . '</h2>';
626
627            if (!empty($namespace)) {
628                $html .= '<p class="static-print-namespace">' . $this->getLang('calendar_label') . ': ' . hsc($namespace) . '</p>';
629            }
630
631            // Collect all events sorted by date
632            $allEvents = [];
633        foreach ($events as $dateKey => $dayEvents) {
634            foreach ($dayEvents as $event) {
635                $event['_date'] = $dateKey;
636                $allEvents[] = $event;
637            }
638        }
639
640        // Sort by date, then time
641        usort($allEvents, function($a, $b) {
642            $dateCompare = strcmp($a['_date'], $b['_date']);
643            if ($dateCompare !== 0) return $dateCompare;
644            $timeA = isset($a['time']) ? $a['time'] : '99:99';
645            $timeB = isset($b['time']) ? $b['time'] : '99:99';
646            return strcmp($timeA, $timeB);
647        });
648
649        if (empty($allEvents)) {
650            $html .= '<p class="static-print-empty">' . $this->getLang('no_events_scheduled') . '</p>';
651        } else {
652            $html .= '<table class="static-itinerary">';
653            $html .= '<thead><tr><th>Date</th><th>Time</th><th>Event</th><th>Details</th></tr></thead>';
654            $html .= '<tbody>';
655
656            $lastDate = '';
657            foreach ($allEvents as $event) {
658                $dateKey = $event['_date'];
659                $dateObj = new \DateTime($dateKey);
660                $dateDisplay = $dateObj->format('D, M j');
661                $eventNs = isset($event['namespace']) ? $event['namespace'] : $namespace;
662
663                // Check if important
664                $isImportant = false;
665                foreach ($importantNsList as $impNs) {
666                    if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
667                        $isImportant = true;
668                        break;
669                    }
670                }
671
672                $rowClass = $isImportant ? 'static-itinerary-important' : '';
673
674                $html .= '<tr class="' . $rowClass . '">';
675
676                // Only show date if different from previous row
677                if ($dateKey !== $lastDate) {
678                    $html .= '<td class="static-itinerary-date">' . $dateDisplay . '</td>';
679                    $lastDate = $dateKey;
680                } else {
681                    $html .= '<td></td>';
682                }
683
684                // Time
685                $time = isset($event['time']) && $event['time'] ? $this->formatTime12Hour($event['time']) : $this->getLang('all_day');
686                if (isset($event['endTime']) && $event['endTime'] && isset($event['time']) && $event['time']) {
687                    $time .= ' - ' . $this->formatTime12Hour($event['endTime']);
688                }
689                $html .= '<td class="static-itinerary-time">' . $time . '</td>';
690
691                // Title with star for important
692                $html .= '<td class="static-itinerary-title">';
693                if ($isImportant) {
694                    $html .= '⭐ ';
695                }
696                $html .= hsc($event['title']);
697                $html .= '</td>';
698
699                // Description - with formatting
700                $desc = isset($event['description']) ? $this->renderDescription($event['description']) : '';
701                $html .= '<td class="static-itinerary-desc">' . $desc . '</td>';
702
703                $html .= '</tr>';
704            }
705
706            $html .= '</tbody></table>';
707        }
708
709        $html .= '</div>'; // End print view
710        } // End noprint check
711
712        $html .= '</div>'; // End container
713
714        return $html;
715    }
716
717    /**
718     * Format time to 12-hour format
719     */
720    private function formatTime12Hour($time) {
721        if (!$time) return '';
722        $parts = explode(':', $time);
723        $hour = (int)$parts[0];
724        $minute = isset($parts[1]) ? $parts[1] : '00';
725        $ampm = $hour >= 12 ? 'PM' : 'AM';
726        $hour12 = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour);
727        return $hour12 . ':' . $minute . ' ' . $ampm;
728    }
729
730    /**
731     * Get list of important namespaces from config
732     */
733    private function getImportantNamespaces() {
734        $configFile = $this->syncConfigPath();
735        $importantNsList = ['important']; // default
736        if (file_exists($configFile)) {
737            $config = include $configFile;
738            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
739                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
740            }
741        }
742        return $importantNsList;
743    }
744
745    private function renderEventListContent($events, $calId, $namespace, $themeStyles = null) {
746        if (empty($events)) {
747            return '<p class="no-events-msg">No events this month</p>';
748        }
749
750        // Default theme styles if not provided
751        if ($themeStyles === null) {
752            $theme = $this->getSidebarTheme();
753            $themeStyles = $this->getSidebarThemeStyles($theme);
754        } else {
755            $theme = $this->getSidebarTheme();
756        }
757
758        // Get important namespaces from config
759        $configFile = $this->syncConfigPath();
760        $importantNsList = ['important']; // default
761        if (file_exists($configFile)) {
762            $config = include $configFile;
763            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
764                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
765            }
766        }
767
768        // Check for time conflicts
769        $events = $this->checkTimeConflicts($events);
770
771        // Sort by date ascending (chronological order - oldest first)
772        ksort($events);
773
774        // Sort events within each day by time
775        foreach ($events as $dateKey => &$dayEvents) {
776            usort($dayEvents, function($a, $b) {
777                $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null;
778                $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null;
779
780                // All-day events (no time) go to the TOP
781                if ($timeA === null && $timeB !== null) return -1; // A before B
782                if ($timeA !== null && $timeB === null) return 1;  // A after B
783                if ($timeA === null && $timeB === null) return 0;  // Both all-day, equal
784
785                // Both have times, sort chronologically
786                return strcmp($timeA, $timeB);
787            });
788        }
789        unset($dayEvents); // Break reference
790
791        // Get today's date for comparison
792        $today = date('Y-m-d');
793        $firstFutureEventId = null;
794
795        // Helper function to check if event is past (with 15-minute grace period for timed events)
796        $isEventPast = function($dateKey, $time) use ($today) {
797            // If event is on a past date, it's definitely past
798            if ($dateKey < $today) {
799                return true;
800            }
801
802            // If event is on a future date, it's definitely not past
803            if ($dateKey > $today) {
804                return false;
805            }
806
807            // Event is today - check time with grace period
808            if ($time && $time !== '') {
809                try {
810                    $currentDateTime = new DateTime();
811                    $eventDateTime = new DateTime($dateKey . ' ' . $time);
812
813                    // Add 15-minute grace period
814                    $eventDateTime->modify('+15 minutes');
815
816                    // Event is past if current time > event time + 15 minutes
817                    return $currentDateTime > $eventDateTime;
818                } catch (Exception $e) {
819                    // If time parsing fails, fall back to date-only comparison
820                    return false;
821                }
822            }
823
824            // No time specified for today's event, treat as future
825            return false;
826        };
827
828        // Build HTML for each event - separate past/completed from future
829        $pastHtml = '';
830        $futureHtml = '';
831        $pastCount = 0;
832
833        foreach ($events as $dateKey => $dayEvents) {
834
835            foreach ($dayEvents as $event) {
836                // Track first future/today event for auto-scroll
837                if (!$firstFutureEventId && $dateKey >= $today) {
838                    $firstFutureEventId = isset($event['id']) ? $event['id'] : '';
839                }
840                $eventId = isset($event['id']) ? $event['id'] : '';
841                $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
842                $timeRaw = isset($event['time']) ? $event['time'] : '';
843                $time = htmlspecialchars($timeRaw);
844                $endTime = isset($event['endTime']) ? htmlspecialchars($event['endTime']) : '';
845                $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db';
846                $description = isset($event['description']) ? $event['description'] : '';
847                $isTask = isset($event['isTask']) ? $event['isTask'] : false;
848                $completed = isset($event['completed']) ? $event['completed'] : false;
849                $endDate = isset($event['endDate']) ? $event['endDate'] : '';
850
851                // Use helper function to determine if event is past (with grace period)
852                $isPast = $isEventPast($dateKey, $timeRaw);
853                $isToday = $dateKey === $today;
854
855                // Check if event should be in past section
856                // EXCEPTION: Uncompleted tasks (isTask && !completed) should stay visible even if past
857                $isPastOrCompleted = ($isPast && (!$isTask || $completed)) || $completed;
858                if ($isPastOrCompleted) {
859                    $pastCount++;
860                }
861
862                // Determine if task is past due (past date, is task, not completed)
863                $isPastDue = $isPast && $isTask && !$completed;
864
865                // Process description for wiki syntax, HTML, images, and links
866                $renderedDescription = $this->renderDescription($description, $themeStyles);
867
868                // Convert to 12-hour format and handle time ranges
869                $displayTime = '';
870                if ($time) {
871                    $timeObj = DateTime::createFromFormat('H:i', $time);
872                    if ($timeObj) {
873                        $displayTime = $timeObj->format('g:i A');
874
875                        // Add end time if present and different from start time
876                        if ($endTime && $endTime !== $time) {
877                            $endTimeObj = DateTime::createFromFormat('H:i', $endTime);
878                            if ($endTimeObj) {
879                                $displayTime .= ' - ' . $endTimeObj->format('g:i A');
880                            }
881                        }
882                    } else {
883                        $displayTime = $time;
884                    }
885                }
886
887                // Format date display with day of week
888                // Use originalStartDate if this is a multi-month event continuation
889                $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey;
890                $dateObj = new DateTime($displayDateKey);
891                $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24"
892
893                // Multi-day indicator
894                $multiDay = '';
895                if ($endDate && $endDate !== $displayDateKey) {
896                    $endObj = new DateTime($endDate);
897                    $multiDay = ' → ' . $endObj->format('D, M j');
898                }
899
900                $completedClass = $completed ? ' event-completed' : '';
901                // Don't grey out past due tasks - they need attention!
902                $pastClass = ($isPast && !$isPastDue) ? ' event-past' : '';
903                $pastDueClass = $isPastDue ? ' event-pastdue' : '';
904                $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : '';
905
906                // Check if this is an important namespace event
907                $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
908                if (!$eventNamespace && isset($event['_namespace'])) {
909                    $eventNamespace = $event['_namespace'];
910                }
911                $isImportantNs = false;
912                foreach ($importantNsList as $impNs) {
913                    if ($eventNamespace === $impNs || strpos($eventNamespace, $impNs . ':') === 0) {
914                        $isImportantNs = true;
915                        break;
916                    }
917                }
918                $importantClass = $isImportantNs ? ' event-important' : '';
919
920                // For all themes: use CSS variables, only keep border-left-color as inline
921                $pastClickHandler = ($isPast && !$isPastDue) ? ' onclick="togglePastEventExpand(this)"' : '';
922                $eventHtml = '<div class="event-compact-item' . $completedClass . $pastClass . $pastDueClass . $importantClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ' !important;"' . $pastClickHandler . $firstFutureAttr . '>';
923                $eventHtml .= '<div class="event-info">';
924
925                $eventHtml .= '<div class="event-title-row">';
926                // Add star for important namespace events
927                if ($isImportantNs) {
928                    $eventHtml .= '<span class="event-important-star" title="Important">⭐</span> ';
929                }
930                $eventHtml .= '<span class="event-title-compact">' . $title . '</span>';
931                $eventHtml .= '</div>';
932
933                // For past events, hide meta and description (collapsed)
934                // EXCEPTION: Past due tasks should show their details
935                if (!$isPast || $isPastDue) {
936                    $eventHtml .= '<div class="event-meta-compact">';
937                    $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay;
938                    if ($displayTime) {
939                        $eventHtml .= ' • ' . $displayTime;
940                    }
941                    // Add TODAY badge for today's events OR PAST DUE for uncompleted past tasks
942                    if ($isPastDue) {
943                        $eventHtml .= ' <span class="event-pastdue-badge" style="background:' . $themeStyles['pastdue_color'] . ' !important; color:white !important; -webkit-text-fill-color:white !important;">' . 'PAST DUE</span>';
944                    } elseif ($isToday) {
945                        $eventHtml .= ' <span class="event-today-badge" style="background:' . $themeStyles['border'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;">' . 'TODAY</span>';
946                    }
947                    // Add namespace badge - ALWAYS show if event has a namespace
948                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
949                    if (!$eventNamespace && isset($event['_namespace'])) {
950                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility
951                    }
952                    // Show badge if namespace exists and is not empty
953                    if ($eventNamespace && $eventNamespace !== '') {
954                        $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer; background:' . $themeStyles['text_bright'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>';
955                    }
956
957                    // Add conflict warning if event has time conflicts
958                    if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) {
959                        $conflictList = [];
960                        foreach ($event['conflictsWith'] as $conflict) {
961                            $conflictText = $conflict['title'];
962                            if (!empty($conflict['time'])) {
963                                // Format time range
964                                $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']);
965                                $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time'];
966
967                                if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) {
968                                    $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']);
969                                    $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime'];
970                                    $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')';
971                                } else {
972                                    $conflictText .= ' (' . $startTimeFormatted . ')';
973                                }
974                            }
975                            $conflictList[] = $conflictText;
976                        }
977                        $conflictCount = count($event['conflictsWith']);
978                        $conflictJson = base64_encode(json_encode($conflictList));
979                        $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>';
980                    }
981
982                    $eventHtml .= '</span>';
983                    $eventHtml .= '</div>';
984
985                    if ($description) {
986                        $eventHtml .= '<div class="event-desc-compact">' . $renderedDescription . '</div>';
987                    }
988                } else {
989                    // Past events: render with display:none for click-to-expand
990                    $eventHtml .= '<div class="event-meta-compact" style="display:none;">';
991                    $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay;
992                    if ($displayTime) {
993                        $eventHtml .= ' • ' . $displayTime;
994                    }
995                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
996                    if (!$eventNamespace && isset($event['_namespace'])) {
997                        $eventNamespace = $event['_namespace'];
998                    }
999                    if ($eventNamespace && $eventNamespace !== '') {
1000                        $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer; background:' . $themeStyles['text_bright'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>';
1001                    }
1002
1003                    // Add conflict warning if event has time conflicts
1004                    if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) {
1005                        $conflictList = [];
1006                        foreach ($event['conflictsWith'] as $conflict) {
1007                            $conflictText = $conflict['title'];
1008                            if (!empty($conflict['time'])) {
1009                                $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']);
1010                                $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time'];
1011
1012                                if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) {
1013                                    $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']);
1014                                    $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime'];
1015                                    $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')';
1016                                } else {
1017                                    $conflictText .= ' (' . $startTimeFormatted . ')';
1018                                }
1019                            }
1020                            $conflictList[] = $conflictText;
1021                        }
1022                        $conflictCount = count($event['conflictsWith']);
1023                        $conflictJson = base64_encode(json_encode($conflictList));
1024                        $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>';
1025                    }
1026
1027                    $eventHtml .= '</span>';
1028                    $eventHtml .= '</div>';
1029
1030                    if ($description) {
1031                        $eventHtml .= '<div class="event-desc-compact" style="display:none;">' . $renderedDescription . '</div>';
1032                    }
1033                }
1034
1035                $eventHtml .= '</div>'; // event-info
1036
1037                // Use stored namespace from event, fallback to passed namespace
1038                $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace;
1039
1040                $eventHtml .= '<div class="event-actions-compact">';
1041                $eventHtml .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">��️</button>';
1042                $eventHtml .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">✏️</button>';
1043                $eventHtml .= '</div>';
1044
1045                // Checkbox for tasks - ON THE FAR RIGHT
1046                if ($isTask) {
1047                    $checked = $completed ? 'checked' : '';
1048                    $eventHtml .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\', this.checked)">';
1049                }
1050
1051                $eventHtml .= '</div>';
1052
1053                // Add to appropriate section
1054                if ($isPastOrCompleted) {
1055                    $pastHtml .= $eventHtml;
1056                } else {
1057                    $futureHtml .= $eventHtml;
1058                }
1059            }
1060        }
1061
1062        // Build final HTML with collapsible past events section
1063        $html = '';
1064
1065        // Add collapsible past events section if any exist
1066        if ($pastCount > 0) {
1067            $html .= '<div class="past-events-section">';
1068            $html .= '<div class="past-events-toggle" onclick="togglePastEvents(\'' . $calId . '\')">';
1069            $html .= '<span class="past-events-arrow" id="past-arrow-' . $calId . '">▶</span> ';
1070            $html .= '<span class="past-events-label">Past Events (' . $pastCount . ')</span>';
1071            $html .= '</div>';
1072            $html .= '<div class="past-events-content" id="past-events-' . $calId . '" style="display:none;">';
1073            $html .= $pastHtml;
1074            $html .= '</div>';
1075            $html .= '</div>';
1076        }
1077
1078        // Add future events
1079        $html .= $futureHtml;
1080
1081        return $html;
1082    }
1083
1084    /**
1085     * Check for time conflicts between events
1086     */
1087    private function checkTimeConflicts($events) {
1088        // Group events by date
1089        $eventsByDate = [];
1090        foreach ($events as $date => $dateEvents) {
1091            if (!is_array($dateEvents)) continue;
1092
1093            foreach ($dateEvents as $evt) {
1094                if (empty($evt['time'])) continue; // Skip all-day events
1095
1096                if (!isset($eventsByDate[$date])) {
1097                    $eventsByDate[$date] = [];
1098                }
1099                $eventsByDate[$date][] = $evt;
1100            }
1101        }
1102
1103        // Check for overlaps on each date
1104        foreach ($eventsByDate as $date => $dateEvents) {
1105            for ($i = 0; $i < count($dateEvents); $i++) {
1106                for ($j = $i + 1; $j < count($dateEvents); $j++) {
1107                    if ($this->eventsOverlap($dateEvents[$i], $dateEvents[$j])) {
1108                        // Mark both events as conflicting
1109                        $dateEvents[$i]['hasConflict'] = true;
1110                        $dateEvents[$j]['hasConflict'] = true;
1111
1112                        // Store conflict info
1113                        if (!isset($dateEvents[$i]['conflictsWith'])) {
1114                            $dateEvents[$i]['conflictsWith'] = [];
1115                        }
1116                        if (!isset($dateEvents[$j]['conflictsWith'])) {
1117                            $dateEvents[$j]['conflictsWith'] = [];
1118                        }
1119
1120                        $dateEvents[$i]['conflictsWith'][] = [
1121                            'id' => $dateEvents[$j]['id'],
1122                            'title' => $dateEvents[$j]['title'],
1123                            'time' => $dateEvents[$j]['time'],
1124                            'endTime' => isset($dateEvents[$j]['endTime']) ? $dateEvents[$j]['endTime'] : ''
1125                        ];
1126
1127                        $dateEvents[$j]['conflictsWith'][] = [
1128                            'id' => $dateEvents[$i]['id'],
1129                            'title' => $dateEvents[$i]['title'],
1130                            'time' => $dateEvents[$i]['time'],
1131                            'endTime' => isset($dateEvents[$i]['endTime']) ? $dateEvents[$i]['endTime'] : ''
1132                        ];
1133                    }
1134                }
1135            }
1136
1137            // Update the events array with conflict information
1138            foreach ($events[$date] as &$evt) {
1139                foreach ($dateEvents as $checkedEvt) {
1140                    if ($evt['id'] === $checkedEvt['id']) {
1141                        if (isset($checkedEvt['hasConflict'])) {
1142                            $evt['hasConflict'] = $checkedEvt['hasConflict'];
1143                        }
1144                        if (isset($checkedEvt['conflictsWith'])) {
1145                            $evt['conflictsWith'] = $checkedEvt['conflictsWith'];
1146                        }
1147                        break;
1148                    }
1149                }
1150            }
1151        }
1152
1153        return $events;
1154    }
1155
1156    /**
1157     * Check if two events overlap in time
1158     */
1159    private function eventsOverlap($evt1, $evt2) {
1160        if (empty($evt1['time']) || empty($evt2['time'])) {
1161            return false; // All-day events don't conflict
1162        }
1163
1164        $start1 = $evt1['time'];
1165        $end1 = isset($evt1['endTime']) && !empty($evt1['endTime']) ? $evt1['endTime'] : $evt1['time'];
1166
1167        $start2 = $evt2['time'];
1168        $end2 = isset($evt2['endTime']) && !empty($evt2['endTime']) ? $evt2['endTime'] : $evt2['time'];
1169
1170        // Convert to minutes for easier comparison
1171        $start1Mins = $this->timeToMinutes($start1);
1172        $end1Mins = $this->timeToMinutes($end1);
1173        $start2Mins = $this->timeToMinutes($start2);
1174        $end2Mins = $this->timeToMinutes($end2);
1175
1176        // Check for overlap: start1 < end2 AND start2 < end1
1177        return $start1Mins < $end2Mins && $start2Mins < $end1Mins;
1178    }
1179
1180    /**
1181     * Convert HH:MM time to minutes since midnight
1182     */
1183    private function timeToMinutes($timeStr) {
1184        $parts = explode(':', $timeStr);
1185        if (count($parts) !== 2) return 0;
1186
1187        return (int)$parts[0] * 60 + (int)$parts[1];
1188    }
1189
1190    private function renderEventPanelOnly($data) {
1191        $year = (int)$data['year'];
1192        $month = (int)$data['month'];
1193        $namespace = $data['namespace'];
1194        $exclude = isset($data['exclude']) ? $data['exclude'] : '';
1195        $excludeList = $this->parseExcludeList($exclude);
1196        $height = isset($data['height']) ? $data['height'] : '400px';
1197
1198        // Validate height format (must be px, em, rem, vh, or %)
1199        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) {
1200            $height = '400px'; // Default fallback
1201        }
1202
1203        // Get theme - prefer inline theme= parameter, fall back to admin default
1204        $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme();        $themeStyles = $this->getSidebarThemeStyles($theme);
1205
1206        // Check if multiple namespaces or wildcard specified
1207        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
1208
1209        if ($isMultiNamespace) {
1210            $events = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
1211        } else {
1212            $events = $this->loadEvents($namespace, $year, $month);
1213        }
1214        $calId = 'panel_' . md5(serialize($data) . microtime());
1215
1216        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
1217
1218        $prevMonth = $month - 1;
1219        $prevYear = $year;
1220        if ($prevMonth < 1) {
1221            $prevMonth = 12;
1222            $prevYear--;
1223        }
1224
1225        $nextMonth = $month + 1;
1226        $nextYear = $year;
1227        if ($nextMonth > 12) {
1228            $nextMonth = 1;
1229            $nextYear++;
1230        }
1231
1232        // Determine button text color based on theme
1233        $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg'];
1234
1235        // Get important namespaces from config for highlighting
1236        $configFile = $this->syncConfigPath();
1237        $importantNsList = ['important']; // default
1238        if (file_exists($configFile)) {
1239            $config = include $configFile;
1240            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
1241                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
1242            }
1243        }
1244
1245        $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-exclude="' . htmlspecialchars($exclude) . '" data-theme="' . $theme . '" data-theme-styles="' . htmlspecialchars(json_encode($themeStyles)) . '" data-important-namespaces="' . htmlspecialchars(json_encode($importantNsList)) . '">';
1246
1247        // Inject CSS variables for this panel instance - same as main calendar
1248        $html .= '<style>
1249        #' . $calId . ' {
1250            --background-site: ' . $themeStyles['bg'] . ';
1251            --background-alt: ' . $themeStyles['cell_bg'] . ';
1252            --background-header: ' . $themeStyles['header_bg'] . ';
1253            --text-primary: ' . $themeStyles['text_primary'] . ';
1254            --text-dim: ' . $themeStyles['text_dim'] . ';
1255            --text-bright: ' . $themeStyles['text_bright'] . ';
1256            --border-color: ' . $themeStyles['grid_border'] . ';
1257            --border-main: ' . $themeStyles['border'] . ';
1258            --cell-bg: ' . $themeStyles['cell_bg'] . ';
1259            --cell-today-bg: ' . $themeStyles['cell_today_bg'] . ';
1260            --shadow-color: ' . $themeStyles['shadow'] . ';
1261            --header-border: ' . $themeStyles['header_border'] . ';
1262            --header-shadow: ' . $themeStyles['header_shadow'] . ';
1263            --grid-bg: ' . $themeStyles['grid_bg'] . ';
1264            --btn-text: ' . $btnTextColor . ';
1265            --pastdue-color: ' . $themeStyles['pastdue_color'] . ';
1266            --pastdue-bg: ' . $themeStyles['pastdue_bg'] . ';
1267            --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . ';
1268            --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . ';
1269            --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . ';
1270            --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . ';
1271            --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . ';
1272        }
1273        #event-search-' . $calId . '::placeholder { color: ' . $themeStyles['text_dim'] . '; opacity: 1; }
1274        </style>';
1275
1276        // Load calendar JavaScript manually (not through DokuWiki concatenation)
1277        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
1278
1279        // Initialize DOKU_BASE for JavaScript
1280        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
1281
1282        // Compact two-row header designed for ~500px width
1283        $html .= '<div class="panel-header-compact">';
1284
1285        // Row 1: Navigation and title
1286        $html .= '<div class="panel-header-row-1">';
1287        $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
1288
1289        // Compact month name (e.g. "Feb 2026" instead of "February 2026 Events")
1290        $shortMonthName = date('M Y', mktime(0, 0, 0, $month, 1, $year));
1291        $html .= '<h3 class="panel-month-title" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $shortMonthName . '</h3>';
1292
1293        $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
1294
1295        // Namespace badge (if applicable)
1296        if ($namespace) {
1297            if ($isMultiNamespace) {
1298                if (strpos($namespace, '*') !== false) {
1299                    $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>';
1300                } else {
1301                    $namespaceList = array_map('trim', explode(';', $namespace));
1302                    $nsCount = count($namespaceList);
1303                    $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars(implode(', ', $namespaceList)) . '">' . $nsCount . ' NS</span>';
1304                }
1305            } else {
1306                $isFiltering = ($namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false);
1307                if ($isFiltering) {
1308                    $html .= '<span class="panel-ns-badge filter-on" style="background:var(--text-bright) !important; color:var(--background-site) !important; -webkit-text-fill-color:var(--background-site) !important;" title="Filtering by ' . htmlspecialchars($namespace) . ' - click to clear" onclick="clearNamespaceFilterPanel(\'' . $calId . '\')">' . htmlspecialchars($namespace) . ' ✕</span>';
1309                } else {
1310                    $html .= '<span class="panel-ns-badge" style="background:var(--cell-today-bg) !important; color:var(--text-bright) !important; -webkit-text-fill-color:var(--text-bright) !important;" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>';
1311                }
1312            }
1313        }
1314
1315        $html .= '<button class="panel-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
1316        $html .= '</div>';
1317
1318        // Row 2: Search and add button
1319        $searchDefault = $this->getSearchDefault();
1320        $searchAllClass = $searchDefault === 'all' ? ' all-dates' : '';
1321        $searchIcon = $searchDefault === 'all' ? '��' : '��';
1322        $searchTitle = $searchDefault === 'all' ? 'Searching all dates' : 'Search this month only';
1323        $searchPlaceholder = $searchDefault === 'all' ? 'Search all dates...' : 'Search this month...';
1324        $html .= '<div class="panel-header-row-2">';
1325        $html .= '<div class="panel-search-box">';
1326        $html .= '<input type="text" class="panel-search-input" id="event-search-' . $calId . '" placeholder="' . htmlspecialchars($searchPlaceholder) . '" oninput="filterEvents(\'' . $calId . '\', this.value)">';
1327        $html .= '<button class="panel-search-clear" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>';
1328        $html .= '<button class="panel-search-mode' . $searchAllClass . '" id="search-mode-' . $calId . '" onclick="toggleSearchMode(\'' . $calId . '\', \'' . $namespace . '\')" title="' . htmlspecialchars($searchTitle) . '">' . $searchIcon . '</button>';
1329        $html .= '</div>';
1330        $html .= '<button class="panel-add-btn" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
1331        $html .= '</div>';
1332
1333        $html .= '</div>';
1334
1335        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">';
1336        $html .= $this->renderEventListContent($events, $calId, $namespace);
1337        $html .= '</div>';
1338
1339        $html .= $this->renderEventDialog($calId, $namespace, $theme);
1340
1341        // Month/Year picker for event panel
1342        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace, $theme, $themeStyles);
1343
1344        $html .= '</div>';
1345
1346        return $html;
1347    }
1348
1349    private function renderStandaloneEventList($data) {
1350        $namespace = $data['namespace'];
1351        // If no namespace specified, show all namespaces
1352        if (empty($namespace)) {
1353            $namespace = '*';
1354        }
1355        $exclude = isset($data['exclude']) ? $data['exclude'] : '';
1356        $excludeList = $this->parseExcludeList($exclude);
1357        $daterange = $data['daterange'];
1358        $date = $data['date'];
1359        $range = isset($data['range']) ? strtolower($data['range']) : '';
1360        $today = isset($data['today']) ? true : false;
1361        $sidebar = isset($data['sidebar']) ? true : false;
1362        $showchecked = isset($data['showchecked']) ? true : false; // New parameter
1363        $noheader = isset($data['noheader']) ? true : false; // New parameter to hide header
1364
1365        // Handle "range" parameter - day, week, or month
1366        if ($range === 'day') {
1367            $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks
1368            $endDate = date('Y-m-d');
1369            $headerText = 'Today';
1370        } elseif ($range === 'week') {
1371            $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks
1372            $endDateTime = new DateTime();
1373            $endDateTime->modify('+7 days');
1374            $endDate = $endDateTime->format('Y-m-d');
1375            $headerText = 'This Week';
1376        } elseif ($range === 'month') {
1377            $startDate = date('Y-m-01', strtotime('-1 month')); // Include previous month for past due tasks
1378            $endDate = date('Y-m-t'); // Last of current month
1379            $dt = new DateTime();
1380            $headerText = $dt->format('F Y');
1381        } elseif ($sidebar) {
1382            // NEW: Sidebar widget - load current week's events
1383            $weekStartDay = $this->getWeekStartDay(); // Get saved preference
1384
1385            if ($weekStartDay === 'monday') {
1386                // Monday start
1387                $weekStart = date('Y-m-d', strtotime('monday this week'));
1388                $weekEnd = date('Y-m-d', strtotime('sunday this week'));
1389            } else {
1390                // Sunday start (default - US/Canada standard)
1391                $today = date('w'); // 0 (Sun) to 6 (Sat)
1392                if ($today == 0) {
1393                    // Today is Sunday
1394                    $weekStart = date('Y-m-d');
1395                } else {
1396                    // Monday-Saturday: go back to last Sunday
1397                    $weekStart = date('Y-m-d', strtotime('-' . $today . ' days'));
1398                }
1399                $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days'));
1400            }
1401
1402            // Load events for the entire week PLUS tomorrow (if tomorrow is outside week)
1403            // PLUS next 2 weeks for Important events
1404            $start = new DateTime($weekStart);
1405            $end = new DateTime($weekEnd);
1406
1407            // Check if we need to extend to include tomorrow
1408            $tomorrowDate = date('Y-m-d', strtotime('+1 day'));
1409            if ($tomorrowDate > $weekEnd) {
1410                // Tomorrow is outside the week, extend end date to include it
1411                $end = new DateTime($tomorrowDate);
1412            }
1413
1414            // Extend 2 weeks into the future for Important events
1415            $twoWeeksOut = date('Y-m-d', strtotime($weekEnd . ' +14 days'));
1416            $end = new DateTime($twoWeeksOut);
1417
1418            $end->modify('+1 day'); // DatePeriod excludes end date
1419            $interval = new DateInterval('P1D');
1420            $period = new DatePeriod($start, $interval, $end);
1421
1422            $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
1423            $allEvents = [];
1424            $loadedMonths = [];
1425
1426            foreach ($period as $dt) {
1427                $year = (int)$dt->format('Y');
1428                $month = (int)$dt->format('n');
1429                $dateKey = $dt->format('Y-m-d');
1430
1431                $monthKey = $year . '-' . $month . '-' . $namespace;
1432
1433                if (!isset($loadedMonths[$monthKey])) {
1434                    if ($isMultiNamespace) {
1435                        $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
1436                    } else {
1437                        $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
1438                    }
1439                }
1440
1441                $monthEvents = $loadedMonths[$monthKey];
1442
1443                if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
1444                    $allEvents[$dateKey] = $monthEvents[$dateKey];
1445                }
1446            }
1447
1448            // Apply time conflict detection
1449            $allEvents = $this->checkTimeConflicts($allEvents);
1450
1451            $calId = 'sidebar-' . substr(md5($namespace . $weekStart), 0, 8);
1452
1453            // Render sidebar widget and return immediately
1454            $themeOverride = !empty($data['theme']) ? $data['theme'] : null;
1455            return $this->renderSidebarWidget($allEvents, $namespace, $calId, $themeOverride);
1456        } elseif ($today) {
1457            $startDate = date('Y-m-d');
1458            $endDate = date('Y-m-d');
1459            $headerText = 'Today';
1460        } elseif ($daterange) {
1461            list($startDate, $endDate) = explode(':', $daterange);
1462            $start = new DateTime($startDate);
1463            $end = new DateTime($endDate);
1464            $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y');
1465        } elseif ($date) {
1466            $startDate = $date;
1467            $endDate = $date;
1468            $dt = new DateTime($date);
1469            $headerText = $dt->format('l, F j, Y');
1470        } else {
1471            $startDate = date('Y-m-01');
1472            $endDate = date('Y-m-t');
1473            $dt = new DateTime($startDate);
1474            $headerText = $dt->format('F Y');
1475        }
1476
1477        // Load all events in date range
1478        $allEvents = array();
1479        $start = new DateTime($startDate);
1480        $end = new DateTime($endDate);
1481        $end->modify('+1 day');
1482
1483        $interval = new DateInterval('P1D');
1484        $period = new DatePeriod($start, $interval, $end);
1485
1486        // Check if multiple namespaces or wildcard specified
1487        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
1488
1489        static $loadedMonths = array();
1490
1491        foreach ($period as $dt) {
1492            $year = (int)$dt->format('Y');
1493            $month = (int)$dt->format('n');
1494            $dateKey = $dt->format('Y-m-d');
1495
1496            $monthKey = $year . '-' . $month . '-' . $namespace;
1497
1498            if (!isset($loadedMonths[$monthKey])) {
1499                if ($isMultiNamespace) {
1500                    $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month, $excludeList);
1501                } else {
1502                    $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
1503                }
1504            }
1505
1506            $monthEvents = $loadedMonths[$monthKey];
1507
1508            if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
1509                $allEvents[$dateKey] = $monthEvents[$dateKey];
1510            }
1511        }
1512
1513        // Sort events by date (already sorted by dateKey), then by time within each day
1514        foreach ($allEvents as $dateKey => &$dayEvents) {
1515            usort($dayEvents, function($a, $b) {
1516                $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null;
1517                $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null;
1518
1519                // All-day events (no time) go to the TOP
1520                if ($timeA === null && $timeB !== null) return -1; // A before B
1521                if ($timeA !== null && $timeB === null) return 1;  // A after B
1522                if ($timeA === null && $timeB === null) return 0;  // Both all-day, equal
1523
1524                // Both have times, sort chronologically
1525                return strcmp($timeA, $timeB);
1526            });
1527        }
1528        unset($dayEvents); // Break reference
1529
1530        // Simple 2-line display widget
1531        $calId = 'eventlist_' . uniqid();
1532        $theme = !empty($data['theme']) ? $data['theme'] : $this->getSidebarTheme();
1533        $themeStyles = $this->getSidebarThemeStyles($theme);
1534        $isDark = in_array($theme, ['matrix', 'purple', 'pink']);
1535        $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg'];
1536
1537        // Theme class for CSS targeting
1538        $themeClass = 'eventlist-theme-' . $theme;
1539
1540        // Container styling - dark themes get border + glow, light themes get subtle border
1541        $containerStyle = 'background:' . $themeStyles['bg'] . ' !important;';
1542        if ($isDark) {
1543            $containerStyle .= ' border:2px solid ' . $themeStyles['border'] . ';';
1544            $containerStyle .= ' border-radius:4px;';
1545            $containerStyle .= ' box-shadow:0 0 10px ' . $themeStyles['shadow'] . ';';
1546        } else {
1547            $containerStyle .= ' border:1px solid ' . $themeStyles['grid_border'] . ';';
1548            $containerStyle .= ' border-radius:4px;';
1549        }
1550
1551        $html = '<div class="eventlist-simple ' . $themeClass . '" id="' . $calId . '" style="' . $containerStyle . '" data-exclude="' . htmlspecialchars($exclude) . '">';
1552
1553        // Inject CSS variables for this eventlist instance
1554        $html .= '<style>
1555        #' . $calId . ' {
1556            --background-site: ' . $themeStyles['bg'] . ';
1557            --background-alt: ' . $themeStyles['cell_bg'] . ';
1558            --text-primary: ' . $themeStyles['text_primary'] . ';
1559            --text-dim: ' . $themeStyles['text_dim'] . ';
1560            --text-bright: ' . $themeStyles['text_bright'] . ';
1561            --border-color: ' . $themeStyles['grid_border'] . ';
1562            --border-main: ' . $themeStyles['border'] . ';
1563            --cell-bg: ' . $themeStyles['cell_bg'] . ';
1564            --cell-today-bg: ' . $themeStyles['cell_today_bg'] . ';
1565            --shadow-color: ' . $themeStyles['shadow'] . ';
1566            --grid-bg: ' . $themeStyles['grid_bg'] . ';
1567            --btn-text: ' . $btnTextColor . ';
1568            --pastdue-color: ' . $themeStyles['pastdue_color'] . ';
1569            --pastdue-bg: ' . $themeStyles['pastdue_bg'] . ';
1570            --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . ';
1571            --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . ';
1572            --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . ';
1573            --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . ';
1574            --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . ';
1575        }
1576        </style>';
1577
1578        // Load calendar JavaScript manually (not through DokuWiki concatenation)
1579        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
1580
1581        // Initialize DOKU_BASE for JavaScript
1582        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
1583
1584        // Add compact header with date and clock for "today" mode (unless noheader is set)
1585        if ($today && !empty($allEvents) && !$noheader) {
1586            $todayDate = new DateTime();
1587            $displayDate = $todayDate->format('D, M j, Y'); // "Fri, Jan 30, 2026"
1588            $currentTime = $todayDate->format('g:i:s A'); // "2:45:30 PM"
1589
1590            $html .= '<div class="eventlist-today-header">';
1591            $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>';
1592            $html .= '<div class="eventlist-bottom-info">';
1593            $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">��️</span> <span id="weather-temp-' . $calId . '">--°</span></span>';
1594            $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>';
1595            $html .= '</div>';
1596
1597            $html .= '</div>';
1598
1599            // Add JavaScript to update clock and weather
1600            $html .= '<script>
1601(function() {
1602    // Update clock every second
1603    function updateClock() {
1604        const now = new Date();
1605        let hours = now.getHours();
1606        const minutes = String(now.getMinutes()).padStart(2, "0");
1607        const seconds = String(now.getSeconds()).padStart(2, "0");
1608        const ampm = hours >= 12 ? "PM" : "AM";
1609        hours = hours % 12 || 12;
1610        const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm;
1611        const clockEl = document.getElementById("clock-' . $calId . '");
1612        if (clockEl) clockEl.textContent = timeStr;
1613    }
1614    setInterval(updateClock, 1000);
1615
1616    // Fetch weather - uses default location, click weather to get local
1617    var userLocationGranted = false;
1618    var userLat = 38.5816;  // Sacramento default
1619    var userLon = -121.4944;
1620
1621    function fetchWeatherData(lat, lon) {
1622        fetch("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "&current_weather=true&temperature_unit=fahrenheit")
1623            .then(response => response.json())
1624            .then(data => {
1625                if (data.current_weather) {
1626                    const temp = Math.round(data.current_weather.temperature);
1627                    const weatherCode = data.current_weather.weathercode;
1628                    const icon = getWeatherIcon(weatherCode);
1629                    const iconEl = document.getElementById("weather-icon-' . $calId . '");
1630                    const tempEl = document.getElementById("weather-temp-' . $calId . '");
1631                    if (iconEl) iconEl.textContent = icon;
1632                    if (tempEl) tempEl.innerHTML = temp + "&deg;";
1633                }
1634            })
1635            .catch(error => {
1636                console.log("Weather fetch error:", error);
1637            });
1638    }
1639
1640    function updateWeather() {
1641        fetchWeatherData(userLat, userLon);
1642    }
1643
1644    // Allow user to click weather to get local weather (requires user gesture)
1645    function requestLocalWeather() {
1646        if (userLocationGranted) return; // Already have permission
1647
1648        if ("geolocation" in navigator) {
1649            navigator.geolocation.getCurrentPosition(function(position) {
1650                userLat = position.coords.latitude;
1651                userLon = position.coords.longitude;
1652                userLocationGranted = true;
1653                fetchWeatherData(userLat, userLon);
1654            }, function(error) {
1655                console.log("Geolocation denied or unavailable, using default location");
1656            });
1657        }
1658    }
1659
1660    // Add click handler to weather widget for local weather
1661    setTimeout(function() {
1662        var weatherEl = document.querySelector("#weather-icon-' . $calId . '");
1663        if (weatherEl) {
1664            weatherEl.style.cursor = "pointer";
1665            weatherEl.title = "Click for local weather";
1666            weatherEl.addEventListener("click", requestLocalWeather);
1667        }
1668    }, 100);
1669
1670    // WMO Weather interpretation codes
1671    function getWeatherIcon(code) {
1672        const icons = {
1673            0: "☀️",   // Clear sky
1674            1: "��️",   // Mainly clear
1675            2: "⛅",   // Partly cloudy
1676            3: "☁️",   // Overcast
1677            45: "��️",  // Fog
1678            48: "��️",  // Depositing rime fog
1679            51: "��️",  // Light drizzle
1680            53: "��️",  // Moderate drizzle
1681            55: "��️",  // Dense drizzle
1682            61: "��️",  // Slight rain
1683            63: "��️",  // Moderate rain
1684            65: "⛈️",  // Heavy rain
1685            71: "��️",  // Slight snow
1686            73: "��️",  // Moderate snow
1687            75: "❄️",  // Heavy snow
1688            77: "��️",  // Snow grains
1689            80: "��️",  // Slight rain showers
1690            81: "��️",  // Moderate rain showers
1691            82: "⛈️",  // Violent rain showers
1692            85: "��️",  // Slight snow showers
1693            86: "❄️",  // Heavy snow showers
1694            95: "⛈️",  // Thunderstorm
1695            96: "⛈️",  // Thunderstorm with slight hail
1696            99: "⛈️"   // Thunderstorm with heavy hail
1697        };
1698        return icons[code] || "��️";
1699    }
1700
1701    // Update weather immediately and every 10 minutes
1702    updateWeather();
1703    setInterval(updateWeather, 600000);
1704})();
1705</script>';
1706        }
1707
1708        if (empty($allEvents)) {
1709            $html .= '<div class="eventlist-simple-empty">';
1710            $html .= '<div class="eventlist-simple-header">' . htmlspecialchars($headerText);
1711            if ($namespace) {
1712                $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($namespace) . '</span>';
1713            }
1714            $html .= '</div>';
1715            $html .= '<div class="eventlist-simple-body">No events</div>';
1716            $html .= '</div>';
1717        } else {
1718            // Calculate today and tomorrow's dates for highlighting
1719            $todayStr = date('Y-m-d');
1720            $tomorrow = date('Y-m-d', strtotime('+1 day'));
1721
1722            foreach ($allEvents as $dateKey => $dayEvents) {
1723                $dateObj = new DateTime($dateKey);
1724                $displayDate = $dateObj->format('D, M j');
1725
1726                // Check if this date is today or tomorrow or past
1727                // Enable highlighting for sidebar mode AND range modes (day, week, month)
1728                $enableHighlighting = $sidebar || !empty($range);
1729                $isToday = $enableHighlighting && ($dateKey === $todayStr);
1730                $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow);
1731                $isPast = $dateKey < $todayStr;
1732
1733                foreach ($dayEvents as $event) {
1734                    // Check if this is a task and if it's completed
1735                    $isTask = !empty($event['isTask']);
1736                    $completed = !empty($event['completed']);
1737
1738                    // ALWAYS skip completed tasks UNLESS showchecked is explicitly set
1739                    if (!$showchecked && $isTask && $completed) {
1740                        continue;
1741                    }
1742
1743                    // Skip past events that are NOT tasks (only show past due tasks from the past)
1744                    if ($isPast && !$isTask) {
1745                        continue;
1746                    }
1747
1748                    // Determine if task is past due (past date, is task, not completed)
1749                    $isPastDue = $isPast && $isTask && !$completed;
1750
1751                    // Line 1: Header (Title, Time, Date, Namespace)
1752                    $todayClass = $isToday ? ' eventlist-simple-today' : '';
1753                    $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : '';
1754                    $pastDueClass = $isPastDue ? ' eventlist-simple-pastdue' : '';
1755                    $html .= '<div class="eventlist-simple-item' . $todayClass . $tomorrowClass . $pastDueClass . '">';
1756                    $html .= '<div class="eventlist-simple-header">';
1757
1758                    // Title
1759                    $html .= '<span class="eventlist-simple-title">' . htmlspecialchars($event['title']) . '</span>';
1760
1761                    // Time (12-hour format)
1762                    if (!empty($event['time'])) {
1763                        $timeParts = explode(':', $event['time']);
1764                        if (count($timeParts) === 2) {
1765                            $hour = (int)$timeParts[0];
1766                            $minute = $timeParts[1];
1767                            $ampm = $hour >= 12 ? 'PM' : 'AM';
1768                            $hour = $hour % 12 ?: 12;
1769                            $displayTime = $hour . ':' . $minute . ' ' . $ampm;
1770                            $html .= ' <span class="eventlist-simple-time">' . $displayTime . '</span>';
1771                        }
1772                    }
1773
1774                    // Date
1775                    $html .= ' <span class="eventlist-simple-date">' . $displayDate . '</span>';
1776
1777                    // Badge: PAST DUE, TODAY, or nothing
1778                    if ($isPastDue) {
1779                        $html .= ' <span class="eventlist-simple-pastdue-badge" style="background:' . $themeStyles['pastdue_color'] . ' !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>';
1780                    } elseif ($isToday) {
1781                        $html .= ' <span class="eventlist-simple-today-badge" style="background:' . $themeStyles['border'] . ' !important; color:' . $themeStyles['bg'] . ' !important; -webkit-text-fill-color:' . $themeStyles['bg'] . ' !important;">TODAY</span>';
1782                    }
1783
1784                    // Namespace badge (show individual event's namespace)
1785                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
1786                    if (!$eventNamespace && isset($event['_namespace'])) {
1787                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading
1788                    }
1789                    if ($eventNamespace) {
1790                        $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($eventNamespace) . '</span>';
1791                    }
1792
1793                    $html .= '</div>'; // header
1794
1795                    // Line 2: Body (Description only) - only show if description exists
1796                    if (!empty($event['description'])) {
1797                        $html .= '<div class="eventlist-simple-body">' . $this->renderDescription($event['description']) . '</div>';
1798                    }
1799
1800                    $html .= '</div>'; // item
1801                }
1802            }
1803        }
1804
1805        $html .= '</div>'; // eventlist-simple
1806
1807        return $html;
1808    }
1809
1810    private function renderEventDialog($calId, $namespace, $theme = null) {
1811        // Get theme for dialog
1812        if ($theme === null) {
1813            $theme = $this->getSidebarTheme();
1814        }
1815        $themeStyles = $this->getSidebarThemeStyles($theme);
1816
1817        $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">';
1818        $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>';
1819
1820        // Draggable dialog with theme
1821        $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">';
1822
1823        // Header with drag handle and close button
1824        $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">';
1825        $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>';
1826        $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>';
1827        $html .= '</div>';
1828
1829        // Form content
1830        $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">';
1831
1832        // Hidden ID field
1833        $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">';
1834
1835        // 1. TITLE
1836        $html .= '<div class="form-field">';
1837        $html .= '<label class="field-label">�� Title</label>';
1838        $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek input-compact" placeholder="Event or task title...">';
1839        $html .= '</div>';
1840
1841        // 1.5 NAMESPACE SELECTOR (Searchable with fuzzy matching)
1842        $html .= '<div class="form-field">';
1843        $html .= '<label class="field-label">�� Namespace</label>';
1844
1845        // Hidden field to store actual selected namespace
1846        $html .= '<input type="hidden" id="event-namespace-' . $calId . '" name="namespace" value="">';
1847
1848        // Searchable input
1849        $html .= '<div class="namespace-search-wrapper">';
1850        $html .= '<input type="text" id="event-namespace-search-' . $calId . '" class="input-sleek input-compact namespace-search-input" placeholder="Type to search or leave empty for default..." autocomplete="off">';
1851        $html .= '<div class="namespace-dropdown" id="event-namespace-dropdown-' . $calId . '" style="display:none;"></div>';
1852        $html .= '</div>';
1853
1854        // Store namespaces as JSON for JavaScript
1855        $allNamespaces = $this->getAllNamespaces();
1856        $html .= '<script type="application/json" id="namespaces-data-' . $calId . '">' . json_encode($allNamespaces) . '</script>';
1857
1858        $html .= '</div>';
1859
1860        // 2. DESCRIPTION
1861        $html .= '<div class="form-field">';
1862        $html .= '<label class="field-label">�� Description</label>';
1863        $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="2" class="input-sleek textarea-sleek textarea-compact" placeholder="Optional details..."></textarea>';
1864        $html .= '</div>';
1865
1866        // 3. START DATE - END DATE (inline)
1867        $html .= '<div class="form-row-group">';
1868
1869        $html .= '<div class="form-field form-field-half">';
1870        $html .= '<label class="field-label-compact">�� Start Date</label>';
1871        $html .= '<div class="date-picker-wrapper">';
1872        $html .= '<input type="hidden" id="event-date-' . $calId . '" name="date" required value="">';
1873        $html .= '<button type="button" class="custom-date-picker input-sleek input-compact" id="date-picker-btn-' . $calId . '" data-target="event-date-' . $calId . '" data-type="start">';
1874        $html .= '<span class="date-display">Select date</span>';
1875        $html .= '<span class="date-arrow">▼</span>';
1876        $html .= '</button>';
1877        $html .= '<div class="date-dropdown" id="date-dropdown-' . $calId . '"></div>';
1878        $html .= '</div>';
1879        $html .= '</div>';
1880
1881        $html .= '<div class="form-field form-field-half">';
1882        $html .= '<label class="field-label-compact">�� End Date</label>';
1883        $html .= '<div class="date-picker-wrapper">';
1884        $html .= '<input type="hidden" id="event-end-date-' . $calId . '" name="endDate" value="">';
1885        $html .= '<button type="button" class="custom-date-picker input-sleek input-compact" id="end-date-picker-btn-' . $calId . '" data-target="event-end-date-' . $calId . '" data-type="end">';
1886        $html .= '<span class="date-display">Optional</span>';
1887        $html .= '<span class="date-arrow">▼</span>';
1888        $html .= '</button>';
1889        $html .= '<div class="date-dropdown" id="end-date-dropdown-' . $calId . '"></div>';
1890        $html .= '</div>';
1891        $html .= '</div>';
1892
1893        $html .= '</div>'; // End row
1894
1895        // 4. IS REPEATING CHECKBOX
1896        $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">';
1897        $html .= '<label class="checkbox-label checkbox-label-compact">';
1898        $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">';
1899        $html .= '<span>�� Repeating Event</span>';
1900        $html .= '</label>';
1901        $html .= '</div>';
1902
1903        // Recurring options (shown when checkbox is checked)
1904        $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none; border:1px solid var(--border-color, #333); border-radius:4px; padding:8px; margin:4px 0; background:var(--background-alt, rgba(0,0,0,0.2));">';
1905
1906        // Row 1: Repeat every [N] [period]
1907        $html .= '<div class="form-row-group" style="margin-bottom:6px;">';
1908
1909        $html .= '<div class="form-field" style="flex:0 0 auto; min-width:0;">';
1910        $html .= '<label class="field-label-compact">Repeat every</label>';
1911        $html .= '<input type="number" id="event-recurrence-interval-' . $calId . '" name="recurrenceInterval" class="input-sleek input-compact" value="1" min="1" max="99" style="width:50px;">';
1912        $html .= '</div>';
1913
1914        $html .= '<div class="form-field" style="flex:1; min-width:0;">';
1915        $html .= '<label class="field-label-compact">&nbsp;</label>';
1916        $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek input-compact" onchange="updateRecurrenceOptions(\'' . $calId . '\')">';
1917        $html .= '<option value="daily">Day(s)</option>';
1918        $html .= '<option value="weekly">Week(s)</option>';
1919        $html .= '<option value="monthly">Month(s)</option>';
1920        $html .= '<option value="yearly">Year(s)</option>';
1921        $html .= '</select>';
1922        $html .= '</div>';
1923
1924        $html .= '</div>'; // End row 1
1925
1926        // Row 2: Weekly options - day of week checkboxes
1927        $html .= '<div id="weekly-options-' . $calId . '" class="weekly-options" style="display:none; margin-bottom:6px;">';
1928        $html .= '<label class="field-label-compact" style="display:block; margin-bottom:4px;">On these days:</label>';
1929        $html .= '<div style="display:flex; flex-wrap:wrap; gap:2px;">';
1930        $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1931        foreach ($dayNames as $idx => $day) {
1932            $html .= '<label style="display:inline-flex; align-items:center; padding:2px 6px; background:var(--cell-bg, #1a1a1a); border:1px solid var(--border-color, #333); border-radius:3px; cursor:pointer; font-size:10px;">';
1933            $html .= '<input type="checkbox" name="weekDays[]" value="' . $idx . '" style="margin-right:3px; width:12px; height:12px;">';
1934            $html .= '<span>' . $day . '</span>';
1935            $html .= '</label>';
1936        }
1937        $html .= '</div>';
1938        $html .= '</div>'; // End weekly options
1939
1940        // Row 3: Monthly options - day of month OR ordinal weekday
1941        $html .= '<div id="monthly-options-' . $calId . '" class="monthly-options" style="display:none; margin-bottom:6px;">';
1942        $html .= '<label class="field-label-compact" style="display:block; margin-bottom:4px;">Repeat on:</label>';
1943
1944        // Radio: Day of month vs Ordinal weekday
1945        $html .= '<div style="margin-bottom:6px;">';
1946        $html .= '<label style="display:inline-flex; align-items:center; margin-right:12px; cursor:pointer; font-size:11px;">';
1947        $html .= '<input type="radio" name="monthlyType" value="dayOfMonth" checked onchange="updateMonthlyType(\'' . $calId . '\')" style="margin-right:4px;">';
1948        $html .= 'Day of month';
1949        $html .= '</label>';
1950        $html .= '<label style="display:inline-flex; align-items:center; cursor:pointer; font-size:11px;">';
1951        $html .= '<input type="radio" name="monthlyType" value="ordinalWeekday" onchange="updateMonthlyType(\'' . $calId . '\')" style="margin-right:4px;">';
1952        $html .= 'Weekday pattern';
1953        $html .= '</label>';
1954        $html .= '</div>';
1955
1956        // Day of month input (shown by default)
1957        $html .= '<div id="monthly-day-' . $calId . '" style="display:flex; align-items:center; gap:6px;">';
1958        $html .= '<span style="font-size:11px;">Day</span>';
1959        $html .= '<input type="number" id="event-month-day-' . $calId . '" name="monthDay" class="input-sleek input-compact" value="1" min="1" max="31" style="width:50px;">';
1960        $html .= '<span style="font-size:10px; color:var(--text-dim, #666);">of each month</span>';
1961        $html .= '</div>';
1962
1963        // Ordinal weekday (hidden by default)
1964        $html .= '<div id="monthly-ordinal-' . $calId . '" style="display:none;">';
1965        $html .= '<div style="display:flex; align-items:center; gap:4px; flex-wrap:wrap;">';
1966        $html .= '<select id="event-ordinal-' . $calId . '" name="ordinalWeek" class="input-sleek input-compact" style="width:auto;">';
1967        $html .= '<option value="1">First</option>';
1968        $html .= '<option value="2">Second</option>';
1969        $html .= '<option value="3">Third</option>';
1970        $html .= '<option value="4">Fourth</option>';
1971        $html .= '<option value="5">Fifth</option>';
1972        $html .= '<option value="-1">Last</option>';
1973        $html .= '</select>';
1974        $html .= '<select id="event-ordinal-day-' . $calId . '" name="ordinalDay" class="input-sleek input-compact" style="width:auto;">';
1975        $html .= '<option value="0">Sunday</option>';
1976        $html .= '<option value="1">Monday</option>';
1977        $html .= '<option value="2">Tuesday</option>';
1978        $html .= '<option value="3">Wednesday</option>';
1979        $html .= '<option value="4">Thursday</option>';
1980        $html .= '<option value="5">Friday</option>';
1981        $html .= '<option value="6">Saturday</option>';
1982        $html .= '</select>';
1983        $html .= '<span style="font-size:10px; color:var(--text-dim, #666);">of each month</span>';
1984        $html .= '</div>';
1985        $html .= '</div>';
1986
1987        $html .= '</div>'; // End monthly options
1988
1989        // Row 4: End date
1990        $html .= '<div class="form-row-group">';
1991        $html .= '<div class="form-field">';
1992        $html .= '<label class="field-label-compact">Repeat Until (optional)</label>';
1993        $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date input-compact" placeholder="Optional">';
1994        $html .= '<div style="font-size:9px; color:var(--text-dim, #666); margin-top:2px;">Leave empty for 1 year of events</div>';
1995        $html .= '</div>';
1996        $html .= '</div>'; // End row 4
1997
1998        $html .= '</div>'; // End recurring options
1999
2000        // 5. TIME (Start & End) - COLOR (inline)
2001        $html .= '<div class="form-row-group">';
2002
2003        $html .= '<div class="form-field form-field-half">';
2004        $html .= '<label class="field-label-compact">�� Start Time</label>';
2005        $html .= '<div class="time-picker-wrapper">';
2006        // Custom time picker button instead of native select
2007        $html .= '<input type="hidden" id="event-time-' . $calId . '" name="time" value="">';
2008        $html .= '<button type="button" class="custom-time-picker input-sleek input-compact" id="time-picker-btn-' . $calId . '" data-target="event-time-' . $calId . '" data-type="start">';
2009        $html .= '<span class="time-display">All day</span>';
2010        $html .= '<span class="time-arrow">▼</span>';
2011        $html .= '</button>';
2012        $html .= '<div class="time-dropdown" id="time-dropdown-' . $calId . '"></div>';
2013        $html .= '</div>';
2014        $html .= '</div>';
2015
2016        $html .= '<div class="form-field form-field-half">';
2017        $html .= '<label class="field-label-compact">�� End Time</label>';
2018        $html .= '<div class="time-picker-wrapper">';
2019        // Custom end time picker
2020        $html .= '<input type="hidden" id="event-end-time-' . $calId . '" name="endTime" value="">';
2021        $html .= '<button type="button" class="custom-time-picker input-sleek input-compact" id="end-time-picker-btn-' . $calId . '" data-target="event-end-time-' . $calId . '" data-type="end" disabled>';
2022        $html .= '<span class="time-display">Same as start</span>';
2023        $html .= '<span class="time-arrow">▼</span>';
2024        $html .= '</button>';
2025        $html .= '<div class="time-dropdown" id="end-time-dropdown-' . $calId . '"></div>';
2026        $html .= '</div>';
2027        $html .= '</div>';
2028
2029        $html .= '</div>'; // End row
2030
2031        // Color field (new row)
2032        $html .= '<div class="form-row-group">';
2033
2034        $html .= '<div class="form-field form-field-full">';
2035        $html .= '<label class="field-label-compact">�� Color</label>';
2036        $html .= '<div class="color-picker-wrapper">';
2037        $html .= '<select id="event-color-' . $calId . '" name="color" class="input-sleek input-compact color-select" onchange="updateCustomColorPicker(\'' . $calId . '\')">';
2038        $html .= '<option value="#3498db" style="background:#3498db;color:white">�� Blue</option>';
2039        $html .= '<option value="#2ecc71" style="background:#2ecc71;color:white">�� Green</option>';
2040        $html .= '<option value="#e74c3c" style="background:#e74c3c;color:white">�� Red</option>';
2041        $html .= '<option value="#f39c12" style="background:#f39c12;color:white">�� Orange</option>';
2042        $html .= '<option value="#9b59b6" style="background:#9b59b6;color:white">�� Purple</option>';
2043        $html .= '<option value="#e91e63" style="background:#e91e63;color:white">�� Pink</option>';
2044        $html .= '<option value="#1abc9c" style="background:#1abc9c;color:white">�� Teal</option>';
2045        $html .= '<option value="custom">�� Custom...</option>';
2046        $html .= '</select>';
2047        $html .= '<input type="color" id="event-color-custom-' . $calId . '" class="color-picker-input color-picker-compact" value="#3498db" onchange="updateColorFromPicker(\'' . $calId . '\')">';
2048        $html .= '</div>';
2049        $html .= '</div>';
2050
2051        $html .= '</div>'; // End row
2052
2053        // Task checkbox
2054        $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">';
2055        $html .= '<label class="checkbox-label checkbox-label-compact">';
2056        $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">';
2057        $html .= '<span>�� This is a task (can be checked off)</span>';
2058        $html .= '</label>';
2059        $html .= '</div>';
2060
2061        // Action buttons
2062        $html .= '<div class="dialog-actions-sleek">';
2063        $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>';
2064        $html .= '<button type="submit" class="btn-sleek btn-save-sleek">�� Save</button>';
2065        $html .= '</div>';
2066
2067        $html .= '</form>';
2068        $html .= '</div>';
2069        $html .= '</div>';
2070
2071        return $html;
2072    }
2073
2074    private function renderMonthPicker($calId, $year, $month, $namespace, $theme = 'matrix', $themeStyles = null) {
2075        // Fallback to default theme if not provided
2076        if ($themeStyles === null) {
2077            $themeStyles = $this->getSidebarThemeStyles($theme);
2078        }
2079
2080        $themeClass = 'calendar-theme-' . $theme;
2081
2082        $html = '<div class="month-picker-overlay ' . $themeClass . '" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">';
2083        $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">';
2084        $html .= '<h4>Jump to Month</h4>';
2085
2086        $html .= '<div class="month-picker-selects">';
2087        $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">';
2088        $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
2089        for ($m = 1; $m <= 12; $m++) {
2090            $selected = ($m == $month) ? ' selected' : '';
2091            $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>';
2092        }
2093        $html .= '</select>';
2094
2095        $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">';
2096        $currentYear = (int)date('Y');
2097        for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) {
2098            $selected = ($y == $year) ? ' selected' : '';
2099            $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>';
2100        }
2101        $html .= '</select>';
2102        $html .= '</div>';
2103
2104        $html .= '<div class="month-picker-actions">';
2105        $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>';
2106        $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>';
2107        $html .= '</div>';
2108
2109        $html .= '</div>';
2110        $html .= '</div>';
2111
2112        return $html;
2113    }
2114
2115    private function renderDescription($description, $themeStyles = null) {
2116        if (empty($description)) {
2117            return '';
2118        }
2119
2120        // Get theme for link colors if not provided
2121        if ($themeStyles === null) {
2122            $theme = $this->getSidebarTheme();
2123            $themeStyles = $this->getSidebarThemeStyles($theme);
2124        }
2125
2126        $linkColor = '';
2127        $linkStyle = ' class="cal-link"';
2128
2129        // Token-based parsing to avoid escaping issues
2130        $rendered = $description;
2131        $tokens = array();
2132        $tokenIndex = 0;
2133
2134        // Convert DokuWiki image syntax {{image.jpg}} to tokens
2135        $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/';
2136        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
2137        foreach ($matches as $match) {
2138            $imagePath = trim($match[1]);
2139            $alt = isset($match[2]) ? trim($match[2]) : '';
2140
2141            // Handle external URLs
2142            if (preg_match('/^https?:\/\//', $imagePath)) {
2143                $imageHtml = '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
2144            } else {
2145                // Handle internal DokuWiki images
2146                $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath);
2147                $imageHtml = '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
2148            }
2149
2150            $token = "\x00TOKEN" . $tokenIndex . "\x00";
2151            $tokens[$tokenIndex] = $imageHtml;
2152            $tokenIndex++;
2153            $rendered = str_replace($match[0], $token, $rendered);
2154        }
2155
2156        // Convert DokuWiki link syntax [[link|text]] to tokens
2157        $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/';
2158        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
2159        foreach ($matches as $match) {
2160            $link = trim($match[1]);
2161            $text = isset($match[2]) ? trim($match[2]) : $link;
2162
2163            // Handle external URLs
2164            if (preg_match('/^https?:\/\//', $link)) {
2165                $linkHtml = '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>';
2166            } else {
2167                // Handle internal DokuWiki links with section anchors
2168                $parts = explode('#', $link, 2);
2169                $pagePart = $parts[0];
2170                $sectionPart = isset($parts[1]) ? '#' . $parts[1] : '';
2171
2172                $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart;
2173                $linkHtml = '<a href="' . $wikiUrl . '"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>';
2174            }
2175
2176            $token = "\x00TOKEN" . $tokenIndex . "\x00";
2177            $tokens[$tokenIndex] = $linkHtml;
2178            $tokenIndex++;
2179            $rendered = str_replace($match[0], $token, $rendered);
2180        }
2181
2182        // Convert markdown-style links [text](url) to tokens
2183        $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
2184        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
2185        foreach ($matches as $match) {
2186            $text = trim($match[1]);
2187            $url = trim($match[2]);
2188
2189            if (preg_match('/^https?:\/\//', $url)) {
2190                $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>';
2191            } else {
2192                $linkHtml = '<a href="' . htmlspecialchars($url) . '"' . $linkStyle . '>' . htmlspecialchars($text) . '</a>';
2193            }
2194
2195            $token = "\x00TOKEN" . $tokenIndex . "\x00";
2196            $tokens[$tokenIndex] = $linkHtml;
2197            $tokenIndex++;
2198            $rendered = str_replace($match[0], $token, $rendered);
2199        }
2200
2201        // Convert plain URLs to tokens
2202        $pattern = '/(https?:\/\/[^\s<]+)/';
2203        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
2204        foreach ($matches as $match) {
2205            $url = $match[1];
2206            $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer"' . $linkStyle . '>' . htmlspecialchars($url) . '</a>';
2207
2208            $token = "\x00TOKEN" . $tokenIndex . "\x00";
2209            $tokens[$tokenIndex] = $linkHtml;
2210            $tokenIndex++;
2211            $rendered = str_replace($match[0], $token, $rendered);
2212        }
2213
2214        // NOW escape HTML (tokens are protected)
2215        $rendered = htmlspecialchars($rendered);
2216
2217        // Convert newlines to <br>
2218        $rendered = nl2br($rendered);
2219
2220        // DokuWiki text formatting
2221        // Bold: **text** or __text__
2222        $boldStyle = '';
2223        $rendered = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $rendered);
2224        $rendered = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $rendered);
2225
2226        // Italic: //text//
2227        $rendered = preg_replace('/\/\/(.+?)\/\//', '<em>$1</em>', $rendered);
2228
2229        // Strikethrough: <del>text</del>
2230        $rendered = preg_replace('/&lt;del&gt;(.+?)&lt;\/del&gt;/', '<del>$1</del>', $rendered);
2231
2232        // Monospace: ''text''
2233        $rendered = preg_replace('/&#039;&#039;(.+?)&#039;&#039;/', '<code>$1</code>', $rendered);
2234
2235        // Subscript: <sub>text</sub>
2236        $rendered = preg_replace('/&lt;sub&gt;(.+?)&lt;\/sub&gt;/', '<sub>$1</sub>', $rendered);
2237
2238        // Superscript: <sup>text</sup>
2239        $rendered = preg_replace('/&lt;sup&gt;(.+?)&lt;\/sup&gt;/', '<sup>$1</sup>', $rendered);
2240
2241        // Restore tokens
2242        foreach ($tokens as $i => $html) {
2243            $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered);
2244        }
2245
2246        return $rendered;
2247    }
2248
2249    private function loadEvents($namespace, $year, $month) {
2250        $dataDir = $this->metaDir();
2251        if ($namespace) {
2252            $dataDir .= str_replace(':', '/', $namespace) . '/';
2253        }
2254        $dataDir .= 'calendar/';
2255
2256        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
2257
2258        if (file_exists($eventFile)) {
2259            $json = file_get_contents($eventFile);
2260            return json_decode($json, true);
2261        }
2262
2263        return array();
2264    }
2265
2266    private function loadEventsMultiNamespace($namespaces, $year, $month, $excludeList = []) {
2267        // Check for wildcard pattern (namespace:*)
2268        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
2269            $baseNamespace = $matches[1];
2270            return $this->loadEventsWildcard($baseNamespace, $year, $month, $excludeList);
2271        }
2272
2273        // Check for root wildcard (just *)
2274        if ($namespaces === '*') {
2275            return $this->loadEventsWildcard('', $year, $month, $excludeList);
2276        }
2277
2278        // Parse namespace list (semicolon separated)
2279        // e.g., "team:projects;personal;work:tasks" = three namespaces
2280        $namespaceList = array_map('trim', explode(';', $namespaces));
2281
2282        // Load events from all namespaces
2283        $allEvents = array();
2284        foreach ($namespaceList as $ns) {
2285            $ns = trim($ns);
2286            if (empty($ns)) continue;
2287
2288            // Skip excluded namespaces
2289            if ($this->isNamespaceExcluded($ns, $excludeList)) continue;
2290
2291            // ACL check: skip namespaces user cannot read
2292            if (!$this->checkNamespaceRead($ns)) continue;
2293
2294            $events = $this->loadEvents($ns, $year, $month);
2295
2296            // Add namespace tag to each event
2297            foreach ($events as $dateKey => $dayEvents) {
2298                if (!isset($allEvents[$dateKey])) {
2299                    $allEvents[$dateKey] = array();
2300                }
2301                foreach ($dayEvents as $event) {
2302                    $event['_namespace'] = $ns;
2303                    $allEvents[$dateKey][] = $event;
2304                }
2305            }
2306        }
2307
2308        return $allEvents;
2309    }
2310
2311    private function loadEventsWildcard($baseNamespace, $year, $month, $excludeList = []) {
2312        $metaDir = $this->metaDir();
2313        $dataDir = $metaDir;
2314        if ($baseNamespace) {
2315            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
2316        }
2317
2318        $allEvents = array();
2319
2320        // Load events from the base namespace itself
2321        if (empty($baseNamespace)) {
2322            $events = $this->loadEvents('', $year, $month);
2323            foreach ($events as $dateKey => $dayEvents) {
2324                if (!isset($allEvents[$dateKey])) {
2325                    $allEvents[$dateKey] = array();
2326                }
2327                foreach ($dayEvents as $event) {
2328                    $event['_namespace'] = '';
2329                    $allEvents[$dateKey][] = $event;
2330                }
2331            }
2332        } else {
2333            if (!$this->isNamespaceExcluded($baseNamespace, $excludeList) && $this->checkNamespaceRead($baseNamespace)) {
2334                $events = $this->loadEvents($baseNamespace, $year, $month);
2335                foreach ($events as $dateKey => $dayEvents) {
2336                    if (!isset($allEvents[$dateKey])) {
2337                        $allEvents[$dateKey] = array();
2338                    }
2339                    foreach ($dayEvents as $event) {
2340                        $event['_namespace'] = $baseNamespace;
2341                        $allEvents[$dateKey][] = $event;
2342                    }
2343                }
2344            }
2345        }
2346
2347        // Find all calendar directories efficiently using iterative glob
2348        // This avoids recursing into every directory in data/meta (thousands on large wikis)
2349        $this->findCalendarNamespaces($dataDir, $metaDir, $year, $month, $allEvents, $excludeList);
2350
2351        return $allEvents;
2352    }
2353
2354    /**
2355     * Find namespaces with calendar data using iterative glob
2356     * Searches for 'calendar/' directories at increasing depth without
2357     * scanning every directory in data/meta
2358     */
2359    private function findCalendarNamespaces($baseDir, $metaDir, $year, $month, &$allEvents, $excludeList = []) {
2360        if (!is_dir($baseDir)) return;
2361
2362        // Use glob at increasing depths to find 'calendar' directories
2363        // This is vastly more efficient than recursive scandir on large wikis
2364        $maxDepth = 10;
2365        $metaDirLen = strlen($metaDir);
2366
2367        for ($depth = 1; $depth <= $maxDepth; $depth++) {
2368            $pattern = $baseDir . str_repeat('*/', $depth) . 'calendar';
2369            $calDirs = glob($pattern, GLOB_ONLYDIR);
2370
2371            if (empty($calDirs)) {
2372                // No calendar dirs at this depth or deeper - stop early
2373                // (only if we also found none at previous depths)
2374                if ($depth > 3) break;
2375                continue;
2376            }
2377
2378            foreach ($calDirs as $calDir) {
2379                // Derive namespace from the parent directory of 'calendar/'
2380                $nsDir = dirname($calDir);
2381                $relPath = substr($nsDir, $metaDirLen);
2382                $namespace = str_replace('/', ':', trim($relPath, '/'));
2383
2384                // Skip the root namespace (already handled above)
2385                if (empty($namespace)) continue;
2386
2387                // Skip excluded namespaces
2388                if ($this->isNamespaceExcluded($namespace, $excludeList)) continue;
2389
2390                // ACL check
2391                if (!$this->checkNamespaceRead($namespace)) continue;
2392
2393                $events = $this->loadEvents($namespace, $year, $month);
2394                foreach ($events as $dateKey => $dayEvents) {
2395                    if (!isset($allEvents[$dateKey])) {
2396                        $allEvents[$dateKey] = array();
2397                    }
2398                    foreach ($dayEvents as $event) {
2399                        $event['_namespace'] = $namespace;
2400                        $allEvents[$dateKey][] = $event;
2401                    }
2402                }
2403            }
2404        }
2405    }
2406
2407    private function getAllNamespaces() {
2408        $dataDir = $this->metaDir();
2409        $namespaces = [];
2410
2411        // Scan for namespaces that have calendar data
2412        $this->scanForCalendarNamespaces($dataDir, '', $namespaces);
2413
2414        // Sort alphabetically
2415        sort($namespaces);
2416
2417        return $namespaces;
2418    }
2419
2420    private function scanForCalendarNamespaces($dir, $baseNamespace, &$namespaces) {
2421        if (!is_dir($dir)) return;
2422
2423        $items = scandir($dir);
2424        foreach ($items as $item) {
2425            if ($item === '.' || $item === '..') continue;
2426
2427            $path = $dir . $item;
2428            if (is_dir($path)) {
2429                // Check if this directory has a calendar subdirectory with data
2430                $calendarDir = $path . '/calendar/';
2431                if (is_dir($calendarDir)) {
2432                    // Check if there are any JSON files in the calendar directory
2433                    $jsonFiles = glob($calendarDir . '*.json');
2434                    if (!empty($jsonFiles)) {
2435                        // This namespace has calendar data
2436                        $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
2437                        $namespaces[] = $namespace;
2438                    }
2439                }
2440
2441                // Recurse into subdirectories
2442                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
2443                $this->scanForCalendarNamespaces($path . '/', $namespace, $namespaces);
2444            }
2445        }
2446    }
2447
2448    /**
2449     * Render new sidebar widget - Week at a glance itinerary (200px wide)
2450     */
2451    private function renderSidebarWidget($events, $namespace, $calId, $themeOverride = null) {
2452        if (empty($events)) {
2453            return '<div style="width:200px; padding:12px; text-align:center; color:#999; font-size:11px;">No events this week</div>';
2454        }
2455
2456        // Get important namespaces from config
2457        $configFile = $this->syncConfigPath();
2458        $importantNsList = ['important']; // default
2459        if (file_exists($configFile)) {
2460            $config = include $configFile;
2461            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
2462                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
2463            }
2464        }
2465
2466        // Calculate date ranges
2467        $todayStr = date('Y-m-d');
2468        $tomorrowStr = date('Y-m-d', strtotime('+1 day'));
2469
2470        // Get week start preference and calculate week range
2471        $weekStartDay = $this->getWeekStartDay();
2472
2473        if ($weekStartDay === 'monday') {
2474            // Monday start
2475            $weekStart = date('Y-m-d', strtotime('monday this week'));
2476            $weekEnd = date('Y-m-d', strtotime('sunday this week'));
2477        } else {
2478            // Sunday start (default - US/Canada standard)
2479            $today = date('w'); // 0 (Sun) to 6 (Sat)
2480            if ($today == 0) {
2481                // Today is Sunday
2482                $weekStart = date('Y-m-d');
2483            } else {
2484                // Monday-Saturday: go back to last Sunday
2485                $weekStart = date('Y-m-d', strtotime('-' . $today . ' days'));
2486            }
2487            $weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days'));
2488        }
2489
2490        // Group events by category
2491        $todayEvents = [];
2492        $tomorrowEvents = [];
2493        $importantEvents = [];
2494        $weekEvents = []; // For week grid
2495
2496        // Process all events
2497        foreach ($events as $dateKey => $dayEvents) {
2498            // Detect conflicts for events on this day
2499            $eventsWithConflicts = $this->detectTimeConflicts($dayEvents);
2500
2501            foreach ($eventsWithConflicts as $event) {
2502                // Always categorize Today and Tomorrow regardless of week boundaries
2503                if ($dateKey === $todayStr) {
2504                    $todayEvents[] = array_merge($event, ['date' => $dateKey]);
2505                }
2506                if ($dateKey === $tomorrowStr) {
2507                    $tomorrowEvents[] = array_merge($event, ['date' => $dateKey]);
2508                }
2509
2510                // Process week grid events (only for current week)
2511                if ($dateKey >= $weekStart && $dateKey <= $weekEnd) {
2512                    // Initialize week grid day if not exists
2513                    if (!isset($weekEvents[$dateKey])) {
2514                        $weekEvents[$dateKey] = [];
2515                    }
2516
2517                    // Pre-render DokuWiki syntax to HTML for JavaScript display
2518                    $eventWithHtml = $event;
2519                    if (isset($event['title'])) {
2520                        $eventWithHtml['title_html'] = $this->renderDokuWikiToHtml($event['title']);
2521                    }
2522                    if (isset($event['description'])) {
2523                        $eventWithHtml['description_html'] = $this->renderDokuWikiToHtml($event['description']);
2524                    }
2525                    $weekEvents[$dateKey][] = $eventWithHtml;
2526                }
2527
2528                // Check if this is an important namespace
2529                $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
2530                $isImportant = false;
2531                foreach ($importantNsList as $impNs) {
2532                    if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
2533                        $isImportant = true;
2534                        break;
2535                    }
2536                }
2537
2538                // Important events: show from today through next 2 weeks
2539                if ($isImportant && $dateKey >= $todayStr) {
2540                    $importantEvents[] = array_merge($event, ['date' => $dateKey]);
2541                }
2542            }
2543        }
2544
2545        // Sort Important Events by date (earliest first)
2546        usort($importantEvents, function($a, $b) {
2547            $dateA = isset($a['date']) ? $a['date'] : '';
2548            $dateB = isset($b['date']) ? $b['date'] : '';
2549
2550            // Compare dates
2551            if ($dateA === $dateB) {
2552                // Same date - sort by time
2553                $timeA = isset($a['time']) ? $a['time'] : '';
2554                $timeB = isset($b['time']) ? $b['time'] : '';
2555
2556                if (empty($timeA) && !empty($timeB)) return 1;  // All-day events last
2557                if (!empty($timeA) && empty($timeB)) return -1;
2558                if (empty($timeA) && empty($timeB)) return 0;
2559
2560                // Both have times
2561                $aMinutes = $this->timeToMinutes($timeA);
2562                $bMinutes = $this->timeToMinutes($timeB);
2563                return $aMinutes - $bMinutes;
2564            }
2565
2566            return strcmp($dateA, $dateB);
2567        });
2568
2569        // Get theme - prefer override from syntax parameter, fall back to admin default
2570        $theme = !empty($themeOverride) ? $themeOverride : $this->getSidebarTheme();
2571        $themeStyles = $this->getSidebarThemeStyles($theme);
2572        $themeClass = 'sidebar-' . $theme;
2573
2574        // Start building HTML - Dynamic width with default font (overflow:visible for tooltips)
2575        $html = '<div class="sidebar-widget ' . $themeClass . '" id="sidebar-widget-' . $calId . '" style="width:100%; max-width:100%; box-sizing:border-box; font-family:system-ui, sans-serif; background:' . $themeStyles['bg'] . '; border:2px solid ' . $themeStyles['border'] . '; border-radius:4px; overflow:visible; box-shadow:0 0 10px ' . $themeStyles['shadow'] . '; position:relative;">';
2576
2577        // Inject CSS variables so the event dialog (shared component) picks up the theme
2578        $btnTextColor = ($theme === 'professional') ? '#fff' : $themeStyles['bg'];
2579        $html .= '<style>
2580        #sidebar-widget-' . $calId . ' {
2581            --background-site: ' . $themeStyles['bg'] . ';
2582            --background-alt: ' . $themeStyles['cell_bg'] . ';
2583            --background-header: ' . $themeStyles['header_bg'] . ';
2584            --text-primary: ' . $themeStyles['text_primary'] . ';
2585            --text-dim: ' . $themeStyles['text_dim'] . ';
2586            --text-bright: ' . $themeStyles['text_bright'] . ';
2587            --border-color: ' . $themeStyles['grid_border'] . ';
2588            --border-main: ' . $themeStyles['border'] . ';
2589            --cell-bg: ' . $themeStyles['cell_bg'] . ';
2590            --cell-today-bg: ' . $themeStyles['cell_today_bg'] . ';
2591            --shadow-color: ' . $themeStyles['shadow'] . ';
2592            --header-border: ' . $themeStyles['header_border'] . ';
2593            --header-shadow: ' . $themeStyles['header_shadow'] . ';
2594            --grid-bg: ' . $themeStyles['grid_bg'] . ';
2595            --btn-text: ' . $btnTextColor . ';
2596            --pastdue-color: ' . $themeStyles['pastdue_color'] . ';
2597            --pastdue-bg: ' . $themeStyles['pastdue_bg'] . ';
2598            --pastdue-bg-strong: ' . $themeStyles['pastdue_bg_strong'] . ';
2599            --pastdue-bg-light: ' . $themeStyles['pastdue_bg_light'] . ';
2600            --tomorrow-bg: ' . $themeStyles['tomorrow_bg'] . ';
2601            --tomorrow-bg-strong: ' . $themeStyles['tomorrow_bg_strong'] . ';
2602            --tomorrow-bg-light: ' . $themeStyles['tomorrow_bg_light'] . ';
2603        }
2604        </style>';
2605
2606        // Add sparkle effect for pink theme
2607        if ($theme === 'pink') {
2608            $html .= '<style>
2609            @keyframes sparkle-' . $calId . ' {
2610                0% {
2611                    opacity: 0;
2612                    transform: translate(0, 0) scale(0) rotate(0deg);
2613                }
2614                50% {
2615                    opacity: 1;
2616                    transform: translate(var(--tx), var(--ty)) scale(1) rotate(180deg);
2617                }
2618                100% {
2619                    opacity: 0;
2620                    transform: translate(calc(var(--tx) * 2), calc(var(--ty) * 2)) scale(0) rotate(360deg);
2621                }
2622            }
2623
2624            @keyframes pulse-glow-' . $calId . ' {
2625                0%, 100% { box-shadow: 0 0 10px rgba(255, 20, 147, 0.4); }
2626                50% { box-shadow: 0 0 25px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4); }
2627            }
2628
2629            @keyframes shimmer-' . $calId . ' {
2630                0% { background-position: -200% center; }
2631                100% { background-position: 200% center; }
2632            }
2633
2634            .sidebar-pink {
2635                animation: pulse-glow-' . $calId . ' 3s ease-in-out infinite;
2636            }
2637
2638            .sidebar-pink:hover {
2639                box-shadow: 0 0 30px rgba(255, 20, 147, 0.9), 0 0 50px rgba(255, 20, 147, 0.5) !important;
2640            }
2641
2642            .sparkle-' . $calId . ' {
2643                position: absolute;
2644                pointer-events: none;
2645                font-size: 20px;
2646                z-index: 1000;
2647                animation: sparkle-' . $calId . ' 1s ease-out forwards;
2648                filter: drop-shadow(0 0 3px rgba(255, 20, 147, 0.8));
2649            }
2650            </style>';
2651
2652            $html .= '<script>
2653            (function() {
2654                const container = document.getElementById("sidebar-widget-' . $calId . '");
2655                const sparkles = ["✨", "��", "��", "⭐", "��", "��", "��", "��", "��", "��"];
2656
2657                function createSparkle(x, y) {
2658                    const sparkle = document.createElement("div");
2659                    sparkle.className = "sparkle-' . $calId . '";
2660                    sparkle.textContent = sparkles[Math.floor(Math.random() * sparkles.length)];
2661                    sparkle.style.left = x + "px";
2662                    sparkle.style.top = y + "px";
2663
2664                    // Random direction
2665                    const angle = Math.random() * Math.PI * 2;
2666                    const distance = 30 + Math.random() * 40;
2667                    sparkle.style.setProperty("--tx", Math.cos(angle) * distance + "px");
2668                    sparkle.style.setProperty("--ty", Math.sin(angle) * distance + "px");
2669
2670                    container.appendChild(sparkle);
2671
2672                    setTimeout(() => sparkle.remove(), 1000);
2673                }
2674
2675                // Click sparkles
2676                container.addEventListener("click", function(e) {
2677                    const rect = container.getBoundingClientRect();
2678                    const x = e.clientX - rect.left;
2679                    const y = e.clientY - rect.top;
2680
2681                    // Create LOTS of sparkles for maximum bling!
2682                    for (let i = 0; i < 8; i++) {
2683                        setTimeout(() => {
2684                            const offsetX = x + (Math.random() - 0.5) * 30;
2685                            const offsetY = y + (Math.random() - 0.5) * 30;
2686                            createSparkle(offsetX, offsetY);
2687                        }, i * 40);
2688                    }
2689                });
2690
2691                // Random auto-sparkles for extra glamour
2692                setInterval(() => {
2693                    const x = Math.random() * container.offsetWidth;
2694                    const y = Math.random() * container.offsetHeight;
2695                    createSparkle(x, y);
2696                }, 3000);
2697            })();
2698            </script>';
2699        }
2700
2701        // Sanitize calId for use in JavaScript variable names (remove dashes)
2702        $jsCalId = str_replace('-', '_', $calId);
2703
2704        // CRITICAL: Add ALL JavaScript FIRST before any HTML that uses it
2705        $html .= '<script>
2706(function() {
2707    // Update clock every second
2708    function updateClock() {
2709        const now = new Date();
2710        let hours = now.getHours();
2711        const minutes = String(now.getMinutes()).padStart(2, "0");
2712        const seconds = String(now.getSeconds()).padStart(2, "0");
2713        const ampm = hours >= 12 ? "PM" : "AM";
2714        hours = hours % 12 || 12;
2715        const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm;
2716        const clockEl = document.getElementById("clock-' . $calId . '");
2717        if (clockEl) clockEl.textContent = timeStr;
2718    }
2719    setInterval(updateClock, 1000);
2720
2721    // Weather - uses default location, click weather to get local
2722    var userLocationGranted = false;
2723    var userLat = 38.5816;  // Sacramento default
2724    var userLon = -121.4944;
2725
2726    function fetchWeatherData(lat, lon) {
2727        fetch("https://api.open-meteo.com/v1/forecast?latitude=" + lat + "&longitude=" + lon + "&current_weather=true&temperature_unit=fahrenheit")
2728            .then(response => response.json())
2729            .then(data => {
2730                if (data.current_weather) {
2731                    const temp = Math.round(data.current_weather.temperature);
2732                    const weatherCode = data.current_weather.weathercode;
2733                    const icon = getWeatherIcon(weatherCode);
2734                    const iconEl = document.getElementById("weather-icon-' . $calId . '");
2735                    const tempEl = document.getElementById("weather-temp-' . $calId . '");
2736                    if (iconEl) iconEl.textContent = icon;
2737                    if (tempEl) tempEl.innerHTML = temp + "&deg;";
2738                }
2739            })
2740            .catch(error => console.log("Weather fetch error:", error));
2741    }
2742
2743    function updateWeather() {
2744        fetchWeatherData(userLat, userLon);
2745    }
2746
2747    // Click weather icon to request local weather (user gesture required)
2748    function requestLocalWeather() {
2749        if (userLocationGranted) return;
2750        if ("geolocation" in navigator) {
2751            navigator.geolocation.getCurrentPosition(function(position) {
2752                userLat = position.coords.latitude;
2753                userLon = position.coords.longitude;
2754                userLocationGranted = true;
2755                fetchWeatherData(userLat, userLon);
2756            }, function(error) {
2757                console.log("Geolocation denied, using default location");
2758            });
2759        }
2760    }
2761
2762    setTimeout(function() {
2763        var weatherEl = document.querySelector("#weather-icon-' . $calId . '");
2764        if (weatherEl) {
2765            weatherEl.style.cursor = "pointer";
2766            weatherEl.title = "Click for local weather";
2767            weatherEl.addEventListener("click", requestLocalWeather);
2768        }
2769    }, 100);
2770
2771    function getWeatherIcon(code) {
2772        const icons = {
2773            0: "☀️", 1: "��️", 2: "⛅", 3: "☁️",
2774            45: "��️", 48: "��️", 51: "��️", 53: "��️", 55: "��️",
2775            61: "��️", 63: "��️", 65: "⛈️", 71: "��️", 73: "��️",
2776            75: "❄️", 77: "��️", 80: "��️", 81: "��️", 82: "⛈️",
2777            85: "��️", 86: "❄️", 95: "⛈️", 96: "⛈️", 99: "⛈️"
2778        };
2779        return icons[code] || "��️";
2780    }
2781
2782    // Update weather immediately and every 10 minutes
2783    updateWeather();
2784    setInterval(updateWeather, 600000);
2785})();
2786</script>';
2787
2788        // NOW add the header HTML (after JavaScript is defined)
2789        $todayDate = new DateTime();
2790        $displayDate = $todayDate->format('D, M j, Y');
2791        $currentTime = $todayDate->format('g:i:s A');
2792
2793        $html .= '<div class="eventlist-today-header" style="background:' . $themeStyles['header_bg'] . '; border:2px solid ' . $themeStyles['header_border'] . '; box-shadow:' . $themeStyles['header_shadow'] . ';">';
2794        $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '" style="color:' . $themeStyles['text_bright'] . ';">' . $currentTime . '</span>';
2795        $html .= '<div class="eventlist-bottom-info">';
2796        $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">��️</span> <span id="weather-temp-' . $calId . '" style="color:' . $themeStyles['text_primary'] . ';">--°</span></span>';
2797        $html .= '<span class="eventlist-today-date" style="color:' . $themeStyles['text_dim'] . ';">' . $displayDate . '</span>';
2798        $html .= '</div>';
2799        $html .= '</div>';
2800
2801        // Get today's date for default event date
2802        $todayStr = date('Y-m-d');
2803
2804        // Thin "Add Event" bar between header and week grid - theme-aware colors
2805        $addBtnBg = $themeStyles['cell_today_bg'];
2806        $addBtnHover = $themeStyles['grid_bg'];
2807        $addBtnTextColor = ($theme === 'professional' || $theme === 'wiki') ?
2808                          $themeStyles['text_bright'] : $themeStyles['text_bright'];
2809        $addBtnShadow = ($theme === 'professional' || $theme === 'wiki') ?
2810                       '0 2px 4px rgba(0,0,0,0.2)' : '0 0 8px ' . $themeStyles['shadow'];
2811        $addBtnHoverShadow = ($theme === 'professional' || $theme === 'wiki') ?
2812                            '0 3px 6px rgba(0,0,0,0.3)' : '0 0 12px ' . $themeStyles['shadow'];
2813
2814        $html .= '<div style="background:' . $addBtnBg . '; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 0, 0, 0.1); border-bottom:1px solid rgba(0, 0, 0, 0.1); box-shadow:' . $addBtnShadow . '; transition:all 0.2s;" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\', \'' . $todayStr . '\');" onmouseover="this.style.background=\'' . $addBtnHover . '\'; this.style.boxShadow=\'' . $addBtnHoverShadow . '\';" onmouseout="this.style.background=\'' . $addBtnBg . '\'; this.style.boxShadow=\'' . $addBtnShadow . '\';">';
2815        $addBtnTextShadow = ($theme === 'pink') ? '0 0 3px ' . $addBtnTextColor : 'none';
2816        $html .= '<span style="color:' . $addBtnTextColor . '; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:' . $addBtnTextShadow . '; position:relative; top:-1px;">+ ADD EVENT</span>';
2817        $html .= '</div>';
2818
2819        // Week grid (7 cells)
2820        $html .= $this->renderWeekGrid($weekEvents, $weekStart, $themeStyles, $theme);
2821
2822        // Section colors - derived from theme palette
2823        // Today: brightest accent, Tomorrow: primary accent, Important: dim/secondary accent
2824        if ($theme === 'matrix') {
2825            $todayColor = '#00ff00';     // Bright green
2826            $tomorrowColor = '#00cc07';  // Standard green
2827            $importantColor = '#00aa00'; // Dim green
2828        } else if ($theme === 'purple') {
2829            $todayColor = '#d4a5ff';     // Bright purple
2830            $tomorrowColor = '#9b59b6';  // Standard purple
2831            $importantColor = '#8e7ab8'; // Dim purple
2832        } else if ($theme === 'pink') {
2833            $todayColor = '#ff1493';     // Hot pink
2834            $tomorrowColor = '#ff69b4';  // Medium pink
2835            $importantColor = '#ff85c1'; // Light pink
2836        } else if ($theme === 'professional') {
2837            $todayColor = '#4a90e2';     // Blue accent
2838            $tomorrowColor = '#5ba3e6';  // Lighter blue
2839            $importantColor = '#7fb8ec'; // Lightest blue
2840        } else {
2841            // Wiki - section header backgrounds from template colors
2842            $todayColor = $themeStyles['text_bright'];      // __link__
2843            $tomorrowColor = $themeStyles['header_bg'];     // __background_alt__
2844            $importantColor = $themeStyles['header_border'];// __border__
2845        }
2846
2847        // Check if there are any itinerary items
2848        $hasItinerary = !empty($todayEvents) || !empty($tomorrowEvents) || !empty($importantEvents);
2849
2850        // Itinerary bar (collapsible toggle) - styled like +Add bar
2851        $itineraryBg = $themeStyles['cell_today_bg'];
2852        $itineraryHover = $themeStyles['grid_bg'];
2853        $itineraryTextColor = ($theme === 'professional' || $theme === 'wiki') ?
2854                              $themeStyles['text_bright'] : $themeStyles['text_bright'];
2855        $itineraryShadow = ($theme === 'professional' || $theme === 'wiki') ?
2856                           '0 2px 4px rgba(0,0,0,0.2)' : '0 0 8px ' . $themeStyles['shadow'];
2857        $itineraryHoverShadow = ($theme === 'professional' || $theme === 'wiki') ?
2858                                '0 3px 6px rgba(0,0,0,0.3)' : '0 0 12px ' . $themeStyles['shadow'];
2859        $itineraryTextShadow = ($theme === 'pink') ? '0 0 3px ' . $itineraryTextColor : 'none';
2860
2861        // Sanitize calId for JavaScript
2862        $jsCalId = str_replace('-', '_', $calId);
2863
2864        // Get itinerary default state from settings
2865        $itineraryDefaultCollapsed = $this->getItineraryCollapsed();
2866        $arrowDefaultStyle = $itineraryDefaultCollapsed ? 'transform:rotate(-90deg);' : '';
2867        $contentDefaultStyle = $itineraryDefaultCollapsed ? 'max-height:0px; opacity:0;' : '';
2868
2869        $html .= '<div id="itinerary-bar-' . $calId . '" style="background:' . $itineraryBg . '; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 0, 0, 0.1); border-bottom:1px solid rgba(0, 0, 0, 0.1); box-shadow:' . $itineraryShadow . '; transition:all 0.2s; display:flex; align-items:center; justify-content:center; gap:4px;" onclick="toggleItinerary_' . $jsCalId . '();" onmouseover="this.style.background=\'' . $itineraryHover . '\'; this.style.boxShadow=\'' . $itineraryHoverShadow . '\';" onmouseout="this.style.background=\'' . $itineraryBg . '\'; this.style.boxShadow=\'' . $itineraryShadow . '\';">';
2870        $html .= '<span id="itinerary-arrow-' . $calId . '" style="color:' . $itineraryTextColor . '; font-size:6px; font-weight:700; font-family:system-ui, sans-serif; text-shadow:' . $itineraryTextShadow . '; position:relative; top:-1px; transition:transform 0.2s; ' . $arrowDefaultStyle . '">▼</span>';
2871        $html .= '<span style="color:' . $itineraryTextColor . '; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:' . $itineraryTextShadow . '; position:relative; top:-1px;">ITINERARY</span>';
2872        $html .= '</div>';
2873
2874        // Itinerary content container (collapsible)
2875        $html .= '<div id="itinerary-content-' . $calId . '" style="transition:max-height 0.3s ease-out, opacity 0.2s ease-out; overflow:hidden; ' . $contentDefaultStyle . '">';
2876
2877        // Today section
2878        if (!empty($todayEvents)) {
2879            $html .= $this->renderSidebarSection('Today', $todayEvents, $todayColor, $calId, $themeStyles, $theme, $importantNsList);
2880        }
2881
2882        // Tomorrow section
2883        if (!empty($tomorrowEvents)) {
2884            $html .= $this->renderSidebarSection('Tomorrow', $tomorrowEvents, $tomorrowColor, $calId, $themeStyles, $theme, $importantNsList);
2885        }
2886
2887        // Important events section
2888        if (!empty($importantEvents)) {
2889            $html .= $this->renderSidebarSection('Important Events', $importantEvents, $importantColor, $calId, $themeStyles, $theme, $importantNsList);
2890        }
2891
2892        // Empty state if no itinerary items
2893        if (!$hasItinerary) {
2894            $html .= '<div style="padding:8px; text-align:center; color:' . $themeStyles['text_dim'] . '; font-size:10px; font-family:system-ui, sans-serif;">No upcoming events</div>';
2895        }
2896
2897        $html .= '</div>'; // Close itinerary-content
2898
2899        // Get itinerary default state from settings
2900        $itineraryDefaultCollapsed = $this->getItineraryCollapsed();
2901        $itineraryExpandedDefault = $itineraryDefaultCollapsed ? 'false' : 'true';
2902        $itineraryArrowDefault = $itineraryDefaultCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
2903        $itineraryContentDefault = $itineraryDefaultCollapsed ? 'max-height:0px; opacity:0;' : 'max-height:none;';
2904
2905        // JavaScript for toggling itinerary
2906        $html .= '<script>
2907        (function() {
2908            let itineraryExpanded_' . $jsCalId . ' = ' . $itineraryExpandedDefault . ';
2909
2910            window.toggleItinerary_' . $jsCalId . ' = function() {
2911                const content = document.getElementById("itinerary-content-' . $calId . '");
2912                const arrow = document.getElementById("itinerary-arrow-' . $calId . '");
2913
2914                if (itineraryExpanded_' . $jsCalId . ') {
2915                    // Collapse
2916                    content.style.maxHeight = "0px";
2917                    content.style.opacity = "0";
2918                    arrow.style.transform = "rotate(-90deg)";
2919                    itineraryExpanded_' . $jsCalId . ' = false;
2920                } else {
2921                    // Expand
2922                    content.style.maxHeight = content.scrollHeight + "px";
2923                    content.style.opacity = "1";
2924                    arrow.style.transform = "rotate(0deg)";
2925                    itineraryExpanded_' . $jsCalId . ' = true;
2926
2927                    // After transition, set to auto for dynamic content
2928                    setTimeout(function() {
2929                        if (itineraryExpanded_' . $jsCalId . ') {
2930                            content.style.maxHeight = "none";
2931                        }
2932                    }, 300);
2933                }
2934            };
2935
2936            // Initialize based on default state
2937            const content = document.getElementById("itinerary-content-' . $calId . '");
2938            const arrow = document.getElementById("itinerary-arrow-' . $calId . '");
2939            if (content && arrow) {
2940                if (' . $itineraryExpandedDefault . ') {
2941                    content.style.maxHeight = "none";
2942                    arrow.style.transform = "rotate(0deg)";
2943                } else {
2944                    content.style.maxHeight = "0px";
2945                    content.style.opacity = "0";
2946                    arrow.style.transform = "rotate(-90deg)";
2947                }
2948            }
2949        })();
2950        </script>';
2951
2952        $html .= '</div>';
2953
2954        // Add event dialog for sidebar widget
2955        $html .= $this->renderEventDialog($calId, $namespace, $theme);
2956
2957        // Add JavaScript for positioning data-tooltip elements
2958        $html .= '<script>
2959        // Position data-tooltip elements to prevent cutoff (up and to the LEFT)
2960        document.addEventListener("DOMContentLoaded", function() {
2961            const tooltipElements = document.querySelectorAll("[data-tooltip]");
2962            const isPinkTheme = document.querySelector(".sidebar-pink") !== null;
2963
2964            tooltipElements.forEach(function(element) {
2965                element.addEventListener("mouseenter", function() {
2966                    const rect = element.getBoundingClientRect();
2967                    const style = window.getComputedStyle(element, ":before");
2968
2969                    // Position above the element, aligned to LEFT (not right)
2970                    element.style.setProperty("--tooltip-left", (rect.left - 150) + "px");
2971                    element.style.setProperty("--tooltip-top", (rect.top - 30) + "px");
2972
2973                    // Pink theme: position heart to the right of tooltip
2974                    if (isPinkTheme) {
2975                        element.style.setProperty("--heart-left", (rect.left - 150 + 210) + "px");
2976                        element.style.setProperty("--heart-top", (rect.top - 30) + "px");
2977                    }
2978                });
2979            });
2980        });
2981
2982        // Apply custom properties to position tooltips
2983        const style = document.createElement("style");
2984        style.textContent = `
2985            [data-tooltip]:hover:before {
2986                left: var(--tooltip-left, 0) !important;
2987                top: var(--tooltip-top, 0) !important;
2988            }
2989            .sidebar-pink [data-tooltip]:hover:after {
2990                left: var(--heart-left, 0) !important;
2991                top: var(--heart-top, 0) !important;
2992            }
2993        `;
2994        document.head.appendChild(style);
2995        </script>';
2996
2997        return $html;
2998    }
2999
3000    /**
3001     * Render compact week grid (7 cells with event bars) - Theme-aware
3002     */
3003    private function renderWeekGrid($weekEvents, $weekStart, $themeStyles, $theme) {
3004        // Generate unique ID for this calendar instance - sanitize for JavaScript
3005        $calId = 'cal_' . substr(md5($weekStart . microtime()), 0, 8);
3006        $jsCalId = str_replace('-', '_', $calId);  // Sanitize for JS variable names
3007
3008        $html = '<div style="display:grid; grid-template-columns:repeat(7, 1fr); gap:1px; background:' . $themeStyles['grid_bg'] . '; border-bottom:2px solid ' . $themeStyles['grid_border'] . ';">';
3009
3010        // Day names depend on week start setting
3011        $weekStartDay = $this->getWeekStartDay();
3012        if ($weekStartDay === 'monday') {
3013            $dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];  // Monday to Sunday
3014        } else {
3015            $dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];  // Sunday to Saturday
3016        }
3017        $today = date('Y-m-d');
3018
3019        for ($i = 0; $i < 7; $i++) {
3020            $date = date('Y-m-d', strtotime($weekStart . ' +' . $i . ' days'));
3021            $dayNum = date('j', strtotime($date));
3022            $isToday = $date === $today;
3023
3024            $events = isset($weekEvents[$date]) ? $weekEvents[$date] : [];
3025            $eventCount = count($events);
3026
3027            $bgColor = $isToday ? $themeStyles['cell_today_bg'] : $themeStyles['cell_bg'];
3028            $textColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary'];
3029            $fontWeight = $isToday ? '700' : '500';
3030
3031            // Theme-aware text shadow
3032            if ($theme === 'pink') {
3033                $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary'];
3034                $textShadow = $isToday ? 'text-shadow:0 0 3px ' . $glowColor . ';' : 'text-shadow:0 0 2px ' . $glowColor . ';';
3035            } else if ($theme === 'matrix') {
3036                $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary'];
3037                $textShadow = $isToday ? 'text-shadow:0 0 2px ' . $glowColor . ';' : 'text-shadow:0 0 1px ' . $glowColor . ';';
3038            } else if ($theme === 'purple') {
3039                $glowColor = $isToday ? $themeStyles['text_bright'] : $themeStyles['text_primary'];
3040                $textShadow = $isToday ? 'text-shadow:0 0 2px ' . $glowColor . ';' : 'text-shadow:0 0 1px ' . $glowColor . ';';
3041            } else {
3042                $textShadow = '';  // No glow for professional/wiki
3043            }
3044
3045            // Border color based on theme
3046            $borderColor = $themeStyles['grid_border'];
3047
3048            $hasEvents = $eventCount > 0;
3049            $clickableStyle = $hasEvents ? 'cursor:pointer;' : '';
3050            $clickHandler = $hasEvents ? ' onclick="showDayEvents_' . $jsCalId . '(\'' . $date . '\')"' : '';
3051
3052            $html .= '<div style="background:' . $bgColor . '; padding:4px 2px; text-align:center; min-height:45px; position:relative; border:1px solid ' . $borderColor . ' !important; ' . $clickableStyle . '" ' . $clickHandler . '>';
3053
3054            // Day letter - theme color
3055            $dayLetterColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary'];
3056            $html .= '<div style="font-size:9px; color:' . $dayLetterColor . '; font-weight:500; font-family:system-ui, sans-serif;">' . $dayNames[$i] . '</div>';
3057
3058            // Day number
3059            $html .= '<div style="font-size:12px; color:' . $textColor . '; font-weight:' . $fontWeight . '; margin:2px 0; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNum . '</div>';
3060
3061            // Event bars (max 4 visible) with theme-aware glow
3062            if ($eventCount > 0) {
3063                $showCount = min($eventCount, 4);
3064                for ($j = 0; $j < $showCount; $j++) {
3065                    $event = $events[$j];
3066                    $color = isset($event['color']) ? $event['color'] : $themeStyles['text_primary'];
3067                    $barShadow = $theme === 'professional' ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . htmlspecialchars($color);
3068                    $html .= '<div style="height:2px; background:' . htmlspecialchars($color) . '; margin:1px 0; border-radius:1px; box-shadow:' . $barShadow . ';"></div>';
3069                }
3070
3071                // Show "+N more" if more than 4 - theme color
3072                if ($eventCount > 4) {
3073                    $moreTextColor = $theme === 'professional' ? '#7f8c8d' : $themeStyles['text_primary'];
3074                    $html .= '<div style="font-size:7px; color:' . $moreTextColor . '; margin-top:1px; font-family:system-ui, sans-serif;">+' . ($eventCount - 4) . '</div>';
3075                }
3076            }
3077
3078            $html .= '</div>';
3079        }
3080
3081        $html .= '</div>';
3082
3083        // Add container for selected day events display (with unique ID) - theme-aware
3084        $panelBorderColor = $themeStyles['border'];
3085        $panelHeaderBg = $themeStyles['border'];
3086        $panelShadow = ($theme === 'professional' || $theme === 'wiki') ?
3087                      '0 1px 3px rgba(0, 0, 0, 0.1)' :
3088                      '0 0 5px ' . $themeStyles['shadow'];
3089        $panelContentBg = ($theme === 'professional') ? 'rgba(255, 255, 255, 0.95)' :
3090                         ($theme === 'wiki' ? $themeStyles['cell_bg'] : 'rgba(36, 36, 36, 0.5)');
3091        $panelHeaderShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $panelHeaderBg;
3092
3093        // Header text color - dark bg text for dark themes, white for light theme accent headers
3094        $panelHeaderColor = ($theme === 'matrix' || $theme === 'purple' || $theme === 'pink') ? $themeStyles['bg'] :
3095                            (($theme === 'wiki') ? $themeStyles['text_primary'] : '#fff');
3096
3097        $html .= '<div id="selected-day-events-' . $calId . '" style="display:none; margin:8px 4px; border-left:3px solid ' . $panelBorderColor . ($theme === 'wiki' ? '' : ' !important') . '; box-shadow:' . $panelShadow . ';">';
3098        if ($theme === 'wiki') {
3099            $html .= '<div style="background:' . $panelHeaderBg . '; color:' . $panelHeaderColor . '; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $panelHeaderShadow . '; display:flex; justify-content:space-between; align-items:center;">';
3100            $html .= '<span id="selected-day-title-' . $calId . '"></span>';
3101            $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700; color:' . $panelHeaderColor . ';">✕</span>';
3102        } else {
3103            $html .= '<div style="background:' . $panelHeaderBg . ' !important; color:' . $panelHeaderColor . ' !important; -webkit-text-fill-color:' . $panelHeaderColor . ' !important; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $panelHeaderShadow . '; display:flex; justify-content:space-between; align-items:center;">';
3104            $html .= '<span id="selected-day-title-' . $calId . '"></span>';
3105            $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700; color:' . $panelHeaderColor . ' !important; -webkit-text-fill-color:' . $panelHeaderColor . ' !important;">✕</span>';
3106        }
3107        $html .= '</div>';
3108        $html .= '<div id="selected-day-content-' . $calId . '" style="padding:4px 0; background:' . $panelContentBg . ';"></div>';
3109        $html .= '</div>';
3110
3111        // Add JavaScript for day selection with event data
3112        $html .= '<script>';
3113        // Sanitize calId for JavaScript variable names
3114        $jsCalId = str_replace('-', '_', $calId);
3115        $html .= 'window.weekEventsData_' . $jsCalId . ' = ' . json_encode($weekEvents) . ';';
3116
3117        // Pass theme colors to JavaScript
3118        $jsThemeColors = json_encode([
3119            'text_primary' => $themeStyles['text_primary'],
3120            'text_bright' => $themeStyles['text_bright'],
3121            'text_dim' => $themeStyles['text_dim'],
3122            'text_shadow' => ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $themeStyles['text_primary'] :
3123                             ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $themeStyles['text_primary'] : ''),
3124            'event_bg' => $theme === 'professional' ? 'rgba(255, 255, 255, 0.5)' :
3125                         ($theme === 'wiki' ? $themeStyles['cell_bg'] : 'rgba(36, 36, 36, 0.3)'),
3126            'border_color' => $theme === 'professional' ? 'rgba(0, 0, 0, 0.1)' :
3127                             ($theme === 'purple' ? 'rgba(155, 89, 182, 0.2)' :
3128                             ($theme === 'pink' ? 'rgba(255, 20, 147, 0.3)' :
3129                             ($theme === 'wiki' ? $themeStyles['grid_border'] : 'rgba(0, 204, 7, 0.2)'))),
3130            'bar_shadow' => $theme === 'professional' ? '0 1px 2px rgba(0,0,0,0.2)' :
3131                           ($theme === 'wiki' ? '0 1px 2px rgba(0,0,0,0.15)' : '0 0 3px')
3132        ]);
3133        $html .= 'window.themeColors_' . $jsCalId . ' = ' . $jsThemeColors . ';';
3134        $html .= '
3135        window.showDayEvents_' . $jsCalId . ' = function(dateKey) {
3136            const eventsData = window.weekEventsData_' . $jsCalId . ';
3137            const container = document.getElementById("selected-day-events-' . $calId . '");
3138            const title = document.getElementById("selected-day-title-' . $calId . '");
3139            const content = document.getElementById("selected-day-content-' . $calId . '");
3140
3141            if (!eventsData[dateKey] || eventsData[dateKey].length === 0) return;
3142
3143            // Format date for display
3144            const dateObj = new Date(dateKey + "T00:00:00");
3145            const dayName = dateObj.toLocaleDateString("en-US", { weekday: "long" });
3146            const monthDay = dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3147            title.textContent = dayName + ", " + monthDay;
3148
3149            // Clear content
3150            content.innerHTML = "";
3151
3152            // Sort events by time (all-day events first, then timed events chronologically)
3153            const sortedEvents = [...eventsData[dateKey]].sort((a, b) => {
3154                // All-day events (no time) go to the beginning
3155                if (!a.time && !b.time) return 0;
3156                if (!a.time) return -1;  // a is all-day, comes first
3157                if (!b.time) return 1;   // b is all-day, comes first
3158
3159                // Compare times (format: "HH:MM")
3160                const timeA = a.time.split(":").map(Number);
3161                const timeB = b.time.split(":").map(Number);
3162                const minutesA = timeA[0] * 60 + timeA[1];
3163                const minutesB = timeB[0] * 60 + timeB[1];
3164
3165                return minutesA - minutesB;
3166            });
3167
3168            // Build events HTML with single color bar (event color only) - theme-aware
3169            const themeColors = window.themeColors_' . $jsCalId . ';
3170            sortedEvents.forEach(event => {
3171                const eventColor = event.color || themeColors.text_primary;
3172
3173                const eventDiv = document.createElement("div");
3174                eventDiv.style.cssText = "padding:4px 6px; border-bottom:1px solid " + themeColors.border_color + "; font-size:10px; display:flex; align-items:stretch; gap:6px; background:" + themeColors.event_bg + "; min-height:20px;";
3175
3176                let eventHTML = "";
3177
3178                // Event assigned color bar (single bar on left) - theme-aware shadow
3179                const barShadow = themeColors.bar_shadow + (themeColors.bar_shadow.includes("rgba") ? "" : " " + eventColor);
3180                eventHTML += "<div style=\\"width:3px; align-self:stretch; background:" + eventColor + "; border-radius:1px; flex-shrink:0; box-shadow:" + barShadow + ";\\"></div>";
3181
3182                // Content wrapper
3183                eventHTML += "<div style=\\"flex:1; min-width:0; display:flex; justify-content:space-between; align-items:start; gap:4px;\\">";
3184
3185                // Left side: event details
3186                eventHTML += "<div style=\\"flex:1; min-width:0;\\">";
3187                eventHTML += "<div style=\\"font-weight:600; color:" + themeColors.text_primary + "; word-wrap:break-word; font-family:system-ui, sans-serif; " + themeColors.text_shadow + ";\\">";
3188
3189                // Time
3190                if (event.time) {
3191                    const timeParts = event.time.split(":");
3192                    let hours = parseInt(timeParts[0]);
3193                    const minutes = timeParts[1];
3194                    const ampm = hours >= 12 ? "PM" : "AM";
3195                    hours = hours % 12 || 12;
3196                    eventHTML += "<span style=\\"color:" + themeColors.text_bright + "; font-weight:500; font-size:9px;\\">" + hours + ":" + minutes + " " + ampm + "</span> ";
3197                }
3198
3199                // Title - use HTML version if available
3200                const titleHTML = event.title_html || event.title || "Untitled";
3201                eventHTML += titleHTML;
3202                eventHTML += "</div>";
3203
3204                // Description if present - use HTML version - theme-aware color
3205                if (event.description_html || event.description) {
3206                    const descHTML = event.description_html || event.description;
3207                    eventHTML += "<div style=\\"font-size:9px; color:" + themeColors.text_dim + "; margin-top:2px;\\">" + descHTML + "</div>";
3208                }
3209
3210                eventHTML += "</div>"; // Close event details
3211
3212                // Right side: conflict badge with tooltip
3213                if (event.conflict) {
3214                    let conflictList = [];
3215                    if (event.conflictingWith && event.conflictingWith.length > 0) {
3216                        event.conflictingWith.forEach(conf => {
3217                            const confTime = conf.time + (conf.end_time ? " - " + conf.end_time : "");
3218                            conflictList.push(conf.title + " (" + confTime + ")");
3219                        });
3220                    }
3221                    const conflictData = btoa(unescape(encodeURIComponent(JSON.stringify(conflictList))));
3222                    eventHTML += "<span class=\\"event-conflict-badge\\" style=\\"font-size:10px;\\" data-conflicts=\\"" + conflictData + "\\" onmouseenter=\\"showConflictTooltip(this)\\" onmouseleave=\\"hideConflictTooltip()\\">⚠️ " + (event.conflictingWith ? event.conflictingWith.length : 1) + "</span>";
3223                }
3224
3225                eventHTML += "</div>"; // Close content wrapper
3226
3227                eventDiv.innerHTML = eventHTML;
3228                content.appendChild(eventDiv);
3229            });
3230
3231            container.style.display = "block";
3232        };
3233        ';
3234        $html .= '</script>';
3235
3236        return $html;
3237    }
3238
3239    /**
3240     * Render a sidebar section (Today/Tomorrow/Important) - Matrix themed with colored borders
3241     */
3242    private function renderSidebarSection($title, $events, $accentColor, $calId, $themeStyles, $theme, $importantNsList = ['important']) {
3243        // Keep the original accent colors for borders
3244        $borderColor = $accentColor;
3245
3246        // Show date for Important Events section
3247        $showDate = ($title === 'Important Events');
3248
3249        // Sort events differently based on section
3250        if ($title === 'Important Events') {
3251            // Important Events: sort by date first, then by time
3252            usort($events, function($a, $b) {
3253                $aDate = isset($a['date']) ? $a['date'] : '';
3254                $bDate = isset($b['date']) ? $b['date'] : '';
3255
3256                // Different dates - sort by date
3257                if ($aDate !== $bDate) {
3258                    return strcmp($aDate, $bDate);
3259                }
3260
3261                // Same date - sort by time
3262                $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : '';
3263                $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : '';
3264
3265                // All-day events last within same date
3266                if (empty($aTime) && !empty($bTime)) return 1;
3267                if (!empty($aTime) && empty($bTime)) return -1;
3268                if (empty($aTime) && empty($bTime)) return 0;
3269
3270                // Both have times
3271                $aMinutes = $this->timeToMinutes($aTime);
3272                $bMinutes = $this->timeToMinutes($bTime);
3273                return $aMinutes - $bMinutes;
3274            });
3275        } else {
3276            // Today/Tomorrow: sort by time only (all same date)
3277            usort($events, function($a, $b) {
3278                $aTime = isset($a['time']) && !empty($a['time']) ? $a['time'] : '';
3279                $bTime = isset($b['time']) && !empty($b['time']) ? $b['time'] : '';
3280
3281                // All-day events (no time) come first
3282                if (empty($aTime) && !empty($bTime)) return -1;
3283                if (!empty($aTime) && empty($bTime)) return 1;
3284                if (empty($aTime) && empty($bTime)) return 0;
3285
3286                // Both have times - convert to minutes for proper chronological sort
3287                $aMinutes = $this->timeToMinutes($aTime);
3288                $bMinutes = $this->timeToMinutes($bTime);
3289
3290                return $aMinutes - $bMinutes;
3291            });
3292        }
3293
3294        // Theme-aware section shadow
3295        $sectionShadow = ($theme === 'professional' || $theme === 'wiki') ?
3296                        '0 1px 3px rgba(0, 0, 0, 0.1)' :
3297                        '0 0 5px ' . $themeStyles['shadow'];
3298
3299        if ($theme === 'wiki') {
3300            // Wiki theme: use a background div for the left bar instead of border-left
3301            // Dark Reader maps border colors differently from background colors, causing mismatch
3302            $html = '<div style="display:flex; margin:8px 4px; box-shadow:' . $sectionShadow . '; background:' . $themeStyles['bg'] . ';">';
3303            $html .= '<div style="width:3px; flex-shrink:0; background:' . $borderColor . ';"></div>';
3304            $html .= '<div style="flex:1; min-width:0;">';
3305        } else {
3306            $html = '<div style="border-left:3px solid ' . $borderColor . ' !important; margin:8px 4px; box-shadow:' . $sectionShadow . ';">';
3307        }
3308
3309        // Section header with accent color background - theme-aware
3310        $headerShadow = ($theme === 'professional' || $theme === 'wiki') ? '0 2px 4px rgba(0, 0, 0, 0.15)' : '0 0 8px ' . $accentColor;
3311        $headerTextColor = ($theme === 'matrix' || $theme === 'purple' || $theme === 'pink') ? $themeStyles['bg'] :
3312                           (($theme === 'wiki') ? $themeStyles['text_primary'] : '#fff');
3313        if ($theme === 'wiki') {
3314            // Wiki theme: no !important — let Dark Reader adjust these
3315            $html .= '<div style="background:' . $accentColor . '; color:' . $headerTextColor . '; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $headerShadow . ';">';
3316        } else {
3317            // Dark themes + professional: lock colors against Dark Reader
3318            $html .= '<div style="background:' . $accentColor . ' !important; color:' . $headerTextColor . ' !important; -webkit-text-fill-color:' . $headerTextColor . ' !important; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:' . $headerShadow . ';">';
3319        }
3320        $html .= htmlspecialchars($title);
3321        $html .= '</div>';
3322
3323        // Events - no background (transparent)
3324        $html .= '<div style="padding:4px 0;">';
3325
3326        foreach ($events as $event) {
3327            $html .= $this->renderSidebarEvent($event, $calId, $showDate, $accentColor, $themeStyles, $theme, $importantNsList);
3328        }
3329
3330        $html .= '</div>';
3331        $html .= '</div>';
3332        if ($theme === 'wiki') {
3333            $html .= '</div>'; // Close flex wrapper
3334        }
3335
3336        return $html;
3337    }
3338
3339    /**
3340     * Render individual event in sidebar - Theme-aware
3341     */
3342    private function renderSidebarEvent($event, $calId, $showDate = false, $sectionColor = '#00cc07', $themeStyles = null, $theme = 'matrix', $importantNsList = ['important']) {
3343        $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
3344        $time = isset($event['time']) ? $event['time'] : '';
3345        $endTime = isset($event['endTime']) ? $event['endTime'] : '';
3346        $eventColor = isset($event['color']) ? htmlspecialchars($event['color']) : ($themeStyles ? $themeStyles['text_primary'] : '#00cc07');
3347        $date = isset($event['date']) ? $event['date'] : '';
3348        $isTask = isset($event['isTask']) && $event['isTask'];
3349        $completed = isset($event['completed']) && $event['completed'];
3350
3351        // Check if this is an important namespace event
3352        $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
3353        $isImportantNs = false;
3354        foreach ($importantNsList as $impNs) {
3355            if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
3356                $isImportantNs = true;
3357                break;
3358            }
3359        }
3360
3361        // Theme-aware colors
3362        $titleColor = $themeStyles ? $themeStyles['text_primary'] : '#00cc07';
3363        $timeColor = $themeStyles ? $themeStyles['text_bright'] : '#00dd00';
3364        $textShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $titleColor . ';' :
3365                      ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $titleColor . ';' : '');
3366
3367        // Check for conflicts (using 'conflict' field set by detectTimeConflicts)
3368        $hasConflict = isset($event['conflict']) && $event['conflict'];
3369        $conflictingWith = isset($event['conflictingWith']) ? $event['conflictingWith'] : [];
3370
3371        // Build conflict list for tooltip
3372        $conflictList = [];
3373        if ($hasConflict && !empty($conflictingWith)) {
3374            foreach ($conflictingWith as $conf) {
3375                $confTime = $this->formatTimeDisplay($conf['time'], isset($conf['end_time']) ? $conf['end_time'] : '');
3376                $conflictList[] = $conf['title'] . ' (' . $confTime . ')';
3377            }
3378        }
3379
3380        // No background on individual events (transparent) - unless important namespace
3381        // Use theme grid_border with slight opacity for subtle divider
3382        $borderColor = $themeStyles['grid_border'];
3383
3384        // Important namespace highlighting - subtle themed background
3385        $importantBg = '';
3386        $importantBorder = '';
3387        if ($isImportantNs) {
3388            // Theme-specific important highlighting
3389            switch ($theme) {
3390                case 'matrix':
3391                    $importantBg = 'background:rgba(0,204,7,0.08);';
3392                    $importantBorder = 'border-right:2px solid rgba(0,204,7,0.4);';
3393                    break;
3394                case 'purple':
3395                    $importantBg = 'background:rgba(156,39,176,0.08);';
3396                    $importantBorder = 'border-right:2px solid rgba(156,39,176,0.4);';
3397                    break;
3398                case 'pink':
3399                    $importantBg = 'background:rgba(255,105,180,0.1);';
3400                    $importantBorder = 'border-right:2px solid rgba(255,105,180,0.5);';
3401                    break;
3402                case 'professional':
3403                    $importantBg = 'background:rgba(33,150,243,0.08);';
3404                    $importantBorder = 'border-right:2px solid rgba(33,150,243,0.4);';
3405                    break;
3406                case 'wiki':
3407                    $importantBg = 'background:rgba(0,102,204,0.06);';
3408                    $importantBorder = 'border-right:2px solid rgba(0,102,204,0.3);';
3409                    break;
3410                default:
3411                    $importantBg = 'background:rgba(0,204,7,0.08);';
3412                    $importantBorder = 'border-right:2px solid rgba(0,204,7,0.4);';
3413            }
3414        }
3415
3416        $html = '<div style="padding:4px 6px; border-bottom:1px solid ' . $borderColor . ' !important; font-size:10px; display:flex; align-items:stretch; gap:6px; min-height:20px; ' . $importantBg . $importantBorder . '">';
3417
3418        // Event's assigned color bar (single bar on the left)
3419        $barShadow = ($theme === 'professional') ? '0 1px 2px rgba(0,0,0,0.2)' : '0 0 3px ' . $eventColor;
3420        $html .= '<div style="width:3px; align-self:stretch; background:' . $eventColor . '; border-radius:1px; flex-shrink:0; box-shadow:' . $barShadow . ';"></div>';
3421
3422        // Content
3423        $html .= '<div style="flex:1; min-width:0;">';
3424
3425        // Time + title
3426        $html .= '<div style="font-weight:600; color:' . $titleColor . '; word-wrap:break-word; font-family:system-ui, sans-serif; ' . $textShadow . '">';
3427
3428        if ($time) {
3429            $displayTime = $this->formatTimeDisplay($time, $endTime);
3430            $html .= '<span style="color:' . $timeColor . '; font-weight:500; font-size:9px;">' . htmlspecialchars($displayTime) . '</span> ';
3431        }
3432
3433        // Task checkbox
3434        if ($isTask) {
3435            $checkIcon = $completed ? '☑' : '☐';
3436            $checkColor = $themeStyles ? $themeStyles['text_bright'] : '#00ff00';
3437            $html .= '<span style="font-size:11px; color:' . $checkColor . ';">' . $checkIcon . '</span> ';
3438        }
3439
3440        // Important indicator icon for important namespace events
3441        if ($isImportantNs) {
3442            $html .= '<span style="font-size:9px;" title="Important">⭐</span> ';
3443        }
3444
3445        $html .= $title; // Already HTML-escaped on line 2625
3446
3447        // Conflict badge using same system as main calendar
3448        if ($hasConflict && !empty($conflictList)) {
3449            $conflictJson = base64_encode(json_encode($conflictList));
3450            $html .= ' <span class="event-conflict-badge" style="font-size:10px;" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . count($conflictList) . '</span>';
3451        }
3452
3453        $html .= '</div>';
3454
3455        // Date display BELOW event name for Important events
3456        if ($showDate && $date) {
3457            $dateObj = new DateTime($date);
3458            $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Feb 10"
3459            $dateColor = $themeStyles ? $themeStyles['text_dim'] : '#00aa00';
3460            $dateShadow = ($theme === 'pink') ? 'text-shadow:0 0 2px ' . $dateColor . ';' :
3461                          ((in_array($theme, ['matrix', 'purple'])) ? 'text-shadow:0 0 1px ' . $dateColor . ';' : '');
3462            $html .= '<div style="font-size:8px; color:' . $dateColor . '; font-weight:500; margin-top:2px; ' . $dateShadow . '">' . htmlspecialchars($displayDate) . '</div>';
3463        }
3464
3465        $html .= '</div>';
3466        $html .= '</div>';
3467
3468        return $html;
3469    }
3470
3471    /**
3472     * Format time display (12-hour format with optional end time)
3473     */
3474    private function formatTimeDisplay($startTime, $endTime = '') {
3475        // Convert start time
3476        list($hour, $minute) = explode(':', $startTime);
3477        $hour = (int)$hour;
3478        $ampm = $hour >= 12 ? 'PM' : 'AM';
3479        $displayHour = $hour % 12;
3480        if ($displayHour === 0) $displayHour = 12;
3481
3482        $display = $displayHour . ':' . $minute . ' ' . $ampm;
3483
3484        // Add end time if provided
3485        if ($endTime && $endTime !== '') {
3486            list($endHour, $endMinute) = explode(':', $endTime);
3487            $endHour = (int)$endHour;
3488            $endAmpm = $endHour >= 12 ? 'PM' : 'AM';
3489            $endDisplayHour = $endHour % 12;
3490            if ($endDisplayHour === 0) $endDisplayHour = 12;
3491
3492            $display .= '-' . $endDisplayHour . ':' . $endMinute . ' ' . $endAmpm;
3493        }
3494
3495        return $display;
3496    }
3497
3498    /**
3499     * Detect time conflicts among events on the same day
3500     * Returns events array with 'conflict' flag and 'conflictingWith' array
3501     */
3502    private function detectTimeConflicts($dayEvents) {
3503        if (empty($dayEvents)) {
3504            return $dayEvents;
3505        }
3506
3507        // If only 1 event, no conflicts possible but still add the flag
3508        if (count($dayEvents) === 1) {
3509            return [array_merge($dayEvents[0], ['conflict' => false, 'conflictingWith' => []])];
3510        }
3511
3512        $eventsWithFlags = [];
3513
3514        foreach ($dayEvents as $i => $event) {
3515            $hasConflict = false;
3516            $conflictingWith = [];
3517
3518            // Skip all-day events (no time)
3519            if (empty($event['time'])) {
3520                $eventsWithFlags[] = array_merge($event, ['conflict' => false, 'conflictingWith' => []]);
3521                continue;
3522            }
3523
3524            // Get this event's time range
3525            $startTime = $event['time'];
3526            // Check both 'end_time' (snake_case) and 'endTime' (camelCase) for compatibility
3527            $endTime = '';
3528            if (isset($event['end_time']) && $event['end_time'] !== '') {
3529                $endTime = $event['end_time'];
3530            } elseif (isset($event['endTime']) && $event['endTime'] !== '') {
3531                $endTime = $event['endTime'];
3532            } else {
3533                // If no end time, use start time (zero duration) - matches main calendar logic
3534                $endTime = $startTime;
3535            }
3536
3537            // Check against all other events
3538            foreach ($dayEvents as $j => $otherEvent) {
3539                if ($i === $j) continue; // Skip self
3540                if (empty($otherEvent['time'])) continue; // Skip all-day events
3541
3542                $otherStart = $otherEvent['time'];
3543                // Check both field name formats
3544                $otherEnd = '';
3545                if (isset($otherEvent['end_time']) && $otherEvent['end_time'] !== '') {
3546                    $otherEnd = $otherEvent['end_time'];
3547                } elseif (isset($otherEvent['endTime']) && $otherEvent['endTime'] !== '') {
3548                    $otherEnd = $otherEvent['endTime'];
3549                } else {
3550                    $otherEnd = $otherStart;
3551                }
3552
3553                // Check for overlap: convert to minutes and compare
3554                $start1Min = $this->timeToMinutes($startTime);
3555                $end1Min = $this->timeToMinutes($endTime);
3556                $start2Min = $this->timeToMinutes($otherStart);
3557                $end2Min = $this->timeToMinutes($otherEnd);
3558
3559                // Overlap if: start1 < end2 AND start2 < end1
3560                // Note: Using < (not <=) so events that just touch at boundaries don't conflict
3561                // e.g., 1:00-2:00 and 2:00-3:00 are NOT in conflict
3562                if ($start1Min < $end2Min && $start2Min < $end1Min) {
3563                    $hasConflict = true;
3564                    $conflictingWith[] = [
3565                        'title' => isset($otherEvent['title']) ? $otherEvent['title'] : 'Untitled',
3566                        'time' => $otherStart,
3567                        'end_time' => $otherEnd
3568                    ];
3569                }
3570            }
3571
3572            $eventsWithFlags[] = array_merge($event, [
3573                'conflict' => $hasConflict,
3574                'conflictingWith' => $conflictingWith
3575            ]);
3576        }
3577
3578        return $eventsWithFlags;
3579    }
3580
3581    /**
3582     * Add hours to a time string
3583     */
3584    private function addHoursToTime($time, $hours) {
3585        $totalMinutes = $this->timeToMinutes($time) + ($hours * 60);
3586        $h = floor($totalMinutes / 60) % 24;
3587        $m = $totalMinutes % 60;
3588        return sprintf('%02d:%02d', $h, $m);
3589    }
3590
3591    /**
3592     * Render DokuWiki syntax to HTML
3593     * Converts **bold**, //italic//, [[links]], etc. to HTML
3594     */
3595    private function renderDokuWikiToHtml($text) {
3596        if (empty($text)) return '';
3597
3598        // Use DokuWiki's parser to render the text
3599        $instructions = p_get_instructions($text);
3600
3601        // Render instructions to XHTML
3602        $xhtml = p_render('xhtml', $instructions, $info);
3603
3604        // Remove surrounding <p> tags if present (we're rendering inline)
3605        $xhtml = preg_replace('/^<p>(.*)<\/p>$/s', '$1', trim($xhtml));
3606
3607        return $xhtml;
3608    }
3609
3610    // Keep old scanForNamespaces for backward compatibility (not used anymore)
3611    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
3612        if (!is_dir($dir)) return;
3613
3614        $items = scandir($dir);
3615        foreach ($items as $item) {
3616            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
3617
3618            $path = $dir . $item;
3619            if (is_dir($path)) {
3620                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
3621                $namespaces[] = $namespace;
3622                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
3623            }
3624        }
3625    }
3626
3627    /**
3628     * Get current sidebar theme
3629     */
3630    private function getSidebarTheme() {
3631        $configFile = $this->metaDir() . 'calendar_theme.txt';
3632        if (file_exists($configFile)) {
3633            $theme = trim(file_get_contents($configFile));
3634            if (in_array($theme, ['matrix', 'purple', 'professional', 'pink', 'wiki'])) {
3635                return $theme;
3636            }
3637        }
3638        return 'matrix'; // Default
3639    }
3640
3641    /**
3642     * Get colors from DokuWiki template's style.ini file
3643     */
3644    private function getWikiTemplateColors() {
3645        global $conf;
3646
3647        // Get current template name
3648        $template = $conf['template'];
3649
3650        // Try multiple possible locations for style.ini (farm-safe)
3651        $possiblePaths = [
3652            DOKU_INC . 'lib/tpl/' . $template . '/style.ini',
3653        ];
3654        // Add farm-specific conf override path if available
3655        if (!empty($conf['savedir'])) {
3656            array_unshift($possiblePaths, $conf['savedir'] . '/tpl/' . $template . '/style.ini');
3657        }
3658        array_unshift($possiblePaths, DOKU_INC . 'conf/tpl/' . $template . '/style.ini');
3659
3660        $styleIni = null;
3661        foreach ($possiblePaths as $path) {
3662            if (file_exists($path)) {
3663                $styleIni = parse_ini_file($path, true);
3664                break;
3665            }
3666        }
3667
3668        if (!$styleIni) {
3669            return null; // Fall back to CSS variables
3670        }
3671
3672        // Extract color replacements
3673        $replacements = isset($styleIni['replacements']) ? $styleIni['replacements'] : [];
3674
3675        // Map style.ini colors to our theme structure
3676        $bgSite = isset($replacements['__background_site__']) ? $replacements['__background_site__'] : '#f5f5f5';
3677        $background = isset($replacements['__background__']) ? $replacements['__background__'] : '#fff';
3678        $bgAlt = isset($replacements['__background_alt__']) ? $replacements['__background_alt__'] : '#e8e8e8';
3679        $bgNeu = isset($replacements['__background_neu__']) ? $replacements['__background_neu__'] : '#eee';
3680        $text = isset($replacements['__text__']) ? $replacements['__text__'] : '#333';
3681        $textAlt = isset($replacements['__text_alt__']) ? $replacements['__text_alt__'] : '#999';
3682        $textNeu = isset($replacements['__text_neu__']) ? $replacements['__text_neu__'] : '#666';
3683        $border = isset($replacements['__border__']) ? $replacements['__border__'] : '#ccc';
3684        $link = isset($replacements['__link__']) ? $replacements['__link__'] : '#2b73b7';
3685        $existing = isset($replacements['__existing__']) ? $replacements['__existing__'] : $link;
3686
3687        // Build theme colors from template colors
3688        // ============================================
3689        // DokuWiki style.ini → Calendar CSS Variable Mapping
3690        // ============================================
3691        //   style.ini key         → CSS variable          → Used for
3692        //   __background_site__   → --background-site     → Container, panel backgrounds
3693        //   __background__        → --cell-bg             → Cell/input backgrounds (typically white)
3694        //   __background_alt__    → --background-alt      → Hover states, header backgrounds
3695        //                         → --background-header
3696        //   __background_neu__    → --cell-today-bg       → Today cell highlight
3697        //   __text__              → --text-primary        → Primary text, labels, titles
3698        //   __text_neu__          → --text-dim            → Secondary text, dates, descriptions
3699        //   __text_alt__          → (not mapped)          → Available for future use
3700        //   __border__            → --border-color        → Grid lines, input borders
3701        //                         → --border-main         → Accent color: buttons, badges, active elements, section headers
3702        //                         → --header-border
3703        //   __link__              → --text-bright         → Links, accent text
3704        //   __existing__          → (fallback to __link__)→ Available for future use
3705        //
3706        // To customize: edit your template's conf/style.ini [replacements]
3707        return [
3708            'bg' => $bgSite,
3709            'border' => $border,         // Accent color from template border
3710            'shadow' => 'rgba(0, 0, 0, 0.1)',
3711            'header_bg' => $bgAlt,       // Headers use alt background
3712            'header_border' => $border,
3713            'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)',
3714            'text_primary' => $text,
3715            'text_bright' => $link,
3716            'text_dim' => $textNeu,
3717            'grid_bg' => $bgSite,
3718            'grid_border' => $border,
3719            'cell_bg' => $background,    // Cells use __background__ (white/light)
3720            'cell_today_bg' => $bgNeu,
3721            'bar_glow' => '0 1px 2px',
3722            'pastdue_color' => '#e74c3c',
3723            'pastdue_bg' => '#ffe6e6',
3724            'pastdue_bg_strong' => '#ffd9d9',
3725            'pastdue_bg_light' => '#fff2f2',
3726            'tomorrow_bg' => '#fff9e6',
3727            'tomorrow_bg_strong' => '#fff4cc',
3728            'tomorrow_bg_light' => '#fffbf0',
3729        ];
3730    }
3731
3732    /**
3733     * Get theme-specific color styles
3734     */
3735    private function getSidebarThemeStyles($theme) {
3736        // For wiki theme, try to read colors from template's style.ini
3737        if ($theme === 'wiki') {
3738            $wikiColors = $this->getWikiTemplateColors();
3739            if (!empty($wikiColors)) {
3740                return $wikiColors;
3741            }
3742            // Fall through to default wiki colors if reading fails
3743        }
3744
3745        $themes = [
3746            'matrix' => [
3747                'bg' => '#242424',
3748                'border' => '#00cc07',
3749                'shadow' => 'rgba(0, 204, 7, 0.3)',
3750                'header_bg' => 'linear-gradient(180deg, #2a2a2a 0%, #242424 100%)',
3751                'header_border' => '#00cc07',
3752                'header_shadow' => '0 2px 8px rgba(0, 204, 7, 0.3)',
3753                'text_primary' => '#00cc07',
3754                'text_bright' => '#00ff00',
3755                'text_dim' => '#00aa00',
3756                'grid_bg' => '#1a3d1a',
3757                'grid_border' => '#00cc07',
3758                'cell_bg' => '#242424',
3759                'cell_today_bg' => '#2a4d2a',
3760                'bar_glow' => '0 0 3px',
3761                'pastdue_color' => '#e74c3c',
3762                'pastdue_bg' => '#3d1a1a',
3763                'pastdue_bg_strong' => '#4d2020',
3764                'pastdue_bg_light' => '#2d1515',
3765                'tomorrow_bg' => '#3d3d1a',
3766                'tomorrow_bg_strong' => '#4d4d20',
3767                'tomorrow_bg_light' => '#2d2d15',
3768            ],
3769            'purple' => [
3770                'bg' => '#2a2030',
3771                'border' => '#9b59b6',
3772                'shadow' => 'rgba(155, 89, 182, 0.3)',
3773                'header_bg' => 'linear-gradient(180deg, #2f2438 0%, #2a2030 100%)',
3774                'header_border' => '#9b59b6',
3775                'header_shadow' => '0 2px 8px rgba(155, 89, 182, 0.3)',
3776                'text_primary' => '#b19cd9',
3777                'text_bright' => '#d4a5ff',
3778                'text_dim' => '#8e7ab8',
3779                'grid_bg' => '#3d2b4d',
3780                'grid_border' => '#9b59b6',
3781                'cell_bg' => '#2a2030',
3782                'cell_today_bg' => '#3d2b4d',
3783                'bar_glow' => '0 0 3px',
3784                'pastdue_color' => '#e74c3c',
3785                'pastdue_bg' => '#3d1a2a',
3786                'pastdue_bg_strong' => '#4d2035',
3787                'pastdue_bg_light' => '#2d1520',
3788                'tomorrow_bg' => '#3d3520',
3789                'tomorrow_bg_strong' => '#4d4028',
3790                'tomorrow_bg_light' => '#2d2a18',
3791            ],
3792            'professional' => [
3793                'bg' => '#f5f7fa',
3794                'border' => '#4a90e2',
3795                'shadow' => 'rgba(74, 144, 226, 0.2)',
3796                'header_bg' => 'linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%)',
3797                'header_border' => '#4a90e2',
3798                'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)',
3799                'text_primary' => '#2c3e50',
3800                'text_bright' => '#4a90e2',
3801                'text_dim' => '#7f8c8d',
3802                'grid_bg' => '#e8ecf1',
3803                'grid_border' => '#d0d7de',
3804                'cell_bg' => '#ffffff',
3805                'cell_today_bg' => '#dce8f7',
3806                'bar_glow' => '0 1px 2px',
3807                'pastdue_color' => '#e74c3c',
3808                'pastdue_bg' => '#ffe6e6',
3809                'pastdue_bg_strong' => '#ffd9d9',
3810                'pastdue_bg_light' => '#fff2f2',
3811                'tomorrow_bg' => '#fff9e6',
3812                'tomorrow_bg_strong' => '#fff4cc',
3813                'tomorrow_bg_light' => '#fffbf0',
3814            ],
3815            'pink' => [
3816                'bg' => '#1a0d14',
3817                'border' => '#ff1493',
3818                'shadow' => 'rgba(255, 20, 147, 0.4)',
3819                'header_bg' => 'linear-gradient(180deg, #2d1a24 0%, #1a0d14 100%)',
3820                'header_border' => '#ff1493',
3821                'header_shadow' => '0 0 12px rgba(255, 20, 147, 0.6)',
3822                'text_primary' => '#ff69b4',
3823                'text_bright' => '#ff1493',
3824                'text_dim' => '#ff85c1',
3825                'grid_bg' => '#2d1a24',
3826                'grid_border' => '#ff1493',
3827                'cell_bg' => '#1a0d14',
3828                'cell_today_bg' => '#3d2030',
3829                'bar_glow' => '0 0 5px',
3830                'pastdue_color' => '#e74c3c',
3831                'pastdue_bg' => '#3d1520',
3832                'pastdue_bg_strong' => '#4d1a28',
3833                'pastdue_bg_light' => '#2d1018',
3834                'tomorrow_bg' => '#3d3020',
3835                'tomorrow_bg_strong' => '#4d3a28',
3836                'tomorrow_bg_light' => '#2d2518',
3837            ],
3838            'wiki' => [
3839                'bg' => '#f5f5f5',
3840                'border' => '#ccc',          // Template __border__ color
3841                'shadow' => 'rgba(0, 0, 0, 0.1)',
3842                'header_bg' => '#e8e8e8',
3843                'header_border' => '#ccc',
3844                'header_shadow' => '0 2px 4px rgba(0, 0, 0, 0.1)',
3845                'text_primary' => '#333',
3846                'text_bright' => '#2b73b7',  // Template __link__ color
3847                'text_dim' => '#666',
3848                'grid_bg' => '#f5f5f5',
3849                'grid_border' => '#ccc',
3850                'cell_bg' => '#fff',
3851                'cell_today_bg' => '#eee',
3852                'bar_glow' => '0 1px 2px',
3853                'pastdue_color' => '#e74c3c',
3854                'pastdue_bg' => '#ffe6e6',
3855                'pastdue_bg_strong' => '#ffd9d9',
3856                'pastdue_bg_light' => '#fff2f2',
3857                'tomorrow_bg' => '#fff9e6',
3858                'tomorrow_bg_strong' => '#fff4cc',
3859                'tomorrow_bg_light' => '#fffbf0',
3860            ],
3861        ];
3862
3863        return isset($themes[$theme]) ? $themes[$theme] : $themes['matrix'];
3864    }
3865
3866    /**
3867     * Get week start day preference
3868     */
3869    private function getWeekStartDay() {
3870        $configFile = $this->metaDir() . 'calendar_week_start.txt';
3871        if (file_exists($configFile)) {
3872            $start = trim(file_get_contents($configFile));
3873            if (in_array($start, ['monday', 'sunday'])) {
3874                return $start;
3875            }
3876        }
3877        return 'sunday'; // Default to Sunday (US/Canada standard)
3878    }
3879
3880    /**
3881     * Get itinerary collapsed default state
3882     */
3883    private function getItineraryCollapsed() {
3884        $configFile = $this->metaDir() . 'calendar_itinerary_collapsed.txt';
3885        if (file_exists($configFile)) {
3886            return trim(file_get_contents($configFile)) === 'yes';
3887        }
3888        return false; // Default to expanded
3889    }
3890
3891    /**
3892     * Get default search scope (month or all)
3893     */
3894    private function getSearchDefault() {
3895        $configFile = $this->metaDir() . 'calendar_search_default.txt';
3896        if (file_exists($configFile)) {
3897            $value = trim(file_get_contents($configFile));
3898            if (in_array($value, ['month', 'all'])) {
3899                return $value;
3900            }
3901        }
3902        return 'month'; // Default to month search
3903    }
3904
3905    /**
3906     * Parse exclude parameter into an array of namespace strings
3907     * Supports semicolon-separated list: "journal;drafts;personal:private"
3908     */
3909    private function parseExcludeList($exclude) {
3910        if (empty($exclude)) return [];
3911        return array_filter(array_map('trim', explode(';', $exclude)), function($v) {
3912            return $v !== '';
3913        });
3914    }
3915
3916    /**
3917     * Check if a namespace should be excluded
3918     * Matches exact names and prefixes (e.g., exclude "journal" also excludes "journal:sub")
3919     */
3920    private function isNamespaceExcluded($namespace, $excludeList) {
3921        if (empty($excludeList) || $namespace === '') return false;
3922        foreach ($excludeList as $excluded) {
3923            // Exact match
3924            if ($namespace === $excluded) return true;
3925            // Prefix match: "journal" excludes "journal:sub:deep"
3926            if (strpos($namespace, $excluded . ':') === 0) return true;
3927        }
3928        return false;
3929    }
3930}