feat(dashboard): guided troubleshooting and first-time UX

Add troubleshooting infrastructure for non-technical users:

- TroubleshootManager: node offline cards with stepped recovery
  guidance, factory reset modal, and detection quality banners
- TooltipManager: first-time feature tooltips with localStorage
  persistence, auto-dismiss, and sequential tour
- Onboarding failure guidance: human-readable error messages for
  browser compatibility, USB connection, WiFi provisioning, and
  node detection failures
- Post-calibration reinforcement card with summary and next steps
- Client-side link health check with auto-recovery
- CSS for offline cards, tooltips, quality banners, and modals
- Script tags wired in index.html for troubleshoot.js and tooltips.js
- 30 tests covering all troubleshooting and tooltip functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-28 04:19:06 -04:00
parent 1b0e7ea2b4
commit 570e5eec41
7 changed files with 1498 additions and 6 deletions

View file

@ -0,0 +1,345 @@
/* ============================================
Troubleshooting UI Styles
============================================ */
/* Troubleshoot section inside node panel */
#troubleshoot-section {
margin-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
}
/* Offline troubleshooting card */
.troubleshoot-card {
background: rgba(255, 167, 38, 0.08);
border: 1px solid rgba(255, 167, 38, 0.25);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
font-size: 12px;
animation: troubleshoot-fade-in 0.3s ease-out;
}
@keyframes troubleshoot-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.troubleshoot-card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #ffa726;
font-weight: 600;
}
.troubleshoot-card-icon {
font-size: 14px;
flex-shrink: 0;
}
.troubleshoot-dismiss {
background: none;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
margin-left: auto;
flex-shrink: 0;
}
.troubleshoot-dismiss:hover {
color: #eee;
}
/* Timeline steps */
.troubleshoot-timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.troubleshoot-step {
display: flex;
gap: 8px;
align-items: flex-start;
}
.troubleshoot-step-num {
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 167, 38, 0.2);
color: #ffa726;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.troubleshoot-step-text {
color: #bbb;
line-height: 1.5;
}
.troubleshoot-step-text strong {
color: #eee;
}
/* More options expander */
.troubleshoot-more {
margin-top: 4px;
}
.troubleshoot-more summary {
color: #888;
font-size: 11px;
cursor: pointer;
padding: 2px 0;
}
.troubleshoot-more summary:hover {
color: #bbb;
}
.troubleshoot-more[open] .troubleshoot-step {
animation: troubleshoot-fade-in 0.2s ease-out;
}
/* Reset button inside card */
.troubleshoot-reset-btn {
background: rgba(244, 67, 54, 0.15);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ef5350;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
}
.troubleshoot-reset-btn:hover {
background: rgba(244, 67, 54, 0.25);
}
/* Quality banner (fixed at top, below status bar) */
.troubleshoot-quality-banner {
position: fixed;
top: 44px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 167, 38, 0.15);
border: 1px solid rgba(255, 167, 38, 0.3);
border-radius: 6px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #ffa726;
z-index: 150;
max-width: 600px;
animation: troubleshoot-slide-down 0.3s ease-out;
white-space: nowrap;
}
@keyframes troubleshoot-slide-down {
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.troubleshoot-quality-icon {
font-size: 14px;
flex-shrink: 0;
}
.troubleshoot-quality-banner .troubleshoot-dismiss {
color: #ffa726;
}
.troubleshoot-quality-banner .troubleshoot-dismiss:hover {
color: #eee;
}
/* Factory reset modal */
.troubleshoot-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.troubleshoot-modal {
background: #1e1e3a;
border-radius: 12px;
padding: 24px 28px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.troubleshoot-modal h3 {
font-size: 16px;
color: #eee;
margin-bottom: 16px;
}
.troubleshoot-list {
color: #bbb;
font-size: 13px;
line-height: 1.8;
padding-left: 20px;
margin-bottom: 16px;
}
.troubleshoot-list li {
margin-bottom: 4px;
}
.troubleshoot-modal-close {
margin-top: 8px;
}
/* ============================================
Tooltip Styles
============================================ */
.spaxel-tooltip {
position: fixed;
z-index: 2000;
background: rgba(30, 30, 58, 0.95);
border: 1px solid rgba(79, 195, 247, 0.4);
border-radius: 6px;
padding: 8px 12px;
max-width: 260px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
animation: spaxel-tooltip-appear 0.3s ease-out;
pointer-events: none;
}
@keyframes spaxel-tooltip-appear {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.spaxel-tooltip-text {
color: #eee;
font-size: 13px;
line-height: 1.4;
}
/* Arrows */
.spaxel-tooltip-arrow {
position: absolute;
width: 0;
height: 0;
}
.spaxel-tooltip-arrow-top {
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(79, 195, 247, 0.4);
}
.spaxel-tooltip-arrow-bottom {
top: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid rgba(79, 195, 247, 0.4);
}
.spaxel-tooltip-arrow-left {
right: -6px;
top: 50%;
transform: translateY(-50%);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid rgba(79, 195, 247, 0.4);
}
.spaxel-tooltip-arrow-right {
left: -6px;
top: 50%;
transform: translateY(-50%);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid rgba(79, 195, 247, 0.4);
}
/* Dismiss all button */
.spaxel-dismiss-all {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #aaa;
font-size: 12px;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
z-index: 2001;
transition: background 0.2s, color 0.2s;
}
.spaxel-dismiss-all:hover {
background: rgba(255, 255, 255, 0.15);
color: #eee;
}
/* ============================================
Post-Calibration Card (inside wizard)
============================================ */
.post-cal-card {
text-align: center;
padding: 12px 0;
}
.post-cal-card .wizard-icon-large {
margin-bottom: 8px;
}
.post-cal-card h3 {
font-size: 18px;
color: #eee;
margin-bottom: 8px;
}
.post-cal-summary {
color: #66bb6a !important;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.post-cal-expect {
color: #bbb;
font-size: 13px;
line-height: 1.5;
margin-bottom: 16px;
}
.post-cal-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spaxel Dashboard</title>
<link rel="stylesheet" href="css/troubleshoot.css">
<style>
* {
margin: 0;
@ -844,6 +845,10 @@
<!-- 3-D spatial visualisation layer -->
<script src="js/viz3d.js"></script>
<!-- Troubleshooting manager (must load before app.js) -->
<script src="js/troubleshoot.js"></script>
<!-- First-time feature tooltips (must load before app.js) -->
<script src="js/tooltips.js"></script>
<!-- Main application -->
<script src="js/app.js"></script>
<!-- esp-web-tools for firmware flashing (Web Serial) -->

View file

@ -263,11 +263,21 @@
lastSeen: Date.now()
});
updateNodeList();
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('node_connected', msg);
}
// Show first-time tooltips on first node connection
if (window.SpaxelTooltips && state.nodes.size === 1) {
setTimeout(function () { window.SpaxelTooltips.showSequence(); }, 2000);
}
break;
case 'node_disconnected':
state.nodes.delete(msg.mac);
updateNodeList();
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg);
}
break;
case 'link_active':
@ -922,4 +932,11 @@
} else {
init();
}
// ============================================
// Public API
// ============================================
window.SpaxelApp = {
getLinks: function () { return state.links; },
};
})();

View file

@ -55,6 +55,7 @@
calibratePhase: 'idle',
ws: null,
csiHistory: [],
calibrationLinks: [], // unique link IDs seen during calibration
container: null,
};
@ -101,8 +102,20 @@
} catch (e) {
if (e.name === 'NotFoundError') {
throw new UserError(
'No device detected. Make sure the USB cable is connected ' +
'and hold the BOOT button while plugging in.'
'No device detected. Did you hold the BOOT button while plugging in? ' +
'Try again: hold BOOT, then plug in the USB cable.'
);
}
if (e.name === 'NotAllowedError') {
throw new UserError(
'Browser blocked USB access. Check your browser\'s site permissions ' +
'for this address and try again.'
);
}
if (e.name === 'NetworkError') {
throw new UserError(
'Another application is using this USB port. Close Arduino IDE, esptool, ' +
'or any other serial monitor and try again.'
);
}
throw new UserError(
@ -598,6 +611,7 @@
function renderCalibrate(contentEl) {
state.calibratePhase = 'walk';
state.csiHistory = [];
state.calibrationLinks = [];
contentEl.innerHTML =
'<div class="wizard-step-content">' +
@ -638,6 +652,11 @@
if (frame && (frame.nodeMAC === state.nodeMAC || frame.peerMAC === state.nodeMAC)) {
pushCSISample(frame);
drawCalibrateWaveform();
// Track unique links for post-calibration card
var linkKey = frame.nodeMAC + ':' + frame.peerMAC;
if (state.calibrationLinks.indexOf(linkKey) === -1) {
state.calibrationLinks.push(linkKey);
}
}
}
};
@ -757,7 +776,8 @@
if (state.csiHistory.length < 5) {
statusEl.innerHTML =
'<span class="wizard-warn">Very little CSI data received. ' +
'The node may not be oriented correctly. Continuing anyway...</span>';
'The node connected but is not sensing yet. Check the antenna ' +
'orientation \u2014 the PCB antenna should face away from walls.</span>';
}
startCalibratePhase('still');
});
@ -791,9 +811,7 @@
statusEl.textContent = 'The sensor can see you!';
runCalibrateCountdown(CONFIG.calibrateWalkThroughDuration, function () {
if (state.calibratePhase !== 'walk_through') return;
statusEl.innerHTML = '<span class="wizard-success">✓ Calibration complete!</span>';
saveState();
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1500);
showPostCalibrationCard();
});
break;
}
@ -816,6 +834,45 @@
tick();
}
// ============================================
// Post-Calibration Reinforcement Card
// ============================================
function showPostCalibrationCard() {
var instructions = document.getElementById('calibrate-instructions');
var statusEl = document.getElementById('calibrate-status');
if (!instructions || !statusEl) return;
var linkCount = state.calibrationLinks.length || 1;
var nodeLabel = state.nodeMAC || 'Node';
instructions.innerHTML =
'<div class="post-cal-card">' +
'<div class="wizard-icon-large wizard-success-icon">\u2713</div>' +
'<h3>You\'re All Set!</h3>' +
'<p class="post-cal-summary">' + escapeAttr(nodeLabel) + ' calibrated. ' +
linkCount + ' sensing link' + (linkCount !== 1 ? 's' : '') +
' active. Motion detection: Ready.</p>' +
'<p class="post-cal-expect">You\'ll see the CSI waveform react when someone walks through the room. ' +
'The system learns your space over the next few hours and becomes more accurate.</p>' +
'<div class="post-cal-actions">' +
'<button class="wizard-btn wizard-btn-secondary" id="post-cal-add">Add another node</button>' +
'<button class="wizard-btn wizard-btn-primary" id="post-cal-done">I\'m done for now</button>' +
'</div>' +
'</div>';
statusEl.innerHTML = '';
document.getElementById('post-cal-add').addEventListener('click', function () {
clearState();
closeWizard();
// Let the user start a new wizard from the dashboard
});
document.getElementById('post-cal-done').addEventListener('click', function () {
saveState();
goToStep(state.currentStepIndex + 1);
});
}
function renderPlacement(contentEl) {
contentEl.innerHTML =
'<div class="wizard-step-content">' +
@ -984,6 +1041,7 @@
window.SpaxelOnboard._provisionAndSend = provisionAndSend;
window.SpaxelOnboard._UserError = UserError;
window.SpaxelOnboard._isUserError = isUserError;
window.SpaxelOnboard._showPostCalibrationCard = showPostCalibrationCard;
// Auto-start if on /onboard path
if (window.location.pathname === '/onboard') {

277
dashboard/js/tooltips.js Normal file
View file

@ -0,0 +1,277 @@
/**
* Spaxel First-Time Feature Tooltips
*
* Shows contextual tooltips on first dashboard open after a node is added.
* Each tooltip is shown once and tracked via localStorage flags.
*
* TooltipManager.show(tooltipId, targetSelector, text, direction)
* TooltipManager.showSequence() walks through the manifest automatically
*/
(function () {
'use strict';
// ============================================
// Configuration
// ============================================
var STORAGE_PREFIX = 'spaxel_tooltip_';
var DISMISS_MS = 8000; // auto-dismiss after 8 s
var SEQUENCE_GAP_MS = 2000; // pause between sequential tooltips
var TOOLTIP_MANIFEST = [
{
id: 'csi-chart',
target: '#chart-panel',
text: 'This is your live signal. Motion causes the waves to change.',
direction: 'left',
},
{
id: '3d-view',
target: '#scene-container',
text: 'This 3D space updates as people move around.',
direction: 'top',
},
{
id: 'presence-indicator',
target: '#presence-indicator',
text: 'Green = no one detected. Red = motion detected.',
direction: 'bottom',
},
{
id: 'link-list',
target: '.link-section',
text: 'Each line between two nodes is a sensing link.',
direction: 'left',
},
];
// ============================================
// Internal State
// ============================================
var state = {
activeTooltip: null,
dismissTimer: null,
sequenceTimer: null,
sequenceIndex: 0,
};
// ============================================
// localStorage Helpers
// ============================================
function hasShown(id) {
try {
return localStorage.getItem(STORAGE_PREFIX + id + '_shown') === 'true';
} catch (e) {
return false;
}
}
function markShown(id) {
try {
localStorage.setItem(STORAGE_PREFIX + id + '_shown', 'true');
} catch (e) { /* ignore */ }
}
function shouldShowSequence() {
try {
return localStorage.getItem('spaxel_tooltips_shown') !== 'true';
} catch (e) {
return true;
}
}
function markAllShown() {
try {
localStorage.setItem('spaxel_tooltips_shown', 'true');
} catch (e) { /* ignore */ }
}
// ============================================
// Tooltip Rendering
// ============================================
function show(tooltipId, targetSelector, text, direction) {
if (hasShown(tooltipId)) return false;
dismiss();
var target = document.querySelector(targetSelector);
if (!target) return false;
var tooltip = document.createElement('div');
tooltip.className = 'spaxel-tooltip';
tooltip.id = 'spaxel-tooltip-' + tooltipId;
tooltip.innerHTML =
'<div class="spaxel-tooltip-text">' + escapeHTML(text) + '</div>' +
'<div class="spaxel-tooltip-arrow spaxel-tooltip-arrow-' + (direction || 'top') + '"></div>';
// Append first so we can measure dimensions
tooltip.style.visibility = 'hidden';
document.body.appendChild(tooltip);
var rect = target.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var top, left, transform;
switch (direction) {
case 'bottom':
top = rect.bottom + 10;
left = rect.left + rect.width / 2 - tipRect.width / 2;
transform = '';
break;
case 'left':
top = rect.top + rect.height / 2 - tipRect.height / 2;
left = rect.left - tipRect.width - 10;
transform = '';
break;
case 'right':
top = rect.top + rect.height / 2 - tipRect.height / 2;
left = rect.right + 10;
transform = '';
break;
default: // top
top = rect.top - tipRect.height - 10;
left = rect.left + rect.width / 2 - tipRect.width / 2;
transform = '';
break;
}
// Clamp to viewport
var vpW = window.innerWidth;
var vpH = window.innerHeight;
left = Math.max(8, Math.min(left, vpW - tipRect.width - 8));
top = Math.max(8, Math.min(top, vpH - tipRect.height - 8));
tooltip.style.top = top + 'px';
tooltip.style.left = left + 'px';
tooltip.style.transform = transform;
tooltip.style.visibility = 'visible';
state.activeTooltip = tooltip;
// Auto-dismiss
state.dismissTimer = setTimeout(function () {
markShown(tooltipId);
dismiss();
}, DISMISS_MS);
return true;
}
function dismiss() {
if (state.dismissTimer) {
clearTimeout(state.dismissTimer);
state.dismissTimer = null;
}
if (state.activeTooltip) {
if (state.activeTooltip.parentNode) {
state.activeTooltip.parentNode.removeChild(state.activeTooltip);
}
state.activeTooltip = null;
}
}
function dismissAll() {
dismiss();
if (state.sequenceTimer) {
clearTimeout(state.sequenceTimer);
state.sequenceTimer = null;
}
markAllShown();
hideDismissAllButton();
}
// ============================================
// Sequential Tooltip Tour
// ============================================
function showSequence() {
if (!shouldShowSequence()) return;
// Collect tooltips whose targets exist in the DOM
var visible = [];
for (var i = 0; i < TOOLTIP_MANIFEST.length; i++) {
var t = TOOLTIP_MANIFEST[i];
if (!hasShown(t.id) && document.querySelector(t.target)) {
visible.push(t);
}
}
if (visible.length === 0) {
markAllShown();
return;
}
showDismissAllButton();
// Show first tooltip
if (show(visible[0].id, visible[0].target, visible[0].text, visible[0].direction)) {
state.sequenceIndex = 1;
scheduleNext(visible);
} else {
markAllShown();
hideDismissAllButton();
}
}
function scheduleNext(visible) {
if (state.sequenceIndex >= visible.length) {
markAllShown();
hideDismissAllButton();
return;
}
state.sequenceTimer = setTimeout(function () {
var next = visible[state.sequenceIndex];
if (show(next.id, next.target, next.text, next.direction)) {
state.sequenceIndex++;
scheduleNext(visible);
} else {
// Target no longer visible — skip
state.sequenceIndex++;
scheduleNext(visible);
}
}, DISMISS_MS + SEQUENCE_GAP_MS);
}
// ============================================
// Dismiss-All Button
// ============================================
function showDismissAllButton() {
if (document.getElementById('spaxel-dismiss-all-tooltips')) return;
var btn = document.createElement('button');
btn.id = 'spaxel-dismiss-all-tooltips';
btn.className = 'spaxel-dismiss-all';
btn.textContent = 'Dismiss all tips';
btn.addEventListener('click', dismissAll);
document.body.appendChild(btn);
}
function hideDismissAllButton() {
var btn = document.getElementById('spaxel-dismiss-all-tooltips');
if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
}
// ============================================
// Helpers
// ============================================
function escapeHTML(s) {
var div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// ============================================
// Public API
// ============================================
window.SpaxelTooltips = {
show: show,
dismiss: dismiss,
dismissAll: dismissAll,
showSequence: showSequence,
// Exposed for testing
_TOOLTIP_MANIFEST: TOOLTIP_MANIFEST,
_STORAGE_PREFIX: STORAGE_PREFIX,
_DISMISS_MS: DISMISS_MS,
_state: state,
};
})();

View file

@ -0,0 +1,311 @@
/**
* Spaxel Troubleshooting Manager
*
* Handles node offline cards, detection quality banners, and
* calibration completion reinforcement. Subscribes to WebSocket
* events via TroubleshootManager.handleEvent() called from app.js.
*
* Issue state machine per issue key:
* DETECTED -> NOTIFIED -> RESOLVED | DISMISSED
*
* State is in-memory only issues re-fire on next event after page refresh.
*/
(function () {
'use strict';
// ============================================
// Constants
// ============================================
var STATES = {
DETECTED: 'detected',
NOTIFIED: 'notified',
RESOLVED: 'resolved',
DISMISSED: 'dismissed',
};
var PACKET_CHECK_MS = 5000; // check link health every 5 s
var NO_FRAME_MS = 60000; // 60 s with no frames = quality issue
var RECOVERY_RATIO = 0.8; // frames resume → resolve if within 80 % of threshold
// ============================================
// Internal State
// ============================================
var state = {
issues: {}, // issueKey -> { state, data, element }
checkTimer: null,
nodePanelSection: null,
};
// ============================================
// Initialization
// ============================================
function init() {
// Create troubleshoot section inside the node panel
var panel = document.getElementById('node-panel');
if (panel) {
state.nodePanelSection = document.createElement('div');
state.nodePanelSection.id = 'troubleshoot-section';
panel.appendChild(state.nodePanelSection);
}
// Periodic client-side link health check
state.checkTimer = setInterval(checkLinkHealth, PACKET_CHECK_MS);
}
// ============================================
// Public Event Handler (called from app.js)
// ============================================
function handleEvent(type, data) {
switch (type) {
case 'node_disconnected':
handleNodeOffline(data);
break;
case 'node_connected':
handleNodeOnline(data);
break;
case 'low_packet_rate':
handleLowPacketRate(data);
break;
case 'calibration_complete':
handleCalibrationComplete(data);
break;
}
}
// ============================================
// Node Offline
// ============================================
function handleNodeOffline(data) {
var mac = data.mac;
var key = 'offline_' + mac;
if (state.issues[key]) return; // already tracking
var issue = { state: STATES.DETECTED, data: data, element: null };
state.issues[key] = issue;
issue.state = STATES.NOTIFIED;
issue.element = renderOfflineCard(mac);
}
function handleNodeOnline(data) {
var mac = data.mac;
var key = 'offline_' + mac;
resolveIssue(key);
// Also clear any quality banners for links involving this node
clearQualityIssuesForNode(mac);
}
function renderOfflineCard(mac) {
var last4 = mac.slice(-5).replace(':', '');
var time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var card = document.createElement('div');
card.className = 'troubleshoot-card troubleshoot-offline-card';
card.innerHTML =
'<div class="troubleshoot-card-header">' +
'<span class="troubleshoot-card-icon">\u26A0</span>' +
'<span>Node <strong>' + escapeAttr(mac) + '</strong> went offline at ' + escapeAttr(time) + '</span>' +
'<button class="troubleshoot-dismiss" title="Dismiss">&times;</button>' +
'</div>' +
'<div class="troubleshoot-timeline">' +
'<div class="troubleshoot-step">' +
'<div class="troubleshoot-step-num">1</div>' +
'<div class="troubleshoot-step-text">Check the node\'s power LED is on (solid green = connected, blinking = attempting WiFi)</div>' +
'</div>' +
'<div class="troubleshoot-step">' +
'<div class="troubleshoot-step-num">2</div>' +
'<div class="troubleshoot-step-text">If blinking: move the node closer to your WiFi router temporarily</div>' +
'</div>' +
'<details class="troubleshoot-more">' +
'<summary>More options</summary>' +
'<div class="troubleshoot-step">' +
'<div class="troubleshoot-step-num">3</div>' +
'<div class="troubleshoot-step-text">If the LED blinks rapidly after 5 minutes: the node has lost its WiFi configuration. Connect to <strong>spaxel-' + escapeAttr(last4) + '</strong> WiFi network to reconfigure.</div>' +
'</div>' +
'<div class="troubleshoot-step">' +
'<div class="troubleshoot-step-num">4</div>' +
'<div class="troubleshoot-step-text">If the LED is off: check the power supply and USB cable</div>' +
'</div>' +
'<div class="troubleshoot-step">' +
'<div class="troubleshoot-step-num">5</div>' +
'<div class="troubleshoot-step-text">Still stuck? <button class="troubleshoot-reset-btn" data-mac="' + escapeAttr(mac) + '">Reset to factory defaults</button></div>' +
'</div>' +
'</details>' +
'</div>';
// Dismiss
card.querySelector('.troubleshoot-dismiss').addEventListener('click', function () {
resolveIssue('offline_' + mac);
});
// Factory reset instructions
var resetBtn = card.querySelector('.troubleshoot-reset-btn');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
showResetInstructions(mac);
});
}
if (state.nodePanelSection) {
state.nodePanelSection.appendChild(card);
}
return card;
}
// ============================================
// Factory Reset Instructions Modal
// ============================================
function showResetInstructions(mac) {
var modal = document.createElement('div');
modal.className = 'troubleshoot-modal-overlay';
modal.innerHTML =
'<div class="troubleshoot-modal">' +
'<h3>Factory Reset Instructions</h3>' +
'<ol class="troubleshoot-list">' +
'<li>Unplug the node from power</li>' +
'<li>Hold the <strong>BOOT</strong> button on the ESP32-S3</li>' +
'<li>While holding BOOT, plug in the USB cable</li>' +
'<li>Keep holding BOOT for 3 seconds, then release</li>' +
'<li>The node will enter setup mode (captive portal)</li>' +
'<li>Look for a WiFi network named <strong>Spaxel-Setup</strong> and connect to it</li>' +
'</ol>' +
'<button class="troubleshoot-modal-close wizard-btn wizard-btn-primary">Got it</button>' +
'</div>';
modal.querySelector('.troubleshoot-modal-close').addEventListener('click', function () {
if (modal.parentNode) modal.parentNode.removeChild(modal);
});
modal.addEventListener('click', function (e) {
if (e.target === modal && modal.parentNode) modal.parentNode.removeChild(modal);
});
document.body.appendChild(modal);
}
// ============================================
// Detection Quality (Low Packet Rate)
// ============================================
function handleLowPacketRate(data) {
var linkID = data.link_id;
var key = 'quality_' + linkID;
if (state.issues[key]) return;
state.issues[key] = { state: STATES.NOTIFIED, data: data, element: null };
state.issues[key].element = renderQualityBanner(data);
}
function renderQualityBanner(data) {
var banner = document.createElement('div');
banner.className = 'troubleshoot-quality-banner';
var safeId = (data.link_id || '').replace(/[^a-zA-Z0-9]/g, '_');
banner.id = 'quality-banner-' + safeId;
banner.innerHTML =
'<span class="troubleshoot-quality-icon">\u26A0</span>' +
'<span>Node is having trouble communicating. Check that it is powered on and within WiFi range.</span>' +
'<button class="troubleshoot-dismiss" title="Dismiss">&times;</button>';
banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function () {
resolveIssue('quality_' + (data.link_id || ''));
});
document.body.appendChild(banner);
return banner;
}
function clearQualityIssuesForNode(mac) {
var keys = Object.keys(state.issues);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (key.indexOf('quality_') !== 0) continue;
var d = state.issues[key].data;
if (d && (d.node_mac === mac || d.peer_mac === mac)) {
resolveIssue(key);
}
}
}
// ============================================
// Client-side Link Health Check
// ============================================
function checkLinkHealth() {
if (!window.SpaxelApp || typeof window.SpaxelApp.getLinks !== 'function') return;
var links = window.SpaxelApp.getLinks();
var now = Date.now();
links.forEach(function (link, linkID) {
if (!link.lastFrame) return;
var elapsed = now - link.lastFrame;
if (elapsed > NO_FRAME_MS) {
// No frames for over 60 s — flag quality issue
var key = 'quality_' + linkID;
if (!state.issues[key]) {
handleLowPacketRate({
link_id: linkID,
node_mac: link.nodeMAC,
peer_mac: link.peerMAC,
});
}
} else if (elapsed < NO_FRAME_MS * RECOVERY_RATIO) {
// Frames resumed — auto-resolve
var key = 'quality_' + linkID;
if (state.issues[key]) {
resolveIssue(key);
}
}
});
}
// ============================================
// Calibration Complete
// ============================================
function handleCalibrationComplete(data) {
// The post-calibration reinforcement card is rendered by the
// onboarding wizard itself (onboard.js). This handler is a
// hook for future dashboard-level use (e.g. showing a
// notification when calibration completes on a node that was
// already on the dashboard).
}
// ============================================
// Issue Helpers
// ============================================
function resolveIssue(key) {
var issue = state.issues[key];
if (!issue) return;
issue.state = STATES.RESOLVED;
if (issue.element && issue.element.parentNode) {
issue.element.parentNode.removeChild(issue.element);
}
delete state.issues[key];
}
function escapeAttr(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ============================================
// Public API
// ============================================
window.SpaxelTroubleshoot = {
init: init,
handleEvent: handleEvent,
// Exposed for testing
_state: state,
_STATES: STATES,
_NO_FRAME_MS: NO_FRAME_MS,
};
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,479 @@
/**
* Tests for troubleshoot.js and tooltips.js
*
* Covers:
* - Node offline card rendering and dismissal
* - Node online event dismissing offline card
* - Low packet rate quality banner
* - Tooltip localStorage persistence
* - Tooltip auto-dismiss and sequential tour
* - Issue state machine
*/
// Set up minimal DOM before IIFEs execute
document.body.innerHTML =
'<div id="node-panel">' +
'<div id="node-list"></div>' +
'<div class="link-section"></div>' +
'</div>' +
'<div id="chart-panel"></div>' +
'<div id="scene-container"></div>' +
'<div id="presence-indicator" class="clear">CLEAR</div>';
// Load modules (IIFEs execute immediately, setting window.SpaxelTroubleshoot / window.SpaxelTooltips)
require('./troubleshoot.js');
require('./tooltips.js');
var TS = window.SpaxelTroubleshoot;
var TT = window.SpaxelTooltips;
function setupDOM() {
document.body.innerHTML =
'<div id="node-panel">' +
'<div id="node-list"></div>' +
'<div class="link-section"></div>' +
'</div>' +
'<div id="chart-panel"></div>' +
'<div id="scene-container"></div>' +
'<div id="presence-indicator" class="clear">CLEAR</div>';
}
beforeEach(function () {
// Clear any real timers from IIFE auto-init
if (TS._state.checkTimer) {
clearInterval(TS._state.checkTimer);
TS._state.checkTimer = null;
}
if (TT._state.dismissTimer) {
clearTimeout(TT._state.dismissTimer);
TT._state.dismissTimer = null;
}
if (TT._state.sequenceTimer) {
clearTimeout(TT._state.sequenceTimer);
TT._state.sequenceTimer = null;
}
TT._state.activeTooltip = null;
TT._state.sequenceIndex = 0;
jest.useFakeTimers();
localStorage.clear();
setupDOM();
// Reset troubleshoot issues
TS._state.issues = {};
// Re-init troubleshoot with fresh DOM (uses fake setInterval)
TS.init();
});
afterEach(function () {
jest.useRealTimers();
});
// ============================================
// Troubleshoot Tests
// ============================================
describe('SpaxelTroubleshoot', function () {
describe('node offline card', function () {
test('renders offline card with correct node label when node_disconnected fires', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var section = document.getElementById('troubleshoot-section');
expect(section).toBeTruthy();
var card = section.querySelector('.troubleshoot-offline-card');
expect(card).toBeTruthy();
expect(card.textContent).toContain('AA:BB:CC:DD:EE:FF');
expect(card.textContent).toContain('went offline');
});
test('offline card includes actionable troubleshooting steps', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
expect(card.textContent).toContain('power LED');
expect(card.textContent).toContain('WiFi router');
});
test('offline card shows captive portal AP SSID with last 4 MAC chars', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
// mac.slice(-5) = 'EE:FF', .replace(':','') = 'EEFF'
expect(card.innerHTML).toContain('spaxel-EEFF');
});
test('offline card can be dismissed via X button', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
expect(card).toBeTruthy();
card.querySelector('.troubleshoot-dismiss').click();
expect(document.querySelector('.troubleshoot-offline-card')).toBeNull();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
test('node_online event dismisses the offline card', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-offline-card')).toBeTruthy();
TS.handleEvent('node_connected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-offline-card')).toBeNull();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
test('does not create duplicate offline cards for the same node', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var cards = document.querySelectorAll('.troubleshoot-offline-card');
expect(cards.length).toBe(1);
});
test('"More options" expander reveals additional steps', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
var more = card.querySelector('.troubleshoot-more');
var steps = more.querySelectorAll('.troubleshoot-step');
expect(steps.length).toBe(3);
expect(more.textContent).toContain('factory defaults');
});
});
describe('factory reset modal', function () {
test('shows modal with reset instructions when button clicked', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
var modal = document.querySelector('.troubleshoot-modal-overlay');
expect(modal).toBeTruthy();
expect(modal.textContent).toContain('BOOT');
expect(modal.textContent).toContain('Spaxel-Setup');
});
test('modal can be closed with Got it button', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeTruthy();
document.querySelector('.troubleshoot-modal-close').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeNull();
});
test('modal can be closed by clicking overlay background', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
document.querySelector('.troubleshoot-modal-overlay').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeNull();
});
});
describe('detection quality banner', function () {
test('renders quality banner when low_packet_rate event fires', function () {
TS.handleEvent('low_packet_rate', { link_id: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66' });
var banner = document.querySelector('.troubleshoot-quality-banner');
expect(banner).toBeTruthy();
expect(banner.textContent).toContain('having trouble communicating');
});
test('quality banner can be dismissed', function () {
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
var banner = document.querySelector('.troubleshoot-quality-banner');
banner.querySelector('.troubleshoot-dismiss').click();
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
});
test('does not create duplicate quality banners for the same link', function () {
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
var banners = document.querySelectorAll('.troubleshoot-quality-banner');
expect(banners.length).toBe(1);
});
test('node_online clears quality issues for links involving that node', function () {
TS.handleEvent('low_packet_rate', {
link_id: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66',
node_mac: 'AA:BB:CC:DD:EE:FF',
peer_mac: '11:22:33:44:55:66',
});
expect(document.querySelector('.troubleshoot-quality-banner')).toBeTruthy();
TS.handleEvent('node_connected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
});
});
describe('client-side link health check', function () {
test('flags quality issue when no frames received for 60+ seconds', function () {
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now() - 65000,
});
return links;
},
};
jest.advanceTimersByTime(5000);
var banner = document.querySelector('.troubleshoot-quality-banner');
expect(banner).toBeTruthy();
delete window.SpaxelApp;
});
test('auto-resolves quality issue when frames resume', function () {
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now() - 65000,
});
return links;
},
};
jest.advanceTimersByTime(5000);
expect(document.querySelector('.troubleshoot-quality-banner')).toBeTruthy();
// Frames resume
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now(),
});
return links;
},
};
jest.advanceTimersByTime(5000);
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
delete window.SpaxelApp;
});
});
describe('calibration_complete handler', function () {
test('does not throw on calibration_complete event', function () {
expect(function () {
TS.handleEvent('calibration_complete', {
node_mac: 'AA:BB:CC:DD:EE:FF',
link_count: 2,
});
}).not.toThrow();
});
});
describe('issue state machine', function () {
test('issue transitions to NOTIFIED when created', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var issue = TS._state.issues['offline_AA:BB:CC:DD:EE:FF'];
expect(issue).toBeTruthy();
expect(issue.state).toBe(TS._STATES.NOTIFIED);
});
test('issue is removed from state when resolved', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeTruthy();
document.querySelector('.troubleshoot-offline-card .troubleshoot-dismiss').click();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
});
});
// ============================================
// Tooltip Tests
// ============================================
describe('SpaxelTooltips', function () {
describe('localStorage persistence', function () {
test('show() returns false if tooltip was previously shown', function () {
localStorage.setItem('spaxel_tooltip_csi-chart_shown', 'true');
var result = TT.show('csi-chart', '#chart-panel', 'Test tooltip', 'left');
expect(result).toBe(false);
});
test('show() returns true and renders tooltip on first display', function () {
var result = TT.show('csi-chart', '#chart-panel', 'Test tooltip text', 'left');
expect(result).toBe(true);
var tooltip = document.getElementById('spaxel-tooltip-csi-chart');
expect(tooltip).toBeTruthy();
expect(tooltip.textContent).toContain('Test tooltip text');
});
test('tooltip sets localStorage flag on auto-dismiss after 8 seconds', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
expect(localStorage.getItem('spaxel_tooltip_csi-chart_shown')).toBeNull();
jest.advanceTimersByTime(8000);
expect(localStorage.getItem('spaxel_tooltip_csi-chart_shown')).toBe('true');
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
test('tooltip does not re-appear after localStorage flag is set', function () {
TT.show('csi-chart', '#chart-panel', 'First show', 'left');
jest.advanceTimersByTime(8000);
var result = TT.show('csi-chart', '#chart-panel', 'Second show', 'left');
expect(result).toBe(false);
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
});
describe('dismiss and dismissAll', function () {
test('dismiss() removes the active tooltip from DOM', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeTruthy();
TT.dismiss();
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
test('dismissAll() sets spaxel_tooltips_shown flag', function () {
expect(localStorage.getItem('spaxel_tooltips_shown')).toBeNull();
TT.dismissAll();
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
});
test('dismissAll() removes dismiss-all button', function () {
TT.showSequence();
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeTruthy();
document.getElementById('spaxel-dismiss-all-tooltips').click();
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeNull();
});
});
describe('sequential tour (showSequence)', function () {
test('showSequence() does nothing if spaxel_tooltips_shown is already set', function () {
localStorage.setItem('spaxel_tooltips_shown', 'true');
TT.showSequence();
expect(document.querySelector('.spaxel-tooltip')).toBeNull();
});
test('showSequence() shows first tooltip and dismiss-all button', function () {
TT.showSequence();
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip).toBeTruthy();
var dismissAllBtn = document.getElementById('spaxel-dismiss-all-tooltips');
expect(dismissAllBtn).toBeTruthy();
expect(dismissAllBtn.textContent).toBe('Dismiss all tips');
});
test('showSequence() advances through tooltips after auto-dismiss + gap', function () {
TT.showSequence();
var firstId = document.querySelector('.spaxel-tooltip').id;
// Advance past auto-dismiss (8s) + gap (2s) = 10s
jest.advanceTimersByTime(10000);
var secondTooltip = document.querySelector('.spaxel-tooltip');
expect(secondTooltip).toBeTruthy();
expect(secondTooltip.id).not.toBe(firstId);
});
test('dismiss-all button stops the tour and sets localStorage', function () {
TT.showSequence();
expect(document.querySelector('.spaxel-tooltip')).toBeTruthy();
document.getElementById('spaxel-dismiss-all-tooltips').click();
expect(document.querySelector('.spaxel-tooltip')).toBeNull();
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
});
test('marks all tooltips shown after completing the full sequence', function () {
// 4 tooltips × (8s dismiss + 2s gap) = 32s total
// Last scheduleNext fires at 30s and immediately calls markAllShown
TT.showSequence();
jest.advanceTimersByTime(31000);
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeNull();
});
});
describe('tooltip manifest', function () {
test('manifest has 4 tooltips with required properties', function () {
var manifest = TT._TOOLTIP_MANIFEST;
expect(manifest.length).toBe(4);
manifest.forEach(function (t) {
expect(t.id).toBeTruthy();
expect(t.target).toBeTruthy();
expect(t.text).toBeTruthy();
expect(t.direction).toBeTruthy();
});
});
test('manifest covers CSI chart, 3D view, presence indicator, and link list', function () {
var ids = TT._TOOLTIP_MANIFEST.map(function (t) { return t.id; });
expect(ids).toContain('csi-chart');
expect(ids).toContain('3d-view');
expect(ids).toContain('presence-indicator');
expect(ids).toContain('link-list');
});
});
describe('tooltip positioning', function () {
test('tooltip is positioned with fixed positioning via CSS class', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip.className).toContain('spaxel-tooltip');
expect(tooltip.style.top).toBeTruthy();
expect(tooltip.style.left).toBeTruthy();
});
test('tooltip has arrow element matching direction', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip.querySelector('.spaxel-tooltip-arrow-left')).toBeTruthy();
});
test('show() returns false if target element does not exist', function () {
var result = TT.show('nonexistent', '#nonexistent-element', 'Test', 'top');
expect(result).toBe(false);
});
});
});