From 3a85eaacc3df6a17960b2239da56563ccb91ccba Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 24 Apr 2026 07:43:13 -0400 Subject: [PATCH] refactor(dashboard): remove duplicate component files Delete non-canonical commandpalette.* and blepanel.js in favor of the hyphenated command-palette.* and ble-panel.* which match the fleet-page.* naming convention. Rename test file accordingly. Co-Authored-By: Claude Opus 4.7 --- dashboard/css/commandpalette.css | 219 ---- dashboard/js/blepanel.js | 656 ------------ ...alette.test.js => command-palette.test.js} | 2 +- dashboard/js/commandpalette.js | 978 ------------------ 4 files changed, 1 insertion(+), 1854 deletions(-) delete mode 100644 dashboard/css/commandpalette.css delete mode 100644 dashboard/js/blepanel.js rename dashboard/js/{commandpalette.test.js => command-palette.test.js} (99%) delete mode 100644 dashboard/js/commandpalette.js diff --git a/dashboard/css/commandpalette.css b/dashboard/css/commandpalette.css deleted file mode 100644 index 59206db..0000000 --- a/dashboard/css/commandpalette.css +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Spaxel Dashboard — Command Palette Styles (Component 34) - * - * Activated by Ctrl+K / Cmd+K in expert mode only. - */ - -/* ===== Overlay backdrop ===== */ -.cp-overlay { - display: none; - position: fixed; - inset: 0; - z-index: 9000; -} - -.cp-overlay.cp-visible { - display: block; -} - -.cp-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - animation: cp-fade-in 0.12s ease-out; -} - -@keyframes cp-fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -/* ===== Container ===== */ -.cp-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90%; - max-width: 600px; - background: #1e293b; - border-radius: 12px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.06); - overflow: hidden; - animation: cp-slide-in 0.14s ease-out; - display: flex; - flex-direction: column; -} - -@keyframes cp-slide-in { - from { - opacity: 0; - transform: translate(-50%, calc(-50% - 12px)); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } -} - -/* ===== Search row ===== */ -.cp-search-row { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); -} - -.cp-search-icon { - font-size: 16px; - flex-shrink: 0; - opacity: 0.6; -} - -.cp-input { - flex: 1; - background: transparent; - border: none; - outline: none; - color: #f1f5f9; - font-size: 15px; - font-family: inherit; - line-height: 1.5; -} - -.cp-input::placeholder { - color: #64748b; -} - -.cp-esc-hint { - font-size: 11px; - color: #475569; - background: rgba(255, 255, 255, 0.07); - border-radius: 4px; - padding: 2px 7px; - flex-shrink: 0; - font-family: monospace; -} - -/* ===== Results list ===== */ -.cp-results { - list-style: none; - margin: 0; - padding: 6px 0; - max-height: 360px; /* ~8 items */ - overflow-y: auto; - overflow-x: hidden; -} - -.cp-results::-webkit-scrollbar { - width: 6px; -} - -.cp-results::-webkit-scrollbar-track { - background: transparent; -} - -.cp-results::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.12); - border-radius: 3px; -} - -/* Group header */ -.cp-group-header { - padding: 6px 16px 4px; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #475569; -} - -/* Empty state */ -.cp-empty { - padding: 32px 16px; - text-align: center; - color: #475569; - font-size: 14px; -} - -/* Result item */ -.cp-item { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 16px; - cursor: pointer; - transition: background 0.1s; -} - -.cp-item:hover { - background: rgba(255, 255, 255, 0.04); -} - -.cp-item-selected { - background: rgba(59, 130, 246, 0.18) !important; /* #3b82f6 at 18% */ -} - -.cp-item-icon { - font-size: 16px; - flex-shrink: 0; - width: 20px; - text-align: center; - line-height: 1; -} - -.cp-item-body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} - -.cp-item-label { - font-size: 14px; - font-weight: 500; - color: #f1f5f9; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cp-item-secondary { - font-size: 12px; - color: #64748b; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cp-item-arrow { - font-size: 18px; - color: #334155; - flex-shrink: 0; - line-height: 1; -} - -.cp-item-selected .cp-item-arrow { - color: #3b82f6; -} - -/* ===== Responsive ===== */ -@media (max-width: 640px) { - .cp-container { - width: 96%; - top: 12%; - transform: translateX(-50%); - } -} - -/* ===== Reduced motion ===== */ -@media (prefers-reduced-motion: reduce) { - .cp-backdrop, - .cp-container { - animation: none; - } -} diff --git a/dashboard/js/blepanel.js b/dashboard/js/blepanel.js deleted file mode 100644 index e19f1d7..0000000 --- a/dashboard/js/blepanel.js +++ /dev/null @@ -1,656 +0,0 @@ -/** - * Spaxel Dashboard - BLE Device Panel (Phase 6) - * - * Provides UI for managing BLE device registry and identity matching. - */ - -(function() { - 'use strict'; - - // State - const state = { - devices: new Map(), // addr -> device record - matches: new Map(), // blobID -> identity match - aliases: new Map(), // addr -> list of aliases - duplicates: [], // possible duplicate devices - expanded: false, - selectedDevice: null, - editingDevice: null, - wsConnected: false - }; - - // DOM elements - let panelEl, listEl, headerEl, countEl; - - // Initialize the panel - function init() { - createPanel(); - startPolling(); - console.log('[BLE Panel] Initialized'); - } - - // Create the panel DOM structure - function createPanel() { - // Find or create panel container - let container = document.getElementById('ble-panel'); - if (!container) { - container = document.createElement('div'); - container.id = 'ble-panel'; - container.className = 'side-panel'; - document.body.appendChild(container); - } - - container.innerHTML = ` -
- - 👤 - People & Devices - - 0 - -
- - - - `; - - panelEl = container; - headerEl = document.getElementById('ble-panel-header'); - listEl = document.getElementById('ble-panel-content'); - countEl = document.getElementById('ble-device-count'); - - // Event listeners - headerEl.addEventListener('click', togglePanel); - document.getElementById('ble-add-person').addEventListener('click', showAddPersonModal); - document.getElementById('modal-close').addEventListener('click', hideModal); - document.getElementById('modal-cancel').addEventListener('click', hideModal); - document.getElementById('modal-save').addEventListener('click', saveDevice); - - // Merge modal event listeners - document.getElementById('merge-modal-close').addEventListener('click', hideMergeModal); - document.getElementById('merge-modal-cancel').addEventListener('click', hideMergeModal); - document.getElementById('merge-modal-confirm').addEventListener('click', confirmMerge); - } - - // Toggle panel expansion - function togglePanel() { - state.expanded = !state.expanded; - listEl.style.display = state.expanded ? 'block' : 'none'; - document.getElementById('ble-panel-toggle').textContent = state.expanded ? '▲' : '▼'; - } - - // Start polling for data - function startPolling() { - fetchDevices(); - fetchMatches(); - fetchCrossings(); - fetchDuplicates(); - - setInterval(fetchDevices, 10000); - setInterval(fetchMatches, 5000); - setInterval(fetchCrossings, 15000); - setInterval(fetchDuplicates, 30000); - } - - // Fetch BLE devices - function fetchDevices() { - fetch('/api/ble/devices') - .then(function(res) { return res.json(); }) - .then(function(data) { - handleDevicesUpdate(data || []); - }) - .catch(function(err) { - console.error('[BLE Panel] Failed to fetch devices:', err); - }); - } - - // Fetch identity matches - function fetchMatches() { - fetch('/api/ble/matches') - .then(function(res) { return res.json(); }) - .then(function(data) { - handleMatchesUpdate(data || []); - }) - .catch(function(err) { - console.error('[BLE Panel] Failed to fetch matches:', err); - }); - } - - // Fetch recent crossings - function fetchCrossings() { - fetch('/api/zones/crossings?limit=10') - .then(function(res) { return res.json(); }) - .then(function(data) { - handleCrossingsUpdate(data || []); - }) - .catch(function(err) { - // Silently ignore - zones may not be configured - }); - } - - // Fetch possible duplicate devices (for MAC rotation) - function fetchDuplicates() { - fetch('/api/ble/duplicates') - .then(function(res) { return res.json(); }) - .then(function(data) { - state.duplicates = data.duplicates || []; - updateDuplicatesList(); - }) - .catch(function(err) { - console.error('[BLE Panel] Failed to fetch duplicates:', err); - }); - } - - // Fetch device aliases - function fetchDeviceAliases(addr) { - fetch('/api/ble/devices/' + encodeURIComponent(addr) + '/aliases') - .then(function(res) { return res.json(); }) - .then(function(data) { - state.aliases.set(addr, data); - updateDeviceList(); // Refresh to show rotation icon - }) - .catch(function(err) { - // Endpoint may not exist yet - console.error('[BLE Panel] Failed to fetch aliases:', err); - }); - } - - // Handle devices update - function handleDevicesUpdate(devices) { - state.devices.clear(); - devices.forEach(function(d) { - state.devices.set(d.addr, d); - }); - - updateDeviceList(); - countEl.textContent = devices.filter(function(d) { return d.is_person; }).length; - } - - // Handle identity matches update - function handleMatchesUpdate(matches) { - state.matches.clear(); - matches.forEach(function(m) { - state.matches.set(m.blob_id, m); - }); - - // Update 3D visualization - if (window.Viz3D && window.Viz3D.updateIdentities) { - Viz3D.updateIdentities(matches); - } - } - - // Handle crossings update - function handleCrossingsUpdate(crossings) { - var list = document.getElementById('ble-crossings-list'); - if (!crossings || crossings.length === 0) { - list.innerHTML = '
No recent crossings
'; - return; - } - - var html = ''; - crossings.forEach(function(c) { - var time = formatTime(new Date(c.timestamp)); - var identity = c.identity || 'Unknown'; - var direction = c.direction > 0 ? '→' : '←'; - html += '
' + - '' + time + '' + - '' + identity + '' + - '' + direction + ' Portal' + - '
'; - }); - list.innerHTML = html; - } - - // Update duplicates list - function updateDuplicatesList() { - var section = document.getElementById('duplicates-section'); - var list = document.getElementById('ble-duplicates-list'); - - if (!state.duplicates || state.duplicates.length === 0) { - section.style.display = 'none'; - return; - } - - section.style.display = 'block'; - var html = ''; - state.duplicates.forEach(function(dup) { - html += '
' + - '
' + - '' + dup.reason + '' + - '' + Math.round(dup.confidence * 100) + '% match' + - '
' + - '
' + - '' + dup.mac1.substr(-8) + '' + - '' + - '' + dup.mac2.substr(-8) + '' + - '
' + - '
' + - '' + - '' + - '
' + - '
'; - }); - list.innerHTML = html; - - // Add event listeners - list.querySelectorAll('.btn-merge').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var mac1 = this.getAttribute('data-mac1'); - var mac2 = this.getAttribute('data-mac2'); - showMergeConfirm(mac1, mac2); - }); - }); - - list.querySelectorAll('.btn-dismiss').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var item = this.closest('.duplicate-item'); - item.style.display = 'none'; - // Remove from state - state.duplicates = state.duplicates.filter(function(d) { - return d.mac1 !== item.getAttribute('data-mac1') || d.mac2 !== item.getAttribute('data-mac2'); - }); - if (state.duplicates.length === 0) { - section.style.display = 'none'; - } - }); - }); - } - - // Update device list UI - function updateDeviceList() { - var peopleList = document.getElementById('ble-people-list'); - var devicesList = document.getElementById('ble-devices-list'); - - var people = []; - var otherDevices = []; - - state.devices.forEach(function(d) { - if (d.is_person) { - people.push(d); - } else { - otherDevices.push(d); - } - }); - - // Sort people by name - people.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); - otherDevices.sort(function(a, b) { return (a.device_name || a.addr).localeCompare(b.device_name || b.addr); }); - - // Update people list - if (people.length === 0) { - peopleList.innerHTML = '
No people configured
'; - } else { - var html = ''; - people.forEach(function(p) { - var color = p.color || '#4fc3f7'; - var loc = p.last_location || {}; - var locStr = ''; - if (loc.confidence > 0) { - locStr = '📍'; - } - var aliasData = state.aliases.get(p.addr); - var hasAliases = aliasData && aliasData.alias_count > 0; - var rotationIcon = hasAliases ? '🔄' : ''; - - html += '
' + - '' + - '' + (p.name || p.label || 'Unknown') + '' + - rotationIcon + - locStr + - '' + - '' + - '
'; - - // Add aliases section if expanded - if (hasAliases && p.expanded) { - html += '
'; - html += '
Address History
'; - aliasData.aliases.forEach(function(alias) { - var age = formatTime(new Date(alias.last_seen)); - html += '
' + - '' + alias.addr + '' + - '' + age + '' + - '
'; - }); - html += '
Phones rotate addresses every 15-30 min. All entries above are the same device.
'; - html += '
'; - } - }); - peopleList.innerHTML = html; - - // Add click handlers - peopleList.querySelectorAll('.device-edit').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var addr = this.getAttribute('data-addr'); - showEditModal(addr); - }); - }); - - peopleList.querySelectorAll('.device-expand').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var addr = this.getAttribute('data-addr'); - toggleDeviceExpanded(addr); - }); - }); - - // Make device items clickable to expand - peopleList.querySelectorAll('.device-item.person').forEach(function(item) { - item.addEventListener('click', function(e) { - if (!e.target.classList.contains('device-edit') && !e.target.classList.contains('device-expand')) { - var addr = this.getAttribute('data-addr'); - toggleDeviceExpanded(addr); - // Fetch aliases when expanding - if (!state.aliases.has(addr)) { - fetchDeviceAliases(addr); - } - } - }); - }); - } - - // Update devices list - document.getElementById('ble-discovered-count').textContent = otherDevices.length; - - if (otherDevices.length === 0) { - devicesList.innerHTML = '
No devices discovered
'; - } else { - var html = ''; - otherDevices.slice(0, 10).forEach(function(d) { - var deviceName = d.device_name || d.addr.substr(-5); - var typeIcon = getTypeIcon(d.device_type); - html += '
' + - '' + typeIcon + '' + - '' + deviceName + '' + - '' + - '
'; - }); - if (otherDevices.length > 10) { - html += ''; - } - devicesList.innerHTML = html; - - // Add click handlers - devicesList.querySelectorAll('.device-edit').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var addr = this.getAttribute('data-addr'); - showEditModal(addr); - }); - }); - } - } - - // Toggle device expanded state - function toggleDeviceExpanded(addr) { - var device = state.devices.get(addr); - if (device) { - device.expanded = !device.expanded; - updateDeviceList(); - } - } - - // Show add person modal - function showAddPersonModal() { - state.editingDevice = null; - document.getElementById('modal-title').textContent = 'Add Person'; - document.getElementById('modal-name').value = ''; - document.getElementById('modal-label').value = ''; - document.getElementById('modal-color').value = '#4fc3f7'; - document.getElementById('modal-is-person').checked = true; - document.getElementById('modal-device-type').value = 'phone'; - document.getElementById('ble-device-modal').style.display = 'flex'; - } - - // Show edit modal - function showEditModal(addr) { - var device = state.devices.get(addr); - if (!device) return; - - state.editingDevice = addr; - document.getElementById('modal-title').textContent = 'Edit Device'; - document.getElementById('modal-name').value = device.name || ''; - document.getElementById('modal-label').value = device.label || ''; - document.getElementById('modal-color').value = device.color || '#4fc3f7'; - document.getElementById('modal-is-person').checked = device.is_person; - document.getElementById('modal-device-type').value = device.device_type || 'unknown'; - document.getElementById('ble-device-modal').style.display = 'flex'; - } - - // Hide modal - function hideModal() { - document.getElementById('ble-device-modal').style.display = 'none'; - state.editingDevice = null; - } - - // Save device - function saveDevice() { - var data = { - name: document.getElementById('modal-name').value, - label: document.getElementById('modal-label').value, - color: document.getElementById('modal-color').value, - is_person: document.getElementById('modal-is-person').checked, - device_type: document.getElementById('modal-device-type').value, - enabled: true - }; - - var addr = state.editingDevice || 'new-' + Date.now(); - var url = '/api/ble/devices/' + encodeURIComponent(addr); - var method = state.editingDevice ? 'PUT' : 'POST'; - - fetch(url, { - method: method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }) - .then(function(res) { - if (res.ok) { - hideModal(); - fetchDevices(); - } else { - return res.json().then(function(err) { - throw new Error(err.error || 'Failed to save'); - }); - } - }) - .catch(function(err) { - alert('Failed to save device: ' + err.message); - }); - } - - // Show merge confirmation modal - function showMergeConfirm(mac1, mac2) { - state.pendingMerge = { mac1: mac1, mac2: mac2 }; - - var device1 = state.devices.get(mac1); - var device2 = state.devices.get(mac2); - - document.getElementById('merge-device-1').querySelector('.merge-mac').textContent = mac1; - document.getElementById('merge-device-1').querySelector('.merge-name').textContent = - device1 ? (device1.name || device1.device_name || 'Unknown') : 'Unknown'; - document.getElementById('merge-device-2').querySelector('.merge-mac').textContent = mac2; - document.getElementById('merge-device-2').querySelector('.merge-name').textContent = - device2 ? (device2.name || device2.device_name || 'Unknown') : 'Unknown'; - - document.getElementById('ble-merge-modal').style.display = 'flex'; - } - - // Hide merge modal - function hideMergeModal() { - document.getElementById('ble-merge-modal').style.display = 'none'; - state.pendingMerge = null; - } - - // Confirm and execute merge - function confirmMerge() { - if (!state.pendingMerge) { - return; - } - - var mac1 = state.pendingMerge.mac1; - var mac2 = state.pendingMerge.mac2; - - fetch('/api/ble/merge', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mac1: mac1, mac2: mac2 }) - }) - .then(function(res) { - if (res.ok) { - return res.json(); - } else { - return res.json().then(function(err) { - throw new Error(err.error || 'Failed to merge'); - }); - } - }) - .then(function(data) { - hideMergeModal(); - // Remove from duplicates list - state.duplicates = state.duplicates.filter(function(d) { - return d.mac1 !== mac1 || d.mac2 !== mac2; - }); - updateDuplicatesList(); - fetchDevices(); - }) - .catch(function(err) { - alert('Failed to merge devices: ' + err.message); - }); - } - - // Get icon for device type - function getTypeIcon(type) { - switch (type) { - case 'phone': return '📱'; - case 'watch': return '⌚'; - case 'tracker': return '📍'; - case 'tablet': return '📱'; - case 'laptop': return '💻'; - case 'headphones': return '🎧'; - default: return '📡'; - } - } - - // Format time relative to now - function formatTime(date) { - var now = new Date(); - var diff = (now - date) / 1000; - - if (diff < 60) return 'just now'; - if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; - if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; - return date.toLocaleDateString(); - } - - // Export public interface - window.BLEPanel = { - init: init, - updateMatches: handleMatchesUpdate, - updateDevices: handleDevicesUpdate - }; - -})(); diff --git a/dashboard/js/commandpalette.test.js b/dashboard/js/command-palette.test.js similarity index 99% rename from dashboard/js/commandpalette.test.js rename to dashboard/js/command-palette.test.js index b92e7b8..6578c62 100644 --- a/dashboard/js/commandpalette.test.js +++ b/dashboard/js/command-palette.test.js @@ -32,7 +32,7 @@ describe('CommandPaletteManager', function () { // Load the module (if not already loaded in this env) if (typeof window.CommandPaletteManager === 'undefined') { - require('./commandpalette.js'); + require('./command-palette.js'); } }); diff --git a/dashboard/js/commandpalette.js b/dashboard/js/commandpalette.js deleted file mode 100644 index 61e9947..0000000 --- a/dashboard/js/commandpalette.js +++ /dev/null @@ -1,978 +0,0 @@ -/** - * Spaxel Dashboard — Command Palette (Component 34) - * - * Ctrl+K / Cmd+K: universal keyboard-driven interface for expert mode. - * Fuzzy search across zones, people, nodes, events, and commands. - * Time navigation via "@" prefix. - * - * Exposes: window.CommandPaletteManager - */ - -(function () { - 'use strict'; - - // ========================================================= - // Constants - // ========================================================= - var STORAGE_KEY = 'spaxel_palette_history'; - var MAX_RECENT = 5; - var MAX_RESULTS = 8; - - // Category priority (lower = higher in results) - var CAT_PRIORITY = { - command: 0, - time: 1, - person: 2, - zone: 3, - node: 4, - event: 5, - recent: -1 // shown only on empty query - }; - - // ========================================================= - // Levenshtein distance (compact) - // ========================================================= - function levenshteinDist(a, b) { - var m = a.length, n = b.length; - if (!m) return n; - if (!n) return m; - var prev = [], curr = []; - for (var j = 0; j <= n; j++) prev[j] = j; - for (var i = 1; i <= m; i++) { - curr[0] = i; - for (var k = 1; k <= n; k++) { - curr[k] = a[i - 1] === b[k - 1] - ? prev[k - 1] - : 1 + Math.min(prev[k], curr[k - 1], prev[k - 1]); - } - var tmp = prev; prev = curr; curr = tmp; - } - return prev[n]; - } - - // ========================================================= - // Fuzzy scorer [0, 1] - // ========================================================= - /** - * Returns a score in [0, 1] indicating how well `needle` matches `haystack`. - * Scores below 0.3 are considered non-matches and are excluded from results. - * - * Matching strategy (in priority order): - * 1. Exact prefix of full haystack → 0.90–1.00 - * 2. Exact substring of full haystack → 0.80 - * 3. Word-level matching (prefix / typo / subseq per word) - * 4. Character subsequence across full string → 0.30–0.40 - */ - function fuzzyScore(needle, haystack) { - if (!needle) return 1; - needle = needle.toLowerCase().trim(); - haystack = haystack.toLowerCase().trim(); - if (!needle) return 1; - if (needle === haystack) return 1; - - // 1. Full prefix - if (haystack.startsWith(needle)) { - return 0.90 + 0.10 * (needle.length / haystack.length); - } - - // 2. Exact substring - if (haystack.includes(needle)) { - return 0.80; - } - - // 3. Word-level matching - var needleWords = needle.split(/\s+/).filter(function (w) { return w.length > 0; }); - var haystackWords = haystack.split(/\s+/).filter(function (w) { return w.length > 0; }); - - if (needleWords.length > 0) { - var allMatch = true; - var totalScore = 0; - - for (var ni = 0; ni < needleWords.length; ni++) { - var nw = needleWords[ni]; - var bestWord = 0; - - for (var hi = 0; hi < haystackWords.length; hi++) { - var hw = haystackWords[hi]; - var ws = 0; - - if (hw.startsWith(nw)) { - ws = 0.90; - } else if (hw.includes(nw)) { - ws = 0.75; - } else if (nw.length > 2 && hw.length > 2) { - var dist = levenshteinDist(nw, hw); - if (dist === 1) { - ws = 0.70; - } else if (dist === 2 && nw.length > 4) { - ws = 0.50; - } - // Per-word subsequence (e.g. "rm" in "room") - if (ws === 0) { - var si = 0; - for (var ci = 0; ci < hw.length && si < nw.length; ci++) { - if (nw[si] === hw[ci]) si++; - } - if (si === nw.length) ws = 0.50; - } - } else if (nw.length <= 2) { - // Short needle: prefix or subsequence within each haystack word - if (hw.startsWith(nw)) { - ws = 0.75; - } else { - var si2 = 0; - for (var ci2 = 0; ci2 < hw.length && si2 < nw.length; ci2++) { - if (nw[si2] === hw[ci2]) si2++; - } - if (si2 === nw.length) ws = 0.50; - } - } - - if (ws > bestWord) bestWord = ws; - } - - if (bestWord === 0) { - allMatch = false; - break; - } - totalScore += bestWord; - } - - if (allMatch) { - return 0.40 + (totalScore / needleWords.length) * 0.30; - } - } - - // 4. Character subsequence across full string - var si3 = 0; - for (var ci3 = 0; ci3 < haystack.length && si3 < needle.length; ci3++) { - if (needle[si3] === haystack[ci3]) si3++; - } - if (si3 === needle.length) { - return 0.30 + 0.10 * (needle.length / haystack.length); - } - - return 0; - } - - // ========================================================= - // Time expression parser - // ========================================================= - /** - * Parse a "@..." time expression. - * @param {string} query - full query string starting with "@" - * @returns {Date|null} - */ - function parseTimeExpression(query) { - var s = query.replace(/^@/, '').trim(); - if (!s) return null; - var now = new Date(); - - // @-30min @-2h - var rel = s.match(/^-(\d+)(min|h)$/i); - if (rel) { - var amount = parseInt(rel[1], 10); - var unit = rel[2].toLowerCase(); - var d = new Date(now); - if (unit === 'min') d.setMinutes(d.getMinutes() - amount); - else d.setHours(d.getHours() - amount); - return d; - } - - // @2026-03-27 14:23 - var abs = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})$/); - if (abs) { - var dt = new Date(abs[1] + 'T' + abs[2] + ':00'); - if (!isNaN(dt.getTime())) return dt; - } - - // @yesterday ... - var yest = s.match(/^yesterday\s+(.+)$/i); - if (yest) { - var base = new Date(now); - base.setDate(base.getDate() - 1); - return parseTimeOfDay(yest[1], base); - } - - // @3am @3:15pm @14:23 - return parseTimeOfDay(s, new Date(now)); - } - - function parseTimeOfDay(s, baseDate) { - // 12-hour: 3am, 3:15am, 11:30pm - var m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); - if (m12) { - var h = parseInt(m12[1], 10); - var min = m12[2] ? parseInt(m12[2], 10) : 0; - var ampm = m12[3].toLowerCase(); - if (ampm === 'pm' && h !== 12) h += 12; - if (ampm === 'am' && h === 12) h = 0; - var r = new Date(baseDate); - r.setHours(h, min, 0, 0); - return r; - } - // 24-hour: 14:23 - var m24 = s.match(/^(\d{1,2}):(\d{2})$/); - if (m24) { - var r2 = new Date(baseDate); - r2.setHours(parseInt(m24[1], 10), parseInt(m24[2], 10), 0, 0); - return r2; - } - return null; - } - - // ========================================================= - // Command registry - // ========================================================= - var COMMANDS = [ - // ---- Navigation ---- - { - id: 'nav-settings', - label: 'Open settings', - category: 'command', - group: 'Navigation', - icon: '⚙', - hint: '', - action: function () { window.location.href = '/settings'; } - }, - { - id: 'nav-fleet', - label: 'Open fleet page', - category: 'command', - group: 'Navigation', - icon: '📡', - hint: '', - action: function () { window.location.href = '/fleet'; } - }, - { - id: 'nav-automations', - label: 'Open automations', - category: 'command', - group: 'Navigation', - icon: '⚡', - hint: '', - action: function () { window.location.href = '/automations'; } - }, - { - id: 'nav-simulator', - label: 'Open simulator', - category: 'command', - group: 'Navigation', - icon: '🔬', - hint: '', - action: function () { window.location.href = '/simulate'; } - }, - // ---- View ---- - { - id: 'view-fresnel', - label: 'Toggle Fresnel overlay', - category: 'command', - group: 'View', - icon: '◈', - hint: '', - action: function () { - if (window.toggleFresnelZones) window.toggleFresnelZones(); - } - }, - { - id: 'view-flowmap', - label: 'Toggle flow map', - category: 'command', - group: 'View', - icon: '🌊', - hint: '', - action: function () { - if (window.Viz3D && window.Viz3D.toggleFlowLayer) window.Viz3D.toggleFlowLayer(); - } - }, - { - id: 'view-heatmap', - label: 'Toggle dwell heatmap', - category: 'command', - group: 'View', - icon: '🔥', - hint: '', - action: function () { - if (window.Viz3D && window.Viz3D.toggleDwellLayer) window.Viz3D.toggleDwellLayer(); - } - }, - { - id: 'view-zones', - label: 'Toggle zone volumes', - category: 'command', - group: 'View', - icon: '📦', - hint: '', - action: function () { - if (window.ZoneEditor && window.ZoneEditor.toggleVolumes) window.ZoneEditor.toggleVolumes(); - else if (window.Viz3D && window.Viz3D.toggleZoneVolumes) window.Viz3D.toggleZoneVolumes(); - } - }, - { - id: 'view-reset-camera', - label: 'Reset camera', - category: 'command', - group: 'View', - icon: '🎥', - hint: '', - action: function () { - if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown'); - } - }, - // ---- System ---- - { - id: 'mode-away', - label: 'Enter away mode', - category: 'command', - group: 'System', - icon: '🏠', - hint: '', - action: function () { - fetch('/api/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'away' }) - }); - } - }, - { - id: 'mode-home', - label: 'Enter home mode', - category: 'command', - group: 'System', - icon: '🏡', - hint: '', - action: function () { - fetch('/api/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'home' }) - }); - } - }, - { - id: 'mode-sleep', - label: 'Enter sleep mode', - category: 'command', - group: 'System', - icon: '🌙', - hint: '', - action: function () { - fetch('/api/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'sleep' }) - }); - } - }, - { - id: 'ota-fleet', - label: 'Trigger fleet OTA', - category: 'command', - group: 'System', - icon: '⬆', - hint: '', - action: function () { - if (window.SpaxelOTA && window.SpaxelOTA.openDialog) { - window.SpaxelOTA.openDialog(); - } else { - fetch('/api/nodes/update-all', { method: 'POST' }); - } - } - }, - { - id: 'add-person', - label: 'Add a person', - category: 'command', - group: 'System', - icon: '👤', - hint: '', - action: function () { - if (window.BLEPanel && window.BLEPanel.openAddPerson) window.BLEPanel.openAddPerson(); - } - }, - { - id: 'add-zone', - label: 'Add a zone', - category: 'command', - group: 'System', - icon: '📍', - hint: '', - action: function () { - if (window.ZoneEditor && window.ZoneEditor.startCreate) window.ZoneEditor.startCreate(); - } - }, - { - id: 'add-portal', - label: 'Add a portal', - category: 'command', - group: 'System', - icon: '🚪', - hint: '', - action: function () { - if (window.PortalEditor && window.PortalEditor.startCreate) window.PortalEditor.startCreate(); - } - }, - // ---- Debug ---- - { - id: 'debug-export-csv', - label: 'Export all events CSV', - category: 'command', - group: 'Debug', - icon: '📥', - hint: '', - action: function () { - var a = document.createElement('a'); - a.href = '/api/events?format=csv'; - a.download = 'spaxel-events.csv'; - a.click(); - } - }, - { - id: 'debug-link-health', - label: 'Show link health table', - category: 'command', - group: 'Debug', - icon: '📊', - hint: '', - action: function () { - if (window.LinkHealth && window.LinkHealth.openPanel) window.LinkHealth.openPanel(); - } - }, - { - id: 'debug-diagnostics', - label: 'Run diagnostics', - category: 'command', - group: 'Debug', - icon: '🔧', - hint: '', - action: function () { - fetch('/api/diagnostics', { method: 'POST' }).then(function (r) { - return r.json(); - }).then(function (data) { - if (window.showToast) window.showToast('Diagnostics: ' + (data.summary || 'done'), 'info'); - }).catch(function () { - if (window.showToast) window.showToast('Diagnostics triggered', 'info'); - }); - } - }, - { - id: 'debug-firmware-check', - label: 'Check firmware updates', - category: 'command', - group: 'Debug', - icon: '🔄', - hint: '', - action: function () { - fetch('/api/firmware').then(function (r) { return r.json(); }).then(function (data) { - var latest = data && data[0] ? data[0].version : '?'; - if (window.showToast) window.showToast('Latest firmware: v' + latest, 'info'); - }).catch(function () { - if (window.showToast) window.showToast('Could not fetch firmware info', 'warning'); - }); - } - } - ]; - - // ========================================================= - // Recent history (localStorage) - // ========================================================= - function loadHistory() { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); - } catch (e) { - return []; - } - } - - function saveHistory(items) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT))); - } catch (e) { - // quota error — ignore - } - } - - function addToHistory(item) { - // Exclude time navigation entries - if (item.category === 'time') return; - var hist = loadHistory().filter(function (h) { return h.id !== item.id; }); - hist.unshift({ id: item.id, label: item.label, category: item.category, icon: item.icon }); - saveHistory(hist); - } - - // ========================================================= - // Entity data source - // ========================================================= - /** - * Returns a snapshot of searchable entities from app state or cached API data. - * @returns {{ nodes: Array, zones: Array, people: Array, events: Array }} - */ - function getEntityData() { - var data = { nodes: [], zones: [], people: [], events: [] }; - - // Nodes: from app.js state exposure - if (window.spaxelGetState) { - var st = window.spaxelGetState(); - data.nodes = st.nodes || []; - } - - // Zones / people / events: use cached API snapshot if available - var cache = Manager._entityCache; - if (cache) { - data.zones = cache.zones || data.zones; - data.people = cache.people || data.people; - data.events = cache.events || data.events; - } - - return data; - } - - // ========================================================= - // Search - // ========================================================= - /** - * Search all categories with the given query. - * @param {string} query - * @returns {Array} sorted result items - */ - function search(query) { - var results = []; - var q = query.trim(); - - // Empty query: show recent history - if (!q) { - var hist = loadHistory(); - return hist.slice(0, MAX_RECENT).map(function (h) { - return { - id: h.id, - label: h.label, - category: 'recent', - icon: h.icon || '🕐', - secondary: 'Recent', - score: 1, - action: findCommandAction(h.id) - }; - }); - } - - // Time navigation - if (q.startsWith('@')) { - var dt = parseTimeExpression(q); - if (dt) { - var label = 'Jump to ' + dt.toLocaleString(); - results.push({ - id: 'time:' + q, - label: label, - category: 'time', - icon: '🕐', - secondary: dt.toISOString(), - score: 1, - action: function () { - if (window.SpaxelReplay && window.SpaxelReplay.seekTo) { - window.SpaxelReplay.seekTo(dt.getTime()); - } - } - }); - } - return results; - } - - var entities = getEntityData(); - - // Commands - COMMANDS.forEach(function (cmd) { - var s = Math.max( - fuzzyScore(q, cmd.label), - fuzzyScore(q, cmd.group || '') - ); - if (s >= 0.3) { - results.push({ - id: cmd.id, - label: cmd.label, - category: 'command', - icon: cmd.icon, - secondary: cmd.group || '', - score: s, - action: cmd.action - }); - } - }); - - // People - entities.people.forEach(function (p) { - var name = p.name || p.label || p.addr || ''; - var s = fuzzyScore(q, name); - if (s >= 0.3) { - results.push({ - id: 'person:' + name, - label: name, - category: 'person', - icon: '👤', - secondary: p.zone || '', - score: s, - action: function () { - if (window.Viz3D && window.Viz3D.flyToPerson) window.Viz3D.flyToPerson(name); - } - }); - } - }); - - // Zones - entities.zones.forEach(function (z) { - var name = z.name || ''; - var s = fuzzyScore(q, name); - if (s >= 0.3) { - var count = z.count != null ? z.count : (z.occupancy || 0); - results.push({ - id: 'zone:' + name, - label: name, - category: 'zone', - icon: '📍', - secondary: count + ' people currently', - score: s, - action: function () { - if (window.Viz3D && window.Viz3D.flyToZone) window.Viz3D.flyToZone(name); - } - }); - } - }); - - // Nodes - entities.nodes.forEach(function (n) { - var label = n.name || n.mac || ''; - var s = Math.max( - fuzzyScore(q, label), - n.mac ? fuzzyScore(q, n.mac) : 0 - ); - if (s >= 0.3) { - results.push({ - id: 'node:' + (n.mac || label), - label: label, - category: 'node', - icon: '📡', - secondary: n.status || '', - score: s, - action: function () { - if (window.Viz3D && window.Viz3D.flyToNode && n.mac) window.Viz3D.flyToNode(n.mac); - } - }); - } - }); - - // Recent events (last 20) - entities.events.forEach(function (evt) { - var title = evt.title || evt.type || ''; - var s = fuzzyScore(q, title); - if (s >= 0.3) { - results.push({ - id: 'event:' + (evt.id || title), - label: title, - category: 'event', - icon: '🕐', - secondary: evt.zone || '', - score: s, - action: function () { - if (window.SpaxelTimeline && window.SpaxelTimeline.openEvent) { - window.SpaxelTimeline.openEvent(evt.id); - } - } - }); - } - }); - - // Sort: commands first (if query starts with "/"), then by category priority, then score desc - results.sort(function (a, b) { - var pa = CAT_PRIORITY[a.category] != null ? CAT_PRIORITY[a.category] : 99; - var pb = CAT_PRIORITY[b.category] != null ? CAT_PRIORITY[b.category] : 99; - if (pa !== pb) return pa - pb; - return b.score - a.score; - }); - - return results.slice(0, MAX_RESULTS); - } - - function findCommandAction(id) { - for (var i = 0; i < COMMANDS.length; i++) { - if (COMMANDS[i].id === id) return COMMANDS[i].action; - } - return function () {}; - } - - // ========================================================= - // Mode detection - // ========================================================= - function isExpertMode() { - // Palette is unavailable in simple mode or ambient mode - if (document.body.classList.contains('simple-mode')) return false; - if (document.body.classList.contains('ambient-mode')) return false; - if (window.currentMode === 'simple' || window.currentMode === 'ambient') return false; - return true; - } - - // ========================================================= - // DOM creation - // ========================================================= - function createDOM() { - if (document.getElementById('cp-root')) return; - - var root = document.createElement('div'); - root.id = 'cp-root'; - root.className = 'cp-overlay'; - root.setAttribute('role', 'dialog'); - root.setAttribute('aria-modal', 'true'); - root.setAttribute('aria-label', 'Command palette'); - root.innerHTML = - '
' + - '
' + - '
' + - ' 🔍' + - ' ' + - ' ESC' + - '
' + - '
    ' + - '
    '; - - document.body.appendChild(root); - Manager.el = root; - } - - // ========================================================= - // Rendering - // ========================================================= - function renderResults(items) { - var list = document.getElementById('cp-listbox'); - if (!list) return; - - if (!items.length) { - list.innerHTML = '
  • No results
  • '; - return; - } - - var html = ''; - var lastCat = null; - - for (var i = 0; i < items.length; i++) { - var item = items[i]; - - // Group header for "Recent" - if (item.category === 'recent' && lastCat !== 'recent') { - html += '
  • Recent
  • '; - } - - var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : ''; - html += - '
  • ' + - ' ' + (item.icon || '•') + '' + - ' ' + - ' ' + escapeHtml(item.label) + '' + - ' ' + escapeHtml(item.secondary || '') + '' + - ' ' + - ' ' + - '
  • '; - - lastCat = item.category; - } - - list.innerHTML = html; - - // Click handlers - list.querySelectorAll('.cp-item').forEach(function (el) { - el.addEventListener('mousedown', function (e) { - e.preventDefault(); // prevent input blur - var idx = parseInt(el.getAttribute('data-index'), 10); - Manager.selectedIndex = idx; - Manager.execute(); - }); - }); - } - - function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - // ========================================================= - // Entity cache loader (one fetch per palette open) - // ========================================================= - function loadEntityCache() { - Manager._entityCache = Manager._entityCache || { zones: [], people: [], events: [] }; - - // Fetch zones - fetch('/api/zones').then(function (r) { return r.json(); }).then(function (data) { - Manager._entityCache.zones = Array.isArray(data) ? data : []; - }).catch(function () {}); - - // Fetch people (BLE devices of type "person") - fetch('/api/ble/devices?registered=true').then(function (r) { return r.json(); }).then(function (data) { - Manager._entityCache.people = (Array.isArray(data) ? data : []) - .filter(function (d) { return d.type === 'person'; }); - }).catch(function () {}); - - // Fetch recent events - fetch('/api/events?limit=20').then(function (r) { return r.json(); }).then(function (data) { - var arr = data && Array.isArray(data.events) ? data.events : (Array.isArray(data) ? data : []); - Manager._entityCache.events = arr.slice(0, 20).map(function (e) { - return { id: e.id, title: e.type || '', zone: e.zone || '', ts: e.timestamp_ms }; - }); - }).catch(function () {}); - } - - // ========================================================= - // Manager - // ========================================================= - var Manager = { - el: null, - isOpen: false, - selectedIndex: 0, - _items: [], - _entityCache: null, - - init: function () { - // Register Ctrl+K / Cmd+K globally - document.addEventListener('keydown', this._onKeydown.bind(this)); - }, - - open: function () { - if (!isExpertMode()) return; - - createDOM(); - - // Refresh entity cache (async, non-blocking) - loadEntityCache(); - - this.isOpen = true; - this.selectedIndex = 0; - this.el.classList.add('cp-visible'); - - var input = this.el.querySelector('.cp-input'); - if (input) { - input.value = ''; - setTimeout(function () { input.focus(); }, 10); - input.addEventListener('input', this._onInput.bind(this)); - input.addEventListener('keydown', this._onInputKeydown.bind(this)); - } - - var backdrop = this.el.querySelector('.cp-backdrop'); - if (backdrop) { - backdrop.addEventListener('click', this.close.bind(this)); - } - - this._showItems([]); - }, - - close: function () { - if (!this.isOpen) return; - this.isOpen = false; - - if (this.el) { - this.el.classList.remove('cp-visible'); - // Detach listeners by replacing input (simple) - var input = this.el.querySelector('.cp-input'); - if (input) { - var newInput = input.cloneNode(true); - input.parentNode.replaceChild(newInput, input); - } - } - }, - - toggle: function () { - if (this.isOpen) this.close(); - else this.open(); - }, - - execute: function () { - var item = this._items[this.selectedIndex]; - if (!item) return; - if (item.action) { - addToHistory(item); - item.action(); - } - this.close(); - }, - - _onKeydown: function (e) { - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - e.preventDefault(); - if (!isExpertMode()) return; - this.toggle(); - } else if (e.key === 'Escape' && this.isOpen) { - e.preventDefault(); - this.close(); - } - }, - - _onInput: function (e) { - var q = e.target.value; - this.selectedIndex = 0; - var items = search(q); - this._showItems(items); - }, - - _onInputKeydown: function (e) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - this.selectedIndex = Math.min(this.selectedIndex + 1, this._items.length - 1); - renderResults(this._items); - this._scrollToSelected(); - break; - case 'ArrowUp': - e.preventDefault(); - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - renderResults(this._items); - this._scrollToSelected(); - break; - case 'Enter': - case 'Tab': - e.preventDefault(); - this.execute(); - break; - case 'Escape': - this.close(); - break; - } - }, - - _showItems: function (items) { - this._items = items; - renderResults(items); - }, - - _scrollToSelected: function () { - var list = document.getElementById('cp-listbox'); - if (!list) return; - var sel = list.querySelector('.cp-item-selected'); - if (sel) sel.scrollIntoView({ block: 'nearest' }); - } - }; - - // ========================================================= - // Public API - // ========================================================= - window.CommandPaletteManager = Manager; - - // Expose internals for testing - Manager._fuzzyScore = fuzzyScore; - Manager._parseTimeExpression = parseTimeExpression; - Manager._parseTimeOfDay = parseTimeOfDay; - Manager._COMMANDS = COMMANDS; - Manager._loadHistory = loadHistory; - Manager._saveHistory = saveHistory; - Manager._addToHistory = addToHistory; - Manager._search = search; - Manager._isExpertMode = isExpertMode; - - // Auto-init when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { Manager.init(); }); - } else { - Manager.init(); - } - -})();