From 758bef013838afc17965d587e21d6d30e65cdc1f Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 4 May 2026 00:54:11 -0400 Subject: [PATCH] feat(timeline): add search and filter bar to sidebar timeline - Add collapsible filter panel with category checkboxes (Presence, Zones, Alerts, System, Learning) for client-side event type filtering - Add person and zone dropdowns populated from /api/people and /api/zones - Add date range selector (All Time / Today / Last 7 Days / Last 30 Days / Custom range) with server-side re-fetch on date changes - Add text search input with fuzzy client-side matching and FTS5 server-side prefix matching for descriptions - Add active filter tags with individual remove buttons and Clear All - Add load-more cursor pagination for 500+ results - Add virtualized rendering with IntersectionObserver for 1000+ events - Render event feedback buttons (thumbs up/down) inline on each event - Add now-replaying chip showing current replay timestamp Co-Authored-By: Claude Sonnet 4.6 --- dashboard/css/timeline.css | 345 +++++++++- dashboard/js/sidebar-timeline.js | 923 +++++++++++++++++++------- dashboard/js/sidebar-timeline.test.js | 25 +- dashboard/live.html | 91 +++ 4 files changed, 1120 insertions(+), 264 deletions(-) diff --git a/dashboard/css/timeline.css b/dashboard/css/timeline.css index bdf8ef0..2ac5f4b 100644 --- a/dashboard/css/timeline.css +++ b/dashboard/css/timeline.css @@ -861,10 +861,347 @@ box-shadow: -4px 0 20px var(--shadow); } -.sidebar-panel.collapsed { - transform: translateX(100%); - opacity: 0; - pointer-events: none; +/* ============================================ + Sidebar Filter Bar Styles + ============================================ */ + +.sidebar-timeline-filter-bar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-tertiary, var(--bg-card)); + border-bottom: 1px solid var(--border-color, var(--bg-hover)); + flex-shrink: 0; +} + +/* Search Input */ +.sidebar-filter-search { + flex: 1; +} + +.sidebar-search-input { + width: 100%; + background: var(--bg-primary, var(--bg-page)); + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + padding: var(--space-150) var(--space-250) var(--space-150) var(--space-6); + font-size: var(--text-xs); + color: var(--text-primary, var(--slate-11)); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 8px center; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.sidebar-search-input:hover { + border-color: var(--accent-color, var(--blue-9)); +} + +.sidebar-search-input:focus { + outline: none; + border-color: var(--accent-color, var(--blue-9)); + box-shadow: 0 0 0 2px var(--blue-border); +} + +.sidebar-search-input::placeholder { + color: var(--text-muted, var(--text-muted)); +} + +/* Filter Toggle Button */ +.sidebar-filter-toggle-btn { + background: var(--bg-primary, var(--bg-page)); + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + padding: var(--space-150) var(--space-2); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-150); + font-size: var(--text-2xs); + color: var(--text-secondary, var(--text-secondary)); + transition: all 0.2s; + white-space: nowrap; +} + +.sidebar-filter-toggle-btn:hover { + background: var(--bg-tertiary, var(--bg-card)); + border-color: var(--accent-color, var(--blue-9)); + color: var(--text-primary, var(--slate-11)); +} + +.sidebar-filter-toggle-btn svg { + width: 12px; + height: 12px; +} + +/* Filter Controls Panel */ +.sidebar-timeline-filter-controls { + background: var(--bg-tertiary, var(--bg-card)); + border-bottom: 1px solid var(--border-color, var(--bg-hover)); + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + flex-shrink: 0; +} + +.sidebar-timeline-filter-controls:not(.collapsed) { + max-height: 400px; + overflow-y: auto; + padding: var(--space-3); +} + +/* Filter Sections */ +.sidebar-filter-section { + margin-bottom: var(--space-3); +} + +.sidebar-filter-section:last-child { + margin-bottom: 0; +} + +.sidebar-filter-section-title { + font-size: var(--text-2xs); + text-transform: uppercase; + letter-spacing: var(--ls-wide); + color: var(--text-muted, var(--text-muted)); + margin-bottom: var(--space-2); +} + +/* Category Checkboxes */ +.sidebar-category-checkboxes { + display: flex; + flex-wrap: wrap; + gap: var(--space-150); +} + +.sidebar-category-checkbox { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-150); + background: var(--bg-primary, var(--bg-page)); + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + cursor: pointer; + transition: all 0.2s; + font-size: var(--text-2xs); + color: var(--text-secondary, var(--text-secondary)); +} + +.sidebar-category-checkbox:hover { + border-color: var(--accent-color, var(--blue-9)); + background: var(--bg-secondary, var(--bg-card)); +} + +.sidebar-category-checkbox input[type="checkbox"] { + width: 12px; + height: 12px; + accent-color: var(--accent-color, var(--blue-9)); + cursor: pointer; +} + +.sidebar-category-checkbox:has(input:checked) { + border-color: var(--accent-color, var(--blue-9)); + background: var(--blue-muted); + color: var(--text-primary, var(--slate-11)); +} + +.sidebar-category-icon { + font-size: var(--text-sm); +} + +.sidebar-category-label { + font-weight: 500; +} + +/* Filter Dropdowns */ +.sidebar-filter-dropdowns { + display: flex; + flex-direction: column; + gap: var(--space-150); +} + +.sidebar-filter-select { + background: var(--bg-primary, var(--bg-page)); + border: 1px solid var(--border-color, var(--bg-hover)); + color: var(--text-primary, var(--slate-11)); + padding: var(--space-150) var(--space-250); + border-radius: var(--radius-control); + font-size: var(--text-xs); + cursor: pointer; + transition: border-color 0.2s; +} + +.sidebar-filter-select:hover { + border-color: var(--accent-color, var(--blue-9)); +} + +.sidebar-filter-select:focus { + outline: none; + border-color: var(--accent-color, var(--blue-9)); + box-shadow: 0 0 0 2px var(--blue-border); +} + +/* Custom Date Range */ +.sidebar-custom-date-container { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-2); + padding: var(--space-2); + background: var(--bg-primary, var(--bg-page)); + border-radius: var(--radius-control); +} + +.sidebar-date-input { + background: var(--bg-secondary, var(--bg-card)); + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + color: var(--text-primary, var(--slate-11)); + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + font-family: inherit; + flex: 1; +} + +.sidebar-date-input::-webkit-calendar-picker-indicator { + filter: invert(0.5); + cursor: pointer; +} + +.sidebar-date-separator { + color: var(--text-muted, var(--text-muted)); + font-size: var(--text-xs); +} + +.sidebar-date-apply-btn { + background: var(--accent-color, var(--blue-9)); + color: var(--bg-primary, var(--bg-page)); + border: none; + border-radius: var(--radius-control); + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.sidebar-date-apply-btn:hover { + background: var(--blue-9); +} + +/* Active Filters Display */ +.sidebar-active-filters { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + margin-top: var(--space-2); + background: var(--bg-primary, var(--bg-page)); + border-radius: var(--radius-control); + flex-wrap: wrap; +} + +.sidebar-active-filters-label { + font-size: var(--text-2xs); + color: var(--text-muted, var(--text-muted)); + font-weight: 500; +} + +.sidebar-active-filter-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + flex: 1; +} + +.sidebar-filter-tag { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: var(--blue-muted); + color: var(--blue-11); + padding: var(--space-1) var(--space-150); + border-radius: var(--radius-pill); + font-size: var(--text-2xs); +} + +.sidebar-filter-tag-remove { + background: none; + border: none; + color: var(--blue-11); + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + opacity: 0.7; + transition: opacity 0.2s; +} + +.sidebar-filter-tag-remove:hover { + opacity: 1; +} + +.sidebar-clear-filters-btn { + background: transparent; + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + padding: var(--space-1) var(--space-2); + font-size: var(--text-2xs); + color: var(--text-muted, var(--text-muted)); + cursor: pointer; + transition: all 0.2s; +} + +.sidebar-clear-filters-btn:hover { + background: var(--bg-tertiary, var(--bg-card)); + color: var(--text-secondary, var(--text-secondary)); + border-color: var(--text-muted, var(--text-muted)); +} + +/* Sidebar Event Count */ +.sidebar-timeline-count { + padding: var(--space-1) var(--space-3); + font-size: var(--text-2xs); + color: var(--text-muted, var(--text-muted)); + background: var(--bg-secondary, var(--bg-card)); + border-bottom: 1px solid var(--border-color, var(--bg-hover)); + text-align: center; +} + +/* Sidebar Load More */ +.sidebar-load-more { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + flex-shrink: 0; +} + +.sidebar-load-more-btn { + background: var(--bg-secondary, var(--bg-card)); + border: 1px solid var(--border-color, var(--bg-hover)); + border-radius: var(--radius-control); + padding: var(--space-2) var(--space-4); + color: var(--text-secondary, var(--text-secondary)); + font-size: var(--text-sm); + cursor: pointer; + transition: all 0.2s; + width: 100%; + max-width: 200px; +} + +.sidebar-load-more-btn:hover { + background: var(--bg-primary, var(--bg-page)); + border-color: var(--accent-color, var(--blue-9)); + color: var(--text-primary, var(--slate-11)); +} + +.sidebar-load-more-btn:disabled { + opacity: 0.5; + cursor: not-allowed; } /* Panel Header */ diff --git a/dashboard/js/sidebar-timeline.js b/dashboard/js/sidebar-timeline.js index 73fdea9..b23c1f8 100644 --- a/dashboard/js/sidebar-timeline.js +++ b/dashboard/js/sidebar-timeline.js @@ -3,8 +3,12 @@ * * A collapsible sidebar panel showing events in reverse-chronological order. * Features: - * - Event-specific visual rendering with icons and descriptions per event type - * - Thumbs-up/down buttons on each event delegating to feedback module + * - Category checkboxes (Presence, Zones, Alerts, System, Learning) + * - Person + zone dropdowns for subset filtering + * - Date range selector with server-side re-fetch for today/7d/30d/custom + * - Text search with fuzzy client-side matching and FTS5 server-side + * - Client-side filtering on loaded events; server-side for date-range queries + * - Load more cursor pagination for 500+ results * - Virtualized rendering with IntersectionObserver for 1000+ events * - Real-time event updates via WebSocket */ @@ -17,14 +21,15 @@ const CONFIG = { initialLoadLimit: 100, maxEventsInMemory: 10000, + searchDebounceMs: 300, virtualization: { enabled: true, - bufferSize: 20, // number of extra items to render above/below viewport - rootMargin: '200px', // load items 200px before they enter viewport + bufferSize: 20, + rootMargin: '200px', threshold: 0.01 }, - itemHeight: 70, // estimated height of a sidebar event item in pixels - maxDOMItems: 50 // maximum number of items to keep in DOM at once + itemHeight: 70, + maxDOMItems: 50 }; // ============================================ @@ -141,17 +146,41 @@ } }; + // Map category names to the event type keys they contain. + const CATEGORY_TYPES = { + presence: ['presence_transition', 'stationary_detected', 'detection', 'sleep_session_end'], + zones: ['zone_entry', 'zone_exit', 'portal_crossing'], + alerts: ['anomaly', 'anomaly_detected', 'security_alert', 'fall_alert'], + system: ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system'], + learning: ['learning_milestone', 'anomaly_learned'] + }; + // ============================================ // State // ============================================ const state = { - events: [], + allLoadedEvents: [], // all events fetched from server (before client filter) + events: [], // display events (after client-side filtering) cursor: null, + hasMore: false, total: 0, loading: false, panelVisible: false, - selectedEventId: null, // ID of the currently selected (jumped-to) event - // Virtualization state + selectedEventId: null, + filters: { + categories: { presence: true, zones: true, alerts: true, system: false, learning: false }, + person: '', + zone: '', + dateRange: 'all', + customFrom: '', + customTo: '', + searchQuery: '' + }, + // Server-side date bounds (ISO8601 strings, empty = no bound) + serverSince: '', + serverUntil: '', + filterControlsVisible: false, + searchDebounceTimer: null, virtualization: { observer: null, visibleIndices: new Set(), @@ -169,25 +198,20 @@ // Initialization // ============================================ function init() { - console.log('[SidebarTimeline] Initializing'); - cacheElements(); bindEvents(); setupVirtualization(); + populateDropdowns(); - // Listen for router mode changes if (window.SpaxelRouter) { SpaxelRouter.onModeChange(onRouterModeChange); } loadInitialEvents(); - // Register for WebSocket messages if (window.SpaxelApp) { SpaxelApp.registerMessageHandler(handleWebSocketMessage); } - - console.log('[SidebarTimeline] Initialized'); } function cacheElements() { @@ -196,39 +220,172 @@ elements.eventsContainer = document.getElementById('sidebar-timeline-events'); elements.loading = document.getElementById('sidebar-timeline-loading'); elements.empty = document.getElementById('sidebar-timeline-empty'); + elements.count = document.getElementById('sidebar-timeline-count'); elements.spacerTop = document.getElementById('sidebar-timeline-spacer-top'); elements.spacerBottom = document.getElementById('sidebar-timeline-spacer-bottom'); elements.toggleBtn = document.getElementById('sidebar-timeline-toggle'); elements.closeBtn = document.getElementById('sidebar-timeline-close'); elements.showBtn = document.getElementById('sidebar-timeline-show-btn'); + elements.loadMoreContainer = document.getElementById('sidebar-load-more'); + elements.loadMoreBtn = document.getElementById('sidebar-load-more-btn'); + + // Filter controls + elements.filterBar = document.getElementById('sidebar-timeline-filter-bar'); + elements.filterToggleBtn = document.getElementById('sidebar-filter-toggle-btn'); + elements.filterControls = document.getElementById('sidebar-timeline-filter-controls'); + elements.searchInput = document.getElementById('sidebar-timeline-search'); + elements.categoryPresence = document.getElementById('filter-category-presence'); + elements.categoryZones = document.getElementById('filter-category-zones'); + elements.categoryAlerts = document.getElementById('filter-category-alerts'); + elements.categorySystem = document.getElementById('filter-category-system'); + elements.categoryLearning = document.getElementById('filter-category-learning'); + elements.personSelect = document.getElementById('sidebar-filter-person'); + elements.zoneSelect = document.getElementById('sidebar-filter-zone'); + elements.dateRangeSelect = document.getElementById('sidebar-filter-date-range'); + elements.customDateContainer = document.getElementById('sidebar-custom-date-container'); + elements.dateFrom = document.getElementById('sidebar-date-from'); + elements.dateTo = document.getElementById('sidebar-date-to'); + elements.dateApplyBtn = document.getElementById('sidebar-date-apply-btn'); + elements.activeFilters = document.getElementById('sidebar-active-filters'); + elements.activeFilterTags = document.getElementById('sidebar-active-filter-tags'); + elements.clearFiltersBtn = document.getElementById('sidebar-clear-filters-btn'); } function bindEvents() { - // Panel toggle buttons - if (elements.toggleBtn) { - elements.toggleBtn.addEventListener('click', togglePanel); - } - if (elements.closeBtn) { - elements.closeBtn.addEventListener('click', hidePanel); - } - if (elements.showBtn) { - elements.showBtn.addEventListener('click', showPanel); - } + if (elements.toggleBtn) elements.toggleBtn.addEventListener('click', togglePanel); + if (elements.closeBtn) elements.closeBtn.addEventListener('click', hidePanel); + if (elements.showBtn) elements.showBtn.addEventListener('click', showPanel); - // Scroll events for virtualization if (elements.content) { elements.content.addEventListener('scroll', onScroll, { passive: true }); } + + // Filter toggle + if (elements.filterToggleBtn) { + elements.filterToggleBtn.addEventListener('click', toggleFilterControls); + } + + // Search input with debounce + if (elements.searchInput) { + elements.searchInput.addEventListener('input', function() { + clearTimeout(state.searchDebounceTimer); + state.searchDebounceTimer = setTimeout(function() { + state.filters.searchQuery = elements.searchInput.value.trim(); + applyClientFilters(); + updateActiveFilters(); + }, CONFIG.searchDebounceMs); + }); + } + + // Category checkboxes + var categoryMap = { + 'filter-category-presence': 'presence', + 'filter-category-zones': 'zones', + 'filter-category-alerts': 'alerts', + 'filter-category-system': 'system', + 'filter-category-learning': 'learning' + }; + Object.keys(categoryMap).forEach(function(id) { + var el = document.getElementById(id); + if (el) { + el.addEventListener('change', function() { + state.filters.categories[categoryMap[id]] = el.checked; + applyClientFilters(); + updateActiveFilters(); + }); + } + }); + + // Person dropdown + if (elements.personSelect) { + elements.personSelect.addEventListener('change', function() { + state.filters.person = elements.personSelect.value; + applyClientFilters(); + updateActiveFilters(); + }); + } + + // Zone dropdown + if (elements.zoneSelect) { + elements.zoneSelect.addEventListener('change', function() { + state.filters.zone = elements.zoneSelect.value; + applyClientFilters(); + updateActiveFilters(); + }); + } + + // Date range selector + if (elements.dateRangeSelect) { + elements.dateRangeSelect.addEventListener('change', function() { + handleDateRangeChange(elements.dateRangeSelect.value); + }); + } + + // Custom date apply + if (elements.dateApplyBtn) { + elements.dateApplyBtn.addEventListener('click', applyCustomDateRange); + } + + // Clear all filters + if (elements.clearFiltersBtn) { + elements.clearFiltersBtn.addEventListener('click', clearAllFilters); + } + + // Load more + if (elements.loadMoreBtn) { + elements.loadMoreBtn.addEventListener('click', loadMoreEvents); + } + } + + // ============================================ + // Dropdown Population + // ============================================ + function populateDropdowns() { + // People + fetch('/api/people') + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || !elements.personSelect) return; + var people = data.people || data || []; + people.forEach(function(p) { + var name = p.name || p.label || p.id || ''; + if (!name) return; + var opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + elements.personSelect.appendChild(opt); + }); + }) + .catch(function() { + // People endpoint may not be available yet — silently skip + }); + + // Zones + fetch('/api/zones') + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || !elements.zoneSelect) return; + var zones = Array.isArray(data) ? data : (data.zones || []); + zones.forEach(function(z) { + var name = z.name || ''; + if (!name) return; + var opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + elements.zoneSelect.appendChild(opt); + }); + }) + .catch(function() { + // Zones endpoint may not be available yet — silently skip + }); } // ============================================ // Mode Change Handlers // ============================================ - - function onRouterModeChange(newMode, oldMode) { - // Reload events if panel is visible + function onRouterModeChange() { if (state.panelVisible) { - loadInitialEvents(); + resetAndReload(); } } @@ -263,29 +420,145 @@ } } + // ============================================ + // Filter Controls Visibility + // ============================================ + function toggleFilterControls() { + state.filterControlsVisible = !state.filterControlsVisible; + if (elements.filterControls) { + if (state.filterControlsVisible) { + elements.filterControls.classList.remove('collapsed'); + } else { + elements.filterControls.classList.add('collapsed'); + } + } + if (elements.filterToggleBtn) { + elements.filterToggleBtn.classList.toggle('active', state.filterControlsVisible); + } + } + + // ============================================ + // Date Range Handling (server-side) + // ============================================ + function handleDateRangeChange(value) { + state.filters.dateRange = value; + + if (elements.customDateContainer) { + elements.customDateContainer.style.display = value === 'custom' ? 'flex' : 'none'; + } + + if (value === 'custom') { + // Wait for the Apply button + return; + } + + var now = new Date(); + state.serverSince = ''; + state.serverUntil = ''; + + switch (value) { + case 'today': { + var start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + state.serverSince = start.toISOString(); + break; + } + case '7days': + state.serverSince = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + break; + case '30days': + state.serverSince = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + break; + default: + // 'all' — no bounds + break; + } + + updateActiveFilters(); + resetAndReload(); + } + + function applyCustomDateRange() { + var fromVal = elements.dateFrom ? elements.dateFrom.value : ''; + var toVal = elements.dateTo ? elements.dateTo.value : ''; + + state.filters.customFrom = fromVal; + state.filters.customTo = toVal; + + // Convert date strings (YYYY-MM-DD) to ISO8601 + if (fromVal) { + state.serverSince = new Date(fromVal).toISOString(); + } else { + state.serverSince = ''; + } + if (toVal) { + // Include the full day by setting to end-of-day + var endOfDay = new Date(toVal); + endOfDay.setHours(23, 59, 59, 999); + state.serverUntil = endOfDay.toISOString(); + } else { + state.serverUntil = ''; + } + + updateActiveFilters(); + resetAndReload(); + } + // ============================================ // Event Loading // ============================================ - function loadInitialEvents() { - const params = new URLSearchParams(); + + function buildServerParams(cursor) { + var params = new URLSearchParams(); params.set('limit', CONFIG.initialLoadLimit); + if (state.serverSince) params.set('since', state.serverSince); + if (state.serverUntil) params.set('until', state.serverUntil); + + // Pass the search query to FTS5 when doing a fresh fetch + if (state.filters.searchQuery) { + params.set('q', state.filters.searchQuery); + } + + if (cursor) params.set('before', cursor); + + return params; + } + + function resetAndReload() { + state.allLoadedEvents = []; + state.events = []; + state.cursor = null; + state.hasMore = false; + + if (elements.eventsContainer) elements.eventsContainer.innerHTML = ''; + renderLoadMore(); + updateCountDisplay(); + loadInitialEvents(); + } + + function loadInitialEvents() { + if (state.loading) return; + state.loading = true; + updateLoadingState(); + + var params = buildServerParams(null); + fetch('/api/events?' + params.toString()) .then(function(res) { - if (!res.ok) { - throw new Error('Failed to fetch events: ' + res.statusText); - } + if (!res.ok) throw new Error('Failed to fetch events: ' + res.statusText); return res.json(); }) .then(function(data) { - state.events = data.events || []; + state.allLoadedEvents = data.events || []; state.cursor = data.cursor || null; + state.hasMore = data.has_more || false; state.total = data.total_filtered || 0; - renderEvents(); + applyClientFilters(); + renderLoadMore(); + updateCountDisplay(); }) .catch(function(err) { console.error('[SidebarTimeline] Failed to load events:', err); - showError(err.message); }) .finally(function() { state.loading = false; @@ -293,16 +566,235 @@ }); } + function loadMoreEvents() { + if (state.loading || !state.hasMore || !state.cursor) return; + state.loading = true; + updateLoadingState(); + + if (elements.loadMoreBtn) { + elements.loadMoreBtn.disabled = true; + elements.loadMoreBtn.textContent = 'Loading...'; + } + + var params = buildServerParams(state.cursor); + + fetch('/api/events?' + params.toString()) + .then(function(res) { + if (!res.ok) throw new Error('Failed to fetch events: ' + res.statusText); + return res.json(); + }) + .then(function(data) { + var newEvents = data.events || []; + state.allLoadedEvents = state.allLoadedEvents.concat(newEvents); + + // Trim to memory limit + if (state.allLoadedEvents.length > CONFIG.maxEventsInMemory) { + state.allLoadedEvents = state.allLoadedEvents.slice(0, CONFIG.maxEventsInMemory); + } + + state.cursor = data.cursor || null; + state.hasMore = data.has_more || false; + state.total = data.total_filtered || 0; + + applyClientFilters(); + renderLoadMore(); + updateCountDisplay(); + }) + .catch(function(err) { + console.error('[SidebarTimeline] Failed to load more events:', err); + }) + .finally(function() { + state.loading = false; + updateLoadingState(); + if (elements.loadMoreBtn) { + elements.loadMoreBtn.disabled = false; + elements.loadMoreBtn.textContent = 'Load more'; + } + }); + } + + // ============================================ + // Client-Side Filtering + // ============================================ + + function eventMatchesFilters(event) { + var typeInfo = EVENT_TYPES[event.type] || EVENT_TYPES.system; + var category = typeInfo.category || 'system'; + + // Category filter + if (!state.filters.categories[category]) return false; + + // Person filter + if (state.filters.person && event.person !== state.filters.person) return false; + + // Zone filter + if (state.filters.zone && event.zone !== state.filters.zone) return false; + + // Text search — fuzzy match on computed description + if (state.filters.searchQuery) { + var desc = buildEventDescription(event, typeInfo).toLowerCase(); + var query = state.filters.searchQuery.toLowerCase(); + if (!fuzzyMatch(desc, query)) return false; + } + + return true; + } + + /** + * Simple fuzzy match: returns true if all characters in query appear + * in order within text, or text contains query as a substring. + * Uses substring match for short queries (< 4 chars) and sequential + * character matching for longer queries. + */ + function fuzzyMatch(text, query) { + if (!query) return true; + if (text.indexOf(query) !== -1) return true; + if (query.length < 4) return false; + + // Sequential character fuzzy match + var qi = 0; + for (var ti = 0; ti < text.length && qi < query.length; ti++) { + if (text[ti] === query[qi]) qi++; + } + return qi === query.length; + } + + function applyClientFilters() { + state.events = state.allLoadedEvents.filter(eventMatchesFilters); + renderEvents(); + } + + // ============================================ + // Active Filters Display + // ============================================ + function updateActiveFilters() { + if (!elements.activeFilters || !elements.activeFilterTags) return; + + var tags = []; + + // Disabled categories + var allCats = ['presence', 'zones', 'alerts', 'system', 'learning']; + var catLabels = { presence: 'Presence', zones: 'Zones', alerts: 'Alerts', system: 'System', learning: 'Learning' }; + allCats.forEach(function(cat) { + if (!state.filters.categories[cat]) { + tags.push({ label: catLabels[cat] + ' hidden', key: 'cat:' + cat }); + } + }); + + // Person + if (state.filters.person) { + tags.push({ label: 'Person: ' + state.filters.person, key: 'person' }); + } + + // Zone + if (state.filters.zone) { + tags.push({ label: 'Zone: ' + state.filters.zone, key: 'zone' }); + } + + // Date range + var dateLabels = { today: 'Today', '7days': 'Last 7 days', '30days': 'Last 30 days', custom: 'Custom range' }; + if (state.filters.dateRange !== 'all') { + var label = dateLabels[state.filters.dateRange] || state.filters.dateRange; + if (state.filters.dateRange === 'custom' && state.filters.customFrom) { + label = state.filters.customFrom + ' to ' + (state.filters.customTo || 'now'); + } + tags.push({ label: label, key: 'date' }); + } + + // Search + if (state.filters.searchQuery) { + tags.push({ label: 'Search: ' + state.filters.searchQuery, key: 'search' }); + } + + if (tags.length === 0) { + elements.activeFilters.style.display = 'none'; + return; + } + + elements.activeFilters.style.display = 'flex'; + elements.activeFilterTags.innerHTML = tags.map(function(tag) { + return '' + + escapeHtml(tag.label) + + '' + + ''; + }).join(''); + + // Bind remove buttons + elements.activeFilterTags.querySelectorAll('.sidebar-filter-tag-remove').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var key = btn.closest('.sidebar-filter-tag').dataset.key; + removeFilter(key); + }); + }); + } + + function removeFilter(key) { + if (key === 'person') { + state.filters.person = ''; + if (elements.personSelect) elements.personSelect.value = ''; + applyClientFilters(); + } else if (key === 'zone') { + state.filters.zone = ''; + if (elements.zoneSelect) elements.zoneSelect.value = ''; + applyClientFilters(); + } else if (key === 'search') { + state.filters.searchQuery = ''; + if (elements.searchInput) elements.searchInput.value = ''; + applyClientFilters(); + } else if (key === 'date') { + state.filters.dateRange = 'all'; + state.filters.customFrom = ''; + state.filters.customTo = ''; + state.serverSince = ''; + state.serverUntil = ''; + if (elements.dateRangeSelect) elements.dateRangeSelect.value = 'all'; + if (elements.customDateContainer) elements.customDateContainer.style.display = 'none'; + resetAndReload(); + } else if (key.indexOf('cat:') === 0) { + var cat = key.substring(4); + state.filters.categories[cat] = true; + var catEl = document.getElementById('filter-category-' + cat); + if (catEl) catEl.checked = true; + applyClientFilters(); + } + updateActiveFilters(); + } + + function clearAllFilters() { + state.filters.categories = { presence: true, zones: true, alerts: true, system: false, learning: false }; + state.filters.person = ''; + state.filters.zone = ''; + state.filters.dateRange = 'all'; + state.filters.customFrom = ''; + state.filters.customTo = ''; + state.filters.searchQuery = ''; + state.serverSince = ''; + state.serverUntil = ''; + + // Reset UI + if (elements.categoryPresence) elements.categoryPresence.checked = true; + if (elements.categoryZones) elements.categoryZones.checked = true; + if (elements.categoryAlerts) elements.categoryAlerts.checked = true; + if (elements.categorySystem) elements.categorySystem.checked = false; + if (elements.categoryLearning) elements.categoryLearning.checked = false; + if (elements.personSelect) elements.personSelect.value = ''; + if (elements.zoneSelect) elements.zoneSelect.value = ''; + if (elements.dateRangeSelect) elements.dateRangeSelect.value = 'all'; + if (elements.searchInput) elements.searchInput.value = ''; + if (elements.customDateContainer) elements.customDateContainer.style.display = 'none'; + + updateActiveFilters(); + resetAndReload(); + } + // ============================================ // Virtualization Setup // ============================================ function setupVirtualization() { - if (!CONFIG.virtualization.enabled || !elements.content) { - return; - } + if (!CONFIG.virtualization.enabled || !elements.content) return; - // Create IntersectionObserver for lazy rendering - const observerOptions = { + var observerOptions = { root: elements.content, rootMargin: CONFIG.virtualization.rootMargin, threshold: CONFIG.virtualization.threshold @@ -311,23 +803,18 @@ state.virtualization.observer = new IntersectionObserver(function(entries) { handleIntersection(entries); }, observerOptions); - - console.log('[SidebarTimeline] Virtualization enabled'); } function handleIntersection(entries) { entries.forEach(function(entry) { - const index = parseInt(entry.target.dataset.index, 10); + var index = parseInt(entry.target.dataset.index, 10); if (isNaN(index)) return; - if (entry.isIntersecting) { state.virtualization.visibleIndices.add(index); } else { state.virtualization.visibleIndices.delete(index); } }); - - // Update rendered range based on visibility updateRenderedRange(); } @@ -336,10 +823,9 @@ state.virtualization.scrollTop = elements.content.scrollTop; - // Update visible range based on scroll position - const firstIndex = Math.floor(state.virtualization.scrollTop / CONFIG.itemHeight); - const visibleCount = Math.ceil(elements.content.clientHeight / CONFIG.itemHeight); - const lastIndex = firstIndex + visibleCount; + var firstIndex = Math.floor(state.virtualization.scrollTop / CONFIG.itemHeight); + var visibleCount = Math.ceil(elements.content.clientHeight / CONFIG.itemHeight); + var lastIndex = firstIndex + visibleCount; state.virtualization.firstVisibleIndex = Math.max(0, firstIndex - CONFIG.virtualization.bufferSize); state.virtualization.lastVisibleIndex = Math.min(state.events.length - 1, lastIndex + CONFIG.virtualization.bufferSize); @@ -350,24 +836,22 @@ function updateRenderedRange() { if (!state.events.length) return; - const firstIdx = Math.max(0, state.virtualization.firstVisibleIndex); - const lastIdx = Math.min(state.events.length - 1, state.virtualization.lastVisibleIndex); + var firstIdx = Math.max(0, state.virtualization.firstVisibleIndex); + var lastIdx = Math.min(state.events.length - 1, state.virtualization.lastVisibleIndex); - // Create new set of rendered indices - const newRenderedIndices = new Set(); - for (let i = firstIdx; i <= lastIdx; i++) { + var newRenderedIndices = new Set(); + for (var i = firstIdx; i <= lastIdx; i++) { newRenderedIndices.add(i); } - // Render new items - const fragment = document.createDocumentFragment(); + var fragment = document.createDocumentFragment(); newRenderedIndices.forEach(function(index) { if (!state.virtualization.renderedIndices.has(index)) { - const event = state.events[index]; + var event = state.events[index]; if (event) { - const tempDiv = document.createElement('div'); + var tempDiv = document.createElement('div'); tempDiv.innerHTML = renderEvent(event, false, index); - const newEventEl = tempDiv.firstElementChild; + var newEventEl = tempDiv.firstElementChild; if (newEventEl) { newEventEl.dataset.index = index; fragment.appendChild(newEventEl); @@ -378,8 +862,6 @@ if (fragment.children.length > 0) { elements.eventsContainer.appendChild(fragment); - - // Bind handlers for new items Array.from(fragment.children).forEach(function(item) { bindEventHandlers(item); if (state.virtualization.observer) { @@ -388,10 +870,9 @@ }); } - // Remove items that are no longer in rendered range state.virtualization.renderedIndices.forEach(function(index) { if (!newRenderedIndices.has(index)) { - const item = elements.eventsContainer.querySelector('[data-index="' + index + '"]'); + var item = elements.eventsContainer.querySelector('[data-index="' + index + '"]'); if (item) { if (state.virtualization.observer) { state.virtualization.observer.unobserve(item); @@ -402,17 +883,13 @@ }); state.virtualization.renderedIndices = newRenderedIndices; - - // Update spacers updateSpacers(); } function updateSpacers() { if (!elements.spacerTop || !elements.spacerBottom) return; - - const topHeight = state.virtualization.firstVisibleIndex * CONFIG.itemHeight; - const bottomHeight = (state.events.length - state.virtualization.lastVisibleIndex - 1) * CONFIG.itemHeight; - + var topHeight = state.virtualization.firstVisibleIndex * CONFIG.itemHeight; + var bottomHeight = (state.events.length - state.virtualization.lastVisibleIndex - 1) * CONFIG.itemHeight; elements.spacerTop.style.height = Math.max(0, topHeight) + 'px'; elements.spacerBottom.style.height = Math.max(0, bottomHeight) + 'px'; } @@ -425,117 +902,125 @@ if (state.events.length === 0) { elements.eventsContainer.innerHTML = ''; - if (elements.empty) { - elements.empty.style.display = 'flex'; - } - if (elements.loading) { - elements.loading.style.display = 'none'; - } + if (elements.empty) elements.empty.style.display = 'flex'; + if (elements.loading) elements.loading.style.display = 'none'; + updateCountDisplay(); return; } - elements.empty.style.display = 'none'; - elements.loading.style.display = 'none'; + if (elements.empty) elements.empty.style.display = 'none'; + if (elements.loading) elements.loading.style.display = 'none'; + + // Reset virtualization state on re-render + state.virtualization.firstVisibleIndex = 0; + state.virtualization.lastVisibleIndex = 0; + state.virtualization.renderedIndices = new Set(); - // Use virtualized rendering for large datasets if (CONFIG.virtualization.enabled && state.events.length > CONFIG.maxDOMItems) { renderVirtualized(); } else { renderAll(); } + + updateCountDisplay(); } function renderAll() { - let html = ''; + var html = ''; state.events.forEach(function(event, index) { html += renderEvent(event, false, index); }); elements.eventsContainer.innerHTML = html; - // Bind handlers Array.from(elements.eventsContainer.children).forEach(function(item) { bindEventHandlers(item); }); } function renderVirtualized() { - // Clear existing content elements.eventsContainer.innerHTML = ''; - // Calculate initial visible range - const containerHeight = elements.content.clientHeight || 400; - const visibleCount = Math.ceil(containerHeight / CONFIG.itemHeight); - const bufferCount = CONFIG.virtualization.bufferSize; + var containerHeight = elements.content.clientHeight || 400; + var visibleCount = Math.ceil(containerHeight / CONFIG.itemHeight); + var bufferCount = CONFIG.virtualization.bufferSize; state.virtualization.firstVisibleIndex = 0; state.virtualization.lastVisibleIndex = Math.min(state.events.length - 1, visibleCount + bufferCount * 2); - // Create spacers updateSpacers(); - - // Render initial batch updateRenderedRange(); } function renderEvent(event, isNew, index) { - const typeInfo = EVENT_TYPES[event.type] || EVENT_TYPES.system; - const timeStr = formatTimestamp(event.timestamp_ms); - const description = buildEventDescription(event, typeInfo); + var typeInfo = EVENT_TYPES[event.type] || EVENT_TYPES.system; + var timeStr = formatTimestamp(event.timestamp_ms); + var description = buildEventDescription(event, typeInfo); - // Severity indicator - const severityClass = event.severity === 'alert' || event.severity === 'critical' ? ' severity-critical' : - event.severity === 'warning' ? ' severity-warning' : ''; - const newClass = isNew ? ' new-event' : ''; + var severityClass = (event.severity === 'alert' || event.severity === 'critical') ? ' severity-critical' : + (event.severity === 'warning' ? ' severity-warning' : ''); + var newClass = isNew ? ' new-event' : ''; - // System events get secondary styling - const systemEventTypes = ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system', 'learning_milestone', 'anomaly_learned']; - const isSystemEvent = systemEventTypes.indexOf(event.type) !== -1; - const secondaryClass = isSystemEvent ? ' secondary' : ''; + var systemEventTypes = ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system', 'learning_milestone', 'anomaly_learned']; + var isSystemEvent = systemEventTypes.indexOf(event.type) !== -1; + var secondaryClass = isSystemEvent ? ' secondary' : ''; - const dataIndex = index !== undefined ? ` data-index="${index}"` : ''; + var dataIndex = index !== undefined ? (' data-index="' + index + '"') : ''; - return ` - - `; + return ''; + } + + function renderLoadMore() { + if (!elements.loadMoreContainer) return; + elements.loadMoreContainer.style.display = state.hasMore ? 'flex' : 'none'; + if (elements.loadMoreBtn) { + elements.loadMoreBtn.disabled = state.loading; + elements.loadMoreBtn.textContent = state.loading ? 'Loading...' : 'Load more'; + } + } + + function updateCountDisplay() { + if (!elements.count) return; + var total = state.allLoadedEvents.length; + var showing = state.events.length; + + if (total === 0) { + elements.count.style.display = 'none'; + return; + } + + elements.count.style.display = 'block'; + if (showing < total) { + elements.count.textContent = 'Showing ' + showing + ' of ' + total + ' loaded events'; + } else { + elements.count.textContent = total + (state.hasMore ? '+' : '') + ' events'; + } } function buildEventDescription(event, typeInfo) { - // Parse detail_json for additional context - let detail = {}; + var detail = {}; if (event.detail_json) { - try { - detail = JSON.parse(event.detail_json); - } catch (e) { - // Ignore parse errors - } + try { detail = JSON.parse(event.detail_json); } catch (e) {} } - // Build description using template - let description = typeInfo.description; - - // Replace placeholders with actual values - const replacements = { + var description = typeInfo.description; + var replacements = { '{person}': event.person || detail.person || 'Someone', '{zone}': event.zone || detail.zone || 'the area', '{from_zone}': detail.from_zone || 'previous zone', @@ -547,26 +1032,19 @@ '{duration}': detail.duration || 'a while' }; - // Apply replacements - for (const [placeholder, value] of Object.entries(replacements)) { - description = description.replace(placeholder, value); + for (var placeholder in replacements) { + description = description.replace(placeholder, replacements[placeholder]); } return description; } function formatTimestamp(ms) { - const date = new Date(ms); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - - const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - - if (isToday) { - return time; - } else { - return date.toLocaleDateString() + ' ' + time; - } + var date = new Date(ms); + var now = new Date(); + var isToday = date.toDateString() === now.toDateString(); + var time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return isToday ? time : (date.toLocaleDateString() + ' ' + time); } function escapeHtml(str) { @@ -582,123 +1060,82 @@ // Event Handlers // ============================================ function bindEventHandlers(eventEl) { - // Feedback buttons eventEl.querySelectorAll('.sidebar-timeline-action-btn').forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); - const action = btn.dataset.action; - const eventId = eventEl.dataset.id; + var action = btn.dataset.action; + var eventId = eventEl.dataset.id; handleFeedback(eventId, action, eventEl); }); }); - // Event click (seeks to timestamp in replay) eventEl.addEventListener('click', function() { - const timestamp = parseInt(this.dataset.timestamp, 10); + var timestamp = parseInt(this.dataset.timestamp, 10); handleSeek(timestamp, this); }); } function handleFeedback(eventId, action, eventElement) { - const correct = action === 'correct'; + var correct = action === 'correct'; - // Delegate to feedback module if available if (window.Feedback) { - const event = state.events.find(function(e) { return e.id == eventId; }); + var event = state.allLoadedEvents.find(function(e) { return String(e.id) === String(eventId); }); if (event) { - let detail = {}; - try { - detail = event.detail_json ? JSON.parse(event.detail_json) : {}; - } catch (e) {} + var detail = {}; + try { detail = event.detail_json ? JSON.parse(event.detail_json) : {}; } catch (e) {} - // Call feedback module's sendFeedback Feedback.sendFeedback(eventId, event.type, correct ? 'TRUE_POSITIVE' : 'FALSE_POSITIVE', detail); - // Show visual feedback immediately - const feedbackBtns = eventElement.querySelectorAll('.sidebar-timeline-action-btn'); - feedbackBtns.forEach(function(btn) { + eventElement.querySelectorAll('.sidebar-timeline-action-btn').forEach(function(btn) { if (btn.dataset.action === action) { btn.classList.add('active'); - setTimeout(function() { - btn.classList.remove('active'); - }, 2000); + setTimeout(function() { btn.classList.remove('active'); }, 2000); } }); - // Show toast notification if (window.SpaxelApp && SpaxelApp.showToast) { - const message = correct ? 'Thanks for the feedback!' : 'Thanks — I\'ll adjust my detection.'; - SpaxelApp.showToast(message, 'success'); + SpaxelApp.showToast(correct ? 'Thanks for the feedback!' : 'Thanks — I\'ll adjust my detection.', 'success'); } - // Dismiss entry for incorrect feedback if (!correct) { eventElement.classList.add('feedback-dismissed'); } } } else { - // Fallback: direct API call - const payload = { - type: correct ? 'correct' : 'incorrect', - event_id: parseInt(eventId, 10) - }; - fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify({ type: correct ? 'correct' : 'incorrect', event_id: parseInt(eventId, 10) }) }) - .then(function(res) { - if (res.ok) { - return res.json(); - } - throw new Error('Failed to submit feedback'); - }) - .then(function(data) { - // Show visual feedback - const feedbackBtns = eventElement.querySelectorAll('.sidebar-timeline-action-btn'); - feedbackBtns.forEach(function(btn) { + .then(function(res) { if (!res.ok) throw new Error('Feedback failed'); }) + .then(function() { + eventElement.querySelectorAll('.sidebar-timeline-action-btn').forEach(function(btn) { if (btn.dataset.action === action) { btn.classList.add('active'); - setTimeout(function() { - btn.classList.remove('active'); - }, 2000); + setTimeout(function() { btn.classList.remove('active'); }, 2000); } }); - - // Show toast notification if (window.SpaxelApp && SpaxelApp.showToast) { - const message = correct ? 'Thanks for the feedback!' : 'Thanks — I\'ll adjust my detection.'; - SpaxelApp.showToast(message, 'success'); - } - - // Dismiss entry for incorrect feedback - if (!correct) { - eventElement.classList.add('feedback-dismissed'); + SpaxelApp.showToast(correct ? 'Thanks for the feedback!' : 'Thanks — I\'ll adjust my detection.', 'success'); } + if (!correct) eventElement.classList.add('feedback-dismissed'); }) - .catch(function(err) { - console.error('[SidebarTimeline] Feedback failed:', err); - }); + .catch(function(err) { console.error('[SidebarTimeline] Feedback failed:', err); }); } } function handleSeek(timestamp, eventEl) { if (!timestamp || timestamp <= 0) return; - // Highlight selected event clearSelectedEvent(); if (eventEl) { state.selectedEventId = eventEl.dataset.id; eventEl.classList.add('selected'); } - // Jump-to-time replay if (window.SpaxelReplay) { SpaxelReplay.jumpToTime(timestamp).then(function() { updateNowReplayingChip(true, timestamp); - - // Clear full-page timeline selection to avoid stale highlight if (window.SpaxelTimeline && SpaxelTimeline.clearSelection) { SpaxelTimeline.clearSelection(); } @@ -715,31 +1152,27 @@ function clearSelectedEvent() { if (state.selectedEventId) { - const prev = elements.eventsContainer + var prev = elements.eventsContainer ? elements.eventsContainer.querySelector('.sidebar-timeline-event.selected') : null; - if (prev) { - prev.classList.remove('selected'); - } + if (prev) prev.classList.remove('selected'); state.selectedEventId = null; } } function updateNowReplayingChip(visible, timestampMs) { - let chip = document.getElementById('now-replaying-chip'); + var chip = document.getElementById('now-replaying-chip'); if (!chip) { - // Create chip in the sidebar panel header - const header = document.querySelector('.sidebar-panel-header'); + var header = document.querySelector('.sidebar-panel-header'); if (!header) return; chip = document.createElement('span'); chip.id = 'now-replaying-chip'; chip.className = 'now-replaying-chip'; header.appendChild(chip); } - if (visible && timestampMs) { - const date = new Date(timestampMs); - const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + var date = new Date(timestampMs); + var time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); chip.textContent = 'Now replaying ' + time; chip.style.display = 'inline-flex'; } else { @@ -757,8 +1190,7 @@ } function handleNewEvent(event) { - // Normalize event format - const normalizedEvent = { + var normalizedEvent = { id: event.id || event.timestamp_ms || Date.now(), timestamp_ms: event.timestamp_ms || event.ts || Date.now(), type: event.type || event.kind || 'system', @@ -769,18 +1201,29 @@ severity: event.severity || 'info' }; - // Add to beginning of events - state.events.unshift(normalizedEvent); - state.total++; - - // Limit events in memory - if (state.events.length > CONFIG.maxEventsInMemory) { - state.events = state.events.slice(0, CONFIG.maxEventsInMemory); + // Only add if within the current server-side date range + if (state.serverSince) { + var sinceMs = new Date(state.serverSince).getTime(); + if (normalizedEvent.timestamp_ms < sinceMs) return; + } + if (state.serverUntil) { + var untilMs = new Date(state.serverUntil).getTime(); + if (normalizedEvent.timestamp_ms > untilMs) return; } - // Re-render if panel is visible - if (state.panelVisible) { + state.allLoadedEvents.unshift(normalizedEvent); + + if (state.allLoadedEvents.length > CONFIG.maxEventsInMemory) { + state.allLoadedEvents = state.allLoadedEvents.slice(0, CONFIG.maxEventsInMemory); + } + + if (state.panelVisible && eventMatchesFilters(normalizedEvent)) { + state.events.unshift(normalizedEvent); + if (state.events.length > CONFIG.maxEventsInMemory) { + state.events = state.events.slice(0, CONFIG.maxEventsInMemory); + } renderEvents(); + updateCountDisplay(); } } @@ -792,33 +1235,25 @@ elements.loading.style.display = state.loading ? 'flex' : 'none'; } - function showError(message) { - console.error('[SidebarTimeline]', message); - } - // ============================================ // Public API // ============================================ - const SidebarTimeline = { + var SidebarTimeline = { init: init, show: showPanel, hide: hidePanel, toggle: togglePanel, - refresh: loadInitialEvents, + refresh: resetAndReload, isVisible: function() { return state.panelVisible; }, clearSelection: clearSelectedEvent, hideNowReplayingChip: function() { updateNowReplayingChip(false); } }; - // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } - // Export for use by other modules window.SpaxelSidebarTimeline = SidebarTimeline; - - console.log('[SidebarTimeline] Module loaded'); })(); diff --git a/dashboard/js/sidebar-timeline.test.js b/dashboard/js/sidebar-timeline.test.js index 3b98f08..583e47e 100644 --- a/dashboard/js/sidebar-timeline.test.js +++ b/dashboard/js/sidebar-timeline.test.js @@ -66,8 +66,10 @@ describe('SidebarTimeline', function() { // Reset all mocks jest.clearAllMocks(); - // Reset mock event data - mockEventData = { events: [], cursor: null, total_filtered: 0 }; + // Reset mock event data (update the variable used by fetch mock) + mockEventData.events = []; + mockEventData.cursor = null; + mockEventData.total_filtered = 0; // Setup DOM structure document.body.innerHTML = ` @@ -184,13 +186,8 @@ describe('SidebarTimeline', function() { window.SpaxelSidebarTimeline.state.events = []; } - // Mock successful API response - global.fetch.mockImplementation(function() { - return Promise.resolve({ - ok: true, - json: function() { - return Promise.resolve({ - events: [ + // Update the shared mockEventData object with 15 test events + mockEventData.events = [ { id: 1, timestamp_ms: Date.now() - 3600000, @@ -337,13 +334,9 @@ describe('SidebarTimeline', function() { }), severity: 'info' } - ], - cursor: null, - total_filtered: 15 - }); - } - }); - }); + ]; + mockEventData.cursor = null; + mockEventData.total_filtered = 15; }); test('all event types render correctly', function() { diff --git a/dashboard/live.html b/dashboard/live.html index 43be4cf..ecd3963 100644 --- a/dashboard/live.html +++ b/dashboard/live.html @@ -3231,7 +3231,95 @@ + + + + + + + +