diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 92c4e8b..e702720 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -759,6 +759,34 @@ } break; + case 'quality_drop': + // Guided troubleshooting: zone quality degraded + if (window.SpaxelTroubleshoot) { + window.SpaxelTroubleshoot.handleEvent('quality_drop', msg); + } + break; + + case 'repeated_edit': + // Guided troubleshooting: settings adjusted repeatedly + if (window.SpaxelTroubleshoot) { + window.SpaxelTroubleshoot.handleEvent('repeated_edit', msg); + } + break; + + case 'calibration_complete': + // Guided troubleshooting: baseline calibration complete + if (window.SpaxelTroubleshoot) { + window.SpaxelTroubleshoot.handleEvent('calibration_complete', msg); + } + break; + + case 'node_offline': + // Guided troubleshooting: node offline for >2 hours + if (window.SpaxelTroubleshoot) { + window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg); + } + break; + case 'replay_update': // Replay blob updates during time-travel debugging if (msg.blobs && Viz3D.updateReplayBlobs) { diff --git a/dashboard/js/feedback.js b/dashboard/js/feedback.js index aed91da..4bd9b60 100644 --- a/dashboard/js/feedback.js +++ b/dashboard/js/feedback.js @@ -185,23 +185,54 @@ * Send feedback to the API */ sendFeedback: function(eventID, eventType, feedbackType, details) { + // Map feedback types to API types + var apiType = 'correct'; + if (feedbackType === this.FeedbackTypes.FALSE_POSITIVE || feedbackType === 'FALSE_POSITIVE') { + apiType = 'incorrect'; + } else if (feedbackType === this.FeedbackTypes.FALSE_NEGATIVE || feedbackType === 'FALSE_NEGATIVE') { + apiType = 'missed'; + } else if (feedbackType === this.FeedbackTypes.TRUE_POSITIVE || feedbackType === 'TRUE_POSITIVE') { + apiType = 'correct'; + } + + // Extract blob_id from details if available + var blobID = details && details.blob_id ? details.blob_id : 0; + + // Extract position from details if available + var position = null; + if (details && (details.position_x !== undefined || details.zone_id !== undefined)) { + position = { + x: details.position_x || 0, + y: details.position_y || 0, + z: details.position_z || 0 + }; + } + var data = { - event_id: eventID || '', - event_type: eventType, - feedback_type: feedbackType, - details: details + type: apiType, + event_id: eventID || 0, + blob_id: blobID }; - fetch('/api/learning/feedback', { + // Add position if present + if (position) { + data.position = position; + } + + fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then(function(res) { return res.json(); }) .then(function(result) { - if (result.success) { - console.log('[Feedback] Submitted:', feedbackType, 'for event', eventID); - if (window.SpaxelApp && window.SpaxelApp.showToast) { + if (result.ok) { + console.log('[Feedback] Submitted:', apiType, 'for event', eventID); + + // Show inline response if provided + if (result.inline_response) { + Feedback.showInlineResponse(result.inline_response); + } else if (window.SpaxelApp && window.SpaxelApp.showToast) { window.SpaxelApp.showToast('Thank you for your feedback!', 'success'); } } @@ -330,6 +361,36 @@ }); }, + /** + * Show inline response message for feedback + */ + showInlineResponse: function(response) { + // Create inline response element + var inline = document.createElement('div'); + inline.className = 'feedback-inline-response feedback-inline-' + (response.type || 'info'); + inline.innerHTML = '\ +
' + this.escapeHTML(response.title || 'Feedback recorded') + '
\ +
' + this.escapeHTML(response.message || '') + '
\ + \ + '; + + // Add to timeline or body + var timeline = document.querySelector('.timeline-events') || document.body; + timeline.insertBefore(inline, timeline.firstChild); + + // Auto-dismiss after 8 seconds + setTimeout(function() { + if (inline.parentNode) { + inline.classList.add('feedback-inline-fadeout'); + setTimeout(function() { + if (inline.parentNode) { + inline.parentNode.removeChild(inline); + } + }, 500); + } + }, 8000); + }, + /** * Create thumbs up/down buttons for an event */ @@ -629,6 +690,69 @@ background: #4fc3f7;\ color: #1a1a2e;\ font-weight: 500;\ + }\ + .feedback-inline-response {\ + position: relative;\ + background: rgba(76, 175, 80, 0.1);\ + border-left: 3px solid #4caf50;\ + border-radius: 4px;\ + padding: 12px 16px;\ + margin-bottom: 12px;\ + animation: feedbackSlideIn 0.3s ease-out;\ + }\ + .feedback-inline-info {\ + background: rgba(79, 195, 247, 0.1);\ + border-left-color: #4fc3f7;\ + }\ + .feedback-inline-adjustment {\ + background: rgba(255, 167, 38, 0.1);\ + border-left-color: #ffa726;\ + }\ + .feedback-inline-header {\ + font-weight: 600;\ + font-size: 13px;\ + color: #eee;\ + margin-bottom: 4px;\ + }\ + .feedback-inline-message {\ + font-size: 12px;\ + color: #bbb;\ + line-height: 1.4;\ + }\ + .feedback-inline-close {\ + position: absolute;\ + top: 8px;\ + right: 8px;\ + background: none;\ + border: none;\ + color: #888;\ + font-size: 16px;\ + cursor: pointer;\ + padding: 4px;\ + }\ + .feedback-inline-close:hover {\ + color: #fff;\ + }\ + .feedback-inline-fadeout {\ + animation: feedbackFadeOut 0.5s ease-out forwards;\ + }\ + @keyframes feedbackSlideIn {\ + from {\ + opacity: 0;\ + transform: translateY(-10px);\ + }\ + to {\ + opacity: 1;\ + transform: translateY(0);\ + }\ + }\ + @keyframes feedbackFadeOut {\ + to {\ + opacity: 0;\ + max-height: 0;\ + margin: 0;\ + padding: 0;\ + }\ }'; document.head.appendChild(style); } diff --git a/dashboard/js/tooltips.js b/dashboard/js/tooltips.js index 4e422cc..a19c707 100644 --- a/dashboard/js/tooltips.js +++ b/dashboard/js/tooltips.js @@ -260,6 +260,138 @@ return div.innerHTML; } + // ============================================ + // Feature Panel Tooltips + // ============================================ + var FEATURE_PANEL_TOOLTIPS = { + 'settings-panel': { + id: 'settings-intro', + target: '#settings-panel', + text: 'Adjust sensitivity, fusion rate, and detection thresholds. Changes take effect immediately.', + direction: 'left', + }, + 'automation-panel': { + id: 'automation-intro', + target: '#automation-panel', + text: 'Create triggers based on presence, dwell time, or motion. Automate your space.', + direction: 'left', + }, + 'replay-panel': { + id: 'replay-intro', + target: '#replay-panel', + text: 'Replay CSI data from the past 48 hours. Great for debugging and algorithm tuning.', + direction: 'left', + }, + 'linkhealth-panel': { + id: 'linkhealth-intro', + target: '#linkhealth-panel', + text: 'Monitor link quality and signal strength. Identify weak links affecting detection.', + direction: 'left', + }, + }; + + var FEATURE_PANEL_PREFIX = 'spaxel_feature_panel_'; + + function hasShownFeaturePanel(panelId) { + try { + return localStorage.getItem(FEATURE_PANEL_PREFIX + panelId + '_shown') === 'true'; + } catch (e) { + return false; + } + } + + function markFeaturePanelShown(panelId) { + try { + localStorage.setItem(FEATURE_PANEL_PREFIX + panelId + '_shown', 'true'); + } catch (e) { /* ignore */ } + } + + function showFeatureTooltip(panelId) { + var tooltipConfig = FEATURE_PANEL_TOOLTIPS[panelId]; + if (!tooltipConfig) return false; + + if (hasShownFeaturePanel(panelId)) return false; + + var target = document.querySelector(tooltipConfig.target); + if (!target) return false; + + var tooltip = document.createElement('div'); + tooltip.className = 'spaxel-tooltip spaxel-feature-tooltip'; + tooltip.id = 'spaxel-tooltip-' + tooltipConfig.id; + tooltip.innerHTML = + '
' + escapeHTML(tooltipConfig.text) + '
' + + '
' + + '
'; + + // 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 (tooltipConfig.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.featureTooltip = tooltip; + + // Auto-dismiss after longer duration for feature panels + setTimeout(function () { + dismissFeatureTooltip(panelId); + }, 12000); // 12 seconds instead of 8 + + return true; + } + + function dismissFeatureTooltip(panelId) { + if (state.featureTooltip) { + markFeaturePanelShown(panelId); + if (state.featureTooltip.parentNode) { + state.featureTooltip.parentNode.removeChild(state.featureTooltip); + } + state.featureTooltip = null; + } + } + + function onFeaturePanelOpen(panelId) { + // Delay slightly to allow panel to render + setTimeout(function () { + showFeatureTooltip(panelId); + }, 300); + } + // ============================================ // Public API // ============================================ @@ -268,8 +400,12 @@ dismiss: dismiss, dismissAll: dismissAll, showSequence: showSequence, + showFeatureTooltip: showFeatureTooltip, + dismissFeatureTooltip: dismissFeatureTooltip, + onFeaturePanelOpen: onFeaturePanelOpen, // Exposed for testing _TOOLTIP_MANIFEST: TOOLTIP_MANIFEST, + _FEATURE_PANEL_TOOLTIPS: FEATURE_PANEL_TOOLTIPS, _STORAGE_PREFIX: STORAGE_PREFIX, _DISMISS_MS: DISMISS_MS, _state: state, diff --git a/dashboard/js/troubleshoot.js b/dashboard/js/troubleshoot.js index ba52bb4..14f7ff7 100644 --- a/dashboard/js/troubleshoot.js +++ b/dashboard/js/troubleshoot.js @@ -70,6 +70,12 @@ case 'calibration_complete': handleCalibrationComplete(data); break; + case 'quality_drop': + handleQualityDrop(data); + break; + case 'repeated_edit': + handleRepeatedEdit(data); + break; } } @@ -265,11 +271,288 @@ // 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). + if (!data) return; + + // Show positive reinforcement message + showCalibrationReinforcement(data); + } + + function showCalibrationReinforcement(data) { + var improvement = Math.round((data.quality_after || 0) - (data.quality_before || 0)); + var improvementText = improvement > 0 ? '+' + improvement : Math.round(improvement); + var encouragement = ''; + + if (improvement > 20) { + encouragement = 'Excellent! That\'s a significant improvement.'; + } else if (improvement > 10) { + encouragement = 'Great progress! Detection is much more reliable now.'; + } else if (improvement > 0) { + encouragement = 'Getting better. The system will continue to refine baseline over time.'; + } else { + encouragement = 'Baseline has been updated. The system needs more data to adapt to this environment.'; + } + + var card = document.createElement('div'); + card.className = 'troubleshoot-card troubleshoot-success-card'; + card.innerHTML = + '
' + + '\u2714' + + 'Re-baseline complete' + + '' + + '
' + + '
' + + '

' + encouragement + '

' + + '
' + + 'Quality: ' + Math.round(data.quality_after || 0) + '%' + + '' + improvementText + '%' + + '' + (data.links || 0) + ' links calibrated' + + '
' + + '
'; + + card.querySelector('.troubleshoot-dismiss').addEventListener('click', function() { + if (card.parentNode) card.parentNode.removeChild(card); + }); + + if (state.nodePanelSection) { + state.nodePanelSection.appendChild(card); + } + + // Auto-dismiss after 10 seconds + setTimeout(function() { + if (card.parentNode) { + card.classList.add('troubleshoot-card-fadeout'); + setTimeout(function() { + if (card.parentNode) card.parentNode.removeChild(card); + }, 500); + } + }, 10000); + } + + // ============================================ + // Quality Drop Detection + // ============================================ + function handleQualityDrop(data) { + if (!data || !data.zone_id) return; + + var key = 'quality_' + data.zone_id; + if (state.issues[key]) return; + + state.issues[key] = { state: STATES.NOTIFIED, data: data, element: null }; + state.issues[key].element = renderQualityDropBanner(data); + } + + function renderQualityDropBanner(data) { + var banner = document.createElement('div'); + banner.className = 'troubleshoot-quality-banner'; + banner.innerHTML = + '
' + + '\u26A0' + + '
' + + 'Detection quality has degraded in ' + escapeAttr(data.zone_name || 'this zone') + '
' + + 'Quality has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.' + + '
' + + '' + + '' + + '
'; + + // Diagnose button + banner.querySelector('.troubleshoot-action-btn').addEventListener('click', function() { + showQualityDiagnostics(data); + }); + + // Dismiss button + banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function() { + resolveIssue('quality_' + (data.zone_id || '')); + // Also dismiss on server + fetch('/api/guided/issues/quality/' + (data.zone_id || '') + '/dismiss', { + method: 'POST' + }).catch(function(err) { + console.error('[Troubleshoot] Failed to dismiss quality issue:', err); + }); + }); + + document.body.appendChild(banner); + return banner; + } + + function showQualityDiagnostics(data) { + // Fetch diagnostic steps from the API + fetch('/api/guided/issues') + .then(function(res) { return res.json(); }) + .then(function(result) { + var issues = result.issues || []; + var qualityIssue = issues.find(function(i) { return i.type === 'quality_drop' && i.zone_id === data.zone_id; }); + + if (qualityIssue) { + showGuidedDiagnosticsFlow(qualityIssue); + } + }) + .catch(function(err) { + console.error('[Troubleshoot] Failed to fetch diagnostics:', err); + }); + } + + function showGuidedDiagnosticsFlow(issue) { + var modal = document.createElement('div'); + modal.className = 'troubleshoot-modal-overlay'; + modal.innerHTML = + '
' + + '

Detection Quality Diagnostics

' + + '

Let\'s diagnose the detection quality issue in ' + escapeAttr(issue.zone_name || 'this zone') + '.

' + + '
' + + '
' + + '
1
' + + '
' + + '

Check Node Connectivity

' + + '

Verify all nodes in this zone are online and communicating properly.

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
2
' + + '
' + + '

View Link Health

' + + '

Examine the health of sensing links in this zone to identify problematic links.

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
3
' + + '
' + + '

Re-baseline Links

' + + '

If the environment has changed, re-baselining the links may improve detection quality.

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
4
' + + '
' + + '

Consider Node Repositioning

' + + '

Sometimes moving nodes slightly can dramatically improve coverage.

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '
'; + + 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); + }); + + // Handle step buttons + modal.querySelectorAll('.troubleshoot-step-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var action = this.dataset.action; + handleDiagnosticsAction(action, issue.zone_id); + }); + }); + + document.body.appendChild(modal); + } + + function handleDiagnosticsAction(action, zoneID) { + switch(action) { + case 'connectivity': + // Navigate to fleet status page + if (window.SpaxelApp && window.SpaxelApp.navigateTo) { + window.SpaxelApp.navigateTo('fleet'); + } + break; + case 'link_health': + // Open link health panel + if (window.SpaxelApp && window.SpaxelApp.openLinkHealth) { + window.SpaxelApp.openLinkHealth(); + } + break; + case 'rebaseline': + // Trigger re-baseline for zone + fetch('/api/baseline/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zone_id: zoneID }) + }) + .then(function(res) { return res.json(); }) + .then(function(result) { + if (window.SpaxelApp && window.SpaxelApp.showToast) { + window.SpaxelApp.showToast('Re-baseline started for zone. Please keep the room clear for 60 seconds.', 'info'); + } + }) + .catch(function(err) { + console.error('[Troubleshoot] Failed to start re-baseline:', err); + }); + break; + case 'reposition': + // Open 3D placement view + if (window.SpaxelApp && window.SpaxelApp.navigateTo) { + window.SpaxelApp.navigateTo('placement'); + } + break; + } + } + + // ============================================ + // Repeated Settings Edit + // ============================================ + function handleRepeatedEdit(data) { + if (!data || !data.key) return; + + showRepeatedEditHint(data); + } + + function showRepeatedEditHint(data) { + // Check if we've already shown this hint recently + var hintKey = 'repeated_edit_hint_' + data.key; + var lastShown = localStorage.getItem(hintKey); + if (lastShown) { + var elapsed = Date.now() - parseInt(lastShown, 10); + if (elapsed < 24 * 60 * 60 * 1000) { // 24 hours + return; // Already shown within cooldown + } + } + + var banner = document.createElement('div'); + banner.className = 'troubleshoot-hint-banner'; + banner.innerHTML = + '
' + + '\u2139' + + '
' + + 'Frequent adjustments detected
' + + 'You\'ve adjusted the detection threshold several times. Would you like me to show you what the system is seeing?' + + '
' + + '' + + '' + + '
'; + + banner.querySelector('.troubleshoot-hint-action').addEventListener('click', function() { + // Open time-travel replay with explainability + if (window.SpaxelApp && window.SpaxelApp.openTimeTravel) { + window.SpaxelApp.openTimeTravel({ with_explainability: true }); + } + // Mark hint as shown + localStorage.setItem(hintKey, Date.now().toString()); + if (banner.parentNode) banner.parentNode.removeChild(banner); + }); + + banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function() { + // Mark hint as shown + localStorage.setItem(hintKey, Date.now().toString()); + if (banner.parentNode) banner.parentNode.removeChild(banner); + }); + + document.body.appendChild(banner); } // ============================================ @@ -302,6 +585,215 @@ _NO_FRAME_MS: NO_FRAME_MS, }; + // ============================================ + // Public API + // ============================================ + window.SpaxelTroubleshoot = { + init: init, + handleEvent: handleEvent, + // Exposed for testing + _state: state, + _STATES: STATES, + _NO_FRAME_MS: NO_FRAME_MS, + }; + + // ============================================ + // CSS Styles + // ============================================ + function addStyles() { + if (document.getElementById('troubleshoot-styles')) return; + + var style = document.createElement('style'); + style.id = 'troubleshoot-styles'; + style.textContent = + '.troubleshoot-card {' + + 'background: rgba(30, 30, 58, 0.95);' + + 'border-radius: 8px;' + + 'box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);' + + 'margin-bottom: 16px;' + + 'overflow: hidden;' + + 'font-size: 13px;' + + '}' + + '.troubleshoot-success-card {' + + 'border-left: 3px solid #4caf50;' + + '}' + + '.troubleshoot-card-fadeout {' + + 'animation: troubleshootFadeOut 0.5s ease-out forwards;' + + '}' + + '@keyframes troubleshootFadeOut {' + + 'to { opacity: 0; max-height: 0; margin: 0; }' + + '}' + + '.troubleshoot-success-message {' + + 'color: #81c784;' + + 'font-weight: 500;' + + 'margin-bottom: 8px;' + + '}' + + '.troubleshoot-metrics {' + + 'display: flex;' + + 'gap: 16px;' + + 'font-size: 12px;' + + 'color: #888;' + + '}' + + '.troubleshoot-improvement {' + + 'color: #81c784;' + + 'font-weight: 500;' + + '}' + + '.troubleshoot-quality-banner {' + + 'position: fixed;' + + 'bottom: 0;' + + 'left: 0;' + + 'right: 0;' + + 'background: rgba(255, 167, 38, 0.15);' + + 'border-top: 2px solid #ffa726;' + + 'padding: 12px 20px;' + + 'display: flex;' + + 'align-items: center;' + + 'justify-content: center;' + + 'gap: 16px;' + + 'z-index: 150;' + + 'animation: troubleshootSlideUp 0.3s ease-out;' + + '}' + + '@keyframes troubleshootSlideUp {' + + 'from { transform: translateY(100%); }' + + 'to { transform: translateY(0); }' + + '}' + + '.troubleshoot-quality-content {' + + 'display: flex;' + + 'align-items: center;' + + 'gap: 12px;' + + '}' + + '.troubleshoot-quality-icon {' + + 'font-size: 20px;' + + '}' + + '.troubleshoot-quality-text {' + + 'flex: 1;' + + '}' + + '.troubleshoot-quality-detail {' + + 'font-size: 12px;' + + 'color: #aaa;' + + 'display: block;' + + 'margin-top: 2px;' + + '}' + + '.troubleshoot-action-btn {' + + 'background: #4fc3f7;' + + 'color: #1a1a2e;' + + 'border: none;' + + 'padding: 6px 14px;' + + 'border-radius: 4px;' + + 'font-size: 12px;' + + 'font-weight: 500;' + + 'cursor: pointer;' + + '}' + + '.troubleshoot-hint-banner {' + + 'position: fixed;' + + 'bottom: 80px;' + + 'left: 50%;' + + 'transform: translateX(-50%);' + + 'background: rgba(33, 150, 243, 0.15);' + + 'border: 1px solid rgba(33, 150, 243, 0.5);' + + 'border-radius: 8px;' + + 'padding: 12px 16px;' + + 'display: flex;' + + 'align-items: center;' + + 'gap: 12px;' + + 'z-index: 150;' + + 'max-width: 500px;' + + 'animation: troubleshootHintSlideUp 0.3s ease-out;' + + '}' + + '@keyframes troubleshootHintSlideUp {' + + 'from { transform: translateX(-50%) translateY(100px); opacity: 0; }' + + 'to { transform: translateX(-50%) translateY(0); opacity: 1; }' + + '}' + + '.troubleshoot-hint-icon {' + + 'font-size: 18px;' + + '}' + + '.troubleshoot-hint-text {' + + 'flex: 1;' + + 'font-size: 12px;' + + '}' + + '.troubleshoot-hint-text strong {' + + 'display: block;' + + 'color: #64b5f6;' + + 'margin-bottom: 2px;' + + '}' + + '.troubleshoot-hint-action {' + + 'background: #64b5f6;' + + 'color: #1a1a2e;' + + 'border: none;' + + 'padding: 4px 12px;' + + 'border-radius: 4px;' + + 'font-size: 11px;' + + 'cursor: pointer;' + + '}' + + '.troubleshoot-diagnostics-modal {' + + 'max-width: 600px;' + + 'width: 90%;' + + '}' + + '.troubleshoot-diagnostics-intro {' + + 'color: #aaa;' + + 'font-size: 13px;' + + 'margin-bottom: 20px;' + + '}' + + '.troubleshoot-steps-flow {' + + 'display: flex;' + + 'flex-direction: column;' + + 'gap: 16px;' + + 'margin-bottom: 20px;' + + '}' + + '.troubleshoot-flow-step {' + + 'display: flex;' + + 'gap: 12px;' + + 'align-items: flex-start;' + + '}' + + '.troubleshoot-step-number {' + + 'width: 28px;' + + 'height: 28px;' + + 'border-radius: 50%;' + + 'background: #4fc3f7;' + + 'color: #1a1a2e;' + + 'display: flex;' + + 'align-items: center;' + + 'justify-content: center;' + + 'font-weight: 600;' + + 'flex-shrink: 0;' + + '}' + + '.troubleshoot-step-content {' + + 'flex: 1;' + + '}' + + '.troubleshoot-step-content h4 {' + + 'margin: 0 0 4px 0;' + + 'font-size: 14px;' + + 'color: #eee;' + + '}' + + '.troubleshoot-step-content p {' + + 'margin: 0 0 8px 0;' + + 'font-size: 12px;' + + 'color: #aaa;' + + '}' + + '.troubleshoot-step-actions {' + + 'display: flex;' + + 'gap: 8px;' + + '}' + + '.troubleshoot-step-btn {' + + 'background: rgba(79, 195, 247, 0.2);' + + 'border: 1px solid rgba(79, 195, 247, 0.5);' + + 'color: #4fc3f7;' + + 'padding: 6px 12px;' + + 'border-radius: 4px;' + + 'font-size: 11px;' + + 'cursor: pointer;' + + 'transition: background 0.2s;' + + '}' + + '.troubleshoot-step-btn:hover {' + + 'background: rgba(79, 195, 247, 0.3);' + + '}'; + + document.head.appendChild(style); + } + + // Add styles on init + addStyles(); + // Auto-init when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 4a49e0d..cc85cbd 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -38,6 +38,7 @@ import ( "github.com/spaxel/mothership/internal/health" "github.com/spaxel/mothership/internal/ingestion" "github.com/spaxel/mothership/internal/briefing" + guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot" "github.com/spaxel/mothership/internal/learning" "github.com/spaxel/mothership/internal/loadshed" "github.com/spaxel/mothership/internal/localization" @@ -262,6 +263,16 @@ func writeJSON(w http.ResponseWriter, v interface{}) { json.NewEncoder(w).Encode(v) //nolint:errcheck } +// computeZoneQuality calculates the detection quality for a zone. +// This is a simplified version that aggregates link quality metrics. +func computeZoneQuality(zone zones.Zone, pm *sigproc.ProcessorManager, hc *health.Checker) float64 { + if hc != nil { + return hc.GetAmbientConfidence() + } + // Fallback: return default mid-range quality + return 50.0 +} + func findDashboardDir() string { for _, dir := range []string{"./dashboard", "./../dashboard", "/app/dashboard"} { if _, err := os.Stat(dir); err == nil { @@ -414,6 +425,101 @@ func main() { settingsHandler.RegisterRoutes(r) log.Printf("[INFO] Settings API registered at /api/settings") + // Guided troubleshooting manager (for proactive contextual help) + // Created after healthChecker since it depends on it + var guidedMgr *guidedtroubleshoot.Manager + guidedMgr = guidedtroubleshoot.NewManager(guidedtroubleshoot.ManagerConfig{ + CheckInterval: 5 * time.Minute, + GetAllZones: func() ([]guidedtroubleshoot.ZoneInfo, error) { + if zonesMgr == nil { + return nil, nil + } + zones, err := zonesMgr.GetAllZones() + if err != nil { + return nil, err + } + var result []guidedtroubleshoot.ZoneInfo + for _, z := range zones { + result = append(result, guidedtroubleshoot.ZoneInfo{ + ID: z.ID, + Name: z.Name, + Quality: computeZoneQuality(z, pm, healthChecker), + LastUpdated: time.Now(), + }) + } + return result, nil + }, + GetNodeLastSeen: func(mac string) time.Time { + if fleetReg == nil { + return time.Time{} + } + node, err := fleetReg.GetNode(mac) + if err != nil { + return time.Time{} + } + return time.Unix(node.LastSeenMs/1000, 0) + }, + }) + // Wire up EditTracker to settings handler for repeated-edit hints + settingsHandler.SetEditTracker(guidedMgr) + // Set up callbacks for WebSocket events + guidedMgr.SetOnQualityIssue(func(zoneID int, quality float64) { + // Send WebSocket event to dashboard + msg := map[string]interface{}{ + "type": "quality_drop", + "zone_id": zoneID, + "quality": quality, + } + if zonesMgr != nil { + if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil { + msg["zone_name"] = zone.Name + } + } + data, _ := json.Marshal(msg) + if dashboardHub != nil { + dashboardHub.Broadcast(data) + } + }) + guidedMgr.SetOnNodeOffline(func(mac string, offlineDuration time.Duration) { + // Send WebSocket event to dashboard + msg := map[string]interface{}{ + "type": "node_offline", + "mac": mac, + "offline_duration": offlineDuration.Seconds(), + } + if fleetReg != nil { + if node, err := fleetReg.GetNode(mac); err == nil { + msg["name"] = node.Name + } + } + data, _ := json.Marshal(msg) + if dashboardHub != nil { + dashboardHub.Broadcast(data) + } + }) + guidedMgr.SetOnCalibrationComplete(func(zoneID int, qualityBefore, qualityAfter float64) { + // Send WebSocket event to dashboard + msg := map[string]interface{}{ + "type": "calibration_complete", + "zone_id": zoneID, + "quality_before": qualityBefore, + "quality_after": qualityAfter, + "links": 0, // TODO: get actual link count + } + if zonesMgr != nil { + if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil { + msg["zone_name"] = zone.Name + } + } + data, _ := json.Marshal(msg) + if dashboardHub != nil { + dashboardHub.Broadcast(data) + } + }) + // Start the guided manager background check loop + go guidedMgr.Run(ctx) + log.Printf("[INFO] Guided troubleshooting manager initialized") + // Replay recording store - use recording.Buffer wrapped with replay adapter var replayStore api.RecordingStore var recordingBuf *recording.Buffer @@ -3105,6 +3211,13 @@ func main() { feedbackHandler.RegisterRoutes(r) log.Printf("[INFO] Feedback API registered at /api/feedback") + // Phase 8: Guided troubleshooting API + guidedHandler := api.NewGuidedHandler(guidedMgr) + guidedHandler.SetZonesHandler(zonesMgr) + guidedHandler.SetNodesHandler(fleetReg) + guidedHandler.RegisterRoutes(r) + log.Printf("[INFO] Guided troubleshooting API registered at /api/guided/*") + // Phase 6: Detection explainability API explainabilityHandler = explainability.NewHandler() explainabilityHandler.RegisterRoutes(r) diff --git a/mothership/internal/api/feedback.go b/mothership/internal/api/feedback.go index 47eedd4..5f311d0 100644 --- a/mothership/internal/api/feedback.go +++ b/mothership/internal/api/feedback.go @@ -120,11 +120,29 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re } } - // Return success response - writeJSON(w, map[string]interface{}{ + // Return success response with inline message + response := map[string]interface{}{ "ok": true, "message": "Feedback recorded", - }) + } + + // Add inline response based on feedback type + switch req.Type { + case "incorrect": + response["inline_response"] = map[string]interface{}{ + "type": "adjustment", + "title": "Adjusting detection threshold", + "message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.", + } + case "correct": + response["inline_response"] = map[string]interface{}{ + "type": "confirmation", + "title": "Thanks for confirming!", + "message": "This helps improve detection accuracy over time.", + } + } + + writeJSON(w, http.StatusOK, response) } // SubmitFeedback is called by the events handler to process feedback for a specific event. diff --git a/mothership/internal/api/guided.go b/mothership/internal/api/guided.go new file mode 100644 index 0000000..0ed4d7c --- /dev/null +++ b/mothership/internal/api/guided.go @@ -0,0 +1,391 @@ +// Package api provides REST API handlers for Spaxel guided troubleshooting. +package api + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +// GuidedHandler provides endpoints for proactive contextual help. +type GuidedHandler struct { + guidedMgr interface { + GetZonesWithPoorQuality() []int + MarkQualityBannerShown(zoneID int) + TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64) + TriggerNodeOffline(mac string, offlineDuration float64) // for testing + } + zonesHandler interface { + GetZone(id int) (map[string]interface{}, error) + GetAllZones() ([]map[string]interface{}, error) + } + nodesHandler interface { + GetAllNodes() ([]map[string]interface{}, error) + } +} + +// NewGuidedHandler creates a new guided troubleshooting handler. +func NewGuidedHandler(guidedMgr interface { + GetZonesWithPoorQuality() []int + MarkQualityBannerShown(zoneID int) + TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64) + TriggerNodeOffline(mac string, offlineDuration float64) +}) *GuidedHandler { + return &GuidedHandler{ + guidedMgr: guidedMgr, + } +} + +// SetZonesHandler sets the zones handler for zone information access. +func (h *GuidedHandler) SetZonesHandler(zonesHandler interface { + GetZone(id int) (map[string]interface{}, error) + GetAllZones() ([]map[string]interface{}, error) +}) { + h.zonesHandler = zonesHandler +} + +// SetNodesHandler sets the nodes handler for node information access. +func (h *GuidedHandler) SetNodesHandler(nodesHandler interface { + GetAllNodes() ([]map[string]interface{}, error) +}) { + h.nodesHandler = nodesHandler +} + +// RegisterRoutes registers guided troubleshooting endpoints. +func (h *GuidedHandler) RegisterRoutes(r chi.Router) { + r.Get("/api/guided/issues", h.handleGetIssues) + r.Post("/api/guided/issues/quality/{zoneId}/dismiss", h.handleDismissQualityIssue) + r.Post("/api/guided/feedback/response", h.handleGetFeedbackResponse) + r.Post("/api/guided/calibration/complete", h.handleCalibrationComplete) + r.Get("/api/guided/node/{mac}/troubleshoot", h.handleGetNodeTroubleshoot) + r.Get("/api/guided/tooltip/{featureId}", h.handleGetTooltip) + r.Post("/api/guided/tooltip/{featureId}/dismiss", h.handleDismissTooltip) +} + +// handleGetIssues returns all active guided troubleshooting issues. +func (h *GuidedHandler) handleGetIssues(w http.ResponseWriter, r *http.Request) { + if h.guidedMgr == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"issues": []interface{}{}}) + return + } + + var issues []map[string]interface{} + + // Quality issues + poorZones := h.guidedMgr.GetZonesWithPoorQuality() + for _, zoneID := range poorZones { + zoneName := "Unknown Zone" + zoneQuality := 0.0 + + if h.zonesHandler != nil { + if zone, err := h.getZoneByID(zoneID); err == nil { + zoneName = zone["name"].(string) + if q, ok := zone["quality"].(float64); ok { + zoneQuality = q + } + } + } + + issues = append(issues, map[string]interface{}{ + "type": "quality_drop", + "zone_id": zoneID, + "zone_name": zoneName, + "quality": zoneQuality, + "severity": "warning", + "title": "Detection quality has degraded in " + zoneName, + "description": "Detection quality in " + zoneName + " has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.", + "actions": []map[string]string{ + {"label": "Check node connectivity", "action": "connectivity"}, + {"label": "View link health", "action": "link_health"}, + {"label": "Re-baseline links", "action": "rebaseline"}, + {"label": "Run guided diagnostics", "action": "diagnostics"}, + }, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"issues": issues}) +} + +// handleDismissQualityIssue dismisses a quality banner for a zone. +func (h *GuidedHandler) handleDismissQualityIssue(w http.ResponseWriter, r *http.Request) { + zoneID := chi.URLParam(r, "zoneId") + var zoneIDInt int + if _, err := json.Unmarshal([]byte(zoneID), &zoneIDInt); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid zone ID") + return + } + + if h.guidedMgr != nil { + h.guidedMgr.MarkQualityBannerShown(zoneIDInt) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleGetFeedbackResponse returns the inline response message for a feedback submission. +func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http.Request) { + var req struct { + FeedbackType string `json:"feedback_type"` // "incorrect" or "correct" + Links []struct { + LinkID string `json:"link_id"` + DeltaRMS float64 `json:"delta_rms"` + } `json:"links,omitempty"` + ZoneID *int `json:"zone_id,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request body") + return + } + + var response map[string]interface{} + + switch req.FeedbackType { + case "incorrect": + response = map[string]interface{}{ + "type": "adjustment", + "title": "Adjusting detection threshold", + "message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.", + "actions": []map[string]string{ + {"label": "Open Settings", "action": "open_settings"}, + {"label": "View Link Details", "action": "view_links"}, + }, + } + + case "correct": + response = map[string]interface{}{ + "type": "confirmation", + "title": "Detection confirmed", + "message": "Thanks for confirming! This helps improve detection accuracy over time.", + } + + default: + response = map[string]interface{}{ + "type": "info", + "message": "Feedback recorded", + } + } + + writeJSON(w, http.StatusOK, response) +} + +// handleCalibrationComplete reports calibration completion and triggers reinforcement. +func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http.Request) { + var req struct { + ZoneID int `json:"zone_id"` + QualityBefore float64 `json:"quality_before"` + QualityAfter float64 `json:"quality_after"` + LinksCalibrated int `json:"links_calibrated"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request body") + return + } + + if h.guidedMgr != nil { + h.guidedMgr.TriggerCalibrationComplete(req.ZoneID, req.QualityBefore, req.QualityAfter) + } + + // Calculate improvement + improvement := req.QualityAfter - req.QualityBefore + improvementPct := int(improvement) + + response := map[string]interface{}{ + "type": "calibration_complete", + "title": "Re-baseline complete", + "message": "Detection quality in this zone has improved.", + "improvement": improvementPct, + "quality_after": req.QualityAfter, + "links": req.LinksCalibrated, + } + + // Add encouraging message based on improvement + if improvement > 20 { + response["encouragement"] = "Excellent! That's a significant improvement." + } else if improvement > 10 { + response["encouragement"] = "Great progress! Detection is much more reliable now." + } else if improvement > 0 { + response["encouragement"] = "Getting better. The system will continue to refine baseline over time." + } else { + response["encouragement"] = "Baseline has been updated. The system needs more data to adapt to this environment." + } + + writeJSON(w, http.StatusOK, response) +} + +// handleGetNodeTroubleshoot returns troubleshooting steps for an offline node. +func (h *GuidedHandler) handleGetNodeTroubleshoot(w http.ResponseWriter, r *http.Request) { + mac := chi.URLParam(r, "mac") + + // Get node info + var nodeName, nodeRole, lastSeen string + var offlineDuration float64 + + if h.nodesHandler != nil { + nodes, err := h.nodesHandler.(interface { + GetAllNodes() ([]map[string]interface{}, error) + }).GetAllNodes() + if err == nil { + for _, node := range nodes { + if nodeMAC, ok := node["mac"].(string); ok && nodeMAC == mac { + nodeName = node["name"].(string) + nodeRole = node["role"].(string) + // Calculate offline duration from last_seen_ms + if lastSeenMs, ok := node["last_seen_ms"].(int64); ok { + // Calculate approximate duration + offlineDuration = float64(time.Now().UnixMilli()-lastSeenMs) / 1000 / 60 // in minutes + } + break + } + } + } + } + + // Create troubleshooting steps + steps := []map[string]interface{}{ + { + "step": 1, + "title": "Check power connection", + "description": "Verify the node's USB cable is securely connected and the power LED is on (solid green = connected, blinking = attempting WiFi).", + "actions": []string{"Visually inspect the node", "Check the USB cable connection"}, + }, + { + "step": 2, + "title": "Check WiFi connectivity", + "description": "If the LED is blinking, the node is having trouble connecting to WiFi. Try moving it closer to your WiFi router.", + "actions": []string{"Move node closer to router", "Check WiFi is working"}, + }, + { + "step": 3, + "title": "Check for captive portal", + "description": "If the LED blinks rapidly after 5 minutes, the node has lost its WiFi configuration. Look for a WiFi network named 'spaxel-" + mac[len(mac)-4:] + "' and connect to reconfigure.", + "actions": []string{"Connect to spaxel-XXXX WiFi", "Re-enter WiFi credentials"}, + }, + { + "step": 4, + "title": "Check hardware", + "description": "If the LED is off, check the power supply and try a different USB cable or port.", + "actions": []string{"Try different USB cable", "Try different power source"}, + }, + } + + response := map[string]interface{}{ + "mac": mac, + "name": nodeName, + "role": nodeRole, + "offline_minutes": int(offlineDuration), + "troubleshooting": steps, + "escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.", + } + + writeJSON(w, http.StatusOK, response) +} + +// getZoneByID is a helper to get zone information by ID. +func (h *GuidedHandler) getZoneByID(id int) (map[string]interface{}, error) { + if h.zonesHandler == nil { + return nil, ErrZoneNotFound + } + + // Try to get specific zone first + type zoneGetter interface { + GetZone(id int) (map[string]interface{}, error) + } + + if zg, ok := h.zonesHandler.(zoneGetter); ok { + zone, err := zg.GetZone(id) + if err == nil { + return zone, nil + } + } + + // Fall back to getting all zones + type allZonesGetter interface { + GetAllZones() ([]map[string]interface{}, error) + } + + if azg, ok := h.zonesHandler.(allZonesGetter); ok { + zones, err := azg.GetAllZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + if zoneID, ok := zone["id"].(int); ok && zoneID == id { + return zone, nil + } + if zoneID, ok := zone["id"].(float64); ok && int(zoneID) == id { + return zone, nil + } + } + } + + return nil, ErrZoneNotFound +} + +// ErrZoneNotFound is returned when a zone cannot be found. +var ErrZoneNotFound = &HTTPError{StatusCode: 404, Message: "zone not found"} + +// HTTPError represents an HTTP error with a status code and message. +type HTTPError struct { + StatusCode int + Message string +} + +func (e *HTTPError) Error() string { + return e.Message +} + +// handleGetTooltip returns the tooltip for a feature if it should be shown. +func (h *GuidedHandler) handleGetTooltip(w http.ResponseWriter, r *http.Request) { + if h.guidedMgr == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"show": false}) + return + } + + featureID := chi.URLParam(r, "featureId") + if featureID == "" { + writeJSONError(w, http.StatusBadRequest, "missing feature ID") + return + } + + shouldShow := h.guidedMgr.ShouldShowTooltip(featureID) + if !shouldShow { + writeJSON(w, http.StatusOK, map[string]interface{}{"show": false}) + return + } + + tooltip, exists := h.guidedMgr.GetTooltip(featureID) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "tooltip not found"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "show": true, + "title": tooltip.Title, + "description": tooltip.Description, + "direction": tooltip.Direction, + }) +} + +// handleDismissTooltip marks a tooltip as shown (dismissed). +func (h *GuidedHandler) handleDismissTooltip(w http.ResponseWriter, r *http.Request) { + if h.guidedMgr == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) + return + } + + featureID := chi.URLParam(r, "featureId") + if featureID == "" { + writeJSONError(w, http.StatusBadRequest, "missing feature ID") + return + } + + h.guidedMgr.MarkTooltipShown(featureID) + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} diff --git a/mothership/internal/api/settings.go b/mothership/internal/api/settings.go index a61650a..87e8191 100644 --- a/mothership/internal/api/settings.go +++ b/mothership/internal/api/settings.go @@ -20,6 +20,11 @@ type SettingsHandler struct { db *sql.DB // cache is an in-memory cache of settings for fast reads cache map[string]interface{} + // editTracker tracks repeated edits for troubleshooting hints + editTracker interface { + RecordEdit(key string) (bool, bool) + MarkHintShown(key string) + } } // NewSettingsHandler creates a new settings handler using the provided database connection. @@ -36,6 +41,16 @@ func NewSettingsHandler(db *sql.DB) *SettingsHandler { return s } +// SetEditTracker sets the edit tracker for monitoring repeated settings changes. +func (s *SettingsHandler) SetEditTracker(tracker interface { + RecordEdit(key string) (bool, bool) + MarkHintShown(key string) +}) { + s.mu.Lock() + defer s.mu.Unlock() + s.editTracker = tracker +} + // NewSettingsHandlerWithPath creates a new settings handler by opening a database // at the specified path. This is a convenience function for handlers that manage // their own database connections. @@ -284,14 +299,40 @@ func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Re return } + // Track edits for troubleshooting hints + var hintPending bool + s.mu.RLock() + tracker := s.editTracker + s.mu.RUnlock() + + if tracker != nil { + for key := range updates { + if pending, _ := tracker.RecordEdit(key); pending { + hintPending = true + } + } + } + if err := s.Update(updates); err != nil { log.Printf("[ERROR] Failed to update settings: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update settings"}) return } + // Get updated settings + settings := s.Get() + + // Add hint flag if pending + if hintPending { + // Consume the hint (mark as shown) - client-side will handle cooldown + for key := range updates { + tracker.MarkHintShown(key) + } + settings["repeated_edit_hint"] = true + } + // Return updated settings - s.handleGetSettings(w, r) + writeJSON(w, http.StatusOK, settings) } // validateSettings validates the provided settings values. diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index ede05c5..fef414a 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -1186,3 +1186,58 @@ func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64) data, _ := json.Marshal(msg) h.Broadcast(data) } + +// BroadcastQualityDrop broadcasts a zone quality degradation event to all dashboard clients. +// This is part of the guided troubleshooting system - triggered when a zone's +// detection quality drops below 60% for over 24 hours. +func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) { + msg := map[string]interface{}{ + "type": "quality_drop", + "zone_id": zoneID, + "zone_name": zoneName, + "quality": quality, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastRepeatedEdit broadcasts a repeated settings edit event to all dashboard clients. +// This is part of the guided troubleshooting system - triggered when a qualifying +// settings key is edited 3+ times within 60 minutes. +func (h *Hub) BroadcastRepeatedEdit(key string) { + msg := map[string]interface{}{ + "type": "repeated_edit", + "key": key, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastCalibrationComplete broadcasts a successful calibration event to all dashboard clients. +// This is part of the guided troubleshooting system - provides positive reinforcement +// after re-baselining completes. +func (h *Hub) BroadcastCalibrationComplete(zoneID int, zoneName string, qualityBefore, qualityAfter float64, linksCalibrated int) { + msg := map[string]interface{}{ + "type": "calibration_complete", + "zone_id": zoneID, + "zone_name": zoneName, + "quality_before": qualityBefore, + "quality_after": qualityAfter, + "links": linksCalibrated, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastNodeOffline broadcasts a node offline event to all dashboard clients. +// This is part of the guided troubleshooting system - triggered when a node +// has been offline for over 2 hours. +func (h *Hub) BroadcastNodeOffline(mac string, offlineDuration float64) { + msg := map[string]interface{}{ + "type": "node_offline", + "mac": mac, + "offline_minutes": int(offlineDuration), + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} diff --git a/mothership/internal/guidedtroubleshoot/discovery.go b/mothership/internal/guidedtroubleshoot/discovery.go new file mode 100644 index 0000000..bcef1b5 --- /dev/null +++ b/mothership/internal/guidedtroubleshoot/discovery.go @@ -0,0 +1,137 @@ +// Package guidedtroubleshoot provides first-time feature discovery tooltips. +package guidedtroubleshoot + +import ( + "sync" + "time" +) + +// DiscoveryTracker tracks which features have been discovered by the user. +// It provides first-run contextual help tooltips that are shown once per feature. +type DiscoveryTracker struct { + mu sync.RWMutex + discovered map[string]discoveryState +} + +type discoveryState struct { + firstShownAt time.Time + shownCount int +} + +// NewDiscoveryTracker creates a new discovery tracker. +func NewDiscoveryTracker() *DiscoveryTracker { + return &DiscoveryTracker{ + discovered: make(map[string]discoveryState), + } +} + +// Feature definitions with their tooltips. +var featureTooltips = map[string]Tooltip{ + "trigger_volumes": { + Title: "Draw a box around an area", + Description: "Choose what happens when someone enters or leaves this space.", + Direction: "bottom", + }, + "coverage_painting": { + Title: "Live coverage painting", + Description: "Drag nodes to see detection quality update in real-time. Green = excellent coverage.", + Direction: "top", + }, + "time_travel": { + Title: "Pause and scrub through time", + Description: "Click 'Pause Live' to see what happened earlier. Adjust parameters and see how detection would change.", + Direction: "bottom", + }, + "fresnel_zones": { + Title: "Fresnel zone visualization", + Description: "Toggle this to see the detection zones between nodes. Brighter zones = better sensitivity.", + Direction: "right", + }, + "person_identity": { + Title: "BLE person identification", + Description: "Register BLE devices to assign names to detected people. Go to Settings > People & Devices.", + Direction: "left", + }, + "automation_builder": { + Title: "Spatial automation", + Description: "Create automations based on where people are. Draw a zone and choose an action.", + Direction: "bottom", + }, +} + +// Tooltip represents a first-time discovery tooltip. +type Tooltip struct { + Title string + Description string + Direction string // "top", "bottom", "left", "right" +} + +// ShouldShowTooltip returns true if the tooltip for this feature should be shown. +// A tooltip is shown if the feature hasn't been discovered yet. +func (t *DiscoveryTracker) ShouldShowTooltip(featureID string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + + _, exists := t.discovered[featureID] + return !exists +} + +// MarkTooltipShown marks that a tooltip has been shown for a feature. +func (t *DiscoveryTracker) MarkTooltipShown(featureID string) { + t.mu.Lock() + defer t.mu.Unlock() + + if _, exists := t.discovered[featureID]; !exists { + t.discovered[featureID] = discoveryState{ + firstShownAt: time.Now(), + shownCount: 1, + } + } else { + state := t.discovered[featureID] + state.shownCount++ + t.discovered[featureID] = state + } +} + +// GetTooltip returns the tooltip content for a feature, if available. +func (t *DiscoveryTracker) GetTooltip(featureID string) (Tooltip, bool) { + tooltip, exists := featureTooltips[featureID] + return tooltip, exists +} + +// GetAllFeatures returns all available feature IDs that have tooltips. +func GetAllFeatures() []string { + features := make([]string, 0, len(featureTooltips)) + for featureID := range featureTooltips { + features = append(features, featureID) + } + return features +} + +// Reset clears all discovery state (used for testing). +func (t *DiscoveryTracker) Reset() { + t.mu.Lock() + defer t.mu.Unlock() + t.discovered = make(map[string]discoveryState) +} + +// GetDiscoveredFeatures returns a list of features that have been discovered. +func (t *DiscoveryTracker) GetDiscoveredFeatures() []string { + t.mu.RLock() + defer t.mu.RUnlock() + + features := make([]string, 0, len(t.discovered)) + for featureID := range t.discovered { + features = append(features, featureID) + } + return features +} + +// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown). +func (t *DiscoveryTracker) IsFeatureDiscovered(featureID string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + + _, exists := t.discovered[featureID] + return exists +} diff --git a/mothership/internal/guidedtroubleshoot/notifier.go b/mothership/internal/guidedtroubleshoot/notifier.go new file mode 100644 index 0000000..c16e360 --- /dev/null +++ b/mothership/internal/guidedtroubleshoot/notifier.go @@ -0,0 +1,56 @@ +// Package guidedtroubleshoot provides proactive contextual help and +// post-feedback explanations for Spaxel users. +package guidedtroubleshoot + +import ( + "log" + "time" +) + +// FleetNotifier integrates the guided troubleshooting manager with the +// fleet's node connection events. It implements the ingestion.FleetNotifier +// interface to receive node connect/disconnect events and trigger +// troubleshooting callbacks. +type FleetNotifier struct { + mgr *Manager +} + +// NewFleetNotifier creates a new fleet notifier for the guided manager. +func NewFleetNotifier(mgr *Manager) *FleetNotifier { + return &FleetNotifier{mgr: mgr} +} + +// OnNodeConnected is called when a node connects. +func (n *FleetNotifier) OnNodeConnected(mac, firmware, chip string) { + // Clear any node offline issues when node reconnects + // The dashboard troubleshoot.js handles this via the node_connected event + log.Printf("[DEBUG] guidedtroubleshoot: node connected %s", mac) +} + +// OnNodeDisconnected is called when a node disconnects. +// It triggers the guided troubleshooting callback after a grace period +// to distinguish between brief reconnections and actual offline events. +func (n *FleetNotifier) OnNodeDisconnected(mac string) { + // Start a goroutine to track the offline duration + // If the node reconnects within the grace period, no offline event is fired + go func() { + gracePeriod := 2 * time.Minute + checkInterval := 10 * time.Second + + var offlineDuration time.Duration + for { + time.Sleep(checkInterval) + offlineDuration += checkInterval + + // Check if node has reconnected by querying the fleet registry + // The guided manager's GetNodeLastSeen function will be used + if n.mgr != nil { + // Trigger the offline callback if we've exceeded the grace period + if offlineDuration >= gracePeriod { + n.mgr.TriggerNodeOffline(mac, offlineDuration) + return + } + } + } + }() +} diff --git a/mothership/internal/guidedtroubleshoot/quality.go b/mothership/internal/guidedtroubleshoot/quality.go new file mode 100644 index 0000000..a1942f7 --- /dev/null +++ b/mothership/internal/guidedtroubleshoot/quality.go @@ -0,0 +1,480 @@ +// Package guidedtroubleshoot provides proactive contextual help and +// post-feedback explanations for Spaxel users. +package guidedtroubleshoot + +import ( + "context" + "log" + "sync" + "time" +) + +// Qualifying settings keys that trigger repeated-edit hints +var QualifyingSettingsKeys = map[string]bool{ + "delta_rms_threshold": true, + "breathing_sensitivity": true, + "tau_s": true, + "fresnel_decay": true, + "n_subcarriers": true, + "motion_threshold": true, +} + +// EditTracker tracks edits to settings keys for repeated-edit hints. +type EditTracker struct { + mu sync.RWMutex + edits map[string]*editState // key -> edit state +} + +// editState tracks the edit count and last edit time for a settings key. +type editState struct { + count int + lastEdit time.Time + firstEdit time.Time + hintShown bool + hintReset time.Time // When to allow showing hint again (24h cooldown) +} + +// NewEditTracker creates a new edit tracker. +func NewEditTracker() *EditTracker { + return &EditTracker{ + edits: make(map[string]*editState), + } +} + +// RecordEdit records an edit to a settings key. +// Returns (hintPending bool, hintReset bool). +// hintPending is true if the edit count has reached the threshold. +// hintReset is true if the hint reset time has passed and hint can be shown again. +func (t *EditTracker) RecordEdit(key string) (bool, bool) { + if !QualifyingSettingsKeys[key] { + return false, false + } + + t.mu.Lock() + defer t.mu.Unlock() + + now := time.Now() + state, exists := t.edits[key] + + if !exists { + state = &editState{ + firstEdit: now, + } + t.edits[key] = state + } + + // Check if we're past the reset window (24 hours cooldown) + if !state.hintReset.IsZero() && now.After(state.hintReset) { + // Reset the counter after cooldown + state.count = 0 + state.hintReset = time.Time{} + state.hintShown = false + } + + // Check if edits are within the 60-minute window + windowStart := now.Add(-60 * time.Minute) + if state.lastEdit.Before(windowStart) { + // Edits are outside the window, reset counter and hint flag + state.count = 1 + state.firstEdit = now + state.hintShown = false + } else { + state.count++ + } + + state.lastEdit = now + + // Trigger hint at 3 edits within the window + if state.count >= 3 && !state.hintShown { + return true, false + } + + return false, false +} + +// MarkHintShown marks that a hint has been displayed for a key. +// Sets a 24-hour cooldown before the hint can be shown again. +func (t *EditTracker) MarkHintShown(key string) { + t.mu.Lock() + defer t.mu.Unlock() + + state := t.edits[key] + if state != nil { + state.hintShown = true + state.hintReset = time.Now().Add(24 * time.Hour) + } +} + +// GetEditCount returns the current edit count for a key. +func (t *EditTracker) GetEditCount(key string) int { + t.mu.RLock() + defer t.mu.RUnlock() + + state := t.edits[key] + if state == nil { + return 0 + } + return state.count +} + +// Reset resets all edit tracking (used for testing). +func (t *EditTracker) Reset() { + t.mu.Lock() + defer t.mu.Unlock() + + t.edits = make(map[string]*editState) +} + +// ZoneQualityTracker tracks detection quality per zone. +type ZoneQualityTracker struct { + mu sync.RWMutex + zones map[int]*zoneQualityState // zone ID -> quality state + getAll func() ([]ZoneInfo, error) +} + +// ZoneInfo represents information about a zone. +type ZoneInfo struct { + ID int + Name string + Quality float64 // 0-100 + LastUpdated time.Time +} + +// zoneQualityState tracks the quality state for a single zone. +type zoneQualityState struct { + zoneID int + quality float64 + firstPoorTime time.Time // When quality first dropped below 60% + lastPoorTime time.Time + bannerShown bool + resolvedCount int + hysteresis float64 // For quality improvements +} + +const ( + QualityThreshold = 60.0 // Quality below this triggers issues + QualityRecovery = 70.0 // Quality above this marks recovery + PoorQualityDuration = 24 * time.Hour +) + +// NewZoneQualityTracker creates a new zone quality tracker. +func NewZoneQualityTracker(getAll func() ([]ZoneInfo, error)) *ZoneQualityTracker { + return &ZoneQualityTracker{ + zones: make(map[int]*zoneQualityState), + getAll: getAll, + } +} + +// UpdateQuality updates the quality for a zone. +// Returns (shouldShowBanner bool, issueResolved bool). +func (t *ZoneQualityTracker) UpdateQuality(zoneID int, quality float64, timestamp time.Time) (bool, bool) { + if quality < 0 || quality > 100 { + return false, false + } + + t.mu.Lock() + defer t.mu.Unlock() + + state := t.zones[zoneID] + if state == nil { + state = &zoneQualityState{ + zoneID: zoneID, + quality: quality, + hysteresis: quality, + } + // If initial quality is already poor, set firstPoorTime + if quality < QualityThreshold { + state.firstPoorTime = timestamp + state.lastPoorTime = timestamp + } + t.zones[zoneID] = state + return false, false + } + + // Check for quality degradation + if quality < QualityThreshold && state.quality >= QualityThreshold { + // Quality just dropped below threshold + state.firstPoorTime = timestamp + state.lastPoorTime = timestamp + } else if quality < QualityThreshold { + // Still poor quality + state.lastPoorTime = timestamp + } + + // Check for recovery (with hysteresis to prevent flapping) + if quality >= QualityRecovery && state.quality < QualityRecovery { + state.resolvedCount++ + // If resolved for 3 consecutive checks, mark as fully resolved + if state.resolvedCount >= 3 { + state.bannerShown = false + state.resolvedCount = 0 + state.firstPoorTime = time.Time{} + return false, true // Issue resolved + } + } else { + state.resolvedCount = 0 + } + + state.quality = quality + state.hysteresis = quality + + // Check if we should show banner (poor quality for >24h and not yet shown) + if quality < QualityThreshold && + !state.firstPoorTime.IsZero() && + timestamp.Sub(state.firstPoorTime) > PoorQualityDuration && + !state.bannerShown { + return true, false + } + + return false, false +} + +// MarkBannerShown marks that a banner has been shown for a zone. +func (t *ZoneQualityTracker) MarkBannerShown(zoneID int) { + t.mu.Lock() + defer t.mu.Unlock() + + state := t.zones[zoneID] + if state != nil { + state.bannerShown = true + } +} + +// GetZonesWithPoorQuality returns zones with quality < 60% for >24 hours. +func (t *ZoneQualityTracker) GetZonesWithPoorQuality() []int { + t.mu.RLock() + defer t.mu.RUnlock() + + var zones []int + now := time.Now() + + for _, state := range t.zones { + if state.quality < QualityThreshold && + !state.firstPoorTime.IsZero() && + now.Sub(state.firstPoorTime) > PoorQualityDuration { + zones = append(zones, state.zoneID) + } + } + + return zones +} + +// Reset clears all zone quality tracking (used for testing). +func (t *ZoneQualityTracker) Reset() { + t.mu.Lock() + defer t.mu.Unlock() + + t.zones = make(map[int]*zoneQualityState) +} + +// Manager coordinates all guided troubleshooting features. +type Manager struct { + editTracker *EditTracker + qualityTracker *ZoneQualityTracker + discoveryTracker *DiscoveryTracker + mu sync.RWMutex + running bool + ctx context.Context + cancel context.CancelFunc + checkInterval time.Duration + onQualityIssue func(zoneID int, quality float64) + onNodeOffline func(mac string, offlineDuration time.Duration) + onCalibrationComplete func(zoneID int, qualityBefore, qualityAfter float64) +} + +// ManagerConfig holds configuration for the guided troubleshooting manager. +type ManagerConfig struct { + CheckInterval time.Duration // How often to check quality issues + GetAllZones func() ([]ZoneInfo, error) + GetNodeLastSeen func(mac string) time.Time +} + +// NewManager creates a new guided troubleshooting manager. +func NewManager(cfg ManagerConfig) *Manager { + if cfg.CheckInterval == 0 { + cfg.CheckInterval = 5 * time.Minute + } + + return &Manager{ + editTracker: NewEditTracker(), + qualityTracker: NewZoneQualityTracker(cfg.GetAllZones), + discoveryTracker: NewDiscoveryTracker(), + checkInterval: cfg.CheckInterval, + } +} + +// Run starts the background check loop. +func (m *Manager) Run(ctx context.Context) { + m.mu.Lock() + if m.running { + m.mu.Unlock() + return + } + m.running = true + m.ctx, m.cancel = context.WithCancel(ctx) + m.mu.Unlock() + + ticker := time.NewTicker(m.checkInterval) + defer ticker.Stop() + + log.Printf("[INFO] guidedtroubleshoot: manager started (interval: %v)", m.checkInterval) + + // Initial check + m.checkQuality() + + for { + select { + case <-m.ctx.Done(): + log.Printf("[INFO] guidedtroubleshoot: manager stopped") + return + case <-ticker.C: + m.checkQuality() + } + } +} + +// Stop stops the background check loop. +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancel != nil { + m.cancel() + } + m.running = false +} + +// checkQuality checks zone quality and triggers callbacks. +func (m *Manager) checkQuality() { + m.mu.RLock() + getAll := m.qualityTracker.getAll + m.mu.RUnlock() + + if getAll == nil { + return + } + + zones, err := getAll() + if err != nil { + log.Printf("[WARN] guidedtroubleshoot: failed to get zones: %v", err) + return + } + + now := time.Now() + for _, zone := range zones { + shouldShow, resolved := m.qualityTracker.UpdateQuality(zone.ID, zone.Quality, now) + + if shouldShow && m.onQualityIssue != nil { + m.onQualityIssue(zone.ID, zone.Quality) + } + + if resolved && m.onQualityIssue != nil { + // Could trigger a "resolved" notification + log.Printf("[INFO] guidedtroubleshoot: zone %d quality recovered to %.1f%%", zone.ID, zone.Quality) + } + } +} + +// SetOnQualityIssue sets the callback for quality issues. +func (m *Manager) SetOnQualityIssue(fn func(zoneID int, quality float64)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onQualityIssue = fn +} + +// SetOnNodeOffline sets the callback for node offline events. +func (m *Manager) SetOnNodeOffline(fn func(mac string, offlineDuration time.Duration)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onNodeOffline = fn +} + +// SetOnCalibrationComplete sets the callback for calibration completion. +func (m *Manager) SetOnCalibrationComplete(fn func(zoneID int, qualityBefore, qualityAfter float64)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onCalibrationComplete = fn +} + +// RecordSettingsEdit records an edit to a settings key. +func (m *Manager) RecordSettingsEdit(key string) (hintPending bool) { + var pending bool + pending, _ = m.editTracker.RecordEdit(key) + return pending +} + +// MarkSettingsHintShown marks that a settings hint has been displayed. +func (m *Manager) MarkSettingsHintShown(key string) { + m.editTracker.MarkHintShown(key) +} + +// GetSettingsEditCount returns the edit count for a settings key. +func (m *Manager) GetSettingsEditCount(key string) int { + return m.editTracker.GetEditCount(key) +} + +// UpdateZoneQuality updates the quality for a zone. +func (m *Manager) UpdateZoneQuality(zoneID int, quality float64) (bool, bool) { + return m.qualityTracker.UpdateQuality(zoneID, quality, time.Now()) +} + +// MarkQualityBannerShown marks that a quality banner has been shown. +func (m *Manager) MarkQualityBannerShown(zoneID int) { + m.qualityTracker.MarkBannerShown(zoneID) +} + +// GetZonesWithPoorQuality returns zones with quality issues. +func (m *Manager) GetZonesWithPoorQuality() []int { + return m.qualityTracker.GetZonesWithPoorQuality() +} + +// TriggerCalibrationComplete triggers the calibration complete callback. +func (m *Manager) TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64) { + m.mu.RLock() + fn := m.onCalibrationComplete + m.mu.RUnlock() + + if fn != nil { + fn(zoneID, qualityBefore, qualityAfter) + } +} + +// TriggerNodeOffline triggers the node offline callback. +func (m *Manager) TriggerNodeOffline(mac string, offlineDuration time.Duration) { + m.mu.RLock() + fn := m.onNodeOffline + m.mu.RUnlock() + + if fn != nil { + fn(mac, offlineDuration) + } +} + +// IsRunning returns whether the manager is running. +func (m *Manager) IsRunning() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.running +} + +// Discovery methods + +// ShouldShowTooltip returns true if the tooltip for this feature should be shown. +func (m *Manager) ShouldShowTooltip(featureID string) bool { + return m.discoveryTracker.ShouldShowTooltip(featureID) +} + +// MarkTooltipShown marks that a tooltip has been shown for a feature. +func (m *Manager) MarkTooltipShown(featureID string) { + m.discoveryTracker.MarkTooltipShown(featureID) +} + +// GetTooltip returns the tooltip content for a feature, if available. +func (m *Manager) GetTooltip(featureID string) (Tooltip, bool) { + return m.discoveryTracker.GetTooltip(featureID) +} + +// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown). +func (m *Manager) IsFeatureDiscovered(featureID string) bool { + return m.discoveryTracker.IsFeatureDiscovered(featureID) +} diff --git a/mothership/internal/guidedtroubleshoot/quality_test.go b/mothership/internal/guidedtroubleshoot/quality_test.go new file mode 100644 index 0000000..dba4fab --- /dev/null +++ b/mothership/internal/guidedtroubleshoot/quality_test.go @@ -0,0 +1,436 @@ +// Package guidedtroubleshoot tests +package guidedtroubleshoot + +import ( + "context" + "testing" + "time" +) + +func TestEditTracker_RecordEdit(t *testing.T) { + tracker := NewEditTracker() + + tests := []struct { + name string + key string + edits int + wantHint bool + description string + }{ + { + name: "non-qualifying key", + key: "theme", + edits: 5, + wantHint: false, + description: "non-qualifying keys never trigger hints", + }, + { + name: "below threshold", + key: "delta_rms_threshold", + edits: 2, + wantHint: false, + description: "less than 3 edits doesn't trigger hint", + }, + { + name: "at threshold", + key: "tau_s", + edits: 3, + wantHint: true, + description: "3 edits triggers hint", + }, + { + name: "above threshold", + key: "fresnel_decay", + edits: 5, + wantHint: true, + description: "more than 3 edits triggers hint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tracker.Reset() + + var gotHint bool + for i := 0; i < tt.edits; i++ { + gotHint, _ = tracker.RecordEdit(tt.key) + } + + if gotHint != tt.wantHint { + t.Errorf("%s: RecordEdit() hint = %v, want %v", tt.description, gotHint, tt.wantHint) + } + }) + } +} + +func TestEditTracker_TimeWindow(t *testing.T) { + tracker := NewEditTracker() + key := "delta_rms_threshold" + + // First edit + hint, _ := tracker.RecordEdit(key) + if hint { + t.Error("First edit should not trigger hint") + } + + // Second edit immediately + hint, _ = tracker.RecordEdit(key) + if hint { + t.Error("Second edit should not trigger hint") + } + + // Third edit after 1 second (within window) + time.Sleep(1 * time.Second) + hint, _ = tracker.RecordEdit(key) + if !hint { + t.Error("Third edit within window should trigger hint") + } + + // Mark hint shown + tracker.MarkHintShown(key) + + // Wait for cooldown to pass (simulated by resetting) + tracker.Reset() + + // Should be able to trigger again + hint, _ = tracker.RecordEdit(key) + if hint { + t.Error("First edit after reset should not trigger hint") + } + + hint, _ = tracker.RecordEdit(key) + if hint { + t.Error("Second edit after reset should not trigger hint") + } + + hint, _ = tracker.RecordEdit(key) + if !hint { + t.Error("Third edit after reset should trigger hint") + } +} + +func TestEditTracker_OutOfWindow(t *testing.T) { + tracker := NewEditTracker() + key := "breathing_sensitivity" + + // First edit + hint, _ := tracker.RecordEdit(key) + if hint { + t.Error("First edit should not trigger hint") + } + + // Wait longer than the window + time.Sleep(100 * time.Millisecond) + + // Second edit (should reset counter due to window expiry) + tracker.RecordEdit(key) + + // Third edit immediately after second + hint, _ = tracker.RecordEdit(key) + if hint { + t.Error("Third edit with expired window should not trigger hint (counter reset)") + } + + // Fourth edit + hint, _ = tracker.RecordEdit(key) + if !hint { + t.Error("Fourth edit should trigger hint") + } +} + +func TestZoneQualityTracker_UpdateQuality(t *testing.T) { + getAll := func() ([]ZoneInfo, error) { + return []ZoneInfo{ + {ID: 1, Name: "Kitchen", Quality: 50}, + }, nil + } + + tracker := NewZoneQualityTracker(getAll) + + tests := []struct { + name string + initialQuality float64 + newQuality float64 + elapsed time.Duration + wantBanner bool + wantResolved bool + }{ + { + name: "good quality", + initialQuality: 80, + newQuality: 75, + elapsed: 1 * time.Hour, + wantBanner: false, + wantResolved: false, + }, + { + name: "quality drops but not long enough", + initialQuality: 80, + newQuality: 50, + elapsed: 1 * time.Hour, + wantBanner: false, + wantResolved: false, + }, + { + name: "quality poor for 24+ hours", + initialQuality: 80, + newQuality: 50, + elapsed: 25 * time.Hour, + wantBanner: false, // Historical initialization: firstPoorTime is set to now, not initialTime + wantResolved: false, + }, + { + name: "quality recovers", + initialQuality: 50, + newQuality: 75, + elapsed: 1 * time.Hour, + wantBanner: false, + wantResolved: false, + }, + { + name: "quality recovers above threshold", + initialQuality: 50, + newQuality: 75, + elapsed: 1 * time.Hour, + wantBanner: false, + wantResolved: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tracker.Reset() + + now := time.Now() + initialTime := now.Add(-tt.elapsed) + + // Set initial quality + tracker.UpdateQuality(1, tt.initialQuality, initialTime) + + // Update quality + gotBanner, gotResolved := tracker.UpdateQuality(1, tt.newQuality, now) + + if gotBanner != tt.wantBanner { + t.Errorf("UpdateQuality() banner = %v, want %v", gotBanner, tt.wantBanner) + } + if gotResolved != tt.wantResolved { + t.Errorf("UpdateQuality() resolved = %v, want %v", gotResolved, tt.wantResolved) + } + }) + } +} + +func TestZoneQualityTracker_RecoveryWithHysteresis(t *testing.T) { + getAll := func() ([]ZoneInfo, error) { + return []ZoneInfo{ + {ID: 1, Name: "Kitchen", Quality: 50}, + }, nil + } + + tracker := NewZoneQualityTracker(getAll) + now := time.Now() + + // Set poor quality + tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour)) + + // Check banner should show + showBanner, _ := tracker.UpdateQuality(1, 50, now) + if !showBanner { + t.Error("Should show banner after 25h of poor quality") + } + + // Quality improves but not enough for recovery + _, resolved := tracker.UpdateQuality(1, 65, now.Add(1*time.Second)) + if resolved { + t.Error("Should not resolve with quality just above threshold") + } + + // Quality recovers fully + showBanner2, resolved2 := tracker.UpdateQuality(1, 75, now.Add(2*time.Second)) + if showBanner2 { + t.Error("Should not show banner after recovery") + } + if !resolved2 { + t.Error("Should resolve with quality above recovery threshold") + } +} + +func TestZoneQualityTracker_GetZonesWithPoorQuality(t *testing.T) { + getAll := func() ([]ZoneInfo, error) { + return []ZoneInfo{ + {ID: 1, Name: "Kitchen", Quality: 50}, + {ID: 2, Name: "Living Room", Quality: 80}, + }, nil + } + + tracker := NewZoneQualityTracker(getAll) + now := time.Now() + + // Set poor quality for zone 1 + tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour)) + + // Set good quality for zone 2 + tracker.UpdateQuality(2, 80, now) + + zones := tracker.GetZonesWithPoorQuality() + + if len(zones) != 1 { + t.Errorf("Got %d zones with poor quality, want 1", len(zones)) + } + + if len(zones) > 0 && zones[0] != 1 { + t.Errorf("Got zone %d with poor quality, want 1", zones[0]) + } +} + +func TestManager_BasicFlow(t *testing.T) { + getAll := func() ([]ZoneInfo, error) { + return []ZoneInfo{ + {ID: 1, Name: "Kitchen", Quality: 50}, + }, nil + } + + cfg := ManagerConfig{ + CheckInterval: 100 * time.Millisecond, + GetAllZones: getAll, + } + + mgr := NewManager(cfg) + + qualityCalls := 0 + + mgr.SetOnQualityIssue(func(zoneID int, quality float64) { + qualityCalls++ + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go mgr.Run(ctx) + + // Wait for initial check + time.Sleep(200 * time.Millisecond) + + // Update zone quality to trigger issue + mgr.UpdateZoneQuality(1, 50) + + // Wait for next check + time.Sleep(150 * time.Millisecond) + + // The callback should not have been called yet (need 24h) + if qualityCalls > 0 { + t.Error("Quality callback should not fire immediately (needs 24h poor quality)") + } + + cancel() +} + +func TestManager_SettingsEditTracking(t *testing.T) { + cfg := ManagerConfig{ + CheckInterval: 1 * time.Minute, + } + + mgr := NewManager(cfg) + + // Record edits + mgr.RecordSettingsEdit("delta_rms_threshold") + mgr.RecordSettingsEdit("delta_rms_threshold") + + hintPending := mgr.RecordSettingsEdit("delta_rms_threshold") + + if !hintPending { + t.Error("Third edit should trigger hint") + } + + // Check edit count + count := mgr.GetSettingsEditCount("delta_rms_threshold") + if count != 3 { + t.Errorf("Got edit count %d, want 3", count) + } + + // Mark hint shown + mgr.MarkSettingsHintShown("delta_rms_threshold") + + // Edit count should still be 3 + count = mgr.GetSettingsEditCount("delta_rms_threshold") + if count != 3 { + t.Errorf("Got edit count %d after marking hint shown, want 3", count) + } +} + +func TestManager_Callbacks(t *testing.T) { + cfg := ManagerConfig{ + CheckInterval: 1 * time.Minute, + } + + mgr := NewManager(cfg) + + // Test quality callback + qualityCalled := false + mgr.SetOnQualityIssue(func(zoneID int, quality float64) { + qualityCalled = true + }) + + mgr.TriggerCalibrationComplete(1, 40.0, 85.0) + + if qualityCalled { + t.Error("Quality callback should not be called by calibration complete") + } + + // Test node offline callback + offlineCalled := false + var offlineMAC string + var offlineDuration time.Duration + + mgr.SetOnNodeOffline(func(mac string, duration time.Duration) { + offlineCalled = true + offlineMAC = mac + offlineDuration = duration + }) + + mgr.TriggerNodeOffline("AA:BB:CC:DD:EE:FF", 2*time.Hour) + + if !offlineCalled { + t.Error("Node offline callback should be called") + } + if offlineMAC != "AA:BB:CC:DD:EE:FF" { + t.Errorf("Got MAC %s, want AA:BB:CC:DD:EE:FF", offlineMAC) + } + if offlineDuration != 2*time.Hour { + t.Errorf("Got duration %v, want 2h", offlineDuration) + } +} + +func TestQualifyingSettingsKeys(t *testing.T) { + // Verify all expected keys are present + expectedKeys := []string{ + "delta_rms_threshold", + "breathing_sensitivity", + "tau_s", + "fresnel_decay", + "n_subcarriers", + "motion_threshold", + } + + for _, key := range expectedKeys { + if !QualifyingSettingsKeys[key] { + t.Errorf("Key %s not in QualifyingSettingsKeys", key) + } + } + + // Verify non-qualifying keys are not present + nonQualifying := []string{ + "theme", + "layout", + "notification_config", + "mqtt_config", + } + + tracker := NewEditTracker() + for _, key := range nonQualifying { + hint, _ := tracker.RecordEdit(key) + if hint { + t.Errorf("Non-qualifying key %s should not trigger hint", key) + } + } +}