/** * Spaxel Dashboard - Activity Timeline * * Scrollable chronological event list with filtering and event interaction. * Click event → jump to that moment in replay mode. * Inline feedback (thumbs up/down) on presence detection events. */ (function() { 'use strict'; // ============================================ // Configuration // ============================================ const CONFIG = { initialLoadLimit: 200, fetchSinceHours: 24, debounceMs: 300, replaySeekWindowSec: 5 // seconds before/after event timestamp }; // ============================================ // State // ============================================ const state = { events: [], cursor: null, total: 0, filters: { type: null, zone: null, person: null, after: null, // ISO8601 string q: null }, loading: false, error: null, // Filter options populated from available events availableTypes: new Set(), availableZones: new Set(), availablePersons: new Set() }; // DOM elements let elements = {}; // ============================================ // Event Type Icons and Colors // ============================================ const eventTypeInfo = { zone_entry: { icon: '🚪', color: '#66bb6a', label: 'Entered', description: 'Person entered a zone' }, zone_exit: { icon: '🚶', color: '#ffa726', label: 'Left', description: 'Person exited a zone' }, portal_crossing: { icon: '→', color: '#42a5f5', label: 'Crossed', description: 'Person crossed a portal' }, presence_transition: { icon: '👤', color: '#ab47bc', label: 'Detected', description: 'Presence detected' }, stationary_detected: { icon: '💤', color: '#7e57c2', label: 'Stationary', description: 'Stationary person detected' }, anomaly: { icon: '⚠️', color: '#ef5350', label: 'Anomaly', description: 'Unusual activity detected' }, security_alert: { icon: '🚨', color: '#d32f2f', label: 'Security', description: 'Security alert' }, fall_alert: { icon: '🆘', color: '#f44336', label: 'Fall', description: 'Fall detected' }, node_online: { icon: '📡', color: '#4caf50', label: 'Online', description: 'Node came online' }, node_offline: { icon: '📵', color: '#9e9e9e', label: 'Offline', description: 'Node went offline' }, ota_update: { icon: '⬆️', color: '#2196f3', label: 'Updated', description: 'Firmware updated' }, baseline_changed: { icon: '📊', color: '#00bcd4', label: 'Baseline', description: 'Baseline updated' }, learning_milestone: { icon: '🎓', color: '#9c27b0', label: 'Learned', description: 'System learned patterns' }, system: { icon: '⚙️', color: '#607d8b', label: 'System', description: 'System event' } }; // ============================================ // Initialization // ============================================ function init() { console.log('[Timeline] Initializing'); cacheElements(); bindEvents(); // Listen for route changes to show/hide timeline if (window.SpaxelRouter) { SpaxelRouter.onModeChange(onModeChange); } // Listen for WebSocket event messages if (window.SpaxelApp) { SpaxelApp.registerMessageHandler(handleWebSocketMessage); } } function cacheElements() { elements = { container: document.getElementById('timeline-view'), eventsList: document.getElementById('timeline-events'), filterType: document.getElementById('timeline-filter-type'), filterZone: document.getElementById('timeline-filter-zone'), filterPerson: document.getElementById('timeline-filter-person'), filterTime: document.getElementById('timeline-filter-time'), filterSearch: document.getElementById('timeline-filter-search'), loading: document.getElementById('timeline-loading'), empty: document.getElementById('timeline-empty'), error: document.getElementById('timeline-error'), loadMore: document.getElementById('timeline-load-more'), loadMoreBtn: document.getElementById('timeline-load-more-btn') }; } function bindEvents() { // Filter changes if (elements.filterType) { elements.filterType.addEventListener('change', onFilterChange); } if (elements.filterZone) { elements.filterZone.addEventListener('change', onFilterChange); } if (elements.filterPerson) { elements.filterPerson.addEventListener('change', onFilterChange); } if (elements.filterTime) { elements.filterTime.addEventListener('change', onFilterChange); } if (elements.filterSearch) { let searchTimeout; elements.filterSearch.addEventListener('input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(onFilterChange, CONFIG.debounceMs); }); } // Load more button if (elements.loadMoreBtn) { elements.loadMoreBtn.addEventListener('click', loadMoreEvents); } } // ============================================ // Mode Change Handler // ============================================ function onModeChange(newMode, oldMode) { const container = elements.container; if (!container) return; if (newMode === 'timeline') { // Container is shown by inline script, just load events if needed if (state.events.length === 0) { loadInitialEvents(); } } } // ============================================ // Event Loading // ============================================ function loadInitialEvents() { const params = new URLSearchParams(); params.set('limit', CONFIG.initialLoadLimit); applyFiltersToParams(params); const url = '/api/events?' + params.toString(); fetchEvents(url, true); } function loadMoreEvents() { if (state.loading || !state.cursor) { return; } const params = new URLSearchParams(); params.set('limit', CONFIG.initialLoadLimit); if (state.cursor) { params.set('before', state.cursor); } applyFiltersToParams(params); const url = '/api/events?' + params.toString(); fetchEvents(url, false); } function fetchEvents(url, isInitial) { state.loading = true; updateLoadingState(); fetch(url) .then(function(res) { if (!res.ok) { throw new Error('Failed to fetch events: ' + res.statusText); } return res.json(); }) .then(function(data) { if (isInitial) { state.events = []; updateFilterOptions(data.events); } state.events = state.events.concat(data.events || []); state.cursor = data.cursor || null; state.total = data.total || 0; renderEvents(); updateLoadMoreButton(); updateFilterOptions(data.events); }) .catch(function(err) { console.error('[Timeline] Failed to load events:', err); state.error = err.message; showError(err.message); }) .finally(function() { state.loading = false; updateLoadingState(); }); } function applyFiltersToParams(params) { if (state.filters.type) { params.set('type', state.filters.type); } if (state.filters.zone) { params.set('zone', state.filters.zone); } if (state.filters.person) { params.set('person', state.filters.person); } if (state.filters.after) { params.set('after', state.filters.after); } if (state.filters.q) { params.set('q', state.filters.q); } } // ============================================ // WebSocket Message Handler // ============================================ function handleWebSocketMessage(msg) { if (msg.type === 'event') { handleNewEvent(msg.event); } } function handleNewEvent(event) { console.log('[Timeline] New event:', event); // Normalize event format (live events use ts/kind/person_name, DB events use timestamp_ms/type/person) 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 array state.events.unshift(normalizedEvent); state.total++; // Update filter options if (normalizedEvent.type) { state.availableTypes.add(normalizedEvent.type); } if (normalizedEvent.zone) { state.availableZones.add(normalizedEvent.zone); } if (normalizedEvent.person) { state.availablePersons.add(normalizedEvent.person); } // Prepend to DOM if timeline is visible (no layout shift) if (elements.container && elements.container.style.display !== 'none' && elements.eventsList) { elements.empty.style.display = 'none'; const tempDiv = document.createElement('div'); tempDiv.innerHTML = renderEvent(normalizedEvent, true); const newEventEl = tempDiv.firstElementChild; elements.eventsList.insertBefore(newEventEl, elements.eventsList.firstChild); // Bind handlers for the new event bindEventHandlersForElement(newEventEl); // Remove animation class after it completes setTimeout(function() { newEventEl.classList.remove('new-event'); }, 300); // Limit DOM elements (keep only most recent 100 in DOM) while (elements.eventsList.children.length > 100) { elements.eventsList.removeChild(elements.eventsList.lastChild); } updateFilterOptions(); } } // Bind handlers for a single event element function bindEventHandlersForElement(eventEl) { // Feedback buttons eventEl.querySelectorAll('.timeline-feedback-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); }); }); // Seek button eventEl.querySelectorAll('.timeline-seek-btn').forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); const timestamp = parseInt(eventEl.dataset.timestamp, 10); handleSeek(timestamp, eventEl); }); }); // Entry click (also seeks) eventEl.addEventListener('click', function() { const timestamp = parseInt(this.dataset.timestamp, 10); handleSeek(timestamp, this); }); } // ============================================ // Rendering // ============================================ function renderEvents() { if (!elements.eventsList) return; if (state.events.length === 0) { elements.eventsList.innerHTML = ''; if (elements.empty) { elements.empty.style.display = 'block'; } if (elements.error) { elements.error.style.display = 'none'; } return; } elements.empty.style.display = 'none'; if (elements.error) { elements.error.style.display = 'none'; } // Build HTML for events let html = ''; state.events.forEach(function(event) { html += renderEvent(event); }); elements.eventsList.innerHTML = html; // Bind click handlers bindEventHandlers(); } function renderEvent(event, isNew) { const info = eventTypeInfo[event.type] || eventTypeInfo.system; const timeStr = formatTimestamp(event.timestamp_ms); const personStr = event.person ? escapeHtml(event.person) : ''; const zoneStr = event.zone ? escapeHtml(event.zone) : ''; const description = buildEventDescription(event); // Severity indicator for alerts const severityClass = event.severity === 'alert' || event.severity === 'critical' ? ' severity-critical' : ''; const newClass = isNew ? ' new-event' : ''; return `