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