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