diff --git a/dashboard/index.html b/dashboard/index.html index d1dc3c1..f98cb1b 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3337,6 +3337,56 @@
+ + + + + +
@@ -3562,6 +3612,8 @@ + + diff --git a/dashboard/js/sidebar-timeline.js b/dashboard/js/sidebar-timeline.js new file mode 100644 index 0000000..6de38f8 --- /dev/null +++ b/dashboard/js/sidebar-timeline.js @@ -0,0 +1,791 @@ +/** + * Spaxel Dashboard - Sidebar Timeline Panel + * + * 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 + * - Virtualized rendering with IntersectionObserver for 1000+ events + * - Real-time event updates via WebSocket + */ +(function() { + 'use strict'; + + // ============================================ + // Configuration + // ============================================ + const CONFIG = { + initialLoadLimit: 100, + maxEventsInMemory: 10000, + virtualization: { + enabled: true, + bufferSize: 20, // number of extra items to render above/below viewport + rootMargin: '200px', // load items 200px before they enter viewport + 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 + }; + + // ============================================ + // Event Type Information + // ============================================ + const EVENT_TYPES = { + zone_entry: { + icon: '🚪', + label: 'Entered', + description: '{person} entered {zone}', + category: 'zones' + }, + zone_exit: { + icon: '🚶', + label: 'Left', + description: '{person} left {zone}', + category: 'zones' + }, + portal_crossing: { + icon: '→', + label: 'Crossed', + description: '{person} crossed from {from_zone} to {to_zone}', + category: 'zones' + }, + presence_transition: { + icon: '👤', + label: 'Detected', + description: '{person} detected in {zone}', + category: 'presence' + }, + stationary_detected: { + icon: '💤', + label: 'Stationary', + description: '{person} is stationary in {zone}', + category: 'presence' + }, + detection: { + icon: '👁️', + label: 'Motion', + description: 'Motion detected in {zone}', + category: 'presence' + }, + anomaly: { + icon: '⚠️', + label: 'Unusual Activity', + description: 'Unusual activity detected in {zone}', + category: 'alerts' + }, + anomaly_detected: { + icon: '⚠️', + label: 'Anomaly', + description: 'Anomaly detected in {zone}', + category: 'alerts' + }, + security_alert: { + icon: '🚨', + label: 'Security Alert', + description: 'Security alert: {description}', + category: 'alerts' + }, + fall_alert: { + icon: '🆘', + label: 'Fall Detected', + description: 'Fall detected: {person} in {zone}', + category: 'alerts' + }, + node_online: { + icon: '📡', + label: 'Node Online', + description: 'Node {node} came online', + category: 'system' + }, + node_offline: { + icon: '📵', + label: 'Node Offline', + description: 'Node {node} went offline', + category: 'system' + }, + ota_update: { + icon: '⬆️', + label: 'Firmware Updated', + description: 'Node {node} firmware updated to {version}', + category: 'system' + }, + baseline_changed: { + icon: '📊', + label: 'Baseline Updated', + description: 'Baseline updated for {link}', + category: 'system' + }, + system: { + icon: '⚙️', + label: 'System', + description: '{description}', + category: 'system' + }, + learning_milestone: { + icon: '🎓', + label: 'Learning Complete', + description: '{description}', + category: 'learning' + }, + anomaly_learned: { + icon: '🧠', + label: 'Pattern Learned', + description: '{description}', + category: 'learning' + }, + sleep_session_end: { + icon: '😴', + label: 'Sleep Session', + description: '{person} slept for {duration}', + category: 'presence' + } + }; + + // ============================================ + // State + // ============================================ + const state = { + events: [], + cursor: null, + total: 0, + loading: false, + panelVisible: false, + dashboardMode: 'expert', // 'expert' or 'simple' - determines timeline mode + // Virtualization state + virtualization: { + observer: null, + visibleIndices: new Set(), + renderedIndices: new Set(), + firstVisibleIndex: 0, + lastVisibleIndex: 0, + scrollTop: 0 + } + }; + + // DOM elements cache + const elements = {}; + + // ============================================ + // Initialization + // ============================================ + function init() { + console.log('[SidebarTimeline] Initializing'); + + cacheElements(); + bindEvents(); + setupVirtualization(); + + // Listen for simple mode changes + if (window.SpaxelSimpleModeDetection) { + SpaxelSimpleModeDetection.onModeChange(onSimpleModeChange); + } + + // 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() { + elements.panel = document.getElementById('sidebar-timeline-panel'); + elements.content = document.getElementById('sidebar-timeline-content'); + elements.eventsContainer = document.getElementById('sidebar-timeline-events'); + elements.loading = document.getElementById('sidebar-timeline-loading'); + elements.empty = document.getElementById('sidebar-timeline-empty'); + 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'); + } + + 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); + } + + // Scroll events for virtualization + if (elements.content) { + elements.content.addEventListener('scroll', onScroll, { passive: true }); + } + } + + // ============================================ + // Mode Change Handlers + // ============================================ + function onSimpleModeChange(newMode, oldMode) { + console.log('[SidebarTimeline] Simple mode changed from', oldMode, 'to', newMode); + + // Update dashboard mode based on simple mode + if (newMode === 'simple') { + state.dashboardMode = 'simple'; + } else { + state.dashboardMode = 'expert'; + } + + // Reload events with new mode + if (state.panelVisible) { + loadInitialEvents(); + } + } + + function onRouterModeChange(newMode, oldMode) { + // Determine dashboard mode: expert mode shows all events, simple mode shows person-relevant only + if (newMode === 'live' || newMode === 'replay' || newMode === 'timeline') { + state.dashboardMode = 'expert'; + } else if (newMode === 'simple' || newMode === 'ambient') { + state.dashboardMode = 'simple'; + } else { + state.dashboardMode = 'expert'; // Default to expert + } + + // Reload events if panel is visible + if (state.panelVisible) { + loadInitialEvents(); + } + } + + // ============================================ + // Panel Visibility + // ============================================ + function showPanel() { + if (elements.panel) { + elements.panel.classList.remove('collapsed'); + state.panelVisible = true; + } + if (elements.showBtn) { + elements.showBtn.classList.add('hidden'); + } + } + + function hidePanel() { + if (elements.panel) { + elements.panel.classList.add('collapsed'); + state.panelVisible = false; + } + if (elements.showBtn) { + elements.showBtn.classList.remove('hidden'); + } + } + + function togglePanel() { + if (state.panelVisible) { + hidePanel(); + } else { + showPanel(); + } + } + + // ============================================ + // Event Loading + // ============================================ + function loadInitialEvents() { + const params = new URLSearchParams(); + params.set('limit', CONFIG.initialLoadLimit); + params.set('mode', state.dashboardMode); + + 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) { + state.events = data.events || []; + state.cursor = data.cursor || null; + state.total = data.total_filtered || 0; + renderEvents(); + }) + .catch(function(err) { + console.error('[SidebarTimeline] Failed to load events:', err); + showError(err.message); + }) + .finally(function() { + state.loading = false; + updateLoadingState(); + }); + } + + // ============================================ + // Virtualization Setup + // ============================================ + function setupVirtualization() { + if (!CONFIG.virtualization.enabled || !elements.content) { + return; + } + + // Create IntersectionObserver for lazy rendering + const observerOptions = { + root: elements.content, + rootMargin: CONFIG.virtualization.rootMargin, + threshold: CONFIG.virtualization.threshold + }; + + 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); + 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(); + } + + function onScroll() { + if (!elements.content) return; + + 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; + + state.virtualization.firstVisibleIndex = Math.max(0, firstIndex - CONFIG.virtualization.bufferSize); + state.virtualization.lastVisibleIndex = Math.min(state.events.length - 1, lastIndex + CONFIG.virtualization.bufferSize); + + updateRenderedRange(); + } + + 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); + + // Create new set of rendered indices + const newRenderedIndices = new Set(); + for (let i = firstIdx; i <= lastIdx; i++) { + newRenderedIndices.add(i); + } + + // Render new items + const fragment = document.createDocumentFragment(); + newRenderedIndices.forEach(function(index) { + if (!state.virtualization.renderedIndices.has(index)) { + const event = state.events[index]; + if (event) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = renderEvent(event, false, index); + const newEventEl = tempDiv.firstElementChild; + if (newEventEl) { + newEventEl.dataset.index = index; + fragment.appendChild(newEventEl); + } + } + } + }); + + 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) { + state.virtualization.observer.observe(item); + } + }); + } + + // 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 + '"]'); + if (item) { + if (state.virtualization.observer) { + state.virtualization.observer.unobserve(item); + } + item.remove(); + } + } + }); + + 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; + + elements.spacerTop.style.height = Math.max(0, topHeight) + 'px'; + elements.spacerBottom.style.height = Math.max(0, bottomHeight) + 'px'; + } + + // ============================================ + // Rendering + // ============================================ + function renderEvents() { + if (!elements.eventsContainer) return; + + if (state.events.length === 0) { + elements.eventsContainer.innerHTML = ''; + if (elements.empty) { + elements.empty.style.display = 'flex'; + } + if (elements.loading) { + elements.loading.style.display = 'none'; + } + return; + } + + elements.empty.style.display = 'none'; + elements.loading.style.display = 'none'; + + // Use virtualized rendering for large datasets + if (CONFIG.virtualization.enabled && state.events.length > CONFIG.maxDOMItems) { + renderVirtualized(); + } else { + renderAll(); + } + } + + function renderAll() { + let 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; + + 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); + + // Severity indicator + const severityClass = event.severity === 'alert' || event.severity === 'critical' ? ' severity-critical' : + event.severity === 'warning' ? ' severity-warning' : ''; + const newClass = isNew ? ' new-event' : ''; + + // Determine if this is a system event (for secondary styling in expert mode only) + const systemEventTypes = ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system', 'learning_milestone', 'anomaly_learned']; + const isSystemEvent = systemEventTypes.indexOf(event.type) !== -1; + // Only apply secondary class in expert mode for system events + const secondaryClass = (state.dashboardMode === 'expert' && isSystemEvent) ? ' secondary' : ''; + + const dataIndex = index !== undefined ? ` data-index="${index}"` : ''; + + return ` + + `; + } + + function buildEventDescription(event, typeInfo) { + // Parse detail_json for additional context + let detail = {}; + if (event.detail_json) { + try { + detail = JSON.parse(event.detail_json); + } catch (e) { + // Ignore parse errors + } + } + + // Build description using template + let description = typeInfo.description; + + // Replace placeholders with actual values + const replacements = { + '{person}': event.person || detail.person || 'Someone', + '{zone}': event.zone || detail.zone || 'the area', + '{from_zone}': detail.from_zone || 'previous zone', + '{to_zone}': detail.to_zone || 'next zone', + '{node}': detail.node || 'a node', + '{version}': detail.version || 'latest', + '{link}': detail.link || 'a link', + '{description}': detail.description || 'activity detected', + '{duration}': detail.duration || 'a while' + }; + + // Apply replacements + for (const [placeholder, value] of Object.entries(replacements)) { + description = description.replace(placeholder, value); + } + + 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; + } + } + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ============================================ + // 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; + handleFeedback(eventId, action, eventEl); + }); + }); + + // Event click (seeks to timestamp in replay) + eventEl.addEventListener('click', function() { + const timestamp = parseInt(this.dataset.timestamp, 10); + handleSeek(timestamp); + }); + } + + function handleFeedback(eventId, action, eventElement) { + const correct = action === 'correct'; + + // Delegate to feedback module if available + if (window.Feedback) { + const event = state.events.find(function(e) { return e.id == eventId; }); + if (event) { + let 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); + } + } 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) + }) + .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) { + if (btn.dataset.action === action) { + btn.classList.add('active'); + 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'); + } + }) + .catch(function(err) { + console.error('[SidebarTimeline] Feedback failed:', err); + }); + } + } + + function handleSeek(timestamp) { + // Convert timestamp to ISO8601 + const targetDate = new Date(timestamp); + const iso8601 = targetDate.toISOString(); + + // Navigate to timeline view with this timestamp + if (window.SpaxelRouter) { + SpaxelRouter.navigate('timeline'); + // The timeline view will handle the seek + if (window.SpaxelTimeline) { + // Let the main timeline handle the seek + setTimeout(function() { + // Trigger seek in main timeline + const seekEvent = new CustomEvent('timeline-seek', { detail: { timestamp: timestamp } }); + document.dispatchEvent(seekEvent); + }, 100); + } + } + + if (window.SpaxelApp && SpaxelApp.showToast) { + SpaxelApp.showToast('Opening timeline...', 'info'); + } + } + + // ============================================ + // WebSocket Message Handler + // ============================================ + function handleWebSocketMessage(msg) { + if (msg.type === 'event') { + handleNewEvent(msg.event); + } + } + + function handleNewEvent(event) { + // Normalize event format + const normalizedEvent = { + id: event.id || event.timestamp_ms || Date.now(), + timestamp_ms: event.timestamp_ms || event.ts || Date.now(), + type: event.type || event.kind || 'system', + zone: event.zone || '', + person: event.person || event.person_name || '', + blob_id: event.blob_id || event.blobID || 0, + detail_json: event.detail_json || '', + 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); + } + + // Re-render if panel is visible + if (state.panelVisible) { + renderEvents(); + } + } + + // ============================================ + // UI Updates + // ============================================ + function updateLoadingState() { + if (!elements.loading) return; + elements.loading.style.display = state.loading ? 'flex' : 'none'; + } + + function showError(message) { + console.error('[SidebarTimeline]', message); + } + + // ============================================ + // Public API + // ============================================ + const SidebarTimeline = { + init: init, + show: showPanel, + hide: hidePanel, + toggle: togglePanel, + refresh: loadInitialEvents, + isVisible: function() { return state.panelVisible; } + }; + + // 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 new file mode 100644 index 0000000..6a48edb --- /dev/null +++ b/dashboard/js/sidebar-timeline.test.js @@ -0,0 +1,1369 @@ +/** + * Tests for Sidebar Timeline Panel + * + * Tests the collapsible sidebar panel showing events in reverse-chronological order. + * Covers: + * - Event-specific visual rendering with icons and descriptions per event type + * - Thumbs-up/down buttons on each event delegating to feedback module + * - Virtualized rendering with IntersectionObserver for 10,000+ events + */ + +'use strict'; + +// Mock dependencies +let mockEventData = { events: [], cursor: null, total_filtered: 0 }; +global.fetch = jest.fn(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return mockEventData; + } + }); +}); + +// Mock Feedback module +global.Feedback = { + sendFeedback: jest.fn() +}; + +// Mock SpaxelApp +global.SpaxelApp = { + registerMessageHandler: jest.fn(), + showToast: jest.fn() +}; + +// Mock SpaxelRouter +global.SpaxelRouter = { + onModeChange: jest.fn(), + navigate: jest.fn() +}; + +// Mock SpaxelTimeline +global.SpaxelTimeline = {}; + +// Mock SpaxelSimpleModeDetection +global.SpaxelSimpleModeDetection = { + onModeChange: jest.fn() +}; + +// Mock IntersectionObserver +global.IntersectionObserver = jest.fn(function(callback, options) { + this.observe = jest.fn(); + this.unobserve = jest.fn(); + this.disconnect = jest.fn(); + this.callback = callback; + this.options = options; + + // Simulate immediate intersection for testing + this.simulateIntersection = function(entries) { + if (this.callback) { + this.callback(entries); + } + }; +}); + +describe('SidebarTimeline', function() { + let SidebarTimeline; + let mockElements; + let mockEventData = { events: [], cursor: null, total_filtered: 0 }; + + beforeEach(function() { + // Reset all mocks + jest.clearAllMocks(); + + // Reset mock event data + mockEventData = { events: [], cursor: null, total_filtered: 0 }; + + // Setup DOM structure + document.body.innerHTML = ` + + + `; + + // Load the module + jest.isolateModules(function() { + require('./sidebar-timeline.js'); + SidebarTimeline = window.SpaxelSidebarTimeline; + }); + + // Cache mock elements + mockElements = { + panel: document.getElementById('sidebar-timeline-panel'), + content: document.getElementById('sidebar-timeline-content'), + eventsContainer: document.getElementById('sidebar-timeline-events'), + loading: document.getElementById('sidebar-timeline-loading'), + empty: document.getElementById('sidebar-timeline-empty'), + spacerTop: document.getElementById('sidebar-timeline-spacer-top'), + spacerBottom: document.getElementById('sidebar-timeline-spacer-bottom'), + toggleBtn: document.getElementById('sidebar-timeline-toggle'), + closeBtn: document.getElementById('sidebar-timeline-close'), + showBtn: document.getElementById('sidebar-timeline-show-btn') + }; + }); + + afterEach(function() { + document.body.innerHTML = ''; + delete window.SpaxelSidebarTimeline; + }); + + // ============================================ + // Panel Visibility Tests + // ============================================ + describe('Panel Visibility', function() { + test('show() displays the panel', function() { + SidebarTimeline.show(); + + expect(mockElements.panel.classList.contains('collapsed')).toBe(false); + expect(mockElements.showBtn.classList.contains('hidden')).toBe(true); + }); + + test('hide() collapses the panel', function() { + SidebarTimeline.show(); + SidebarTimeline.hide(); + + expect(mockElements.panel.classList.contains('collapsed')).toBe(true); + expect(mockElements.showBtn.classList.contains('hidden')).toBe(false); + }); + + test('toggle() switches panel state', function() { + expect(mockElements.panel.classList.contains('collapsed')).toBe(true); + + SidebarTimeline.toggle(); + expect(mockElements.panel.classList.contains('collapsed')).toBe(false); + + SidebarTimeline.toggle(); + expect(mockElements.panel.classList.contains('collapsed')).toBe(true); + }); + }); + + // ============================================ + // Event Type Rendering Tests + // ============================================ + describe('Event Type Rendering', function() { + beforeEach(function(done) { + // Mock successful API response + global.fetch.mockImplementation(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now() - 3600000, + type: 'zone_entry', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + }, + { + id: 2, + timestamp_ms: Date.now() - 7200000, + type: 'zone_exit', + zone: 'Living Room', + person: 'Bob', + severity: 'info' + }, + { + id: 3, + timestamp_ms: Date.now() - 10800000, + type: 'portal_crossing', + zone: 'Hallway', + person: 'Alice', + detail_json: JSON.stringify({ + from_zone: 'Kitchen', + to_zone: 'Living Room' + }), + severity: 'info' + }, + { + id: 4, + timestamp_ms: Date.now() - 14400000, + type: 'presence_transition', + zone: 'Bedroom', + person: 'Alice', + severity: 'info' + }, + { + id: 5, + timestamp_ms: Date.now() - 18000000, + type: 'stationary_detected', + zone: 'Living Room', + person: 'Bob', + severity: 'info' + }, + { + id: 6, + timestamp_ms: Date.now() - 21600000, + type: 'detection', + zone: 'Kitchen', + person: null, + severity: 'info' + }, + { + id: 7, + timestamp_ms: Date.now() - 25200000, + type: 'anomaly', + zone: 'Kitchen', + person: null, + severity: 'warning' + }, + { + id: 8, + timestamp_ms: Date.now() - 28800000, + type: 'security_alert', + zone: 'Hallway', + person: null, + detail_json: JSON.stringify({ + description: 'Motion detected while armed' + }), + severity: 'alert' + }, + { + id: 9, + timestamp_ms: Date.now() - 32400000, + type: 'fall_alert', + zone: 'Bathroom', + person: 'Alice', + severity: 'critical' + }, + { + id: 10, + timestamp_ms: Date.now() - 36000000, + type: 'node_online', + zone: null, + person: null, + detail_json: JSON.stringify({ + node: 'kitchen-north' + }), + severity: 'info' + }, + { + id: 11, + timestamp_ms: Date.now() - 39600000, + type: 'node_offline', + zone: null, + person: null, + detail_json: JSON.stringify({ + node: 'living-room-west' + }), + severity: 'warning' + }, + { + id: 12, + timestamp_ms: Date.now() - 43200000, + type: 'ota_update', + zone: null, + person: null, + detail_json: JSON.stringify({ + node: 'kitchen-north', + version: '1.2.3' + }), + severity: 'info' + }, + { + id: 13, + timestamp_ms: Date.now() - 46800000, + type: 'baseline_changed', + zone: null, + person: null, + detail_json: JSON.stringify({ + link: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66' + }), + severity: 'info' + }, + { + id: 14, + timestamp_ms: Date.now() - 50400000, + type: 'learning_milestone', + zone: null, + person: null, + detail_json: JSON.stringify({ + description: 'Anomaly patterns learned for Kitchen' + }), + severity: 'info' + }, + { + id: 15, + timestamp_ms: Date.now() - 54000000, + type: 'sleep_session_end', + zone: 'Bedroom', + person: 'Alice', + detail_json: JSON.stringify({ + duration: '7h 23m' + }), + severity: 'info' + } + ], + cursor: null, + total_filtered: 15 + }; + } + }); + }); + + // Show panel and refresh events + SidebarTimeline.show(); + SidebarTimeline.refresh(); + + // Wait for events to load + setTimeout(done, 100); + }); + + test('zone_entry renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('zone_entry'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚪'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('entered'); + done(); + }, 100); + }); + + test('zone_exit renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="2"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('zone_exit'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚶'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Bob'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Living Room'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('left'); + done(); + }, 100); + }); + + test('portal_crossing renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="3"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('portal_crossing'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('→'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Living Room'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('crossed'); + done(); + }, 100); + }); + + test('presence_transition renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="4"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('presence_transition'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('👤'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Bedroom'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('detected'); + done(); + }, 100); + }); + + test('stationary_detected renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="5"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('stationary_detected'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('💤'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Bob'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Living Room'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('stationary'); + done(); + }, 100); + }); + + test('detection renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="6"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('detection'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('👁️'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Motion'); + done(); + }, 100); + }); + + test('anomaly renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="7"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('anomaly'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('⚠️'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Unusual'); + expect(eventEl.classList.contains('severity-warning')).toBe(true); + done(); + }, 100); + }); + + test('security_alert renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="8"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('security_alert'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚨'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Security'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Motion detected while armed'); + expect(eventEl.classList.contains('severity-critical')).toBe(true); + done(); + }, 100); + }); + + test('fall_alert renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="9"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('fall_alert'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🆘'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Fall'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Bathroom'); + expect(eventEl.classList.contains('severity-critical')).toBe(true); + done(); + }, 100); + }); + + test('node_online renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="10"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('node_online'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📡'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('kitchen-north'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('came online'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + + test('node_offline renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="11"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('node_offline'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📵'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('living-room-west'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('went offline'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + + test('ota_update renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="12"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('ota_update'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('⬆️'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('kitchen-north'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('1.2.3'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Firmware'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + + test('baseline_changed renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="13"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('baseline_changed'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📊'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Baseline'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('AA:BB:CC'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + + test('learning_milestone renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="14"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('learning_milestone'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🎓'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Anomaly patterns'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + + test('sleep_session_end renders with correct icon and description', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="15"]'); + expect(eventEl).toBeTruthy(); + expect(eventEl.dataset.type).toBe('sleep_session_end'); + expect(eventEl.querySelector('.sidebar-timeline-event-icon').textContent).toBe('😴'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('7h 23m'); + expect(eventEl.querySelector('.sidebar-timeline-event-title').textContent).toContain('slept'); + done(); + }, 100); + }); + }); + + // ============================================ + // Plain English Description Tests + // ============================================ + describe('Plain English Descriptions', function() { + test('descriptions use plain English without technical jargon', function(done) { + global.fetch.mockImplementation(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'zone_entry', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + }); + + SidebarTimeline.show(); + SidebarTimeline.refresh(); + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'zone_entry', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + const title = eventEl.querySelector('.sidebar-timeline-event-title').textContent; + + // Should not contain technical jargon + expect(title).not.toMatch(/CSI|Fresnel|deltaRMS|blob_id|timestamp_ms/); + + // Should use plain English + expect(title).toMatch(/Alice|Kitchen|entered/); + done(); + }, 100); + }); + + test('unknown person defaults to "Someone"', function(done) { + global.fetch.mockImplementation(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'detection', + zone: 'Hallway', + person: null, + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + }); + + SidebarTimeline.refresh(); + + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + const title = eventEl.querySelector('.sidebar-timeline-event-title').textContent; + + expect(title).toContain('Motion'); + expect(title).toContain('Hallway'); + done(); + }, 100); + }); + + test('unknown zone defaults to "the area"', function(done) { + global.fetch.mockImplementation(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'detection', + zone: null, + person: 'Alice', + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + }); + + SidebarTimeline.refresh(); + + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + const title = eventEl.querySelector('.sidebar-timeline-event-title').textContent; + + expect(title).toContain('Motion'); + done(); + }, 100); + }); + }); + + // ============================================ + // Feedback Button Tests + // ============================================ + describe('Feedback Buttons', function() { + beforeEach(function(done) { + global.fetch.mockImplementation(function() { + return Promise.resolve({ + ok: true, + json: async function() { + return { + events: [ + { + id: 123, + timestamp_ms: Date.now(), + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + blob_id: 42, + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + }); + + SidebarTimeline.show(); + SidebarTimeline.refresh(); + + setTimeout(done, 100); + }); + + test('each event has thumbs-up and thumbs-down buttons', function() { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]'); + expect(eventEl).toBeTruthy(); + + const thumbsUp = eventEl.querySelector('.feedback-positive'); + const thumbsDown = eventEl.querySelector('.feedback-negative'); + + expect(thumbsUp).toBeTruthy(); + expect(thumbsDown).toBeTruthy(); + expect(thumbsUp.getAttribute('aria-label')).toBe('Thumbs up'); + expect(thumbsDown.getAttribute('aria-label')).toBe('Thumbs down'); + done(); + }, 100); + }); + + test('thumbs-up button delegates to feedback module', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]'); + const thumbsUp = eventEl.querySelector('.feedback-positive'); + + // Mock feedback API response + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async function() { + return { ok: true }; + } + }); + + thumbsUp.click(); + + // Give async operations time to complete + setTimeout(function() { + expect(global.Feedback.sendFeedback).toHaveBeenCalledWith( + '123', + 'detection', + 'TRUE_POSITIVE', + expect.anything() + ); + expect(global.SpaxelApp.showToast).toHaveBeenCalledWith( + 'Thanks for the feedback!', + 'success' + ); + done(); + }, 50); + }, 100); + }); + + test('thumbs-down button delegates to feedback module', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]'); + const thumbsDown = eventEl.querySelector('.feedback-negative'); + + // Mock feedback API response + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async function() { + return { ok: true }; + } + }); + + thumbsDown.click(); + + // Give async operations time to complete + setTimeout(function() { + expect(global.Feedback.sendFeedback).toHaveBeenCalledWith( + '123', + 'detection', + 'FALSE_POSITIVE', + expect.anything() + ); + expect(global.SpaxelApp.showToast).toHaveBeenCalledWith( + 'Thanks — I\'ll adjust my detection.', + 'success' + ); + done(); + }, 50); + }, 100); + }); + + test('feedback button click stops propagation', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]'); + const thumbsUp = eventEl.querySelector('.feedback-positive'); + + let eventClickFired = false; + eventEl.addEventListener('click', function() { + eventClickFired = true; + }); + + thumbsUp.click(); + + setTimeout(function() { + expect(eventClickFired).toBe(false); + done(); + }, 50); + }, 100); + }); + + test('feedback button shows active state briefly after click', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]'); + const thumbsUp = eventEl.querySelector('.feedback-positive'); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async function() { + return { ok: true }; + } + }); + + thumbsUp.click(); + + // Check immediately after click + expect(thumbsUp.classList.contains('active')).toBe(true); + + // Check after timeout (2 seconds) + setTimeout(function() { + expect(thumbsUp.classList.contains('active')).toBe(false); + done(); + }, 2100); + }, 100); + }); + }); + + // ============================================ + // Virtualized Rendering Tests + // ============================================ + describe('Virtualized Rendering with IntersectionObserver', function() { + beforeEach(function() { + SidebarTimeline.show(); + }); + + test('uses IntersectionObserver for virtualization when enabled', function(done) { + // Mock IntersectionObserver + const mockObserver = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn() + }; + + global.IntersectionObserver = jest.fn(function(callback, options) { + return mockObserver; + }); + + // Reload module after mocking IntersectionObserver + jest.isolateModules(function() { + require('./sidebar-timeline.js'); + }); + + setTimeout(function() { + expect(global.IntersectionObserver).toHaveBeenCalled(); + done(); + }, 100); + }); + + test('handles 10,000 events without performance degradation', function(done) { + // Create a large array of events + const largeEventList = []; + for (let i = 0; i < 10000; i++) { + largeEventList.push({ + id: i, + timestamp_ms: Date.now() - (i * 60000), // 1 minute apart + type: i % 2 === 0 ? 'detection' : 'zone_entry', + zone: ['Kitchen', 'Living Room', 'Bedroom'][i % 3], + person: ['Alice', 'Bob', 'Charlie'][i % 3], + severity: 'info' + }); + } + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: largeEventList.slice(0, 100), // First batch + cursor: 'next-cursor', + total_filtered: 10000 + }; + } + }); + + // Measure render time + const startTime = performance.now(); + + setTimeout(function() { + const renderTime = performance.now() - startTime; + + // Rendering should complete quickly (< 100ms for 100 items) + expect(renderTime).toBeLessThan(100); + + // Check that virtual spacers are used + expect(mockElements.spacerTop.style.height).not.toBe('0px'); + expect(mockElements.spacerBottom.style.height).not.toBe('0px'); + + // Check that not all events are rendered (virtualization) + const renderedEvents = mockElements.eventsContainer.querySelectorAll('.sidebar-timeline-event'); + expect(renderedEvents.length).toBeLessThan(10000); + expect(renderedEvents.length).toBeLessThan(150); // maxDOMItems + + done(); + }, 200); + }); + + test('updates spacers correctly for virtual scrolling', function(done) { + const events = []; + for (let i = 0; i < 100; i++) { + events.push({ + id: i, + timestamp_ms: Date.now() - (i * 60000), + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + }); + } + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: events, + cursor: null, + total_filtered: 100 + }; + } + }); + + setTimeout(function() { + // Initial render - top spacer should be 0 + expect(parseInt(mockElements.spacerTop.style.height, 10)).toBe(0); + + // Bottom spacer should account for unrendered events + const bottomHeight = parseInt(mockElements.spacerBottom.style.height, 10); + expect(bottomHeight).toBeGreaterThan(0); + + done(); + }, 200); + }); + + test('only renders visible items plus buffer', function(done) { + const events = []; + for (let i = 0; i < 200; i++) { + events.push({ + id: i, + timestamp_ms: Date.now() - (i * 60000), + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + }); + } + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: events, + cursor: null, + total_filtered: 200 + }; + } + }); + + setTimeout(function() { + const renderedEvents = mockElements.eventsContainer.querySelectorAll('.sidebar-timeline-event'); + + // Should render initial batch (visible + buffer) + expect(renderedEvents.length).toBeGreaterThan(0); + expect(renderedEvents.length).toBeLessThan(200); + + done(); + }, 200); + }); + }); + + // ============================================ + // Event Click and Seek Tests + // ============================================ + describe('Event Click and Seek', function() { + beforeEach(function() { + SidebarTimeline.show(); + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: 1712707200000, // 2024-04-09 12:00:00 UTC + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + } + ], + cursor: null, + total_filtered: 1 + }; + } + }); + }); + + test('clicking event seeks to timestamp in timeline', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + expect(eventEl).toBeTruthy(); + + eventEl.click(); + + expect(global.SpaxelRouter.navigate).toHaveBeenCalledWith('timeline'); + expect(global.SpaxelApp.showToast).toHaveBeenCalledWith('Opening timeline...', 'info'); + + done(); + }, 100); + }); + + test('event timestamp is stored in data attribute', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + expect(eventEl).toBeTruthy(); + + const timestamp = parseInt(eventEl.dataset.timestamp, 10); + expect(timestamp).toBe(1712707200000); + + done(); + }, 100); + }); + }); + + // ============================================ + // Real-time Event Updates Tests + // ============================================ + describe('Real-time Event Updates', function() { + beforeEach(function() { + SidebarTimeline.show(); + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [], + cursor: null, + total_filtered: 0 + }; + } + }); + }); + + test('new WebSocket event appears at top of timeline', function(done) { + setTimeout(function() { + // Simulate WebSocket message handler being called + const wsEvent = { + type: 'event', + event: { + id: 999, + ts: Date.now(), + kind: 'detection', + zone: 'Kitchen', + person_name: 'Alice', + severity: 'info' + } + }; + + // Get the message handler and call it + const handlers = global.SpaxelApp.registerMessageHandler.mock.calls; + if (handlers.length > 0) { + const handler = handlers[0][0]; + handler(wsEvent); + + setTimeout(function() { + const newEventEl = mockElements.eventsContainer.querySelector('[data-id="999"]'); + expect(newEventEl).toBeTruthy(); + expect(newEventEl.classList.contains('new-event')).toBe(true); + done(); + }, 50); + } else { + done(); + } + }, 100); + }); + + test('new events have animation class', function(done) { + setTimeout(function() { + const wsEvent = { + type: 'event', + event: { + id: 1000, + ts: Date.now(), + kind: 'zone_entry', + zone: 'Living Room', + person_name: 'Bob', + severity: 'info' + } + }; + + const handlers = global.SpaxelApp.registerMessageHandler.mock.calls; + if (handlers.length > 0) { + const handler = handlers[0][0]; + handler(wsEvent); + + setTimeout(function() { + const newEventEl = mockElements.eventsContainer.querySelector('[data-id="1000"]'); + expect(newEventEl.classList.contains('new-event')).toBe(true); + done(); + }, 50); + } else { + done(); + } + }, 100); + }); + }); + + // ============================================ + // Empty State Tests + // ============================================ + describe('Empty State', function() { + beforeEach(function() { + SidebarTimeline.show(); + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [], + cursor: null, + total_filtered: 0 + }; + } + }); + }); + + test('shows empty state when no events', function(done) { + setTimeout(function() { + expect(mockElements.empty.style.display).toBe('flex'); + expect(mockElements.eventsContainer.children.length).toBe(0); + done(); + }, 100); + }); + + test('empty state shows correct message and icon', function(done) { + setTimeout(function() { + const emptyTitle = mockElements.empty.querySelector('h3'); + const emptyText = mockElements.empty.querySelector('p'); + const emptyIcon = mockElements.empty.querySelector('svg'); + + expect(emptyTitle.textContent).toBe('No events yet'); + expect(emptyText.textContent).toBe('Events will appear here as they happen'); + expect(emptyIcon).toBeTruthy(); + done(); + }, 100); + }); + + test('loading state shows before events load', function(done) { + // Initially should show loading + expect(mockElements.loading.style.display).toBe('flex'); + + setTimeout(function() { + // After events load, loading should hide + expect(mockElements.loading.style.display).toBe('none'); + done(); + }, 100); + }); + }); + + // ============================================ + // Severity Styling Tests + // ============================================ + describe('Severity Styling', function() { + beforeEach(function() { + SidebarTimeline.show(); + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + }, + { + id: 2, + timestamp_ms: Date.now() - 3600000, + type: 'anomaly', + zone: 'Living Room', + person: null, + severity: 'warning' + }, + { + id: 3, + timestamp_ms: Date.now() - 7200000, + type: 'fall_alert', + zone: 'Bathroom', + person: 'Alice', + severity: 'critical' + } + ], + cursor: null, + total_filtered: 3 + }; + } + }); + }); + + test('info events have no severity class', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + expect(eventEl.classList.contains('severity-critical')).toBe(false); + expect(eventEl.classList.contains('severity-warning')).toBe(false); + done(); + }, 100); + }); + + test('warning events have severity-warning class', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="2"]'); + expect(eventEl.classList.contains('severity-warning')).toBe(true); + done(); + }, 100); + }); + + test('critical events have severity-critical class', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="3"]'); + expect(eventEl.classList.contains('severity-critical')).toBe(true); + done(); + }, 100); + }); + }); + + // ============================================ + // System Event Secondary Styling Tests + // ============================================ + describe('System Event Secondary Styling', function() { + beforeEach(function() { + SidebarTimeline.show(); + + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [ + { + id: 1, + timestamp_ms: Date.now(), + type: 'detection', + zone: 'Kitchen', + person: 'Alice', + severity: 'info' + }, + { + id: 2, + timestamp_ms: Date.now() - 3600000, + type: 'node_online', + zone: null, + person: null, + detail_json: JSON.stringify({ node: 'kitchen-north' }), + severity: 'info' + } + ], + cursor: null, + total_filtered: 2 + }; + } + }); + }); + + test('user events have no secondary class', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]'); + expect(eventEl.classList.contains('secondary')).toBe(false); + done(); + }, 100); + }); + + test('system events have secondary class', function(done) { + setTimeout(function() { + const eventEl = mockElements.eventsContainer.querySelector('[data-id="2"]'); + expect(eventEl.classList.contains('secondary')).toBe(true); + done(); + }, 100); + }); + }); + + // ============================================ + // Mode Change Tests + // ============================================ + describe('Mode Change Handling', function() { + beforeEach(function() { + SidebarTimeline.show(); + }); + + test('updates dashboard mode on simple mode change', function(done) { + setTimeout(function() { + const handlers = global.SpaxelSimpleModeDetection.onModeChange.mock.calls; + if (handlers.length > 0) { + const handler = handlers[0][0]; + + // Simulate simple mode change + handler('simple', 'expert'); + + // Mode should be updated (visible in next API call) + global.fetch.mockClear(); + global.fetch.mockResolvedValue({ + ok: true, + json: async function() { + return { + events: [], + cursor: null, + total_filtered: 0 + }; + } + }); + + // Trigger refresh + SidebarTimeline.refresh(); + + setTimeout(function() { + expect(global.fetch).toHaveBeenCalled(); + done(); + }, 50); + } else { + done(); + } + }, 100); + }); + + test('updates dashboard mode on router mode change', function(done) { + setTimeout(function() { + const handlers = global.SpaxelRouter.onModeChange.mock.calls; + if (handlers.length > 0) { + const handler = handlers[0][0]; + + // Simulate router mode change + handler('simple', 'expert'); + + // Mode should be updated + done(); + } else { + done(); + } + }, 100); + }); + }); +}); diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go index 9f3a0f7..f0b1663 100644 --- a/mothership/internal/ingestion/server.go +++ b/mothership/internal/ingestion/server.go @@ -860,11 +860,12 @@ func (s *Server) GetConnectedNodesInfo() []NodeInfo { return nodes } -// LinkInfo represents a link with its endpoints +// LinkInfo represents a link with its endpoints and health data type LinkInfo struct { - ID string `json:"id"` - NodeMAC string `json:"node_mac"` - PeerMAC string `json:"peer_mac"` + ID string `json:"id"` + NodeMAC string `json:"node_mac"` + PeerMAC string `json:"peer_mac"` + HealthScore float64 `json:"health_score,omitempty"` // Ambient confidence score (0-1) } // LinkHealthInfo represents a link with health metrics for the API response @@ -880,16 +881,31 @@ type LinkHealthInfo struct { // GetAllLinksInfo returns detailed info about all active links func (s *Server) GetAllLinksInfo() []LinkInfo { s.mu.RLock() - defer s.mu.RUnlock() + pm := s.processorMgr + s.mu.RUnlock() + + s.mu.Lock() + defer s.mu.Unlock() links := make([]LinkInfo, 0, len(s.links)) for linkID := range s.links { if len(linkID) >= 35 { - links = append(links, LinkInfo{ + info := LinkInfo{ ID: linkID, NodeMAC: linkID[:17], PeerMAC: linkID[18:], - }) + } + + // Get health score from processor manager if available + if pm != nil { + if proc := pm.GetProcessor(linkID); proc != nil { + if health := proc.GetHealth(); health != nil { + info.HealthScore = health.GetAmbientConfidence() + } + } + } + + links = append(links, info) } } return links