From 2d34f77d87d73793a1428ed71e8dd13497e90d69 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sat, 11 Apr 2026 13:36:19 -0400 Subject: [PATCH] feat: implement expert vs simple mode for timeline panel Add mode switching for timeline panel. Expert mode shows all event types with system events as secondary (smaller, greyed). Simple mode shows only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd. Mode is set by dashboard mode and passed as ?mode=expert or ?mode=simple to API. Changes: - dashboard/index.html: Add sidebar timeline panel HTML and script include - dashboard/js/sidebar-timeline.js: Add sidebar timeline with mode switching - dashboard/js/sidebar-timeline.test.js: Add tests for mode switching behavior - mothership/internal/ingestion/server.go: Add health score to link info Co-Authored-By: Claude Opus 4.6 --- dashboard/index.html | 52 + dashboard/js/sidebar-timeline.js | 791 +++++++++++++ dashboard/js/sidebar-timeline.test.js | 1369 +++++++++++++++++++++++ mothership/internal/ingestion/server.go | 30 +- 4 files changed, 2235 insertions(+), 7 deletions(-) create mode 100644 dashboard/js/sidebar-timeline.js create mode 100644 dashboard/js/sidebar-timeline.test.js 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