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