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