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        // Check if multiple namespaces or wildcard specified
91        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
92
93        if ($isMultiNamespace) {
94            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
95        } else {
96            $events = $this->loadEvents($namespace, $year, $month);
97        }
98        $calId = 'cal_' . md5(serialize($data) . microtime());
99
100        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
101
102        $prevMonth = $month - 1;
103        $prevYear = $year;
104        if ($prevMonth < 1) {
105            $prevMonth = 12;
106            $prevYear--;
107        }
108
109        $nextMonth = $month + 1;
110        $nextYear = $year;
111        if ($nextMonth > 12) {
112            $nextMonth = 1;
113            $nextYear++;
114        }
115
116        $html = '<div class="calendar-compact-container" id="' . $calId . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '" data-year="' . $year . '" data-month="' . $month . '">';
117
118        // Load calendar JavaScript manually (not through DokuWiki concatenation)
119        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
120
121        // Initialize DOKU_BASE for JavaScript
122        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
123
124        // Embed events data as JSON for JavaScript access
125        $html .= '<script type="application/json" id="events-data-' . $calId . '">' . json_encode($events) . '</script>';
126
127        // Left side: Calendar
128        $html .= '<div class="calendar-compact-left">';
129
130        // Header with navigation
131        $html .= '<div class="calendar-compact-header">';
132        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
133        $html .= '<h3 class="calendar-month-picker" onclick="openMonthPicker(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $monthName . '</h3>';
134        $html .= '<button class="cal-nav-btn" onclick="navCalendar(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
135        $html .= '<button class="cal-today-btn" onclick="jumpToToday(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
136        $html .= '</div>';
137
138        // Namespace filter indicator - only show if actively filtering a specific namespace
139        if ($namespace && $namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false) {
140            $html .= '<div class="calendar-namespace-filter" id="namespace-filter-' . $calId . '">';
141            $html .= '<span class="namespace-filter-label">Filtering:</span>';
142            $html .= '<span class="namespace-filter-name">' . htmlspecialchars($namespace) . '</span>';
143            $html .= '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' . $calId . '\')" title="Clear filter and show all namespaces">✕</button>';
144            $html .= '</div>';
145        }
146
147        // Calendar grid
148        $html .= '<table class="calendar-compact-grid">';
149        $html .= '<thead><tr>';
150        $html .= '<th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>';
151        $html .= '</tr></thead><tbody>';
152
153        $firstDay = mktime(0, 0, 0, $month, 1, $year);
154        $daysInMonth = date('t', $firstDay);
155        $dayOfWeek = date('w', $firstDay);
156
157        // Build a map of all events with their date ranges for the calendar grid
158        $eventRanges = array();
159        foreach ($events as $dateKey => $dayEvents) {
160            foreach ($dayEvents as $evt) {
161                $eventId = isset($evt['id']) ? $evt['id'] : '';
162                $startDate = $dateKey;
163                $endDate = isset($evt['endDate']) && $evt['endDate'] ? $evt['endDate'] : $dateKey;
164
165                // Only process events that touch this month
166                $eventStart = new DateTime($startDate);
167                $eventEnd = new DateTime($endDate);
168                $monthStart = new DateTime(sprintf('%04d-%02d-01', $year, $month));
169                $monthEnd = new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth));
170
171                // Skip if event doesn't overlap with current month
172                if ($eventEnd < $monthStart || $eventStart > $monthEnd) {
173                    continue;
174                }
175
176                // Create entry for each day the event spans
177                $current = clone $eventStart;
178                while ($current <= $eventEnd) {
179                    $currentKey = $current->format('Y-m-d');
180
181                    // Check if this date is in current month
182                    $currentDate = DateTime::createFromFormat('Y-m-d', $currentKey);
183                    if ($currentDate && $currentDate->format('Y-m') === sprintf('%04d-%02d', $year, $month)) {
184                        if (!isset($eventRanges[$currentKey])) {
185                            $eventRanges[$currentKey] = array();
186                        }
187
188                        // Add event with span information
189                        $evt['_span_start'] = $startDate;
190                        $evt['_span_end'] = $endDate;
191                        $evt['_is_first_day'] = ($currentKey === $startDate);
192                        $evt['_is_last_day'] = ($currentKey === $endDate);
193                        $evt['_original_date'] = $dateKey; // Keep track of original date
194
195                        // Check if event continues from previous month or to next month
196                        $evt['_continues_from_prev'] = ($eventStart < $monthStart);
197                        $evt['_continues_to_next'] = ($eventEnd > $monthEnd);
198
199                        $eventRanges[$currentKey][] = $evt;
200                    }
201
202                    $current->modify('+1 day');
203                }
204            }
205        }
206
207        $currentDay = 1;
208        $rowCount = ceil(($daysInMonth + $dayOfWeek) / 7);
209
210        for ($row = 0; $row < $rowCount; $row++) {
211            $html .= '<tr>';
212            for ($col = 0; $col < 7; $col++) {
213                if (($row === 0 && $col < $dayOfWeek) || $currentDay > $daysInMonth) {
214                    $html .= '<td class="cal-empty"></td>';
215                } else {
216                    $dateKey = sprintf('%04d-%02d-%02d', $year, $month, $currentDay);
217                    $isToday = ($dateKey === date('Y-m-d'));
218                    $hasEvents = isset($eventRanges[$dateKey]) && !empty($eventRanges[$dateKey]);
219
220                    $classes = 'cal-day';
221                    if ($isToday) $classes .= ' cal-today';
222                    if ($hasEvents) $classes .= ' cal-has-events';
223
224                    $html .= '<td class="' . $classes . '" data-date="' . $dateKey . '" onclick="showDayPopup(\'' . $calId . '\', \'' . $dateKey . '\', \'' . $namespace . '\')">';
225                    $html .= '<span class="day-num">' . $currentDay . '</span>';
226
227                    if ($hasEvents) {
228                        // Sort events by time (no time first, then by time)
229                        $sortedEvents = $eventRanges[$dateKey];
230                        usort($sortedEvents, function($a, $b) {
231                            $timeA = isset($a['time']) ? $a['time'] : '';
232                            $timeB = isset($b['time']) ? $b['time'] : '';
233
234                            // Events without time go first
235                            if (empty($timeA) && !empty($timeB)) return -1;
236                            if (!empty($timeA) && empty($timeB)) return 1;
237                            if (empty($timeA) && empty($timeB)) return 0;
238
239                            // Sort by time
240                            return strcmp($timeA, $timeB);
241                        });
242
243                        // Show colored stacked bars for each event
244                        $html .= '<div class="event-indicators">';
245                        foreach ($sortedEvents as $evt) {
246                            $eventId = isset($evt['id']) ? $evt['id'] : '';
247                            $eventColor = isset($evt['color']) ? htmlspecialchars($evt['color']) : '#3498db';
248                            $eventTime = isset($evt['time']) ? $evt['time'] : '';
249                            $eventTitle = isset($evt['title']) ? htmlspecialchars($evt['title']) : 'Event';
250                            $originalDate = isset($evt['_original_date']) ? $evt['_original_date'] : $dateKey;
251                            $isFirstDay = isset($evt['_is_first_day']) ? $evt['_is_first_day'] : true;
252                            $isLastDay = isset($evt['_is_last_day']) ? $evt['_is_last_day'] : true;
253
254                            $barClass = empty($eventTime) ? 'event-bar-no-time' : 'event-bar-timed';
255
256                            // Add classes for multi-day spanning
257                            if (!$isFirstDay) $barClass .= ' event-bar-continues';
258                            if (!$isLastDay) $barClass .= ' event-bar-continuing';
259
260                            $html .= '<span class="event-bar ' . $barClass . '" ';
261                            $html .= 'style="background: ' . $eventColor . ';" ';
262                            $html .= 'title="' . $eventTitle . ($eventTime ? ' @ ' . $eventTime : '') . '" ';
263                            $html .= 'onclick="event.stopPropagation(); highlightEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $originalDate . '\');">';
264                            $html .= '</span>';
265                        }
266                        $html .= '</div>';
267                    }
268
269                    $html .= '</td>';
270                    $currentDay++;
271                }
272            }
273            $html .= '</tr>';
274        }
275
276        $html .= '</tbody></table>';
277        $html .= '</div>'; // End calendar-left
278
279        // Right side: Event list
280        $html .= '<div class="calendar-compact-right">';
281        $html .= '<div class="event-list-header">';
282        $html .= '<div class="event-list-header-content">';
283        $html .= '<h4 id="eventlist-title-' . $calId . '">Events</h4>';
284        if ($namespace) {
285            $html .= '<span class="namespace-badge">' . htmlspecialchars($namespace) . '</span>';
286        }
287        $html .= '</div>';
288
289        // Search bar in header
290        $html .= '<div class="event-search-container-inline">';
291        $html .= '<input type="text" class="event-search-input-inline" id="event-search-' . $calId . '" placeholder="�� Search..." oninput="filterEvents(\'' . $calId . '\', this.value)">';
292        $html .= '<button class="event-search-clear-inline" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>';
293        $html .= '</div>';
294
295        $html .= '<button class="add-event-compact" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
296        $html .= '</div>';
297
298        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '">';
299        $html .= $this->renderEventListContent($events, $calId, $namespace);
300        $html .= '</div>';
301
302        $html .= '</div>'; // End calendar-right
303
304        // Event dialog
305        $html .= $this->renderEventDialog($calId, $namespace);
306
307        // Month/Year picker dialog (at container level for proper overlay)
308        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
309
310        $html .= '</div>'; // End container
311
312        return $html;
313    }
314
315    private function renderEventListContent($events, $calId, $namespace) {
316        if (empty($events)) {
317            return '<p class="no-events-msg">No events this month</p>';
318        }
319
320        // Check for time conflicts
321        $events = $this->checkTimeConflicts($events);
322
323        // Sort by date ascending (chronological order - oldest first)
324        ksort($events);
325
326        // Sort events within each day by time
327        foreach ($events as $dateKey => &$dayEvents) {
328            usort($dayEvents, function($a, $b) {
329                $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null;
330                $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null;
331
332                // All-day events (no time) go to the TOP
333                if ($timeA === null && $timeB !== null) return -1; // A before B
334                if ($timeA !== null && $timeB === null) return 1;  // A after B
335                if ($timeA === null && $timeB === null) return 0;  // Both all-day, equal
336
337                // Both have times, sort chronologically
338                return strcmp($timeA, $timeB);
339            });
340        }
341        unset($dayEvents); // Break reference
342
343        // Get today's date for comparison
344        $today = date('Y-m-d');
345        $firstFutureEventId = null;
346
347        // Helper function to check if event is past (with 15-minute grace period for timed events)
348        $isEventPast = function($dateKey, $time) use ($today) {
349            // If event is on a past date, it's definitely past
350            if ($dateKey < $today) {
351                return true;
352            }
353
354            // If event is on a future date, it's definitely not past
355            if ($dateKey > $today) {
356                return false;
357            }
358
359            // Event is today - check time with grace period
360            if ($time && $time !== '') {
361                try {
362                    $currentDateTime = new DateTime();
363                    $eventDateTime = new DateTime($dateKey . ' ' . $time);
364
365                    // Add 15-minute grace period
366                    $eventDateTime->modify('+15 minutes');
367
368                    // Event is past if current time > event time + 15 minutes
369                    return $currentDateTime > $eventDateTime;
370                } catch (Exception $e) {
371                    // If time parsing fails, fall back to date-only comparison
372                    return false;
373                }
374            }
375
376            // No time specified for today's event, treat as future
377            return false;
378        };
379
380        // Build HTML for each event - separate past/completed from future
381        $pastHtml = '';
382        $futureHtml = '';
383        $pastCount = 0;
384
385        foreach ($events as $dateKey => $dayEvents) {
386
387            foreach ($dayEvents as $event) {
388                // Track first future/today event for auto-scroll
389                if (!$firstFutureEventId && $dateKey >= $today) {
390                    $firstFutureEventId = isset($event['id']) ? $event['id'] : '';
391                }
392                $eventId = isset($event['id']) ? $event['id'] : '';
393                $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
394                $timeRaw = isset($event['time']) ? $event['time'] : '';
395                $time = htmlspecialchars($timeRaw);
396                $endTime = isset($event['endTime']) ? htmlspecialchars($event['endTime']) : '';
397                $color = isset($event['color']) ? htmlspecialchars($event['color']) : '#3498db';
398                $description = isset($event['description']) ? $event['description'] : '';
399                $isTask = isset($event['isTask']) ? $event['isTask'] : false;
400                $completed = isset($event['completed']) ? $event['completed'] : false;
401                $endDate = isset($event['endDate']) ? $event['endDate'] : '';
402
403                // Use helper function to determine if event is past (with grace period)
404                $isPast = $isEventPast($dateKey, $timeRaw);
405                $isToday = $dateKey === $today;
406
407                // Check if event should be in past section
408                // EXCEPTION: Uncompleted tasks (isTask && !completed) should stay visible even if past
409                $isPastOrCompleted = ($isPast && (!$isTask || $completed)) || $completed;
410                if ($isPastOrCompleted) {
411                    $pastCount++;
412                }
413
414                // Determine if task is past due (past date, is task, not completed)
415                $isPastDue = $isPast && $isTask && !$completed;
416
417                // Process description for wiki syntax, HTML, images, and links
418                $renderedDescription = $this->renderDescription($description);
419
420                // Convert to 12-hour format and handle time ranges
421                $displayTime = '';
422                if ($time) {
423                    $timeObj = DateTime::createFromFormat('H:i', $time);
424                    if ($timeObj) {
425                        $displayTime = $timeObj->format('g:i A');
426
427                        // Add end time if present and different from start time
428                        if ($endTime && $endTime !== $time) {
429                            $endTimeObj = DateTime::createFromFormat('H:i', $endTime);
430                            if ($endTimeObj) {
431                                $displayTime .= ' - ' . $endTimeObj->format('g:i A');
432                            }
433                        }
434                    } else {
435                        $displayTime = $time;
436                    }
437                }
438
439                // Format date display with day of week
440                // Use originalStartDate if this is a multi-month event continuation
441                $displayDateKey = isset($event['originalStartDate']) ? $event['originalStartDate'] : $dateKey;
442                $dateObj = new DateTime($displayDateKey);
443                $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Jan 24"
444
445                // Multi-day indicator
446                $multiDay = '';
447                if ($endDate && $endDate !== $displayDateKey) {
448                    $endObj = new DateTime($endDate);
449                    $multiDay = ' → ' . $endObj->format('D, M j');
450                }
451
452                $completedClass = $completed ? ' event-completed' : '';
453                // Don't grey out past due tasks - they need attention!
454                $pastClass = ($isPast && !$isPastDue) ? ' event-past' : '';
455                $pastDueClass = $isPastDue ? ' event-pastdue' : '';
456                $firstFutureAttr = ($firstFutureEventId === $eventId) ? ' data-first-future="true"' : '';
457
458                $eventHtml = '<div class="event-compact-item' . $completedClass . $pastClass . $pastDueClass . '" data-event-id="' . $eventId . '" data-date="' . $dateKey . '" style="border-left-color: ' . $color . ';"' . $firstFutureAttr . '>';
459
460                $eventHtml .= '<div class="event-info">';
461                $eventHtml .= '<div class="event-title-row">';
462                $eventHtml .= '<span class="event-title-compact">' . $title . '</span>';
463                $eventHtml .= '</div>';
464
465                // For past events, hide meta and description (collapsed)
466                // EXCEPTION: Past due tasks should show their details
467                if (!$isPast || $isPastDue) {
468                    $eventHtml .= '<div class="event-meta-compact">';
469                    $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay;
470                    if ($displayTime) {
471                        $eventHtml .= ' • ' . $displayTime;
472                    }
473                    // Add TODAY badge for today's events OR PAST DUE for uncompleted past tasks
474                    if ($isPastDue) {
475                        $eventHtml .= ' <span class="event-pastdue-badge">PAST DUE</span>';
476                    } elseif ($isToday) {
477                        $eventHtml .= ' <span class="event-today-badge">TODAY</span>';
478                    }
479                    // Add namespace badge - ALWAYS show if event has a namespace
480                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
481                    if (!$eventNamespace && isset($event['_namespace'])) {
482                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for backward compatibility
483                    }
484                    // Show badge if namespace exists and is not empty
485                    if ($eventNamespace && $eventNamespace !== '') {
486                        $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>';
487                    }
488
489                    // Add conflict warning if event has time conflicts
490                    if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) {
491                        $conflictList = [];
492                        foreach ($event['conflictsWith'] as $conflict) {
493                            $conflictText = htmlspecialchars($conflict['title']);
494                            if (!empty($conflict['time'])) {
495                                // Format time range
496                                $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']);
497                                $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time'];
498
499                                if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) {
500                                    $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']);
501                                    $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime'];
502                                    $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')';
503                                } else {
504                                    $conflictText .= ' (' . $startTimeFormatted . ')';
505                                }
506                            }
507                            $conflictList[] = $conflictText;
508                        }
509                        $conflictCount = count($event['conflictsWith']);
510                        $conflictJson = htmlspecialchars(json_encode($conflictList), ENT_QUOTES, 'UTF-8');
511                        $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>';
512                    }
513
514                    $eventHtml .= '</span>';
515                    $eventHtml .= '</div>';
516
517                    if ($description) {
518                        $eventHtml .= '<div class="event-desc-compact">' . $renderedDescription . '</div>';
519                    }
520                } else {
521                    // Past events: render with display:none for click-to-expand
522                    $eventHtml .= '<div class="event-meta-compact" style="display:none;">';
523                    $eventHtml .= '<span class="event-date-time">' . $displayDate . $multiDay;
524                    if ($displayTime) {
525                        $eventHtml .= ' • ' . $displayTime;
526                    }
527                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
528                    if (!$eventNamespace && isset($event['_namespace'])) {
529                        $eventNamespace = $event['_namespace'];
530                    }
531                    if ($eventNamespace && $eventNamespace !== '') {
532                        $eventHtml .= ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' . $calId . '\', \'' . htmlspecialchars($eventNamespace) . '\')" style="cursor:pointer;" title="Click to filter by this namespace">' . htmlspecialchars($eventNamespace) . '</span>';
533                    }
534
535                    // Add conflict warning if event has time conflicts
536                    if (isset($event['hasConflict']) && $event['hasConflict'] && isset($event['conflictsWith'])) {
537                        $conflictList = [];
538                        foreach ($event['conflictsWith'] as $conflict) {
539                            $conflictText = htmlspecialchars($conflict['title']);
540                            if (!empty($conflict['time'])) {
541                                $startTimeObj = DateTime::createFromFormat('H:i', $conflict['time']);
542                                $startTimeFormatted = $startTimeObj ? $startTimeObj->format('g:i A') : $conflict['time'];
543
544                                if (!empty($conflict['endTime']) && $conflict['endTime'] !== $conflict['time']) {
545                                    $endTimeObj = DateTime::createFromFormat('H:i', $conflict['endTime']);
546                                    $endTimeFormatted = $endTimeObj ? $endTimeObj->format('g:i A') : $conflict['endTime'];
547                                    $conflictText .= ' (' . $startTimeFormatted . ' - ' . $endTimeFormatted . ')';
548                                } else {
549                                    $conflictText .= ' (' . $startTimeFormatted . ')';
550                                }
551                            }
552                            $conflictList[] = $conflictText;
553                        }
554                        $conflictCount = count($event['conflictsWith']);
555                        $conflictJson = htmlspecialchars(json_encode($conflictList), ENT_QUOTES, 'UTF-8');
556                        $eventHtml .= ' <span class="event-conflict-badge" data-conflicts="' . $conflictJson . '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' . $conflictCount . '</span>';
557                    }
558
559                    $eventHtml .= '</span>';
560                    $eventHtml .= '</div>';
561
562                    if ($description) {
563                        $eventHtml .= '<div class="event-desc-compact" style="display:none;">' . $renderedDescription . '</div>';
564                    }
565                }
566
567                $eventHtml .= '</div>'; // event-info
568
569                // Use stored namespace from event, fallback to passed namespace
570                $buttonNamespace = isset($event['namespace']) ? $event['namespace'] : $namespace;
571
572                $eventHtml .= '<div class="event-actions-compact">';
573                $eventHtml .= '<button class="event-action-btn" onclick="deleteEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">��️</button>';
574                $eventHtml .= '<button class="event-action-btn" onclick="editEvent(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\')">✏️</button>';
575                $eventHtml .= '</div>';
576
577                // Checkbox for tasks - ON THE FAR RIGHT
578                if ($isTask) {
579                    $checked = $completed ? 'checked' : '';
580                    $eventHtml .= '<input type="checkbox" class="task-checkbox" ' . $checked . ' onclick="toggleTaskComplete(\'' . $calId . '\', \'' . $eventId . '\', \'' . $dateKey . '\', \'' . $buttonNamespace . '\', this.checked)">';
581                }
582
583                $eventHtml .= '</div>';
584
585                // Add to appropriate section
586                if ($isPastOrCompleted) {
587                    $pastHtml .= $eventHtml;
588                } else {
589                    $futureHtml .= $eventHtml;
590                }
591            }
592        }
593
594        // Build final HTML with collapsible past events section
595        $html = '';
596
597        // Add collapsible past events section if any exist
598        if ($pastCount > 0) {
599            $html .= '<div class="past-events-section">';
600            $html .= '<div class="past-events-toggle" onclick="togglePastEvents(\'' . $calId . '\')">';
601            $html .= '<span class="past-events-arrow" id="past-arrow-' . $calId . '">▶</span> ';
602            $html .= '<span class="past-events-label">Past Events (' . $pastCount . ')</span>';
603            $html .= '</div>';
604            $html .= '<div class="past-events-content" id="past-events-' . $calId . '" style="display:none;">';
605            $html .= $pastHtml;
606            $html .= '</div>';
607            $html .= '</div>';
608        }
609
610        // Add future events
611        $html .= $futureHtml;
612
613        return $html;
614    }
615
616    /**
617     * Check for time conflicts between events
618     */
619    private function checkTimeConflicts($events) {
620        // Group events by date
621        $eventsByDate = [];
622        foreach ($events as $date => $dateEvents) {
623            if (!is_array($dateEvents)) continue;
624
625            foreach ($dateEvents as $evt) {
626                if (empty($evt['time'])) continue; // Skip all-day events
627
628                if (!isset($eventsByDate[$date])) {
629                    $eventsByDate[$date] = [];
630                }
631                $eventsByDate[$date][] = $evt;
632            }
633        }
634
635        // Check for overlaps on each date
636        foreach ($eventsByDate as $date => $dateEvents) {
637            for ($i = 0; $i < count($dateEvents); $i++) {
638                for ($j = $i + 1; $j < count($dateEvents); $j++) {
639                    if ($this->eventsOverlap($dateEvents[$i], $dateEvents[$j])) {
640                        // Mark both events as conflicting
641                        $dateEvents[$i]['hasConflict'] = true;
642                        $dateEvents[$j]['hasConflict'] = true;
643
644                        // Store conflict info
645                        if (!isset($dateEvents[$i]['conflictsWith'])) {
646                            $dateEvents[$i]['conflictsWith'] = [];
647                        }
648                        if (!isset($dateEvents[$j]['conflictsWith'])) {
649                            $dateEvents[$j]['conflictsWith'] = [];
650                        }
651
652                        $dateEvents[$i]['conflictsWith'][] = [
653                            'id' => $dateEvents[$j]['id'],
654                            'title' => $dateEvents[$j]['title'],
655                            'time' => $dateEvents[$j]['time'],
656                            'endTime' => isset($dateEvents[$j]['endTime']) ? $dateEvents[$j]['endTime'] : ''
657                        ];
658
659                        $dateEvents[$j]['conflictsWith'][] = [
660                            'id' => $dateEvents[$i]['id'],
661                            'title' => $dateEvents[$i]['title'],
662                            'time' => $dateEvents[$i]['time'],
663                            'endTime' => isset($dateEvents[$i]['endTime']) ? $dateEvents[$i]['endTime'] : ''
664                        ];
665                    }
666                }
667            }
668
669            // Update the events array with conflict information
670            foreach ($events[$date] as &$evt) {
671                foreach ($dateEvents as $checkedEvt) {
672                    if ($evt['id'] === $checkedEvt['id']) {
673                        if (isset($checkedEvt['hasConflict'])) {
674                            $evt['hasConflict'] = $checkedEvt['hasConflict'];
675                        }
676                        if (isset($checkedEvt['conflictsWith'])) {
677                            $evt['conflictsWith'] = $checkedEvt['conflictsWith'];
678                        }
679                        break;
680                    }
681                }
682            }
683        }
684
685        return $events;
686    }
687
688    /**
689     * Check if two events overlap in time
690     */
691    private function eventsOverlap($evt1, $evt2) {
692        if (empty($evt1['time']) || empty($evt2['time'])) {
693            return false; // All-day events don't conflict
694        }
695
696        $start1 = $evt1['time'];
697        $end1 = isset($evt1['endTime']) && !empty($evt1['endTime']) ? $evt1['endTime'] : $evt1['time'];
698
699        $start2 = $evt2['time'];
700        $end2 = isset($evt2['endTime']) && !empty($evt2['endTime']) ? $evt2['endTime'] : $evt2['time'];
701
702        // Convert to minutes for easier comparison
703        $start1Mins = $this->timeToMinutes($start1);
704        $end1Mins = $this->timeToMinutes($end1);
705        $start2Mins = $this->timeToMinutes($start2);
706        $end2Mins = $this->timeToMinutes($end2);
707
708        // Check for overlap: start1 < end2 AND start2 < end1
709        return $start1Mins < $end2Mins && $start2Mins < $end1Mins;
710    }
711
712    /**
713     * Convert HH:MM time to minutes since midnight
714     */
715    private function timeToMinutes($timeStr) {
716        $parts = explode(':', $timeStr);
717        if (count($parts) !== 2) return 0;
718
719        return (int)$parts[0] * 60 + (int)$parts[1];
720    }
721
722    private function renderEventPanelOnly($data) {
723        $year = (int)$data['year'];
724        $month = (int)$data['month'];
725        $namespace = $data['namespace'];
726        $height = isset($data['height']) ? $data['height'] : '400px';
727
728        // Validate height format (must be px, em, rem, vh, or %)
729        if (!preg_match('/^\d+(\.\d+)?(px|em|rem|vh|%)$/', $height)) {
730            $height = '400px'; // Default fallback
731        }
732
733        // Check if multiple namespaces or wildcard specified
734        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
735
736        if ($isMultiNamespace) {
737            $events = $this->loadEventsMultiNamespace($namespace, $year, $month);
738        } else {
739            $events = $this->loadEvents($namespace, $year, $month);
740        }
741        $calId = 'panel_' . md5(serialize($data) . microtime());
742
743        $monthName = date('F Y', mktime(0, 0, 0, $month, 1, $year));
744
745        $prevMonth = $month - 1;
746        $prevYear = $year;
747        if ($prevMonth < 1) {
748            $prevMonth = 12;
749            $prevYear--;
750        }
751
752        $nextMonth = $month + 1;
753        $nextYear = $year;
754        if ($nextMonth > 12) {
755            $nextMonth = 1;
756            $nextYear++;
757        }
758
759        $html = '<div class="event-panel-standalone" id="' . $calId . '" data-height="' . htmlspecialchars($height) . '" data-namespace="' . htmlspecialchars($namespace) . '" data-original-namespace="' . htmlspecialchars($namespace) . '">';
760
761        // Load calendar JavaScript manually (not through DokuWiki concatenation)
762        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
763
764        // Initialize DOKU_BASE for JavaScript
765        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
766
767        // Compact two-row header designed for ~500px width
768        $html .= '<div class="panel-header-compact">';
769
770        // Row 1: Navigation and title
771        $html .= '<div class="panel-header-row-1">';
772        $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $prevYear . ', ' . $prevMonth . ', \'' . $namespace . '\')">‹</button>';
773
774        // Compact month name (e.g. "Feb 2026" instead of "February 2026 Events")
775        $shortMonthName = date('M Y', mktime(0, 0, 0, $month, 1, $year));
776        $html .= '<h3 class="panel-month-title" onclick="openMonthPickerPanel(\'' . $calId . '\', ' . $year . ', ' . $month . ', \'' . $namespace . '\')" title="Click to jump to month">' . $shortMonthName . '</h3>';
777
778        $html .= '<button class="panel-nav-btn" onclick="navEventPanel(\'' . $calId . '\', ' . $nextYear . ', ' . $nextMonth . ', \'' . $namespace . '\')">›</button>';
779
780        // Namespace badge (if applicable)
781        if ($namespace) {
782            if ($isMultiNamespace) {
783                if (strpos($namespace, '*') !== false) {
784                    $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>';
785                } else {
786                    $namespaceList = array_map('trim', explode(';', $namespace));
787                    $nsCount = count($namespaceList);
788                    $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars(implode(', ', $namespaceList)) . '">' . $nsCount . ' NS</span>';
789                }
790            } else {
791                $isFiltering = ($namespace !== '*' && strpos($namespace, '*') === false && strpos($namespace, ';') === false);
792                if ($isFiltering) {
793                    $html .= '<span class="panel-ns-badge filter-on" title="Filtering by ' . htmlspecialchars($namespace) . ' - click to clear" onclick="clearNamespaceFilterPanel(\'' . $calId . '\')">' . htmlspecialchars($namespace) . ' ✕</span>';
794                } else {
795                    $html .= '<span class="panel-ns-badge" title="' . htmlspecialchars($namespace) . '">' . htmlspecialchars($namespace) . '</span>';
796                }
797            }
798        }
799
800        $html .= '<button class="panel-today-btn" onclick="jumpTodayPanel(\'' . $calId . '\', \'' . $namespace . '\')">Today</button>';
801        $html .= '</div>';
802
803        // Row 2: Search and add button
804        $html .= '<div class="panel-header-row-2">';
805        $html .= '<div class="panel-search-box">';
806        $html .= '<input type="text" class="panel-search-input" id="event-search-' . $calId . '" placeholder="Search events..." oninput="filterEvents(\'' . $calId . '\', this.value)">';
807        $html .= '<button class="panel-search-clear" id="search-clear-' . $calId . '" onclick="clearEventSearch(\'' . $calId . '\')" style="display:none;">✕</button>';
808        $html .= '</div>';
809        $html .= '<button class="panel-add-btn" onclick="openAddEventPanel(\'' . $calId . '\', \'' . $namespace . '\')">+ Add</button>';
810        $html .= '</div>';
811
812        $html .= '</div>';
813
814        $html .= '<div class="event-list-compact" id="eventlist-' . $calId . '" style="max-height: ' . htmlspecialchars($height) . ';">';
815        $html .= $this->renderEventListContent($events, $calId, $namespace);
816        $html .= '</div>';
817
818        $html .= $this->renderEventDialog($calId, $namespace);
819
820        // Month/Year picker for event panel
821        $html .= $this->renderMonthPicker($calId, $year, $month, $namespace);
822
823        $html .= '</div>';
824
825        return $html;
826    }
827
828    private function renderStandaloneEventList($data) {
829        $namespace = $data['namespace'];
830        // If no namespace specified, show all namespaces
831        if (empty($namespace)) {
832            $namespace = '*';
833        }
834        $daterange = $data['daterange'];
835        $date = $data['date'];
836        $range = isset($data['range']) ? strtolower($data['range']) : '';
837        $today = isset($data['today']) ? true : false;
838        $sidebar = isset($data['sidebar']) ? true : false;
839        $showchecked = isset($data['showchecked']) ? true : false; // New parameter
840        $noheader = isset($data['noheader']) ? true : false; // New parameter to hide header
841
842        // Handle "range" parameter - day, week, or month
843        if ($range === 'day') {
844            $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks
845            $endDate = date('Y-m-d');
846            $headerText = 'Today';
847        } elseif ($range === 'week') {
848            $startDate = date('Y-m-d', strtotime('-30 days')); // Include past 30 days for past due tasks
849            $endDateTime = new DateTime();
850            $endDateTime->modify('+7 days');
851            $endDate = $endDateTime->format('Y-m-d');
852            $headerText = 'This Week';
853        } elseif ($range === 'month') {
854            $startDate = date('Y-m-01', strtotime('-1 month')); // Include previous month for past due tasks
855            $endDate = date('Y-m-t'); // Last of current month
856            $dt = new DateTime();
857            $headerText = $dt->format('F Y');
858        } elseif ($sidebar) {
859            // NEW: Sidebar widget - load current week's events
860            $weekStart = date('Y-m-d', strtotime('monday this week'));
861            $weekEnd = date('Y-m-d', strtotime('sunday this week'));
862
863            // Load events for the entire week
864            $start = new DateTime($weekStart);
865            $end = new DateTime($weekEnd);
866            $end->modify('+1 day'); // DatePeriod excludes end date
867            $interval = new DateInterval('P1D');
868            $period = new DatePeriod($start, $interval, $end);
869
870            $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
871            $allEvents = [];
872            $loadedMonths = [];
873
874            foreach ($period as $dt) {
875                $year = (int)$dt->format('Y');
876                $month = (int)$dt->format('n');
877                $dateKey = $dt->format('Y-m-d');
878
879                $monthKey = $year . '-' . $month . '-' . $namespace;
880
881                if (!isset($loadedMonths[$monthKey])) {
882                    if ($isMultiNamespace) {
883                        $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month);
884                    } else {
885                        $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
886                    }
887                }
888
889                $monthEvents = $loadedMonths[$monthKey];
890
891                if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
892                    $allEvents[$dateKey] = $monthEvents[$dateKey];
893                }
894            }
895
896            // Apply time conflict detection
897            $allEvents = $this->checkTimeConflicts($allEvents);
898
899            $calId = 'sidebar-' . substr(md5($namespace . $weekStart), 0, 8);
900
901            // Render sidebar widget and return immediately
902            return $this->renderSidebarWidget($allEvents, $namespace, $calId);
903        } elseif ($today) {
904            $startDate = date('Y-m-d');
905            $endDate = date('Y-m-d');
906            $headerText = 'Today';
907        } elseif ($daterange) {
908            list($startDate, $endDate) = explode(':', $daterange);
909            $start = new DateTime($startDate);
910            $end = new DateTime($endDate);
911            $headerText = $start->format('M j') . ' - ' . $end->format('M j, Y');
912        } elseif ($date) {
913            $startDate = $date;
914            $endDate = $date;
915            $dt = new DateTime($date);
916            $headerText = $dt->format('l, F j, Y');
917        } else {
918            $startDate = date('Y-m-01');
919            $endDate = date('Y-m-t');
920            $dt = new DateTime($startDate);
921            $headerText = $dt->format('F Y');
922        }
923
924        // Load all events in date range
925        $allEvents = array();
926        $start = new DateTime($startDate);
927        $end = new DateTime($endDate);
928        $end->modify('+1 day');
929
930        $interval = new DateInterval('P1D');
931        $period = new DatePeriod($start, $interval, $end);
932
933        // Check if multiple namespaces or wildcard specified
934        $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false);
935
936        static $loadedMonths = array();
937
938        foreach ($period as $dt) {
939            $year = (int)$dt->format('Y');
940            $month = (int)$dt->format('n');
941            $dateKey = $dt->format('Y-m-d');
942
943            $monthKey = $year . '-' . $month . '-' . $namespace;
944
945            if (!isset($loadedMonths[$monthKey])) {
946                if ($isMultiNamespace) {
947                    $loadedMonths[$monthKey] = $this->loadEventsMultiNamespace($namespace, $year, $month);
948                } else {
949                    $loadedMonths[$monthKey] = $this->loadEvents($namespace, $year, $month);
950                }
951            }
952
953            $monthEvents = $loadedMonths[$monthKey];
954
955            if (isset($monthEvents[$dateKey]) && !empty($monthEvents[$dateKey])) {
956                $allEvents[$dateKey] = $monthEvents[$dateKey];
957            }
958        }
959
960        // Sort events by date (already sorted by dateKey), then by time within each day
961        foreach ($allEvents as $dateKey => &$dayEvents) {
962            usort($dayEvents, function($a, $b) {
963                $timeA = isset($a['time']) && !empty($a['time']) ? $a['time'] : null;
964                $timeB = isset($b['time']) && !empty($b['time']) ? $b['time'] : null;
965
966                // All-day events (no time) go to the TOP
967                if ($timeA === null && $timeB !== null) return -1; // A before B
968                if ($timeA !== null && $timeB === null) return 1;  // A after B
969                if ($timeA === null && $timeB === null) return 0;  // Both all-day, equal
970
971                // Both have times, sort chronologically
972                return strcmp($timeA, $timeB);
973            });
974        }
975        unset($dayEvents); // Break reference
976
977        // Simple 2-line display widget
978        $calId = 'eventlist_' . uniqid();
979        $html = '<div class="eventlist-simple" id="' . $calId . '">';
980
981        // Load calendar JavaScript manually (not through DokuWiki concatenation)
982        $html .= '<script src="' . DOKU_BASE . 'lib/plugins/calendar/calendar-main.js"></script>';
983
984        // Initialize DOKU_BASE for JavaScript
985        $html .= '<script>if(typeof DOKU_BASE==="undefined"){window.DOKU_BASE="' . DOKU_BASE . '";}</script>';
986
987        // Add compact header with date and clock for "today" mode (unless noheader is set)
988        if ($today && !empty($allEvents) && !$noheader) {
989            $todayDate = new DateTime();
990            $displayDate = $todayDate->format('D, M j, Y'); // "Fri, Jan 30, 2026"
991            $currentTime = $todayDate->format('g:i:s A'); // "2:45:30 PM"
992
993            $html .= '<div class="eventlist-today-header">';
994            $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>';
995            $html .= '<div class="eventlist-bottom-info">';
996            $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">��️</span> <span id="weather-temp-' . $calId . '">--°</span></span>';
997            $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>';
998            $html .= '</div>';
999
1000            // Three CPU/Memory bars (all update live)
1001            $html .= '<div class="eventlist-stats-container">';
1002
1003            // 5-minute load average (green, updates every 2 seconds)
1004            $html .= '<div class="eventlist-cpu-bar" onmouseover="showTooltip_' . $calId . '(\'green\')" onmouseout="hideTooltip_' . $calId . '(\'green\')">';
1005            $html .= '<div class="eventlist-cpu-fill" id="cpu-5min-' . $calId . '" style="width: 0%;"></div>';
1006            $html .= '<div class="system-tooltip" id="tooltip-green-' . $calId . '" style="display:none;"></div>';
1007            $html .= '</div>';
1008
1009            // Real-time CPU (purple, updates with 5-sec average)
1010            $html .= '<div class="eventlist-cpu-bar eventlist-cpu-realtime" onmouseover="showTooltip_' . $calId . '(\'purple\')" onmouseout="hideTooltip_' . $calId . '(\'purple\')">';
1011            $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-purple" id="cpu-realtime-' . $calId . '" style="width: 0%;"></div>';
1012            $html .= '<div class="system-tooltip" id="tooltip-purple-' . $calId . '" style="display:none;"></div>';
1013            $html .= '</div>';
1014
1015            // Real-time Memory (orange, updates)
1016            $html .= '<div class="eventlist-cpu-bar eventlist-mem-realtime" onmouseover="showTooltip_' . $calId . '(\'orange\')" onmouseout="hideTooltip_' . $calId . '(\'orange\')">';
1017            $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-orange" id="mem-realtime-' . $calId . '" style="width: 0%;"></div>';
1018            $html .= '<div class="system-tooltip" id="tooltip-orange-' . $calId . '" style="display:none;"></div>';
1019            $html .= '</div>';
1020
1021            $html .= '</div>';
1022            $html .= '</div>';
1023
1024            // Add JavaScript to update clock and weather
1025            $html .= '<script>
1026(function() {
1027    // Update clock every second
1028    function updateClock() {
1029        const now = new Date();
1030        let hours = now.getHours();
1031        const minutes = String(now.getMinutes()).padStart(2, "0");
1032        const seconds = String(now.getSeconds()).padStart(2, "0");
1033        const ampm = hours >= 12 ? "PM" : "AM";
1034        hours = hours % 12 || 12;
1035        const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm;
1036        const clockEl = document.getElementById("clock-' . $calId . '");
1037        if (clockEl) clockEl.textContent = timeStr;
1038    }
1039    setInterval(updateClock, 1000);
1040
1041    // Fetch weather (geolocation-based)
1042    function updateWeather() {
1043        if ("geolocation" in navigator) {
1044            navigator.geolocation.getCurrentPosition(function(position) {
1045                const lat = position.coords.latitude;
1046                const lon = position.coords.longitude;
1047
1048                // Use Open-Meteo API (free, no key required)
1049                fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current_weather=true&temperature_unit=fahrenheit`)
1050                    .then(response => response.json())
1051                    .then(data => {
1052                        if (data.current_weather) {
1053                            const temp = Math.round(data.current_weather.temperature);
1054                            const weatherCode = data.current_weather.weathercode;
1055                            const icon = getWeatherIcon(weatherCode);
1056                            const iconEl = document.getElementById("weather-icon-' . $calId . '");
1057                            const tempEl = document.getElementById("weather-temp-' . $calId . '");
1058                            if (iconEl) iconEl.textContent = icon;
1059                            if (tempEl) tempEl.innerHTML = temp + "&deg;";
1060                        }
1061                    })
1062                    .catch(error => {
1063                        console.log("Weather fetch error:", error);
1064                    });
1065            }, function(error) {
1066                // If geolocation fails, use Sacramento as default
1067                fetch("https://api.open-meteo.com/v1/forecast?latitude=38.5816&longitude=-121.4944&current_weather=true&temperature_unit=fahrenheit")
1068                    .then(response => response.json())
1069                    .then(data => {
1070                        if (data.current_weather) {
1071                            const temp = Math.round(data.current_weather.temperature);
1072                            const weatherCode = data.current_weather.weathercode;
1073                            const icon = getWeatherIcon(weatherCode);
1074                            const iconEl = document.getElementById("weather-icon-' . $calId . '");
1075                            const tempEl = document.getElementById("weather-temp-' . $calId . '");
1076                            if (iconEl) iconEl.textContent = icon;
1077                            if (tempEl) tempEl.innerHTML = temp + "&deg;";
1078                        }
1079                    })
1080                    .catch(err => console.log("Weather error:", err));
1081            });
1082        } else {
1083            // No geolocation, use Sacramento
1084            fetch("https://api.open-meteo.com/v1/forecast?latitude=38.5816&longitude=-121.4944&current_weather=true&temperature_unit=fahrenheit")
1085                .then(response => response.json())
1086                .then(data => {
1087                    if (data.current_weather) {
1088                        const temp = Math.round(data.current_weather.temperature);
1089                        const weatherCode = data.current_weather.weathercode;
1090                        const icon = getWeatherIcon(weatherCode);
1091                        const iconEl = document.getElementById("weather-icon-' . $calId . '");
1092                        const tempEl = document.getElementById("weather-temp-' . $calId . '");
1093                        if (iconEl) iconEl.textContent = icon;
1094                        if (tempEl) tempEl.innerHTML = temp + "&deg;";
1095                    }
1096                })
1097                .catch(err => console.log("Weather error:", err));
1098        }
1099    }
1100
1101    // WMO Weather interpretation codes
1102    function getWeatherIcon(code) {
1103        const icons = {
1104            0: "☀️",   // Clear sky
1105            1: "��️",   // Mainly clear
1106            2: "⛅",   // Partly cloudy
1107            3: "☁️",   // Overcast
1108            45: "��️",  // Fog
1109            48: "��️",  // Depositing rime fog
1110            51: "��️",  // Light drizzle
1111            53: "��️",  // Moderate drizzle
1112            55: "��️",  // Dense drizzle
1113            61: "��️",  // Slight rain
1114            63: "��️",  // Moderate rain
1115            65: "⛈️",  // Heavy rain
1116            71: "��️",  // Slight snow
1117            73: "��️",  // Moderate snow
1118            75: "❄️",  // Heavy snow
1119            77: "��️",  // Snow grains
1120            80: "��️",  // Slight rain showers
1121            81: "��️",  // Moderate rain showers
1122            82: "⛈️",  // Violent rain showers
1123            85: "��️",  // Slight snow showers
1124            86: "❄️",  // Heavy snow showers
1125            95: "⛈️",  // Thunderstorm
1126            96: "⛈️",  // Thunderstorm with slight hail
1127            99: "⛈️"   // Thunderstorm with heavy hail
1128        };
1129        return icons[code] || "��️";
1130    }
1131
1132    // Update weather immediately and every 10 minutes
1133    updateWeather();
1134    setInterval(updateWeather, 600000);
1135
1136    // CPU load history for 4-second rolling average
1137    const cpuHistory = [];
1138    const CPU_HISTORY_SIZE = 2; // 2 samples × 2 seconds = 4 seconds
1139
1140    // Store latest system stats for tooltips
1141    let latestStats = {
1142        load: {"1min": 0, "5min": 0, "15min": 0},
1143        uptime: "",
1144        memory_details: {},
1145        top_processes: []
1146    };
1147
1148    // Tooltip functions
1149    window["showTooltip_' . $calId . '"] = function(color) {
1150        const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '");
1151        if (!tooltip) {
1152            console.log("Tooltip element not found for color:", color);
1153            return;
1154        }
1155
1156        console.log("Showing tooltip for:", color, "latestStats:", latestStats);
1157
1158        let content = "";
1159
1160        if (color === "green") {
1161            // Green bar: Load averages and uptime
1162            content = "<div class=\"tooltip-title\">CPU Load Average</div>";
1163            content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>";
1164            content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>";
1165            content += "<div>15 min: " + (latestStats.load["15min"] || "N/A") + "</div>";
1166            if (latestStats.uptime) {
1167                content += "<div style=\"margin-top:3px; padding-top:2px; border-top:1px solid rgba(0,204,7,0.3);\">Uptime: " + latestStats.uptime + "</div>";
1168            }
1169            tooltip.style.borderColor = "#00cc07";
1170            tooltip.style.color = "#00cc07";
1171        } else if (color === "purple") {
1172            // Purple bar: Load averages (short-term) and top processes
1173            content = "<div class=\"tooltip-title\">CPU Load (Short-term)</div>";
1174            content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>";
1175            content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>";
1176            if (latestStats.top_processes && latestStats.top_processes.length > 0) {
1177                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>";
1178                latestStats.top_processes.slice(0, 5).forEach(proc => {
1179                    content += "<div>" + proc.cpu + " " + proc.command + "</div>";
1180                });
1181            }
1182            tooltip.style.borderColor = "#9b59b6";
1183            tooltip.style.color = "#9b59b6";
1184        } else if (color === "orange") {
1185            // Orange bar: Memory details and top processes
1186            content = "<div class=\"tooltip-title\">Memory Usage</div>";
1187            if (latestStats.memory_details && latestStats.memory_details.total) {
1188                content += "<div>Total: " + latestStats.memory_details.total + "</div>";
1189                content += "<div>Used: " + latestStats.memory_details.used + "</div>";
1190                content += "<div>Available: " + latestStats.memory_details.available + "</div>";
1191                if (latestStats.memory_details.cached) {
1192                    content += "<div>Cached: " + latestStats.memory_details.cached + "</div>";
1193                }
1194            } else {
1195                content += "<div>Loading...</div>";
1196            }
1197            if (latestStats.top_processes && latestStats.top_processes.length > 0) {
1198                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>";
1199                latestStats.top_processes.slice(0, 5).forEach(proc => {
1200                    content += "<div>" + proc.cpu + " " + proc.command + "</div>";
1201                });
1202            }
1203            tooltip.style.borderColor = "#ff9800";
1204            tooltip.style.color = "#ff9800";
1205        }
1206
1207        console.log("Tooltip content:", content);
1208        tooltip.innerHTML = content;
1209        tooltip.style.display = "block";
1210
1211        // Position tooltip using fixed positioning above the bar
1212        const bar = tooltip.parentElement;
1213        const barRect = bar.getBoundingClientRect();
1214        const tooltipRect = tooltip.getBoundingClientRect();
1215
1216        // Center horizontally on the bar
1217        const left = barRect.left + (barRect.width / 2) - (tooltipRect.width / 2);
1218        // Position above the bar with 8px gap
1219        const top = barRect.top - tooltipRect.height - 8;
1220
1221        tooltip.style.left = left + "px";
1222        tooltip.style.top = top + "px";
1223    };
1224
1225    window["hideTooltip_' . $calId . '"] = function(color) {
1226        const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '");
1227        if (tooltip) {
1228            tooltip.style.display = "none";
1229        }
1230    };
1231
1232    // Update CPU and memory bars every 2 seconds
1233    function updateSystemStats() {
1234        // Fetch real system stats from server
1235        fetch("' . DOKU_BASE . 'lib/plugins/calendar/get_system_stats.php")
1236            .then(response => response.json())
1237            .then(data => {
1238                console.log("System stats received:", data);
1239
1240                // Store data for tooltips
1241                latestStats = {
1242                    load: data.load || {"1min": 0, "5min": 0, "15min": 0},
1243                    uptime: data.uptime || "",
1244                    memory_details: data.memory_details || {},
1245                    top_processes: data.top_processes || []
1246                };
1247
1248                console.log("latestStats updated to:", latestStats);
1249
1250                // Update green bar (5-minute average) - updates live now!
1251                const greenBar = document.getElementById("cpu-5min-' . $calId . '");
1252                if (greenBar) {
1253                    greenBar.style.width = Math.min(100, data.cpu_5min) + "%";
1254                }
1255
1256                // Add current CPU to history for purple bar
1257                cpuHistory.push(data.cpu);
1258                if (cpuHistory.length > CPU_HISTORY_SIZE) {
1259                    cpuHistory.shift(); // Remove oldest
1260                }
1261
1262                // Calculate 5-second average for CPU
1263                const cpuAverage = cpuHistory.reduce((sum, val) => sum + val, 0) / cpuHistory.length;
1264
1265                // Update CPU bar (purple) with 5-second average
1266                const cpuBar = document.getElementById("cpu-realtime-' . $calId . '");
1267                if (cpuBar) {
1268                    cpuBar.style.width = Math.min(100, cpuAverage) + "%";
1269                }
1270
1271                // Update memory bar (orange) with real data
1272                const memBar = document.getElementById("mem-realtime-' . $calId . '");
1273                if (memBar) {
1274                    memBar.style.width = Math.min(100, data.memory) + "%";
1275                }
1276            })
1277            .catch(error => {
1278                console.log("System stats error:", error);
1279                // Fallback to client-side estimates on error
1280                const cpuFallback = Math.random() * 100;
1281                cpuHistory.push(cpuFallback);
1282                if (cpuHistory.length > CPU_HISTORY_SIZE) {
1283                    cpuHistory.shift();
1284                }
1285                const cpuAverage = cpuHistory.reduce((sum, val) => sum + val, 0) / cpuHistory.length;
1286
1287                const greenBar = document.getElementById("cpu-5min-' . $calId . '");
1288                if (greenBar) greenBar.style.width = Math.min(100, cpuFallback) + "%";
1289
1290                const cpuBar = document.getElementById("cpu-realtime-' . $calId . '");
1291                if (cpuBar) cpuBar.style.width = Math.min(100, cpuAverage) + "%";
1292
1293                let memoryUsage = 0;
1294                if (performance.memory) {
1295                    memoryUsage = (performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100;
1296                } else {
1297                    memoryUsage = Math.random() * 100;
1298                }
1299                const memBar = document.getElementById("mem-realtime-' . $calId . '");
1300                if (memBar) memBar.style.width = Math.min(100, memoryUsage) + "%";
1301            });
1302    }
1303
1304    // Update immediately and then every 2 seconds
1305    updateSystemStats();
1306    setInterval(updateSystemStats, 2000);
1307})();
1308</script>';
1309        }
1310
1311        if (empty($allEvents)) {
1312            $html .= '<div class="eventlist-simple-empty">';
1313            $html .= '<div class="eventlist-simple-header">' . htmlspecialchars($headerText);
1314            if ($namespace) {
1315                $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($namespace) . '</span>';
1316            }
1317            $html .= '</div>';
1318            $html .= '<div class="eventlist-simple-body">No events</div>';
1319            $html .= '</div>';
1320        } else {
1321            // Calculate today and tomorrow's dates for highlighting
1322            $todayStr = date('Y-m-d');
1323            $tomorrow = date('Y-m-d', strtotime('+1 day'));
1324
1325            foreach ($allEvents as $dateKey => $dayEvents) {
1326                $dateObj = new DateTime($dateKey);
1327                $displayDate = $dateObj->format('D, M j');
1328
1329                // Check if this date is today or tomorrow or past
1330                // Enable highlighting for sidebar mode AND range modes (day, week, month)
1331                $enableHighlighting = $sidebar || !empty($range);
1332                $isToday = $enableHighlighting && ($dateKey === $todayStr);
1333                $isTomorrow = $enableHighlighting && ($dateKey === $tomorrow);
1334                $isPast = $dateKey < $todayStr;
1335
1336                foreach ($dayEvents as $event) {
1337                    // Check if this is a task and if it's completed
1338                    $isTask = !empty($event['isTask']);
1339                    $completed = !empty($event['completed']);
1340
1341                    // ALWAYS skip completed tasks UNLESS showchecked is explicitly set
1342                    if (!$showchecked && $isTask && $completed) {
1343                        continue;
1344                    }
1345
1346                    // Skip past events that are NOT tasks (only show past due tasks from the past)
1347                    if ($isPast && !$isTask) {
1348                        continue;
1349                    }
1350
1351                    // Determine if task is past due (past date, is task, not completed)
1352                    $isPastDue = $isPast && $isTask && !$completed;
1353
1354                    // Line 1: Header (Title, Time, Date, Namespace)
1355                    $todayClass = $isToday ? ' eventlist-simple-today' : '';
1356                    $tomorrowClass = $isTomorrow ? ' eventlist-simple-tomorrow' : '';
1357                    $pastDueClass = $isPastDue ? ' eventlist-simple-pastdue' : '';
1358                    $html .= '<div class="eventlist-simple-item' . $todayClass . $tomorrowClass . $pastDueClass . '">';
1359                    $html .= '<div class="eventlist-simple-header">';
1360
1361                    // Title
1362                    $html .= '<span class="eventlist-simple-title">' . htmlspecialchars($event['title']) . '</span>';
1363
1364                    // Time (12-hour format)
1365                    if (!empty($event['time'])) {
1366                        $timeParts = explode(':', $event['time']);
1367                        if (count($timeParts) === 2) {
1368                            $hour = (int)$timeParts[0];
1369                            $minute = $timeParts[1];
1370                            $ampm = $hour >= 12 ? 'PM' : 'AM';
1371                            $hour = $hour % 12 ?: 12;
1372                            $displayTime = $hour . ':' . $minute . ' ' . $ampm;
1373                            $html .= ' <span class="eventlist-simple-time">' . $displayTime . '</span>';
1374                        }
1375                    }
1376
1377                    // Date
1378                    $html .= ' <span class="eventlist-simple-date">' . $displayDate . '</span>';
1379
1380                    // Badge: PAST DUE, TODAY, or nothing
1381                    if ($isPastDue) {
1382                        $html .= ' <span class="eventlist-simple-pastdue-badge">PAST DUE</span>';
1383                    } elseif ($isToday) {
1384                        $html .= ' <span class="eventlist-simple-today-badge">TODAY</span>';
1385                    }
1386
1387                    // Namespace badge (show individual event's namespace)
1388                    $eventNamespace = isset($event['namespace']) ? $event['namespace'] : '';
1389                    if (!$eventNamespace && isset($event['_namespace'])) {
1390                        $eventNamespace = $event['_namespace']; // Fallback to _namespace for multi-namespace loading
1391                    }
1392                    if ($eventNamespace) {
1393                        $html .= ' <span class="eventlist-simple-namespace">' . htmlspecialchars($eventNamespace) . '</span>';
1394                    }
1395
1396                    $html .= '</div>'; // header
1397
1398                    // Line 2: Body (Description only) - only show if description exists
1399                    if (!empty($event['description'])) {
1400                        $html .= '<div class="eventlist-simple-body">' . $this->renderDescription($event['description']) . '</div>';
1401                    }
1402
1403                    $html .= '</div>'; // item
1404                }
1405            }
1406        }
1407
1408        $html .= '</div>'; // eventlist-simple
1409
1410        return $html;
1411    }
1412
1413    private function renderEventDialog($calId, $namespace) {
1414        $html = '<div class="event-dialog-compact" id="dialog-' . $calId . '" style="display:none;">';
1415        $html .= '<div class="dialog-overlay" onclick="closeEventDialog(\'' . $calId . '\')"></div>';
1416
1417        // Draggable dialog
1418        $html .= '<div class="dialog-content-sleek" id="dialog-content-' . $calId . '">';
1419
1420        // Header with drag handle and close button
1421        $html .= '<div class="dialog-header-sleek dialog-drag-handle" id="drag-handle-' . $calId . '">';
1422        $html .= '<h3 id="dialog-title-' . $calId . '">Add Event</h3>';
1423        $html .= '<button type="button" class="dialog-close-btn" onclick="closeEventDialog(\'' . $calId . '\')">×</button>';
1424        $html .= '</div>';
1425
1426        // Form content
1427        $html .= '<form id="eventform-' . $calId . '" onsubmit="saveEventCompact(\'' . $calId . '\', \'' . $namespace . '\'); return false;" class="sleek-form">';
1428
1429        // Hidden ID field
1430        $html .= '<input type="hidden" id="event-id-' . $calId . '" name="eventId" value="">';
1431
1432        // 1. TITLE
1433        $html .= '<div class="form-field">';
1434        $html .= '<label class="field-label">�� Title</label>';
1435        $html .= '<input type="text" id="event-title-' . $calId . '" name="title" required class="input-sleek input-compact" placeholder="Event or task title...">';
1436        $html .= '</div>';
1437
1438        // 1.5 NAMESPACE SELECTOR (Searchable with fuzzy matching)
1439        $html .= '<div class="form-field">';
1440        $html .= '<label class="field-label">�� Namespace</label>';
1441
1442        // Hidden field to store actual selected namespace
1443        $html .= '<input type="hidden" id="event-namespace-' . $calId . '" name="namespace" value="">';
1444
1445        // Searchable input
1446        $html .= '<div class="namespace-search-wrapper">';
1447        $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">';
1448        $html .= '<div class="namespace-dropdown" id="event-namespace-dropdown-' . $calId . '" style="display:none;"></div>';
1449        $html .= '</div>';
1450
1451        // Store namespaces as JSON for JavaScript
1452        $allNamespaces = $this->getAllNamespaces();
1453        $html .= '<script type="application/json" id="namespaces-data-' . $calId . '">' . json_encode($allNamespaces) . '</script>';
1454
1455        $html .= '</div>';
1456
1457        // 2. DESCRIPTION
1458        $html .= '<div class="form-field">';
1459        $html .= '<label class="field-label">�� Description</label>';
1460        $html .= '<textarea id="event-desc-' . $calId . '" name="description" rows="1" class="input-sleek textarea-sleek textarea-compact" placeholder="Optional details..."></textarea>';
1461        $html .= '</div>';
1462
1463        // 3. START DATE - END DATE (inline)
1464        $html .= '<div class="form-row-group">';
1465
1466        $html .= '<div class="form-field form-field-half">';
1467        $html .= '<label class="field-label-compact">�� Start Date</label>';
1468        $html .= '<input type="date" id="event-date-' . $calId . '" name="date" required class="input-sleek input-date input-compact">';
1469        $html .= '</div>';
1470
1471        $html .= '<div class="form-field form-field-half">';
1472        $html .= '<label class="field-label-compact">�� End Date</label>';
1473        $html .= '<input type="date" id="event-end-date-' . $calId . '" name="endDate" class="input-sleek input-date input-compact" placeholder="Optional">';
1474        $html .= '</div>';
1475
1476        $html .= '</div>'; // End row
1477
1478        // 4. IS REPEATING CHECKBOX
1479        $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">';
1480        $html .= '<label class="checkbox-label checkbox-label-compact">';
1481        $html .= '<input type="checkbox" id="event-recurring-' . $calId . '" name="isRecurring" class="recurring-toggle" onchange="toggleRecurringOptions(\'' . $calId . '\')">';
1482        $html .= '<span>�� Repeating Event</span>';
1483        $html .= '</label>';
1484        $html .= '</div>';
1485
1486        // Recurring options (shown when checkbox is checked)
1487        $html .= '<div id="recurring-options-' . $calId . '" class="recurring-options" style="display:none;">';
1488
1489        $html .= '<div class="form-row-group">';
1490
1491        $html .= '<div class="form-field form-field-half">';
1492        $html .= '<label class="field-label-compact">Repeat Every</label>';
1493        $html .= '<select id="event-recurrence-type-' . $calId . '" name="recurrenceType" class="input-sleek input-compact">';
1494        $html .= '<option value="daily">Daily</option>';
1495        $html .= '<option value="weekly">Weekly</option>';
1496        $html .= '<option value="monthly">Monthly</option>';
1497        $html .= '<option value="yearly">Yearly</option>';
1498        $html .= '</select>';
1499        $html .= '</div>';
1500
1501        $html .= '<div class="form-field form-field-half">';
1502        $html .= '<label class="field-label-compact">Repeat Until</label>';
1503        $html .= '<input type="date" id="event-recurrence-end-' . $calId . '" name="recurrenceEnd" class="input-sleek input-date input-compact" placeholder="Optional">';
1504        $html .= '</div>';
1505
1506        $html .= '</div>'; // End row
1507        $html .= '</div>'; // End recurring options
1508
1509        // 5. TIME (Start & End) - COLOR (inline)
1510        $html .= '<div class="form-row-group">';
1511
1512        $html .= '<div class="form-field form-field-half">';
1513        $html .= '<label class="field-label-compact">�� Start Time</label>';
1514        $html .= '<select id="event-time-' . $calId . '" name="time" class="input-sleek input-compact" onchange="updateEndTimeOptions(\'' . $calId . '\')">';
1515        $html .= '<option value="">All day</option>';
1516
1517        // Generate time options in 15-minute intervals
1518        for ($hour = 0; $hour < 24; $hour++) {
1519            for ($minute = 0; $minute < 60; $minute += 15) {
1520                $timeValue = sprintf('%02d:%02d', $hour, $minute);
1521                $displayHour = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour);
1522                $ampm = $hour < 12 ? 'AM' : 'PM';
1523                $displayTime = sprintf('%d:%02d %s', $displayHour, $minute, $ampm);
1524                $html .= '<option value="' . $timeValue . '">' . $displayTime . '</option>';
1525            }
1526        }
1527
1528        $html .= '</select>';
1529        $html .= '</div>';
1530
1531        $html .= '<div class="form-field form-field-half">';
1532        $html .= '<label class="field-label-compact">�� End Time</label>';
1533        $html .= '<select id="event-end-time-' . $calId . '" name="endTime" class="input-sleek input-compact">';
1534        $html .= '<option value="">Same as start</option>';
1535
1536        // Generate time options in 15-minute intervals
1537        for ($hour = 0; $hour < 24; $hour++) {
1538            for ($minute = 0; $minute < 60; $minute += 15) {
1539                $timeValue = sprintf('%02d:%02d', $hour, $minute);
1540                $displayHour = $hour == 0 ? 12 : ($hour > 12 ? $hour - 12 : $hour);
1541                $ampm = $hour < 12 ? 'AM' : 'PM';
1542                $displayTime = sprintf('%d:%02d %s', $displayHour, $minute, $ampm);
1543                $html .= '<option value="' . $timeValue . '">' . $displayTime . '</option>';
1544            }
1545        }
1546
1547        $html .= '</select>';
1548        $html .= '</div>';
1549
1550        $html .= '</div>'; // End row
1551
1552        // Color field (new row)
1553        $html .= '<div class="form-row-group">';
1554
1555        $html .= '<div class="form-field form-field-full">';
1556        $html .= '<label class="field-label-compact">�� Color</label>';
1557        $html .= '<div class="color-picker-wrapper">';
1558        $html .= '<select id="event-color-' . $calId . '" name="color" class="input-sleek input-compact color-select" onchange="updateCustomColorPicker(\'' . $calId . '\')">';
1559        $html .= '<option value="#3498db" style="background:#3498db;color:white">�� Blue</option>';
1560        $html .= '<option value="#2ecc71" style="background:#2ecc71;color:white">�� Green</option>';
1561        $html .= '<option value="#e74c3c" style="background:#e74c3c;color:white">�� Red</option>';
1562        $html .= '<option value="#f39c12" style="background:#f39c12;color:white">�� Orange</option>';
1563        $html .= '<option value="#9b59b6" style="background:#9b59b6;color:white">�� Purple</option>';
1564        $html .= '<option value="#e91e63" style="background:#e91e63;color:white">�� Pink</option>';
1565        $html .= '<option value="#1abc9c" style="background:#1abc9c;color:white">�� Teal</option>';
1566        $html .= '<option value="custom">�� Custom...</option>';
1567        $html .= '</select>';
1568        $html .= '<input type="color" id="event-color-custom-' . $calId . '" class="color-picker-input color-picker-compact" value="#3498db" onchange="updateColorFromPicker(\'' . $calId . '\')">';
1569        $html .= '</div>';
1570        $html .= '</div>';
1571
1572        $html .= '</div>'; // End row
1573
1574        // Task checkbox
1575        $html .= '<div class="form-field form-field-checkbox form-field-checkbox-compact">';
1576        $html .= '<label class="checkbox-label checkbox-label-compact">';
1577        $html .= '<input type="checkbox" id="event-is-task-' . $calId . '" name="isTask" class="task-toggle">';
1578        $html .= '<span>�� This is a task (can be checked off)</span>';
1579        $html .= '</label>';
1580        $html .= '</div>';
1581
1582        // Action buttons
1583        $html .= '<div class="dialog-actions-sleek">';
1584        $html .= '<button type="button" class="btn-sleek btn-cancel-sleek" onclick="closeEventDialog(\'' . $calId . '\')">Cancel</button>';
1585        $html .= '<button type="submit" class="btn-sleek btn-save-sleek">�� Save</button>';
1586        $html .= '</div>';
1587
1588        $html .= '</form>';
1589        $html .= '</div>';
1590        $html .= '</div>';
1591
1592        return $html;
1593    }
1594
1595    private function renderMonthPicker($calId, $year, $month, $namespace) {
1596        $html = '<div class="month-picker-overlay" id="month-picker-overlay-' . $calId . '" style="display:none;" onclick="closeMonthPicker(\'' . $calId . '\')">';
1597        $html .= '<div class="month-picker-dialog" onclick="event.stopPropagation();">';
1598        $html .= '<h4>Jump to Month</h4>';
1599
1600        $html .= '<div class="month-picker-selects">';
1601        $html .= '<select id="month-picker-month-' . $calId . '" class="month-picker-select">';
1602        $monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
1603        for ($m = 1; $m <= 12; $m++) {
1604            $selected = ($m == $month) ? ' selected' : '';
1605            $html .= '<option value="' . $m . '"' . $selected . '>' . $monthNames[$m - 1] . '</option>';
1606        }
1607        $html .= '</select>';
1608
1609        $html .= '<select id="month-picker-year-' . $calId . '" class="month-picker-select">';
1610        $currentYear = (int)date('Y');
1611        for ($y = $currentYear - 5; $y <= $currentYear + 5; $y++) {
1612            $selected = ($y == $year) ? ' selected' : '';
1613            $html .= '<option value="' . $y . '"' . $selected . '>' . $y . '</option>';
1614        }
1615        $html .= '</select>';
1616        $html .= '</div>';
1617
1618        $html .= '<div class="month-picker-actions">';
1619        $html .= '<button class="btn-sleek btn-cancel-sleek" onclick="closeMonthPicker(\'' . $calId . '\')">Cancel</button>';
1620        $html .= '<button class="btn-sleek btn-save-sleek" onclick="jumpToSelectedMonth(\'' . $calId . '\', \'' . $namespace . '\')">Go</button>';
1621        $html .= '</div>';
1622
1623        $html .= '</div>';
1624        $html .= '</div>';
1625
1626        return $html;
1627    }
1628
1629    private function renderDescription($description) {
1630        if (empty($description)) {
1631            return '';
1632        }
1633
1634        // Token-based parsing to avoid escaping issues
1635        $rendered = $description;
1636        $tokens = array();
1637        $tokenIndex = 0;
1638
1639        // Convert DokuWiki image syntax {{image.jpg}} to tokens
1640        $pattern = '/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/';
1641        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
1642        foreach ($matches as $match) {
1643            $imagePath = trim($match[1]);
1644            $alt = isset($match[2]) ? trim($match[2]) : '';
1645
1646            // Handle external URLs
1647            if (preg_match('/^https?:\/\//', $imagePath)) {
1648                $imageHtml = '<img src="' . htmlspecialchars($imagePath) . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
1649            } else {
1650                // Handle internal DokuWiki images
1651                $imageUrl = DOKU_BASE . 'lib/exe/fetch.php?media=' . rawurlencode($imagePath);
1652                $imageHtml = '<img src="' . $imageUrl . '" alt="' . htmlspecialchars($alt) . '" class="event-image" />';
1653            }
1654
1655            $token = "\x00TOKEN" . $tokenIndex . "\x00";
1656            $tokens[$tokenIndex] = $imageHtml;
1657            $tokenIndex++;
1658            $rendered = str_replace($match[0], $token, $rendered);
1659        }
1660
1661        // Convert DokuWiki link syntax [[link|text]] to tokens
1662        $pattern = '/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/';
1663        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
1664        foreach ($matches as $match) {
1665            $link = trim($match[1]);
1666            $text = isset($match[2]) ? trim($match[2]) : $link;
1667
1668            // Handle external URLs
1669            if (preg_match('/^https?:\/\//', $link)) {
1670                $linkHtml = '<a href="' . htmlspecialchars($link) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
1671            } else {
1672                // Handle internal DokuWiki links with section anchors
1673                $parts = explode('#', $link, 2);
1674                $pagePart = $parts[0];
1675                $sectionPart = isset($parts[1]) ? '#' . $parts[1] : '';
1676
1677                $wikiUrl = DOKU_BASE . 'doku.php?id=' . rawurlencode($pagePart) . $sectionPart;
1678                $linkHtml = '<a href="' . $wikiUrl . '">' . htmlspecialchars($text) . '</a>';
1679            }
1680
1681            $token = "\x00TOKEN" . $tokenIndex . "\x00";
1682            $tokens[$tokenIndex] = $linkHtml;
1683            $tokenIndex++;
1684            $rendered = str_replace($match[0], $token, $rendered);
1685        }
1686
1687        // Convert markdown-style links [text](url) to tokens
1688        $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
1689        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
1690        foreach ($matches as $match) {
1691            $text = trim($match[1]);
1692            $url = trim($match[2]);
1693
1694            if (preg_match('/^https?:\/\//', $url)) {
1695                $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($text) . '</a>';
1696            } else {
1697                $linkHtml = '<a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($text) . '</a>';
1698            }
1699
1700            $token = "\x00TOKEN" . $tokenIndex . "\x00";
1701            $tokens[$tokenIndex] = $linkHtml;
1702            $tokenIndex++;
1703            $rendered = str_replace($match[0], $token, $rendered);
1704        }
1705
1706        // Convert plain URLs to tokens
1707        $pattern = '/(https?:\/\/[^\s<]+)/';
1708        preg_match_all($pattern, $rendered, $matches, PREG_SET_ORDER);
1709        foreach ($matches as $match) {
1710            $url = $match[1];
1711            $linkHtml = '<a href="' . htmlspecialchars($url) . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($url) . '</a>';
1712
1713            $token = "\x00TOKEN" . $tokenIndex . "\x00";
1714            $tokens[$tokenIndex] = $linkHtml;
1715            $tokenIndex++;
1716            $rendered = str_replace($match[0], $token, $rendered);
1717        }
1718
1719        // NOW escape HTML (tokens are protected)
1720        $rendered = htmlspecialchars($rendered);
1721
1722        // Convert newlines to <br>
1723        $rendered = nl2br($rendered);
1724
1725        // DokuWiki text formatting
1726        // Bold: **text** or __text__
1727        $rendered = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $rendered);
1728        $rendered = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $rendered);
1729
1730        // Italic: //text//
1731        $rendered = preg_replace('/\/\/(.+?)\/\//', '<em>$1</em>', $rendered);
1732
1733        // Strikethrough: <del>text</del>
1734        $rendered = preg_replace('/&lt;del&gt;(.+?)&lt;\/del&gt;/', '<del>$1</del>', $rendered);
1735
1736        // Monospace: ''text''
1737        $rendered = preg_replace('/&#039;&#039;(.+?)&#039;&#039;/', '<code>$1</code>', $rendered);
1738
1739        // Subscript: <sub>text</sub>
1740        $rendered = preg_replace('/&lt;sub&gt;(.+?)&lt;\/sub&gt;/', '<sub>$1</sub>', $rendered);
1741
1742        // Superscript: <sup>text</sup>
1743        $rendered = preg_replace('/&lt;sup&gt;(.+?)&lt;\/sup&gt;/', '<sup>$1</sup>', $rendered);
1744
1745        // Restore tokens
1746        foreach ($tokens as $i => $html) {
1747            $rendered = str_replace("\x00TOKEN" . $i . "\x00", $html, $rendered);
1748        }
1749
1750        return $rendered;
1751    }
1752
1753    private function loadEvents($namespace, $year, $month) {
1754        $dataDir = DOKU_INC . 'data/meta/';
1755        if ($namespace) {
1756            $dataDir .= str_replace(':', '/', $namespace) . '/';
1757        }
1758        $dataDir .= 'calendar/';
1759
1760        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
1761
1762        if (file_exists($eventFile)) {
1763            $json = file_get_contents($eventFile);
1764            return json_decode($json, true);
1765        }
1766
1767        return array();
1768    }
1769
1770    private function loadEventsMultiNamespace($namespaces, $year, $month) {
1771        // Check for wildcard pattern (namespace:*)
1772        if (preg_match('/^(.+):\*$/', $namespaces, $matches)) {
1773            $baseNamespace = $matches[1];
1774            return $this->loadEventsWildcard($baseNamespace, $year, $month);
1775        }
1776
1777        // Check for root wildcard (just *)
1778        if ($namespaces === '*') {
1779            return $this->loadEventsWildcard('', $year, $month);
1780        }
1781
1782        // Parse namespace list (semicolon separated)
1783        // e.g., "team:projects;personal;work:tasks" = three namespaces
1784        $namespaceList = array_map('trim', explode(';', $namespaces));
1785
1786        // Load events from all namespaces
1787        $allEvents = array();
1788        foreach ($namespaceList as $ns) {
1789            $ns = trim($ns);
1790            if (empty($ns)) continue;
1791
1792            $events = $this->loadEvents($ns, $year, $month);
1793
1794            // Add namespace tag to each event
1795            foreach ($events as $dateKey => $dayEvents) {
1796                if (!isset($allEvents[$dateKey])) {
1797                    $allEvents[$dateKey] = array();
1798                }
1799                foreach ($dayEvents as $event) {
1800                    $event['_namespace'] = $ns;
1801                    $allEvents[$dateKey][] = $event;
1802                }
1803            }
1804        }
1805
1806        return $allEvents;
1807    }
1808
1809    private function loadEventsWildcard($baseNamespace, $year, $month) {
1810        // Find all subdirectories under the base namespace
1811        $dataDir = DOKU_INC . 'data/meta/';
1812        if ($baseNamespace) {
1813            $dataDir .= str_replace(':', '/', $baseNamespace) . '/';
1814        }
1815
1816        $allEvents = array();
1817
1818        // First, load events from the base namespace itself
1819        if (empty($baseNamespace)) {
1820            // Root wildcard - load from root calendar
1821            $events = $this->loadEvents('', $year, $month);
1822            foreach ($events as $dateKey => $dayEvents) {
1823                if (!isset($allEvents[$dateKey])) {
1824                    $allEvents[$dateKey] = array();
1825                }
1826                foreach ($dayEvents as $event) {
1827                    $event['_namespace'] = '';
1828                    $allEvents[$dateKey][] = $event;
1829                }
1830            }
1831        } else {
1832            $events = $this->loadEvents($baseNamespace, $year, $month);
1833            foreach ($events as $dateKey => $dayEvents) {
1834                if (!isset($allEvents[$dateKey])) {
1835                    $allEvents[$dateKey] = array();
1836                }
1837                foreach ($dayEvents as $event) {
1838                    $event['_namespace'] = $baseNamespace;
1839                    $allEvents[$dateKey][] = $event;
1840                }
1841            }
1842        }
1843
1844        // Recursively find all subdirectories
1845        $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents);
1846
1847        return $allEvents;
1848    }
1849
1850    private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) {
1851        if (!is_dir($dir)) return;
1852
1853        $items = scandir($dir);
1854        foreach ($items as $item) {
1855            if ($item === '.' || $item === '..') continue;
1856
1857            $path = $dir . $item;
1858            if (is_dir($path) && $item !== 'calendar') {
1859                // This is a namespace directory
1860                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1861
1862                // Load events from this namespace
1863                $events = $this->loadEvents($namespace, $year, $month);
1864                foreach ($events as $dateKey => $dayEvents) {
1865                    if (!isset($allEvents[$dateKey])) {
1866                        $allEvents[$dateKey] = array();
1867                    }
1868                    foreach ($dayEvents as $event) {
1869                        $event['_namespace'] = $namespace;
1870                        $allEvents[$dateKey][] = $event;
1871                    }
1872                }
1873
1874                // Recurse into subdirectories
1875                $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents);
1876            }
1877        }
1878    }
1879
1880    private function getAllNamespaces() {
1881        $dataDir = DOKU_INC . 'data/meta/';
1882        $namespaces = [];
1883
1884        // Scan for namespaces that have calendar data
1885        $this->scanForCalendarNamespaces($dataDir, '', $namespaces);
1886
1887        // Sort alphabetically
1888        sort($namespaces);
1889
1890        return $namespaces;
1891    }
1892
1893    private function scanForCalendarNamespaces($dir, $baseNamespace, &$namespaces) {
1894        if (!is_dir($dir)) return;
1895
1896        $items = scandir($dir);
1897        foreach ($items as $item) {
1898            if ($item === '.' || $item === '..') continue;
1899
1900            $path = $dir . $item;
1901            if (is_dir($path)) {
1902                // Check if this directory has a calendar subdirectory with data
1903                $calendarDir = $path . '/calendar/';
1904                if (is_dir($calendarDir)) {
1905                    // Check if there are any JSON files in the calendar directory
1906                    $jsonFiles = glob($calendarDir . '*.json');
1907                    if (!empty($jsonFiles)) {
1908                        // This namespace has calendar data
1909                        $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1910                        $namespaces[] = $namespace;
1911                    }
1912                }
1913
1914                // Recurse into subdirectories
1915                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
1916                $this->scanForCalendarNamespaces($path . '/', $namespace, $namespaces);
1917            }
1918        }
1919    }
1920
1921    /**
1922     * Render new sidebar widget - Week at a glance itinerary (200px wide)
1923     */
1924    private function renderSidebarWidget($events, $namespace, $calId) {
1925        if (empty($events)) {
1926            return '<div style="width:200px; padding:12px; text-align:center; color:#999; font-size:11px;">No events this week</div>';
1927        }
1928
1929        // Get important namespaces from config
1930        $configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
1931        $importantNsList = ['important']; // default
1932        if (file_exists($configFile)) {
1933            $config = include $configFile;
1934            if (isset($config['important_namespaces']) && !empty($config['important_namespaces'])) {
1935                $importantNsList = array_map('trim', explode(',', $config['important_namespaces']));
1936            }
1937        }
1938
1939        // Calculate date ranges
1940        $todayStr = date('Y-m-d');
1941        $tomorrowStr = date('Y-m-d', strtotime('+1 day'));
1942        $weekStart = date('Y-m-d', strtotime('monday this week'));
1943        $weekEnd = date('Y-m-d', strtotime('sunday this week'));
1944
1945        // Group events by category
1946        $todayEvents = [];
1947        $tomorrowEvents = [];
1948        $importantEvents = [];
1949        $weekEvents = []; // For week grid
1950
1951        // Process all events
1952        foreach ($events as $dateKey => $dayEvents) {
1953            // Skip events before this week
1954            if ($dateKey < $weekStart) continue;
1955
1956            // Initialize week grid day if in current week
1957            if ($dateKey >= $weekStart && $dateKey <= $weekEnd) {
1958                if (!isset($weekEvents[$dateKey])) {
1959                    $weekEvents[$dateKey] = [];
1960                }
1961            }
1962
1963            foreach ($dayEvents as $event) {
1964                // Add to week grid if in week range
1965                if ($dateKey >= $weekStart && $dateKey <= $weekEnd) {
1966                    // Pre-render DokuWiki syntax to HTML for JavaScript display
1967                    $eventWithHtml = $event;
1968                    if (isset($event['title'])) {
1969                        $eventWithHtml['title_html'] = $this->renderDokuWikiToHtml($event['title']);
1970                    }
1971                    if (isset($event['description'])) {
1972                        $eventWithHtml['description_html'] = $this->renderDokuWikiToHtml($event['description']);
1973                    }
1974                    $weekEvents[$dateKey][] = $eventWithHtml;
1975                }
1976
1977                // Categorize for detailed sections
1978                if ($dateKey === $todayStr) {
1979                    $todayEvents[] = array_merge($event, ['date' => $dateKey]);
1980                } elseif ($dateKey === $tomorrowStr) {
1981                    $tomorrowEvents[] = array_merge($event, ['date' => $dateKey]);
1982                } else {
1983                    // Check if this is an important namespace
1984                    $eventNs = isset($event['namespace']) ? $event['namespace'] : '';
1985                    $isImportant = false;
1986                    foreach ($importantNsList as $impNs) {
1987                        if ($eventNs === $impNs || strpos($eventNs, $impNs . ':') === 0) {
1988                            $isImportant = true;
1989                            break;
1990                        }
1991                    }
1992
1993                    // Important events: this week but not today/tomorrow
1994                    if ($isImportant && $dateKey >= $weekStart && $dateKey <= $weekEnd) {
1995                        $importantEvents[] = array_merge($event, ['date' => $dateKey]);
1996                    }
1997                }
1998            }
1999        }
2000
2001        // Start building HTML - Dynamic width with default font
2002        $html = '<div class="sidebar-widget sidebar-matrix" style="width:100%; max-width:100%; box-sizing:border-box; font-family:system-ui, sans-serif; background:#242424; border:2px solid #00cc07; border-radius:4px; overflow:hidden; box-shadow:0 0 10px rgba(0, 204, 7, 0.3);">';
2003
2004        // Sanitize calId for use in JavaScript variable names (remove dashes)
2005        $jsCalId = str_replace('-', '_', $calId);
2006
2007        // CRITICAL: Add ALL JavaScript FIRST before any HTML that uses it
2008        $html .= '<script>
2009(function() {
2010    // Shared state for system stats and tooltips
2011    const sharedState_' . $jsCalId . ' = {
2012        latestStats: {
2013            load: {"1min": 0, "5min": 0, "15min": 0},
2014            uptime: "",
2015            memory_details: {},
2016            top_processes: []
2017        },
2018        cpuHistory: [],
2019        CPU_HISTORY_SIZE: 2
2020    };
2021
2022    // Tooltip functions - MUST be defined before HTML uses them
2023    window["showTooltip_' . $jsCalId . '"] = function(color) {
2024        const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '");
2025        if (!tooltip) {
2026            console.log("Tooltip element not found for color:", color);
2027            return;
2028        }
2029
2030        const latestStats = sharedState_' . $jsCalId . '.latestStats;
2031        let content = "";
2032
2033        if (color === "green") {
2034            content = "<div class=\\"tooltip-title\\">CPU Load Average</div>";
2035            content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>";
2036            content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>";
2037            content += "<div>15 min: " + (latestStats.load["15min"] || "N/A") + "</div>";
2038            if (latestStats.uptime) {
2039                content += "<div style=\\"margin-top:3px; padding-top:2px; border-top:1px solid rgba(0,204,7,0.3);\\">Uptime: " + latestStats.uptime + "</div>";
2040            }
2041            tooltip.style.borderColor = "#00cc07";
2042            tooltip.style.color = "#00cc07";
2043        } else if (color === "purple") {
2044            content = "<div class=\\"tooltip-title\\">CPU Load (Short-term)</div>";
2045            content += "<div>1 min: " + (latestStats.load["1min"] || "N/A") + "</div>";
2046            content += "<div>5 min: " + (latestStats.load["5min"] || "N/A") + "</div>";
2047            if (latestStats.top_processes && latestStats.top_processes.length > 0) {
2048                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>";
2049                latestStats.top_processes.slice(0, 5).forEach(proc => {
2050                    content += "<div>" + proc.cpu + " " + proc.command + "</div>";
2051                });
2052            }
2053            tooltip.style.borderColor = "#9b59b6";
2054            tooltip.style.color = "#9b59b6";
2055        } else if (color === "orange") {
2056            content = "<div class=\\"tooltip-title\\">Memory Usage</div>";
2057            if (latestStats.memory_details && latestStats.memory_details.total) {
2058                content += "<div>Total: " + latestStats.memory_details.total + "</div>";
2059                content += "<div>Used: " + latestStats.memory_details.used + "</div>";
2060                content += "<div>Available: " + latestStats.memory_details.available + "</div>";
2061                if (latestStats.memory_details.cached) {
2062                    content += "<div>Cached: " + latestStats.memory_details.cached + "</div>";
2063                }
2064            } else {
2065                content += "<div>Loading...</div>";
2066            }
2067            if (latestStats.top_processes && latestStats.top_processes.length > 0) {
2068                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>";
2069                latestStats.top_processes.slice(0, 5).forEach(proc => {
2070                    content += "<div>" + proc.cpu + " " + proc.command + "</div>";
2071                });
2072            }
2073            tooltip.style.borderColor = "#ff9800";
2074            tooltip.style.color = "#ff9800";
2075        }
2076
2077        tooltip.innerHTML = content;
2078        tooltip.style.display = "block";
2079
2080        const bar = tooltip.parentElement;
2081        const barRect = bar.getBoundingClientRect();
2082        const tooltipRect = tooltip.getBoundingClientRect();
2083
2084        const left = barRect.left + (barRect.width / 2) - (tooltipRect.width / 2);
2085        const top = barRect.top - tooltipRect.height - 8;
2086
2087        tooltip.style.left = left + "px";
2088        tooltip.style.top = top + "px";
2089    };
2090
2091    window["hideTooltip_' . $jsCalId . '"] = function(color) {
2092        const tooltip = document.getElementById("tooltip-" + color + "-' . $calId . '");
2093        if (tooltip) {
2094            tooltip.style.display = "none";
2095        }
2096    };
2097
2098    // Update clock every second
2099    function updateClock() {
2100        const now = new Date();
2101        let hours = now.getHours();
2102        const minutes = String(now.getMinutes()).padStart(2, "0");
2103        const seconds = String(now.getSeconds()).padStart(2, "0");
2104        const ampm = hours >= 12 ? "PM" : "AM";
2105        hours = hours % 12 || 12;
2106        const timeStr = hours + ":" + minutes + ":" + seconds + " " + ampm;
2107        const clockEl = document.getElementById("clock-' . $calId . '");
2108        if (clockEl) clockEl.textContent = timeStr;
2109    }
2110    setInterval(updateClock, 1000);
2111
2112    // Weather update function
2113    function updateWeather() {
2114        if ("geolocation" in navigator) {
2115            navigator.geolocation.getCurrentPosition(function(position) {
2116                const lat = position.coords.latitude;
2117                const lon = position.coords.longitude;
2118
2119                fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current_weather=true&temperature_unit=fahrenheit`)
2120                    .then(response => response.json())
2121                    .then(data => {
2122                        if (data.current_weather) {
2123                            const temp = Math.round(data.current_weather.temperature);
2124                            const weatherCode = data.current_weather.weathercode;
2125                            const icon = getWeatherIcon(weatherCode);
2126                            const iconEl = document.getElementById("weather-icon-' . $calId . '");
2127                            const tempEl = document.getElementById("weather-temp-' . $calId . '");
2128                            if (iconEl) iconEl.textContent = icon;
2129                            if (tempEl) tempEl.innerHTML = temp + "&deg;";
2130                        }
2131                    })
2132                    .catch(error => console.log("Weather fetch error:", error));
2133            }, function(error) {
2134                // If geolocation fails, use default location (Irvine, CA)
2135                fetch("https://api.open-meteo.com/v1/forecast?latitude=33.6846&longitude=-117.8265&current_weather=true&temperature_unit=fahrenheit")
2136                    .then(response => response.json())
2137                    .then(data => {
2138                        if (data.current_weather) {
2139                            const temp = Math.round(data.current_weather.temperature);
2140                            const weatherCode = data.current_weather.weathercode;
2141                            const icon = getWeatherIcon(weatherCode);
2142                            const iconEl = document.getElementById("weather-icon-' . $calId . '");
2143                            const tempEl = document.getElementById("weather-temp-' . $calId . '");
2144                            if (iconEl) iconEl.textContent = icon;
2145                            if (tempEl) tempEl.innerHTML = temp + "&deg;";
2146                        }
2147                    })
2148                    .catch(err => console.log("Weather error:", err));
2149            });
2150        } else {
2151            // No geolocation, use default (Irvine, CA)
2152            fetch("https://api.open-meteo.com/v1/forecast?latitude=33.6846&longitude=-117.8265&current_weather=true&temperature_unit=fahrenheit")
2153                .then(response => response.json())
2154                .then(data => {
2155                    if (data.current_weather) {
2156                        const temp = Math.round(data.current_weather.temperature);
2157                        const weatherCode = data.current_weather.weathercode;
2158                        const icon = getWeatherIcon(weatherCode);
2159                        const iconEl = document.getElementById("weather-icon-' . $calId . '");
2160                        const tempEl = document.getElementById("weather-temp-' . $calId . '");
2161                        if (iconEl) iconEl.textContent = icon;
2162                        if (tempEl) tempEl.innerHTML = temp + "&deg;";
2163                    }
2164                })
2165                .catch(err => console.log("Weather error:", err));
2166        }
2167    }
2168
2169    function getWeatherIcon(code) {
2170        const icons = {
2171            0: "☀️", 1: "��️", 2: "⛅", 3: "☁️",
2172            45: "��️", 48: "��️", 51: "��️", 53: "��️", 55: "��️",
2173            61: "��️", 63: "��️", 65: "⛈️", 71: "��️", 73: "��️",
2174            75: "❄️", 77: "��️", 80: "��️", 81: "��️", 82: "⛈️",
2175            85: "��️", 86: "❄️", 95: "⛈️", 96: "⛈️", 99: "⛈️"
2176        };
2177        return icons[code] || "��️";
2178    }
2179
2180    // Update weather immediately and every 10 minutes
2181    updateWeather();
2182    setInterval(updateWeather, 600000);
2183
2184    // Update system stats and tooltips data
2185    function updateSystemStats() {
2186        fetch("' . DOKU_BASE . 'lib/plugins/calendar/get_system_stats.php")
2187            .then(response => response.json())
2188            .then(data => {
2189                sharedState_' . $jsCalId . '.latestStats = {
2190                    load: data.load || {"1min": 0, "5min": 0, "15min": 0},
2191                    uptime: data.uptime || "",
2192                    memory_details: data.memory_details || {},
2193                    top_processes: data.top_processes || []
2194                };
2195
2196                const greenBar = document.getElementById("cpu-5min-' . $calId . '");
2197                if (greenBar) {
2198                    greenBar.style.width = Math.min(100, data.cpu_5min) + "%";
2199                }
2200
2201                sharedState_' . $jsCalId . '.cpuHistory.push(data.cpu);
2202                if (sharedState_' . $jsCalId . '.cpuHistory.length > sharedState_' . $jsCalId . '.CPU_HISTORY_SIZE) {
2203                    sharedState_' . $jsCalId . '.cpuHistory.shift();
2204                }
2205
2206                const cpuAverage = sharedState_' . $jsCalId . '.cpuHistory.reduce((sum, val) => sum + val, 0) / sharedState_' . $jsCalId . '.cpuHistory.length;
2207
2208                const cpuBar = document.getElementById("cpu-realtime-' . $calId . '");
2209                if (cpuBar) {
2210                    cpuBar.style.width = Math.min(100, cpuAverage) + "%";
2211                }
2212
2213                const memBar = document.getElementById("mem-realtime-' . $calId . '");
2214                if (memBar) {
2215                    memBar.style.width = Math.min(100, data.memory) + "%";
2216                }
2217            })
2218            .catch(error => {
2219                console.log("System stats error:", error);
2220            });
2221    }
2222
2223    updateSystemStats();
2224    setInterval(updateSystemStats, 2000);
2225})();
2226</script>';
2227
2228        // NOW add the header HTML (after JavaScript is defined)
2229        $todayDate = new DateTime();
2230        $displayDate = $todayDate->format('D, M j, Y');
2231        $currentTime = $todayDate->format('g:i:s A');
2232
2233        $html .= '<div class="eventlist-today-header">';
2234        $html .= '<span class="eventlist-today-clock" id="clock-' . $calId . '">' . $currentTime . '</span>';
2235        $html .= '<div class="eventlist-bottom-info">';
2236        $html .= '<span class="eventlist-weather"><span id="weather-icon-' . $calId . '">��️</span> <span id="weather-temp-' . $calId . '">--°</span></span>';
2237        $html .= '<span class="eventlist-today-date">' . $displayDate . '</span>';
2238        $html .= '</div>';
2239
2240        // Three CPU/Memory bars (all update live)
2241        $html .= '<div class="eventlist-stats-container">';
2242
2243        // 5-minute load average (green, updates every 2 seconds)
2244        $html .= '<div class="eventlist-cpu-bar" onmouseover="showTooltip_' . $jsCalId . '(\'green\')" onmouseout="hideTooltip_' . $jsCalId . '(\'green\')">';
2245        $html .= '<div class="eventlist-cpu-fill" id="cpu-5min-' . $calId . '" style="width: 0%;"></div>';
2246        $html .= '<div class="system-tooltip" id="tooltip-green-' . $calId . '" style="display:none;"></div>';
2247        $html .= '</div>';
2248
2249        // Real-time CPU (purple, updates with 5-sec average)
2250        $html .= '<div class="eventlist-cpu-bar eventlist-cpu-realtime" onmouseover="showTooltip_' . $jsCalId . '(\'purple\')" onmouseout="hideTooltip_' . $jsCalId . '(\'purple\')">';
2251        $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-purple" id="cpu-realtime-' . $calId . '" style="width: 0%;"></div>';
2252        $html .= '<div class="system-tooltip" id="tooltip-purple-' . $calId . '" style="display:none;"></div>';
2253        $html .= '</div>';
2254
2255        // Real-time Memory (orange, updates)
2256        $html .= '<div class="eventlist-cpu-bar eventlist-mem-realtime" onmouseover="showTooltip_' . $jsCalId . '(\'orange\')" onmouseout="hideTooltip_' . $jsCalId . '(\'orange\')">';
2257        $html .= '<div class="eventlist-cpu-fill eventlist-cpu-fill-orange" id="mem-realtime-' . $calId . '" style="width: 0%;"></div>';
2258        $html .= '<div class="system-tooltip" id="tooltip-orange-' . $calId . '" style="display:none;"></div>';
2259        $html .= '</div>';
2260
2261        $html .= '</div>';
2262        $html .= '</div>';
2263
2264        // Get today's date for default event date
2265        $todayStr = date('Y-m-d');
2266
2267        // Thin dark green "Add Event" bar between header and week grid (zero margin, smaller text, text positioned higher)
2268        $html .= '<div style="background:#006400; padding:0; margin:0; height:12px; line-height:10px; text-align:center; cursor:pointer; border-top:1px solid rgba(0, 100, 0, 0.3); border-bottom:1px solid rgba(0, 100, 0, 0.3); box-shadow:0 0 8px rgba(0, 100, 0, 0.4); transition:all 0.2s;" onclick="openAddEvent(\'' . $calId . '\', \'' . $namespace . '\', \'' . $todayStr . '\');" onmouseover="this.style.background=\'#004d00\'; this.style.boxShadow=\'0 0 12px rgba(0, 100, 0, 0.6)\';" onmouseout="this.style.background=\'#006400\'; this.style.boxShadow=\'0 0 8px rgba(0, 100, 0, 0.4)\';">';
2269        $html .= '<span style="color:#00ff00; font-size:8px; font-weight:700; letter-spacing:0.4px; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 255, 0, 0.5); position:relative; top:-1px;">+ ADD EVENT</span>';
2270        $html .= '</div>';
2271
2272        // Week grid (7 cells)
2273        $html .= $this->renderWeekGrid($weekEvents, $weekStart);
2274
2275        // Today section (orange)
2276        if (!empty($todayEvents)) {
2277            $html .= $this->renderSidebarSection('Today', $todayEvents, '#ff9800', $calId);
2278        }
2279
2280        // Tomorrow section (green)
2281        if (!empty($tomorrowEvents)) {
2282            $html .= $this->renderSidebarSection('Tomorrow', $tomorrowEvents, '#4caf50', $calId);
2283        }
2284
2285        // Important events section (purple)
2286        if (!empty($importantEvents)) {
2287            $html .= $this->renderSidebarSection('Important Events', $importantEvents, '#9b59b6', $calId);
2288        }
2289
2290        $html .= '</div>';
2291
2292        // Add event dialog for sidebar widget
2293        $html .= $this->renderEventDialog($calId, $namespace);
2294
2295        return $html;
2296    }
2297
2298    /**
2299     * Render compact week grid (7 cells with event bars) - Matrix themed with clickable days
2300     */
2301    private function renderWeekGrid($weekEvents, $weekStart) {
2302        // Generate unique ID for this calendar instance - sanitize for JavaScript
2303        $calId = 'cal_' . substr(md5($weekStart . microtime()), 0, 8);
2304        $jsCalId = str_replace('-', '_', $calId);  // Sanitize for JS variable names
2305
2306        $html = '<div style="display:grid; grid-template-columns:repeat(7, 1fr); gap:1px; background:#1a3d1a; border-bottom:2px solid #00cc07;">';
2307
2308        $dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
2309        $today = date('Y-m-d');
2310
2311        for ($i = 0; $i < 7; $i++) {
2312            $date = date('Y-m-d', strtotime($weekStart . ' +' . $i . ' days'));
2313            $dayNum = date('j', strtotime($date));
2314            $isToday = $date === $today;
2315
2316            $events = isset($weekEvents[$date]) ? $weekEvents[$date] : [];
2317            $eventCount = count($events);
2318
2319            $bgColor = $isToday ? '#2a4d2a' : '#242424';
2320            $textColor = $isToday ? '#00ff00' : '#00cc07';
2321            $fontWeight = $isToday ? '700' : '500';
2322            $textShadow = $isToday ? 'text-shadow:0 0 6px rgba(0, 255, 0, 0.6);' : 'text-shadow:0 0 4px rgba(0, 204, 7, 0.4);';
2323
2324            $hasEvents = $eventCount > 0;
2325            $clickableStyle = $hasEvents ? 'cursor:pointer;' : '';
2326            $clickHandler = $hasEvents ? ' onclick="showDayEvents_' . $jsCalId . '(\'' . $date . '\')"' : '';
2327
2328            $html .= '<div style="background:' . $bgColor . '; padding:4px 2px; text-align:center; min-height:45px; position:relative; border:1px solid rgba(0, 204, 7, 0.2); ' . $clickableStyle . '" ' . $clickHandler . '>';
2329
2330            // Day letter
2331            $html .= '<div style="font-size:9px; color:#00cc07; font-weight:500; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNames[$i] . '</div>';
2332
2333            // Day number
2334            $html .= '<div style="font-size:12px; color:' . $textColor . '; font-weight:' . $fontWeight . '; margin:2px 0; font-family:system-ui, sans-serif; ' . $textShadow . '">' . $dayNum . '</div>';
2335
2336            // Event bars (max 3 visible) with glow effect
2337            if ($eventCount > 0) {
2338                $showCount = min($eventCount, 3);
2339                for ($j = 0; $j < $showCount; $j++) {
2340                    $event = $events[$j];
2341                    $color = isset($event['color']) ? $event['color'] : '#00cc07';
2342                    $html .= '<div style="height:2px; background:' . htmlspecialchars($color) . '; margin:1px 0; border-radius:1px; box-shadow:0 0 3px ' . htmlspecialchars($color) . ';"></div>';
2343                }
2344
2345                // Show "+N more" if more than 3
2346                if ($eventCount > 3) {
2347                    $html .= '<div style="font-size:7px; color:#00cc07; margin-top:1px; font-family:system-ui, sans-serif;">+' . ($eventCount - 3) . '</div>';
2348                }
2349            }
2350
2351            $html .= '</div>';
2352        }
2353
2354        $html .= '</div>';
2355
2356        // Add container for selected day events display (with unique ID)
2357        $html .= '<div id="selected-day-events-' . $calId . '" style="display:none; margin:8px 4px; border-left:3px solid #3498db; box-shadow:0 0 5px rgba(0, 204, 7, 0.2);">';
2358        $html .= '<div style="background:#3498db; color:#000; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:0 0 8px #3498db; display:flex; justify-content:space-between; align-items:center;">';
2359        $html .= '<span id="selected-day-title-' . $calId . '"></span>';
2360        $html .= '<span onclick="document.getElementById(\'selected-day-events-' . $calId . '\').style.display=\'none\';" style="cursor:pointer; font-size:12px; padding:0 4px; font-weight:700;">✕</span>';
2361        $html .= '</div>';
2362        $html .= '<div id="selected-day-content-' . $calId . '" style="padding:4px 0; background:rgba(36, 36, 36, 0.5);"></div>';
2363        $html .= '</div>';
2364
2365        // Add JavaScript for day selection with event data
2366        $html .= '<script>';
2367        // Sanitize calId for JavaScript variable names
2368        $jsCalId = str_replace('-', '_', $calId);
2369        $html .= 'window.weekEventsData_' . $jsCalId . ' = ' . json_encode($weekEvents) . ';';
2370        $html .= '
2371        window.showDayEvents_' . $jsCalId . ' = function(dateKey) {
2372            const eventsData = window.weekEventsData_' . $jsCalId . ';
2373            const container = document.getElementById("selected-day-events-' . $calId . '");
2374            const title = document.getElementById("selected-day-title-' . $calId . '");
2375            const content = document.getElementById("selected-day-content-' . $calId . '");
2376
2377            if (!eventsData[dateKey] || eventsData[dateKey].length === 0) return;
2378
2379            // Format date for display
2380            const dateObj = new Date(dateKey + "T00:00:00");
2381            const dayName = dateObj.toLocaleDateString("en-US", { weekday: "long" });
2382            const monthDay = dateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2383            title.textContent = dayName + ", " + monthDay;
2384
2385            // Clear content
2386            content.innerHTML = "";
2387
2388            // Sort events by time (all-day events first, then timed events chronologically)
2389            const sortedEvents = [...eventsData[dateKey]].sort((a, b) => {
2390                // All-day events (no time) go to the beginning
2391                if (!a.time && !b.time) return 0;
2392                if (!a.time) return -1;  // a is all-day, comes first
2393                if (!b.time) return 1;   // b is all-day, comes first
2394
2395                // Compare times (format: "HH:MM")
2396                const timeA = a.time.split(":").map(Number);
2397                const timeB = b.time.split(":").map(Number);
2398                const minutesA = timeA[0] * 60 + timeA[1];
2399                const minutesB = timeB[0] * 60 + timeB[1];
2400
2401                return minutesA - minutesB;
2402            });
2403
2404            // Build events HTML with single color bar (event color only)
2405            sortedEvents.forEach(event => {
2406                const eventColor = event.color || "#00cc07";
2407
2408                const eventDiv = document.createElement("div");
2409                eventDiv.style.cssText = "padding:4px 6px; border-bottom:1px solid rgba(0, 204, 7, 0.2); font-size:10px; display:flex; align-items:stretch; gap:6px; background:rgba(36, 36, 36, 0.3); min-height:20px;";
2410
2411                let eventHTML = "";
2412
2413                // Event assigned color bar (single bar on left)
2414                eventHTML += "<div style=\\"width:3px; align-self:stretch; background:" + eventColor + "; border-radius:1px; flex-shrink:0; box-shadow:0 0 3px " + eventColor + ";\\"></div>";
2415
2416                // Content wrapper
2417                eventHTML += "<div style=\\"flex:1; min-width:0; display:flex; justify-content:space-between; align-items:start; gap:4px;\\">";
2418
2419                // Left side: event details
2420                eventHTML += "<div style=\\"flex:1; min-width:0;\\">";
2421                eventHTML += "<div style=\\"font-weight:600; color:#00cc07; word-wrap:break-word; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 204, 7, 0.4);\\">";
2422
2423                // Time
2424                if (event.time) {
2425                    const timeParts = event.time.split(":");
2426                    let hours = parseInt(timeParts[0]);
2427                    const minutes = timeParts[1];
2428                    const ampm = hours >= 12 ? "PM" : "AM";
2429                    hours = hours % 12 || 12;
2430                    eventHTML += "<span style=\\"color:#00dd00; font-weight:500; font-size:9px;\\">" + hours + ":" + minutes + " " + ampm + "</span> ";
2431                }
2432
2433                // Title - use HTML version if available
2434                const titleHTML = event.title_html || event.title || "Untitled";
2435                eventHTML += titleHTML;
2436                eventHTML += "</div>";
2437
2438                // Description if present - use HTML version
2439                if (event.description_html || event.description) {
2440                    const descHTML = event.description_html || event.description;
2441                    eventHTML += "<div style=\\"font-size:9px; color:#00aa00; margin-top:2px;\\">" + descHTML + "</div>";
2442                }
2443
2444                eventHTML += "</div>"; // Close event details
2445
2446                // Right side: conflict badge (if present)
2447                if (event.conflict) {
2448                    eventHTML += "<div style=\\"flex-shrink:0; color:#ff9800; font-size:10px; margin-top:2px; opacity:0.8;\\" title=\\"Time conflict detected\\">⚠</div>";
2449                }
2450
2451                eventHTML += "</div>"; // Close content wrapper
2452
2453                eventDiv.innerHTML = eventHTML;
2454                content.appendChild(eventDiv);
2455            });
2456
2457            container.style.display = "block";
2458        };
2459        ';
2460        $html .= '</script>';
2461
2462        return $html;
2463    }
2464
2465    /**
2466     * Render a sidebar section (Today/Tomorrow/Important) - Matrix themed with colored borders
2467     */
2468    private function renderSidebarSection($title, $events, $accentColor, $calId) {
2469        // Keep the original accent colors for borders
2470        $borderColor = $accentColor;
2471
2472        // Show date for Important Events section
2473        $showDate = ($title === 'Important Events');
2474
2475        $html = '<div style="border-left:3px solid ' . $borderColor . '; margin:8px 4px; box-shadow:0 0 5px rgba(0, 204, 7, 0.2);">';
2476
2477        // Section header with accent color background - smaller, not all caps
2478        $html .= '<div style="background:' . $accentColor . '; color:#000; padding:4px 6px; font-size:9px; font-weight:700; letter-spacing:0.3px; font-family:system-ui, sans-serif; box-shadow:0 0 8px ' . $accentColor . ';">';
2479        $html .= htmlspecialchars($title);
2480        $html .= '</div>';
2481
2482        // Events
2483        $html .= '<div style="padding:4px 0; background:rgba(36, 36, 36, 0.5);">';
2484
2485        foreach ($events as $event) {
2486            $html .= $this->renderSidebarEvent($event, $calId, $showDate, $accentColor);
2487        }
2488
2489        $html .= '</div>';
2490        $html .= '</div>';
2491
2492        return $html;
2493    }
2494
2495    /**
2496     * Render individual event in sidebar - Matrix themed with dual color bars
2497     */
2498    private function renderSidebarEvent($event, $calId, $showDate = false, $sectionColor = '#00cc07') {
2499        $title = isset($event['title']) ? htmlspecialchars($event['title']) : 'Untitled';
2500        $time = isset($event['time']) ? $event['time'] : '';
2501        $endTime = isset($event['endTime']) ? $event['endTime'] : '';
2502        $eventColor = isset($event['color']) ? htmlspecialchars($event['color']) : '#00cc07';
2503        $date = isset($event['date']) ? $event['date'] : '';
2504        $isTask = isset($event['isTask']) && $event['isTask'];
2505        $completed = isset($event['completed']) && $event['completed'];
2506
2507        // Check for conflicts
2508        $hasConflict = isset($event['conflicts']) && !empty($event['conflicts']);
2509
2510        $html = '<div style="padding:4px 6px; border-bottom:1px solid rgba(0, 204, 7, 0.2); font-size:10px; display:flex; align-items:stretch; gap:6px; background:rgba(36, 36, 36, 0.3); min-height:20px;">';
2511
2512        // Event's assigned color bar (single bar on the left)
2513        $html .= '<div style="width:3px; align-self:stretch; background:' . $eventColor . '; border-radius:1px; flex-shrink:0; box-shadow:0 0 3px ' . $eventColor . ';"></div>';
2514
2515        // Content
2516        $html .= '<div style="flex:1; min-width:0;">';
2517
2518        // Time + title
2519        $html .= '<div style="font-weight:600; color:#00cc07; word-wrap:break-word; font-family:system-ui, sans-serif; text-shadow:0 0 3px rgba(0, 204, 7, 0.4);">';
2520
2521        if ($time) {
2522            $displayTime = $this->formatTimeDisplay($time, $endTime);
2523            $html .= '<span style="color:#00dd00; font-weight:500; font-size:9px;">' . htmlspecialchars($displayTime) . '</span> ';
2524        }
2525
2526        // Task checkbox
2527        if ($isTask) {
2528            $checkIcon = $completed ? '☑' : '☐';
2529            $html .= '<span style="font-size:11px; color:#00ff00;">' . $checkIcon . '</span> ';
2530        }
2531
2532        $html .= htmlspecialchars($title);
2533
2534        // Conflict badge
2535        if ($hasConflict) {
2536            $conflictCount = count($event['conflicts']);
2537            $html .= ' <span style="background:#ff0000; color:#000; padding:1px 3px; border-radius:2px; font-size:8px; font-weight:700; box-shadow:0 0 4px #ff0000;">⚠ ' . $conflictCount . '</span>';
2538        }
2539
2540        $html .= '</div>';
2541
2542        // Date display BELOW event name for Important events
2543        if ($showDate && $date) {
2544            $dateObj = new DateTime($date);
2545            $displayDate = $dateObj->format('D, M j'); // e.g., "Mon, Feb 10"
2546            $html .= '<div style="font-size:8px; color:#00aa00; font-weight:500; margin-top:2px; text-shadow:0 0 2px rgba(0, 170, 0, 0.3);">' . htmlspecialchars($displayDate) . '</div>';
2547        }
2548
2549        $html .= '</div>';
2550        $html .= '</div>';
2551
2552        return $html;
2553    }
2554
2555    /**
2556     * Format time display (12-hour format with optional end time)
2557     */
2558    private function formatTimeDisplay($startTime, $endTime = '') {
2559        // Convert start time
2560        list($hour, $minute) = explode(':', $startTime);
2561        $hour = (int)$hour;
2562        $ampm = $hour >= 12 ? 'PM' : 'AM';
2563        $displayHour = $hour % 12;
2564        if ($displayHour === 0) $displayHour = 12;
2565
2566        $display = $displayHour . ':' . $minute . ' ' . $ampm;
2567
2568        // Add end time if provided
2569        if ($endTime && $endTime !== '') {
2570            list($endHour, $endMinute) = explode(':', $endTime);
2571            $endHour = (int)$endHour;
2572            $endAmpm = $endHour >= 12 ? 'PM' : 'AM';
2573            $endDisplayHour = $endHour % 12;
2574            if ($endDisplayHour === 0) $endDisplayHour = 12;
2575
2576            $display .= '-' . $endDisplayHour . ':' . $endMinute . ' ' . $endAmpm;
2577        }
2578
2579        return $display;
2580    }
2581
2582    /**
2583     * Render DokuWiki syntax to HTML
2584     * Converts **bold**, //italic//, [[links]], etc. to HTML
2585     */
2586    private function renderDokuWikiToHtml($text) {
2587        if (empty($text)) return '';
2588
2589        // Use DokuWiki's parser to render the text
2590        $instructions = p_get_instructions($text);
2591
2592        // Render instructions to XHTML
2593        $xhtml = p_render('xhtml', $instructions, $info);
2594
2595        // Remove surrounding <p> tags if present (we're rendering inline)
2596        $xhtml = preg_replace('/^<p>(.*)<\/p>$/s', '$1', trim($xhtml));
2597
2598        return $xhtml;
2599    }
2600
2601    // Keep old scanForNamespaces for backward compatibility (not used anymore)
2602    private function scanForNamespaces($dir, $baseNamespace, &$namespaces) {
2603        if (!is_dir($dir)) return;
2604
2605        $items = scandir($dir);
2606        foreach ($items as $item) {
2607            if ($item === '.' || $item === '..' || $item === 'calendar') continue;
2608
2609            $path = $dir . $item;
2610            if (is_dir($path)) {
2611                $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item;
2612                $namespaces[] = $namespace;
2613                $this->scanForNamespaces($path . '/', $namespace, $namespaces);
2614            }
2615        }
2616    }
2617}
2618