From 6bfe4aad01c7c07c8b4be70bb8329547103f7534 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sat, 11 Apr 2026 13:21:28 -0400 Subject: [PATCH] feat: implement expert vs simple mode for timeline panel - Add mode switching for timeline panel with ?mode=expert or ?mode=simple - Expert mode displays all event types with system events as secondary (smaller, greyed) - Simple mode shows only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd, zone_entry/exit, portal_crossing, fall_alert, anomaly, security_alert - Backend defaults to expert mode when mode parameter is empty or invalid - Frontend syncs dashboard mode with SpaxelSimpleModeDetection for mode changes - Add CSS styling for new event types (ZoneTransition, FallDetected, AnomalyDetected, sleep_session_end) - Update isValidEventType to include new event types --- dashboard/css/timeline.css | 412 ++++++++++++++++++++++++++++++ dashboard/js/timeline.js | 74 +++++- mothership/internal/api/events.go | 13 +- 3 files changed, 483 insertions(+), 16 deletions(-) diff --git a/dashboard/css/timeline.css b/dashboard/css/timeline.css index 98de3ee..77fbb69 100644 --- a/dashboard/css/timeline.css +++ b/dashboard/css/timeline.css @@ -466,6 +466,11 @@ --event-color-bg: rgba(255, 107, 107, 0.15); } +.timeline-event[data-type="ZoneTransition"] { + --event-color: #ffa726; + --event-color-bg: rgba(255, 167, 38, 0.15); +} + .timeline-event[data-type="portal_crossing"] { --event-color: #4a9eff; --event-color-bg: rgba(74, 158, 255, 0.15); @@ -476,11 +481,21 @@ --event-color-bg: rgba(255, 71, 87, 0.15); } +.timeline-event[data-type="AnomalyDetected"] { + --event-color: #ff4757; + --event-color-bg: rgba(255, 71, 87, 0.15); +} + .timeline-event[data-type="anomaly_detected"] { --event-color: #ff4757; --event-color-bg: rgba(255, 71, 87, 0.15); } +.timeline-event[data-type="FallDetected"] { + --event-color: #f44336; + --event-color-bg: rgba(244, 67, 54, 0.15); +} + .timeline-event[data-type="security_alert"] { --event-color: #ffa502; --event-color-bg: rgba(255, 165, 2, 0.15); @@ -496,6 +511,11 @@ --event-color-bg: rgba(116, 125, 140, 0.15); } +.timeline-event[data-type="sleep_session_end"] { + --event-color: #4fc3f7; + --event-color-bg: rgba(79, 195, 247, 0.15); +} + /* Loading States */ .timeline-loading { display: flex; @@ -757,3 +777,395 @@ .timeline-date-apply-btn:hover { background: #3a8ee6; } + +/* ============================================ + Sidebar Timeline Panel Styles + ============================================ */ + +/* Main Sidebar Panel Container */ +.sidebar-panel { + position: fixed; + top: 44px; /* Below status bar */ + right: 0; + width: 320px; + height: calc(100vh - 44px); + background: var(--bg-secondary, #16162a); + border-left: 1px solid var(--border-color, #2a2a4a); + display: flex; + flex-direction: column; + z-index: 60; + transition: transform 0.3s ease, opacity 0.3s ease; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3); +} + +.sidebar-panel.collapsed { + transform: translateX(100%); + opacity: 0; + pointer-events: none; +} + +/* Panel Header */ +.sidebar-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-tertiary, #1a1a2e); + border-bottom: 1px solid var(--border-color, #2a2a4a); + flex-shrink: 0; +} + +.sidebar-panel-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.sidebar-panel-title svg { + color: var(--accent-color, #4a9eff); +} + +.sidebar-panel-actions { + display: flex; + gap: 4px; +} + +.sidebar-panel-btn { + background: transparent; + border: none; + color: var(--text-secondary, #a0a0b0); + padding: 6px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.sidebar-panel-btn:hover { + background: var(--bg-primary, #121225); + color: var(--text-primary, #e0e0e0); +} + +/* Panel Content */ +.sidebar-panel-content { + flex: 1; + overflow-y: auto; + position: relative; +} + +.sidebar-panel-content::-webkit-scrollbar { + width: 4px; +} + +.sidebar-panel-content::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-panel-content::-webkit-scrollbar-thumb { + background: var(--border-color, #2a2a4a); + border-radius: 2px; +} + +.sidebar-panel-content::-webkit-scrollbar-thumb:hover { + background: var(--text-muted, #666); +} + +/* Timeline Events in Sidebar */ +.sidebar-timeline-events { + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Compact Event Card for Sidebar */ +.sidebar-timeline-event { + background: var(--bg-primary, #121225); + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 6px; + padding: 8px 10px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + display: flex; + gap: 8px; + align-items: flex-start; +} + +.sidebar-timeline-event::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--event-color, #4a9eff); +} + +.sidebar-timeline-event:hover { + border-color: var(--accent-color, #4a9eff); + transform: translateX(-2px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.sidebar-timeline-event.severity-critical { + border-color: var(--color-critical, #ff4757); +} + +.sidebar-timeline-event.severity-critical::before { + background: var(--color-critical, #ff4757); +} + +.sidebar-timeline-event.severity-warning { + border-color: var(--color-warning, #ffa502); +} + +.sidebar-timeline-event.severity-warning::before { + background: var(--color-warning, #ffa502); +} + +.sidebar-timeline-event.new-event { + animation: sidebarEventSlideIn 0.3s ease-out; +} + +@keyframes sidebarEventSlideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Event Icon */ +.sidebar-timeline-event-icon { + flex-shrink: 0; + font-size: 16px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(74, 158, 255, 0.1); + border-radius: 4px; +} + +/* Event Content */ +.sidebar-timeline-event-content { + flex: 1; + min-width: 0; +} + +.sidebar-timeline-event-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #e0e0e0); + line-height: 1.3; + margin-bottom: 2px; + word-wrap: break-word; +} + +.sidebar-timeline-event-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted, #888); +} + +.sidebar-timeline-event-time { + color: var(--text-secondary, #a0a0b0); +} + +.sidebar-timeline-event-zone { + color: var(--accent-color, #4a9eff); +} + +.sidebar-timeline-event-person { + color: #81c784; +} + +/* Event Actions */ +.sidebar-timeline-event-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.sidebar-timeline-action-btn { + background: transparent; + border: none; + color: var(--text-muted, #888); + padding: 4px; + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + font-size: 12px; + min-width: 24px; + height: 24px; +} + +.sidebar-timeline-action-btn:hover { + background: var(--bg-tertiary, #1a1a2e); + color: var(--text-secondary, #a0a0b0); +} + +.sidebar-timeline-action-btn.feedback-positive:hover { + background: rgba(76, 175, 80, 0.2); + color: #81c784; +} + +.sidebar-timeline-action-btn.feedback-negative:hover { + background: rgba(244, 67, 54, 0.2); + color: #e57373; +} + +.sidebar-timeline-action-btn.active { + background: var(--accent-color, #4a9eff); + color: var(--bg-primary, #121225); +} + +/* Feedback Dismissed State */ +.sidebar-timeline-event.feedback-dismissed { + opacity: 0.5; + text-decoration: line-through; +} + +/* Loading State */ +.sidebar-timeline-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-muted, #888); + gap: 12px; +} + +.sidebar-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-color, #2a2a4a); + border-top-color: var(--accent-color, #4a9eff); + border-radius: 50%; + animation: sidebarSpin 0.8s linear infinite; +} + +@keyframes sidebarSpin { + to { transform: rotate(360deg); } +} + +/* Empty State */ +.sidebar-timeline-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--text-muted, #888); +} + +.sidebar-timeline-empty svg { + color: var(--border-color, #2a2a4a); + margin-bottom: 12px; +} + +.sidebar-timeline-empty h3 { + font-size: 14px; + color: var(--text-secondary, #a0a0b0); + margin: 0 0 4px 0; +} + +.sidebar-timeline-empty p { + font-size: 12px; + margin: 0; +} + +/* Show Button (when panel is collapsed) */ +.sidebar-show-btn { + position: fixed; + top: 50%; + right: 0; + transform: translateY(-50%); + background: var(--bg-secondary, #16162a); + border: 1px solid var(--border-color, #2a2a4a); + border-right: none; + border-radius: 8px 0 0 8px; + padding: 12px 8px; + cursor: pointer; + z-index: 59; + transition: all 0.3s ease; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2); +} + +.sidebar-show-btn:hover { + background: var(--bg-tertiary, #1a1a2e); + padding-right: 12px; +} + +.sidebar-show-btn svg { + color: var(--text-secondary, #a0a0b0); +} + +.sidebar-show-btn.hidden { + transform: translateY(-50%) translateX(100%); + opacity: 0; + pointer-events: none; +} + +/* Virtual Spacers */ +.timeline-spacer { + flex-shrink: 0; +} + +/* Event Type Colors */ +.sidebar-timeline-event[data-type="zone_entry"] { --event-color: #66bb6a; } +.sidebar-timeline-event[data-type="zone_exit"] { --event-color: #ffa726; } +.sidebar-timeline-event[data-type="portal_crossing"] { --event-color: #42a5f5; } +.sidebar-timeline-event[data-type="detection"] { --event-color: #ab47bc; } +.sidebar-timeline-event[data-type="presence_transition"] { --event-color: #ab47bc; } +.sidebar-timeline-event[data-type="stationary_detected"] { --event-color: #7e57c2; } +.sidebar-timeline-event[data-type="anomaly"] { --event-color: #ef5350; } +.sidebar-timeline-event[data-type="security_alert"] { --event-color: #d32f2f; } +.sidebar-timeline-event[data-type="fall_alert"] { --event-color: #f44336; } +.sidebar-timeline-event[data-type="node_online"] { --event-color: #4caf50; } +.sidebar-timeline-event[data-type="node_offline"] { --event-color: #9e9e9e; } +.sidebar-timeline-event[data-type="ota_update"] { --event-color: #2196f3; } +.sidebar-timeline-event[data-type="baseline_changed"] { --event-color: #00bcd4; } +.sidebar-timeline-event[data-type="system"] { --event-color: #607d8b; } +.sidebar-timeline-event[data-type="learning_milestone"] { --event-color: #9c27b0; } +.sidebar-timeline-event[data-type="anomaly_learned"] { --event-color: #9c27b0; } + +/* Responsive Design */ +@media (max-width: 768px) { + .sidebar-panel { + width: 100%; + max-width: 320px; + } + + .sidebar-show-btn { + display: none; /* Always show panel on mobile, use close button instead */ + } +} + +/* Secondary System Events (dimmed in expert mode) */ +.sidebar-timeline-event.secondary { + opacity: 0.6; +} + +.sidebar-timeline-event.secondary:hover { + opacity: 0.9; +} diff --git a/dashboard/js/timeline.js b/dashboard/js/timeline.js index ff44aae..3fcfc7c 100644 --- a/dashboard/js/timeline.js +++ b/dashboard/js/timeline.js @@ -32,8 +32,8 @@ // ============================================ const EVENT_CATEGORIES = { presence: ['presence_transition', 'stationary_detected', 'detection'], - zones: ['zone_entry', 'zone_exit', 'portal_crossing'], - alerts: ['fall_alert', 'anomaly', 'security_alert'], + zones: ['zone_entry', 'zone_exit', 'ZoneTransition', 'portal_crossing', 'sleep_session_end'], + alerts: ['fall_alert', 'FallDetected', 'anomaly', 'AnomalyDetected', 'security_alert'], system: ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system'], learning: ['learning_milestone', 'anomaly_learned'] }; @@ -105,6 +105,13 @@ description: 'Person exited a zone', category: 'zones' }, + ZoneTransition: { + icon: '🚶', + color: '#ffa726', + label: 'Moved', + description: 'Person moved between zones', + category: 'zones' + }, portal_crossing: { icon: '→', color: '#42a5f5', @@ -112,6 +119,13 @@ description: 'Person crossed a portal', category: 'zones' }, + sleep_session_end: { + icon: '🌅', + color: '#4fc3f7', + label: 'Woke Up', + description: 'Sleep session ended', + category: 'zones' + }, presence_transition: { icon: '👤', color: '#ab47bc', @@ -133,6 +147,20 @@ description: 'Motion detected', category: 'presence' }, + fall_alert: { + icon: '🆘', + color: '#f44336', + label: 'Fall', + description: 'Fall detected', + category: 'alerts' + }, + FallDetected: { + icon: '🆘', + color: '#f44336', + label: 'Fall', + description: 'Fall detected', + category: 'alerts' + }, anomaly: { icon: '⚠️', color: '#ef5350', @@ -140,6 +168,13 @@ description: 'Unusual activity detected', category: 'alerts' }, + AnomalyDetected: { + icon: '⚠️', + color: '#ef5350', + label: 'Anomaly', + description: 'Unusual activity detected', + category: 'alerts' + }, security_alert: { icon: '🚨', color: '#d32f2f', @@ -147,13 +182,6 @@ description: 'Security alert', category: 'alerts' }, - fall_alert: { - icon: '🆘', - color: '#f44336', - label: 'Fall', - description: 'Fall detected', - category: 'alerts' - }, node_online: { icon: '📡', color: '#4caf50', @@ -220,12 +248,36 @@ SpaxelRouter.onModeChange(onModeChange); } + // Listen for simple mode changes + if (window.SpaxelSimpleModeDetection) { + SpaxelSimpleModeDetection.onModeChange(onSimpleModeChange); + } + // Listen for WebSocket event messages if (window.SpaxelApp) { SpaxelApp.registerMessageHandler(handleWebSocketMessage); } } + // ============================================ + // Simple Mode Change Handler + // ============================================ + function onSimpleModeChange(newMode, oldMode) { + console.log('[Timeline] 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 if timeline is visible + if (elements.container && elements.container.style.display !== 'none') { + loadInitialEvents(); + } + } + // ============================================ // Virtualization Setup // ============================================ @@ -683,8 +735,8 @@ elements.categoryPresence, elements.categoryZones, elements.categoryAlerts, - elements.categorySystem, - elements.categoryLearning + elements.categorySystem, + elements.categoryLearning ]; checkboxes.forEach(function(cb) { if (cb) { diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 41b91b5..f11451c 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -180,9 +180,9 @@ func createEventsSchema(db *sql.DB) error { func isValidEventType(t string) bool { switch t { case "detection", "zone_entry", "zone_exit", "portal_crossing", - "trigger_fired", "fall_alert", "anomaly", "anomaly_detected", "security_alert", + "trigger_fired", "fall_alert", "FallDetected", "anomaly", "AnomalyDetected", "security_alert", "node_online", "node_offline", "ota_update", "baseline_changed", - "system", "learning_milestone", "sleep_session_end": + "system", "learning_milestone", "sleep_session_end", "ZoneTransition": return true } return false @@ -316,19 +316,22 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { } // Person-relevant event types for simple mode - // Simple mode shows only person-relevant events: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, anomaly_detected, security_alert, sleep_session_end + // Simple mode shows only person-relevant events: zone_entry, zone_exit, ZoneTransition, portal_crossing, fall_alert, FallDetected, anomaly, AnomalyDetected, security_alert, sleep_session_end // Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system, learning_milestone, detection, presence_transition, stationary_detected simpleModeTypes := map[string]bool{ "zone_entry": true, "zone_exit": true, + "ZoneTransition": true, "portal_crossing": true, "fall_alert": true, + "FallDetected": true, "anomaly": true, - "anomaly_detected": true, + "AnomalyDetected": true, "security_alert": true, "sleep_session_end": true, } - isSimpleMode := mode != "expert" + // Default to expert mode when mode parameter is empty or invalid + isSimpleMode := mode == "simple" // Prepare FTS5 query with prefix matching if q != "" {