When the server sets repeated_edit_hint:true, pick the most-changed qualifying setting from localStorage history instead of passing the literal string 'detected_by_server' into formatSettingName, which rendered as broken text in the hint banner.
2437 lines
82 KiB
JavaScript
2437 lines
82 KiB
JavaScript
/**
|
|
* Spaxel Dashboard - Proactive Quality Assistance
|
|
*
|
|
* Provides proactive prompts for:
|
|
* - Detection quality degradation warnings with root-cause analysis
|
|
* - Repeated-setting change detection (user struggling to tune)
|
|
* - Post-feedback explanations after false positive reports
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ===== State =====
|
|
let qualityPromptActive = false;
|
|
let qualityPromptLinkID = null;
|
|
let qualityPromptStartTime = null;
|
|
let dismissedQualityPrompts = new Set();
|
|
let pulsingAnimationID = null; // For link pulsing animation
|
|
let pulseTime = 0;
|
|
|
|
// Repeated-setting change tracking
|
|
let settingChangeHistory = JSON.parse(localStorage.getItem('spaxel_setting_changes') || '{}');
|
|
let settingChangeHintShown = JSON.parse(localStorage.getItem('spaxel_setting_hints_shown') || '{}');
|
|
|
|
// Settings that qualify for repeated-change detection
|
|
const QUALIFYING_SETTINGS = new Set([
|
|
'delta_rms_threshold',
|
|
'breathing_sensitivity',
|
|
'tau_s',
|
|
'fresnel_decay',
|
|
'n_subcarriers'
|
|
]);
|
|
|
|
// ===== Quality Prompt Detection =====
|
|
/**
|
|
* Monitor link quality and show prompts when quality drops below 0.6 for >5 minutes
|
|
*/
|
|
function monitorLinkQuality(links) {
|
|
if (!links || !Array.isArray(links)) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const QUALITY_THRESHOLD = 0.6;
|
|
const DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Find links with poor quality
|
|
let poorQualityLinks = links.filter(link => {
|
|
const quality = link.composite_score || link.quality || 0;
|
|
return quality < QUALITY_THRESHOLD;
|
|
});
|
|
|
|
if (poorQualityLinks.length === 0) {
|
|
// Quality recovered - clear any active prompt and tracking state
|
|
if (qualityPromptActive || qualityPromptLinkID) {
|
|
// Clear dismiss-for-today so the prompt can re-appear if condition reoccurs
|
|
if (qualityPromptLinkID) {
|
|
const today = new Date().toDateString();
|
|
const dismissKey = `${qualityPromptLinkID}_${today}`;
|
|
dismissedQualityPrompts.delete(dismissKey);
|
|
}
|
|
dismissQualityPrompt();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check each poor quality link
|
|
for (const link of poorQualityLinks) {
|
|
const linkID = link.link_id || (link.node_mac + ':' + link.peer_mac);
|
|
|
|
// Check if this link was already being tracked
|
|
if (qualityPromptLinkID === linkID && qualityPromptStartTime) {
|
|
const elapsed = now - qualityPromptStartTime;
|
|
if (elapsed >= DURATION_MS && !qualityPromptActive) {
|
|
// Show prompt - duration threshold met
|
|
showQualityPrompt(link);
|
|
}
|
|
} else if (!qualityPromptActive) {
|
|
// Only start tracking if we're not already showing a prompt for another link
|
|
// This prevents rapid switching between different degraded links
|
|
qualityPromptLinkID = linkID;
|
|
qualityPromptStartTime = now;
|
|
qualityPromptActive = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a quality degradation prompt card
|
|
*/
|
|
function showQualityPrompt(link) {
|
|
// Use consistent linkID computation (matches monitorLinkQuality)
|
|
const linkID = link.link_id || (link.node_mac + ':' + link.peer_mac);
|
|
|
|
// Check if already dismissed today
|
|
const today = new Date().toDateString();
|
|
const dismissKey = `${linkID}_${today}`;
|
|
if (dismissedQualityPrompts.has(dismissKey)) {
|
|
return;
|
|
}
|
|
|
|
qualityPromptActive = true;
|
|
|
|
const linkName = link.name || formatLinkID(linkID);
|
|
const qualityPercent = Math.round((link.composite_score || link.quality || 0) * 100);
|
|
|
|
// Remove existing prompt if present
|
|
const existing = document.getElementById('quality-prompt-card');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const prompt = document.createElement('div');
|
|
prompt.id = 'quality-prompt-card';
|
|
prompt.className = 'quality-prompt-card';
|
|
prompt.innerHTML = `
|
|
<div class="quality-prompt-header">
|
|
<span class="quality-prompt-icon">⚠️</span>
|
|
<span class="quality-prompt-title">Detection quality has dropped</span>
|
|
<button class="quality-prompt-close" onclick="Proactive.dismissQualityPrompt()">×</button>
|
|
</div>
|
|
<div class="quality-prompt-content">
|
|
<p>Detection quality has dropped on <strong>${linkName}</strong> to <strong>${qualityPercent}%</strong> reliability.</p>
|
|
<p class="quality-prompt-detail">This link is experiencing degraded performance, which may affect detection accuracy in this area.</p>
|
|
</div>
|
|
<div class="quality-prompt-actions">
|
|
<button class="quality-prompt-btn quality-prompt-diagnose" onclick="Proactive.diagnoseLink('${linkID}')">
|
|
<span class="btn-icon">🔍</span> Diagnose
|
|
</button>
|
|
<button class="quality-prompt-btn quality-prompt-dismiss" onclick="Proactive.dismissQualityPromptForToday()">
|
|
Dismiss for today
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Add to document
|
|
document.body.appendChild(prompt);
|
|
|
|
// Highlight the 3D link line if available
|
|
highlightLinkIn3D(linkID);
|
|
}
|
|
|
|
/**
|
|
* Dismiss the current quality prompt
|
|
*/
|
|
function dismissQualityPrompt() {
|
|
qualityPromptActive = false;
|
|
qualityPromptLinkID = null;
|
|
qualityPromptStartTime = null;
|
|
|
|
const prompt = document.getElementById('quality-prompt-card');
|
|
if (prompt) {
|
|
prompt.remove();
|
|
}
|
|
|
|
// Remove link highlight
|
|
unhighlightLinkIn3D();
|
|
}
|
|
|
|
/**
|
|
* Dismiss the quality prompt for today only
|
|
*/
|
|
function dismissQualityPromptForToday() {
|
|
if (qualityPromptLinkID) {
|
|
const today = new Date().toDateString();
|
|
const dismissKey = `${qualityPromptLinkID}_${today}`;
|
|
dismissedQualityPrompts.add(dismissKey);
|
|
|
|
// Persist to localStorage
|
|
try {
|
|
const dismissed = Array.from(dismissedQualityPrompts);
|
|
localStorage.setItem('spaxel_dismissed_quality_prompts', dismissed.join(','));
|
|
} catch (e) {
|
|
console.warn('Failed to save dismissed prompts:', e);
|
|
}
|
|
}
|
|
|
|
dismissQualityPrompt();
|
|
}
|
|
|
|
/**
|
|
* Load dismissed prompts from localStorage
|
|
*/
|
|
function loadDismissedPrompts() {
|
|
try {
|
|
const saved = localStorage.getItem('spaxel_dismissed_quality_prompts');
|
|
if (saved) {
|
|
const today = new Date().toDateString();
|
|
const all = saved.split(',').filter(id => id);
|
|
// Only keep today's entries to prevent stale data buildup
|
|
dismissedQualityPrompts = new Set(all.filter(key => key.endsWith(today)));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load dismissed prompts:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Highlight a link in the 3D view with pulsing amber animation
|
|
*/
|
|
function highlightLinkIn3D(linkID) {
|
|
// Store original state before highlighting
|
|
if (window.Viz3D && window.Viz3D.forEachLink) {
|
|
window.Viz3D.forEachLink(function(line, id) {
|
|
if (id === linkID && !line.userData.originalState) {
|
|
// Store original material state
|
|
line.userData.originalState = {
|
|
opacity: line.material.opacity,
|
|
transparent: line.material.transparent,
|
|
color: line.material.color ? line.material.color.getHex() : null,
|
|
emissiveColor: line.material.emissive ? line.material.emissive.getHex() : undefined,
|
|
emissiveIntensity: line.material.emissive ? line.material.emissiveIntensity : 0
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Use Viz3D highlightLink with amber color (0xff9800) and emissive glow
|
|
if (window.Viz3D && window.Viz3D.highlightLink) {
|
|
window.Viz3D.highlightLink(linkID, 0xff9800, 0xff9800, 0.5);
|
|
}
|
|
|
|
// Dispatch custom event
|
|
window.dispatchEvent(new CustomEvent('spaxel:highlight-link', {
|
|
detail: { linkID: linkID, highlight: true, pulsing: true }
|
|
}));
|
|
|
|
// Start pulsing animation for this link
|
|
startLinkPulsing(linkID);
|
|
}
|
|
|
|
/**
|
|
* Remove link highlight in 3D view
|
|
*/
|
|
function unhighlightLinkIn3D() {
|
|
const linkID = qualityPromptLinkID;
|
|
|
|
if (linkID && window.Viz3D) {
|
|
// Restore the link's original appearance using Viz3D's restore mechanism
|
|
if (window.Viz3D.forEachLink) {
|
|
window.Viz3D.forEachLink(function(line, id) {
|
|
if (id === linkID && line.userData.originalState) {
|
|
const orig = line.userData.originalState;
|
|
line.material.opacity = orig.opacity;
|
|
line.material.transparent = orig.transparent;
|
|
if (line.material.color && orig.color !== null) {
|
|
line.material.color.setHex(orig.color);
|
|
}
|
|
if (line.material.emissive) {
|
|
line.material.emissiveIntensity = orig.emissiveIntensity || 0;
|
|
if (orig.emissiveColor !== undefined) {
|
|
line.material.emissive.setHex(orig.emissiveColor);
|
|
}
|
|
}
|
|
line.material.needsUpdate = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
window.dispatchEvent(new CustomEvent('spaxel:highlight-link', {
|
|
detail: { linkID: linkID, highlight: false }
|
|
}));
|
|
}
|
|
|
|
// Stop pulsing animation
|
|
stopLinkPulsing();
|
|
}
|
|
|
|
/**
|
|
* Start pulsing animation for a highlighted link
|
|
*/
|
|
function startLinkPulsing(linkID) {
|
|
// Stop any existing animation
|
|
stopLinkPulsing();
|
|
|
|
pulseTime = 0;
|
|
const PULSE_CYCLE = 1.5; // 1.5 second pulse cycle
|
|
|
|
function animatePulse(timestamp) {
|
|
if (!qualityPromptActive || qualityPromptLinkID !== linkID) {
|
|
return; // Stop animation
|
|
}
|
|
|
|
if (!pulsingAnimationID) {
|
|
pulsingAnimationID = requestAnimationFrame(animatePulse);
|
|
return;
|
|
}
|
|
|
|
pulseTime += 0.016; // Approximately 60fps
|
|
|
|
// Calculate pulse phase (0 to 1)
|
|
const phase = (pulseTime % PULSE_CYCLE) / PULSE_CYCLE;
|
|
|
|
// Opacity oscillates: 0.3 -> 0.8 -> 0.3
|
|
const intensity = 0.3 + 0.5 * (1 - Math.abs(phase - 0.5) * 2);
|
|
|
|
// Apply pulsing effect to link
|
|
if (window.Viz3D && window.Viz3D.highlightLink) {
|
|
// Vary the emissive intensity for pulsing glow effect
|
|
const baseIntensity = 0.3;
|
|
const pulseIntensity = baseIntensity + (0.7 * intensity);
|
|
window.Viz3D.highlightLink(linkID, 0xff9800, 0xff9800, pulseIntensity);
|
|
}
|
|
|
|
pulsingAnimationID = requestAnimationFrame(animatePulse);
|
|
}
|
|
|
|
pulsingAnimationID = requestAnimationFrame(animatePulse);
|
|
}
|
|
|
|
/**
|
|
* Stop the link pulsing animation
|
|
*/
|
|
function stopLinkPulsing() {
|
|
if (pulsingAnimationID) {
|
|
cancelAnimationFrame(pulsingAnimationID);
|
|
pulsingAnimationID = null;
|
|
}
|
|
pulseTime = 0;
|
|
}
|
|
|
|
/**
|
|
* Run diagnostics for a link and show results
|
|
*/
|
|
function diagnoseLink(linkID) {
|
|
// Fetch diagnostic results from API
|
|
fetch(`/api/diagnostics/link/${encodeURIComponent(linkID)}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
showDiagnosticResults(linkID, data);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to fetch diagnostics:', err);
|
|
showDiagnosticError(linkID);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show diagnostic results in a slide-out panel
|
|
*/
|
|
function showDiagnosticResults(linkID, response) {
|
|
// Remove existing panel if present
|
|
const existing = document.getElementById('diagnostic-results-panel');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const panel = document.createElement('div');
|
|
panel.id = 'diagnostic-results-panel';
|
|
panel.className = 'diagnostic-results-panel';
|
|
|
|
// Handle both old format (diagnosis array) and new format (response with diagnosis + health)
|
|
const diagnosis = response.diagnosis || response[0] || {};
|
|
const health = response.health || diagnosis.health || null;
|
|
const hasDiagnosis = diagnosis.title !== undefined;
|
|
const severity = (diagnosis.severity || 'info').toLowerCase();
|
|
|
|
panel.innerHTML = `
|
|
<div class="diagnostic-panel-header">
|
|
<h3>Link Diagnostics</h3>
|
|
<button class="diagnostic-close" onclick="document.getElementById('diagnostic-results-panel').remove()">×</button>
|
|
</div>
|
|
<div class="diagnostic-panel-content">
|
|
<div class="diagnostic-link-info">
|
|
<span class="diagnostic-link-label">Link:</span>
|
|
<span class="diagnostic-link-id">${formatLinkID(linkID)}</span>
|
|
</div>
|
|
|
|
${hasDiagnosis ? `
|
|
<div class="diagnostic-result diagnostic-severity-${severity}">
|
|
<div class="diagnostic-result-title">${diagnosis.title}</div>
|
|
<div class="diagnostic-result-detail">${diagnosis.detail}</div>
|
|
${diagnosis.advice ? `<div class="diagnostic-result-advice"><strong>What to do:</strong> ${diagnosis.advice}</div>` : ''}
|
|
${diagnosis.confidence !== undefined ? `<div class="diagnostic-confidence">Confidence: ${Math.round(diagnosis.confidence * 100)}%</div>` : ''}
|
|
</div>
|
|
` : `
|
|
<div class="diagnostic-result diagnostic-severity-info">
|
|
<div class="diagnostic-result-title">No issues detected</div>
|
|
<div class="diagnostic-result-detail">The link health metrics appear normal. The quality drop may be temporary.</div>
|
|
</div>
|
|
`}
|
|
|
|
${health ? `
|
|
<div class="diagnostic-health-metrics">
|
|
<h4>Current Health Metrics</h4>
|
|
${renderHealthMetrics(health)}
|
|
</div>
|
|
` : ''}
|
|
|
|
${response.repositioning ? `
|
|
<div class="diagnostic-repositioning">
|
|
<h4>Suggested Repositioning</h4>
|
|
<p>Move <strong>${response.repositioning.node_mac}</strong> to:</p>
|
|
<div class="repositioning-coords">
|
|
X: ${response.repositioning.position.x.toFixed(1)}m,
|
|
Y: ${response.repositioning.position.y.toFixed(1)}m,
|
|
Z: ${response.repositioning.position.z.toFixed(1)}m
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(panel);
|
|
}
|
|
|
|
/**
|
|
* Show diagnostic error message
|
|
*/
|
|
function showDiagnosticError(linkID) {
|
|
const panel = document.createElement('div');
|
|
panel.id = 'diagnostic-results-panel';
|
|
panel.className = 'diagnostic-results-panel';
|
|
panel.innerHTML = `
|
|
<div class="diagnostic-panel-header">
|
|
<h3>Link Diagnostics</h3>
|
|
<button class="diagnostic-close" onclick="this.closest('#diagnostic-results-panel').remove()">×</button>
|
|
</div>
|
|
<div class="diagnostic-panel-content">
|
|
<p class="diagnostic-error">Unable to fetch diagnostic data. Please try again.</p>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(panel);
|
|
}
|
|
|
|
/**
|
|
* Render health metrics
|
|
*/
|
|
function renderHealthMetrics(health) {
|
|
if (!health) {
|
|
return '';
|
|
}
|
|
|
|
// Handle both snapshot format and report format
|
|
const snr = health.snr !== undefined ? health.snr : (health.SNR || 'N/A');
|
|
const phaseStability = health.phase_stability !== undefined ? health.phase_stability : (health.PhaseStability || 'N/A');
|
|
const packetRate = health.packet_rate !== undefined ? health.packet_rate : (health.PacketRate || 'N/A');
|
|
const driftRate = health.drift_rate !== undefined ? health.drift_rate : (health.DriftRate || 'N/A');
|
|
const compositeScore = health.composite_score !== undefined ? health.composite_score : (health.CompositeScore || 'N/A');
|
|
|
|
return `
|
|
<div class="health-metric">
|
|
<span class="metric-label">Packet Rate:</span>
|
|
<span class="metric-value">${typeof packetRate === 'number' ? packetRate.toFixed(1) : packetRate} Hz</span>
|
|
</div>
|
|
<div class="health-metric">
|
|
<span class="metric-label">SNR:</span>
|
|
<span class="metric-value">${typeof snr === 'number' ? snr.toFixed(2) : snr}</span>
|
|
</div>
|
|
<div class="health-metric">
|
|
<span class="metric-label">Phase Stability:</span>
|
|
<span class="metric-value">${typeof phaseStability === 'number' ? phaseStability.toFixed(2) : phaseStability}</span>
|
|
</div>
|
|
<div class="health-metric">
|
|
<span class="metric-label">Drift Rate:</span>
|
|
<span class="metric-value">${typeof driftRate === 'number' ? driftRate.toFixed(4) : driftRate}</span>
|
|
</div>
|
|
${typeof compositeScore === 'number' ? `
|
|
<div class="health-metric">
|
|
<span class="metric-label">Composite Score:</span>
|
|
<span class="metric-value">${Math.round(compositeScore * 100)}%</span>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
// ===== Repeated-Setting Change Detection =====
|
|
|
|
/**
|
|
* Track a settings change for repeated-change detection
|
|
*/
|
|
function trackSettingChange(settingKey, settingValue) {
|
|
if (!QUALIFYING_SETTINGS.has(settingKey)) {
|
|
return; // Not a qualifying setting
|
|
}
|
|
|
|
const now = Date.now();
|
|
const windowMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
// Initialize history for this setting if needed
|
|
if (!settingChangeHistory[settingKey]) {
|
|
settingChangeHistory[settingKey] = [];
|
|
}
|
|
|
|
// Add new change
|
|
settingChangeHistory[settingKey].push({
|
|
timestamp: now,
|
|
value: settingValue
|
|
});
|
|
|
|
// Remove old changes outside the 24h window
|
|
const cutoff = now - windowMs;
|
|
settingChangeHistory[settingKey] = settingChangeHistory[settingKey].filter(
|
|
change => change.timestamp > cutoff
|
|
);
|
|
|
|
// Save to localStorage
|
|
saveSettingHistory();
|
|
|
|
// Check if we should show a hint
|
|
checkSettingChangeHint(settingKey);
|
|
}
|
|
|
|
/**
|
|
* Check if a setting change hint should be shown
|
|
*/
|
|
function checkSettingChangeHint(settingKey) {
|
|
// Check if hint was already shown for this setting
|
|
if (settingChangeHintShown[settingKey]) {
|
|
const lastShown = settingChangeHintShown[settingKey];
|
|
const cooldownMs = 24 * 60 * 60 * 1000; // 24 hour cooldown
|
|
if (Date.now() - lastShown < cooldownMs) {
|
|
return; // Still in cooldown
|
|
}
|
|
}
|
|
|
|
const changes = settingChangeHistory[settingKey] || [];
|
|
if (changes.length >= 3) {
|
|
// Show the hint
|
|
showSettingChangeHint(settingKey);
|
|
settingChangeHintShown[settingKey] = Date.now();
|
|
saveSettingHintShown();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a hint for repeated setting changes
|
|
*/
|
|
function showSettingChangeHint(settingKey) {
|
|
const settingName = formatSettingName(settingKey);
|
|
|
|
// Remove existing hint if present
|
|
const existing = document.getElementById('setting-change-hint');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const hint = document.createElement('div');
|
|
hint.id = 'setting-change-hint';
|
|
hint.className = 'setting-change-hint';
|
|
hint.innerHTML = `
|
|
<div class="hint-header">
|
|
<span class="hint-icon">💡</span>
|
|
<span class="hint-title">Fine-tuning assistance</span>
|
|
<button class="hint-close" onclick="Proactive.dismissSettingHint()">×</button>
|
|
</div>
|
|
<div class="hint-content">
|
|
<p>Looks like you're fine-tuning the <strong>${settingName}</strong>. Would you like help finding the right value for your space?</p>
|
|
</div>
|
|
<div class="hint-actions">
|
|
<button class="hint-btn hint-btn-primary" onclick="Proactive.startCalibrationFlow('${settingKey}')">
|
|
Help me tune this
|
|
</button>
|
|
<button class="hint-btn hint-btn-secondary" onclick="Proactive.dismissSettingHint()">
|
|
No thanks
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(hint);
|
|
}
|
|
|
|
/**
|
|
* Dismiss the setting change hint
|
|
*/
|
|
function dismissSettingHint() {
|
|
const hint = document.getElementById('setting-change-hint');
|
|
if (hint) {
|
|
hint.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the guided calibration flow for a setting
|
|
*/
|
|
function startCalibrationFlow(settingKey) {
|
|
// Remove the hint
|
|
dismissSettingHint();
|
|
|
|
// Show the calibration flow modal
|
|
showCalibrationFlow(settingKey);
|
|
}
|
|
|
|
/**
|
|
* Show the guided calibration flow modal
|
|
*/
|
|
function showCalibrationFlow(settingKey) {
|
|
// Remove existing calibration modal if present
|
|
const existing = document.getElementById('calibration-flow-modal');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const settingName = formatSettingName(settingKey);
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'calibration-flow-modal';
|
|
modal.className = 'calibration-flow-modal';
|
|
modal.innerHTML = `
|
|
<div class="calibration-flow-backdrop"></div>
|
|
<div class="calibration-flow-container">
|
|
<div class="calibration-flow-header">
|
|
<h2>Guided Calibration: ${settingName}</h2>
|
|
<button class="calibration-close" onclick="Proactive.closeCalibrationFlow()">×</button>
|
|
</div>
|
|
<div class="calibration-flow-content" id="calibration-flow-content">
|
|
<!-- Steps will be rendered here -->
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Start with the introduction step
|
|
renderCalibrationStep(settingKey, 'intro');
|
|
}
|
|
|
|
/**
|
|
* Render a calibration step
|
|
*/
|
|
function renderCalibrationStep(settingKey, step, data = {}) {
|
|
const content = document.getElementById('calibration-flow-content');
|
|
if (!content) return;
|
|
|
|
const steps = {
|
|
intro: `
|
|
<div class="calibration-step calibration-step-intro">
|
|
<div class="calibration-icon">🎯</div>
|
|
<h3>Let's find the optimal value together</h3>
|
|
<p>I'll guide you through two quick tests to analyze your space and suggest the best ${formatSettingName(settingKey)} value.</p>
|
|
<div class="calibration-steps-overview">
|
|
<div class="overview-step">
|
|
<span class="overview-number">1</span>
|
|
<span class="overview-text">Walk around your room to test for false positives</span>
|
|
</div>
|
|
<div class="overview-step">
|
|
<span class="overview-number">2</span>
|
|
<span class="overview-text">Sit still to test for missed motion detection</span>
|
|
</div>
|
|
<div class="overview-step">
|
|
<span class="overview-number">3</span>
|
|
<span class="overview-text">I'll suggest an optimal value based on your space</span>
|
|
</div>
|
|
</div>
|
|
<button class="calibration-btn calibration-btn-primary" onclick="Proactive.startCalibrationStep('${settingKey}', 'false-positive-test')">
|
|
Start Calibration
|
|
</button>
|
|
</div>
|
|
`,
|
|
|
|
'false-positive-test': `
|
|
<div class="calibration-step calibration-step-active">
|
|
<div class="calibration-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="calibration-progress-fill" style="width: 0%"></div>
|
|
</div>
|
|
<span class="progress-text">Step 1 of 2</span>
|
|
</div>
|
|
<div class="calibration-icon">🚶</div>
|
|
<h3>Walk around your room</h3>
|
|
<p id="calibration-instruction">For the next <strong>15 seconds</strong>, walk around your room normally. This helps me understand your space's baseline signal patterns.</p>
|
|
<div class="calibration-timer" id="calibration-timer">15</div>
|
|
<div class="calibration-status" id="calibration-status">Getting ready...</div>
|
|
</div>
|
|
`,
|
|
|
|
'missed-motion-test': `
|
|
<div class="calibration-step calibration-step-active">
|
|
<div class="calibration-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="calibration-progress-fill" style="width: 50%"></div>
|
|
</div>
|
|
<span class="progress-text">Step 2 of 2</span>
|
|
</div>
|
|
<div class="calibration-icon">🧘</div>
|
|
<h3>Sit perfectly still</h3>
|
|
<p id="calibration-instruction">For the next <strong>10 seconds</strong>, sit or stand perfectly still. This helps me detect motion sensitivity issues.</p>
|
|
<div class="calibration-timer" id="calibration-timer">10</div>
|
|
<div class="calibration-status" id="calibration-status">Getting ready...</div>
|
|
</div>
|
|
`,
|
|
|
|
analyzing: `
|
|
<div class="calibration-step">
|
|
<div class="calibration-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: 100%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="calibration-spinner"></div>
|
|
<h3>Analyzing your space...</h3>
|
|
<p>Collecting diurnal baseline data and link health metrics to calculate the optimal value.</p>
|
|
</div>
|
|
`,
|
|
|
|
suggestion: `
|
|
<div class="calibration-step">
|
|
<div class="calibration-icon">✨</div>
|
|
<h3>Here's my recommendation</h3>
|
|
<div class="calibration-suggestion">
|
|
<div class="suggestion-value">
|
|
<span class="suggestion-label">Suggested ${formatSettingName(settingKey)}:</span>
|
|
<span class="suggestion-number" id="suggested-value">--</span>
|
|
</div>
|
|
<div class="suggestion-reason" id="suggestion-reason">
|
|
Calculating...
|
|
</div>
|
|
</div>
|
|
<div class="calibration-metrics" id="calibration-metrics">
|
|
<!-- Metrics will be populated -->
|
|
</div>
|
|
<div class="calibration-actions">
|
|
<button class="calibration-btn calibration-btn-primary" id="apply-suggestion-btn" onclick="Proactive.applySuggestedValue('${settingKey}')" disabled>
|
|
Apply Suggested Value
|
|
</button>
|
|
<button class="calibration-btn calibration-btn-secondary" onclick="Proactive.closeCalibrationFlow()">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
content.innerHTML = steps[step] || steps.intro;
|
|
|
|
// Execute step-specific logic
|
|
if (step === 'false-positive-test') {
|
|
runCalibrationTest(settingKey, 'false-positive', 15);
|
|
} else if (step === 'missed-motion-test') {
|
|
runCalibrationTest(settingKey, 'missed-motion', 10);
|
|
} else if (step === 'analyzing') {
|
|
analyzeAndSuggest(settingKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a calibration test with countdown timer
|
|
*/
|
|
function runCalibrationTest(settingKey, testType, duration) {
|
|
const timerEl = document.getElementById('calibration-timer');
|
|
const statusEl = document.getElementById('calibration-status');
|
|
const progressFill = document.getElementById('calibration-progress-fill');
|
|
const startTime = Date.now();
|
|
|
|
// Update progress bar fill
|
|
if (progressFill) {
|
|
const targetWidth = testType === 'false-positive' ? '0%' : '50%';
|
|
progressFill.style.width = targetWidth;
|
|
}
|
|
|
|
// Start collecting test data
|
|
collectTestData(settingKey, testType);
|
|
|
|
const interval = setInterval(() => {
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
const remaining = Math.max(0, Math.ceil(duration - elapsed));
|
|
|
|
if (timerEl) {
|
|
timerEl.textContent = remaining;
|
|
}
|
|
|
|
// Update progress
|
|
if (progressFill) {
|
|
const baseWidth = testType === 'false-positive' ? 0 : 50;
|
|
const progress = (elapsed / duration) * 50;
|
|
progressFill.style.width = (baseWidth + progress) + '%';
|
|
}
|
|
|
|
if (elapsed >= 1 && elapsed < 3) {
|
|
if (statusEl) statusEl.textContent = 'Recording baseline...';
|
|
} else if (elapsed >= 3 && elapsed < duration - 2) {
|
|
if (statusEl) statusEl.textContent = testType === 'false-positive' ? 'Walking... keep moving!' : 'Holding still...';
|
|
} else if (elapsed >= duration - 2) {
|
|
if (statusEl) statusEl.textContent = 'Finishing up...';
|
|
}
|
|
|
|
if (elapsed >= duration) {
|
|
clearInterval(interval);
|
|
finalizeTestData(settingKey, testType);
|
|
|
|
// Move to next step
|
|
if (testType === 'false-positive') {
|
|
renderCalibrationStep(settingKey, 'missed-motion-test');
|
|
} else {
|
|
renderCalibrationStep(settingKey, 'analyzing');
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Collect test data from the system
|
|
*/
|
|
function collectTestData(settingKey, testType) {
|
|
// Store test metadata for later analysis
|
|
if (!window.calibrationTestData) {
|
|
window.calibrationTestData = {};
|
|
}
|
|
window.calibrationTestData[testType] = {
|
|
startTime: Date.now(),
|
|
settingKey: settingKey
|
|
};
|
|
|
|
// Notify the system to start recording for this test
|
|
window.dispatchEvent(new CustomEvent('spaxel:calibration-start', {
|
|
detail: { testType: testType, settingKey: settingKey }
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Finalize test data collection
|
|
*/
|
|
function finalizeTestData(settingKey, testType) {
|
|
if (!window.calibrationTestData || !window.calibrationTestData[testType]) {
|
|
return;
|
|
}
|
|
|
|
window.calibrationTestData[testType].endTime = Date.now();
|
|
|
|
// Notify the system to stop recording
|
|
window.dispatchEvent(new CustomEvent('spaxel:calibration-end', {
|
|
detail: { testType: testType, settingKey: settingKey }
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Analyze test data and suggest optimal value
|
|
*/
|
|
function analyzeAndSuggest(settingKey) {
|
|
// Fetch diurnal baseline status and link health data
|
|
Promise.all([
|
|
fetch('/api/diurnal/status').then(r => r.json()).catch(() => null),
|
|
fetch('/api/links').then(r => r.json()).catch(() => null)
|
|
]).then(([diurnalData, linksData]) => {
|
|
const suggestion = calculateSuggestedValue(settingKey, diurnalData, linksData);
|
|
renderSuggestion(settingKey, suggestion);
|
|
}).catch(err => {
|
|
console.error('Failed to fetch calibration data:', err);
|
|
// Show fallback suggestion
|
|
renderSuggestion(settingKey, getFallbackSuggestion(settingKey));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate suggested value based on system data
|
|
*/
|
|
function calculateSuggestedValue(settingKey, diurnalData, linksData) {
|
|
const suggestion = {
|
|
value: null,
|
|
reason: '',
|
|
metrics: []
|
|
};
|
|
|
|
const now = new Date();
|
|
const currentHour = now.getHours();
|
|
|
|
// Get average health score across all links (higher is better, 0-1 range)
|
|
let avgHealthScore = 0.5; // default fallback
|
|
let activeLinkCount = 0;
|
|
|
|
if (Array.isArray(linksData)) {
|
|
const healthScores = linksData.map(l => l.health_score || 0.5).filter(s => s >= 0);
|
|
if (healthScores.length > 0) {
|
|
avgHealthScore = healthScores.reduce((a, b) => a + b, 0) / healthScores.length;
|
|
activeLinkCount = linksData.length;
|
|
}
|
|
}
|
|
|
|
// Get diurnal baseline readiness/learning status
|
|
let diurnalReady = false;
|
|
let learningProgress = 0;
|
|
|
|
if (Array.isArray(diurnalData) && diurnalData.length > 0) {
|
|
// diurnalData is an array of DiurnalLearningStatus objects
|
|
const readyCount = diurnalData.filter(d => d.is_ready).length;
|
|
diurnalReady = readyCount > (diurnalData.length / 2); // Majority ready
|
|
|
|
// Average learning progress (backend returns 'progress' field, 0-100 range)
|
|
const progressValues = diurnalData.map(d => (d.progress || 0) / 100).filter(p => p >= 0);
|
|
if (progressValues.length > 0) {
|
|
learningProgress = progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
|
|
}
|
|
}
|
|
|
|
// Calculate suggestion based on setting type
|
|
// Note: avgHealthScore is 0-1 where higher is better (unlike raw SNR)
|
|
switch (settingKey) {
|
|
case 'delta_rms_threshold':
|
|
// Lower threshold for high health scores, higher for low health scores
|
|
// Range: 0.01 - 0.10
|
|
if (avgHealthScore > 0.8) {
|
|
suggestion.value = 0.02;
|
|
suggestion.reason = 'Your space has excellent link health. A lower threshold will detect subtle movements while minimizing false positives.';
|
|
} else if (avgHealthScore > 0.6) {
|
|
suggestion.value = 0.03;
|
|
suggestion.reason = 'Your space has good link health. This threshold balances sensitivity with noise immunity.';
|
|
} else {
|
|
suggestion.value = 0.05;
|
|
suggestion.reason = 'Your space has lower link health. A higher threshold reduces false positives from environmental noise.';
|
|
}
|
|
// Adjust based on diurnal readiness
|
|
if (diurnalReady && learningProgress < 0.5) {
|
|
suggestion.value *= 1.3; // Still learning needs higher threshold
|
|
suggestion.reason += ' Adjusted up since your baselines are still learning.';
|
|
}
|
|
break;
|
|
|
|
case 'breathing_sensitivity':
|
|
// Range: 0.001 - 0.02
|
|
if (avgHealthScore > 0.75) {
|
|
suggestion.value = 0.005;
|
|
suggestion.reason = 'High link health enables fine-grained breathing detection.';
|
|
} else if (avgHealthScore > 0.55) {
|
|
suggestion.value = 0.008;
|
|
suggestion.reason = 'Good link health for reliable breathing detection.';
|
|
} else {
|
|
suggestion.value = 0.015;
|
|
suggestion.reason = 'Lower link health requires higher sensitivity for breathing detection.';
|
|
}
|
|
break;
|
|
|
|
case 'tau_s':
|
|
// Baseline time constant: 5 - 120 seconds
|
|
if (diurnalReady && learningProgress > 0.8) {
|
|
suggestion.value = 30;
|
|
suggestion.reason = 'Your diurnal baselines are well-calibrated. A 30-second time constant adapts quickly to real changes.';
|
|
} else if (diurnalReady || learningProgress > 0.5) {
|
|
suggestion.value = 60;
|
|
suggestion.reason = 'Moderate baseline stability. A 60-second time constant provides stable adaptation.';
|
|
} else {
|
|
suggestion.value = 120;
|
|
suggestion.reason = 'Your environment is still learning baselines. A longer time constant (120s) provides more stable detection.';
|
|
}
|
|
break;
|
|
|
|
case 'fresnel_decay':
|
|
// Zone decay rate: 1.0 - 4.0
|
|
if (avgHealthScore > 0.75) {
|
|
suggestion.value = 2.5;
|
|
suggestion.reason = 'Strong link health supports tighter zone focus for better localization accuracy.';
|
|
} else if (avgHealthScore > 0.55) {
|
|
suggestion.value = 2.0;
|
|
suggestion.reason = 'Balanced decay rate for your link health.';
|
|
} else {
|
|
suggestion.value = 1.5;
|
|
suggestion.reason = 'Lower link health benefits from broader zone contribution.';
|
|
}
|
|
break;
|
|
|
|
case 'n_subcarriers':
|
|
// Subcarrier count: 8 - 16
|
|
if (avgHealthScore > 0.75) {
|
|
suggestion.value = 16;
|
|
suggestion.reason = 'Excellent link health supports using all 16 subcarriers for maximum detail.';
|
|
} else if (avgHealthScore > 0.55) {
|
|
suggestion.value = 12;
|
|
suggestion.reason = 'Good link health. 12 subcarriers balance detail with noise immunity.';
|
|
} else {
|
|
suggestion.value = 8;
|
|
suggestion.reason = 'Lower link health. Fewer subcarriers reduce noise impact.';
|
|
}
|
|
break;
|
|
|
|
default:
|
|
suggestion.value = 0.03;
|
|
suggestion.reason = 'Default suggestion based on average system performance.';
|
|
}
|
|
|
|
// Build metrics display
|
|
const healthPercent = Math.round(avgHealthScore * 100);
|
|
suggestion.metrics = [
|
|
{ label: 'Link Health', value: healthPercent + '%' },
|
|
{ label: 'Diurnal Ready', value: diurnalReady ? 'Yes' : 'Learning' },
|
|
{ label: 'Active Links', value: activeLinkCount.toString() }
|
|
];
|
|
|
|
return suggestion;
|
|
}
|
|
|
|
/**
|
|
* Get fallback suggestion when API data is unavailable
|
|
*/
|
|
function getFallbackSuggestion(settingKey) {
|
|
const defaults = {
|
|
'delta_rms_threshold': { value: 0.03, reason: 'Default value for typical home environments.' },
|
|
'breathing_sensitivity': { value: 0.008, reason: 'Default sensitivity for reliable breathing detection.' },
|
|
'tau_s': { value: 60, reason: 'Default 60-second time constant for stable adaptation.' },
|
|
'fresnel_decay': { value: 2.0, reason: 'Default inverse-square decay for balanced localization.' },
|
|
'n_subcarriers': { value: 12, reason: 'Default 12 subcarriers for balanced performance.' }
|
|
};
|
|
return defaults[settingKey] || { value: 0.03, reason: 'Default value.' };
|
|
}
|
|
|
|
/**
|
|
* Render the suggestion step with calculated values
|
|
*/
|
|
function renderSuggestion(settingKey, suggestion) {
|
|
const content = document.getElementById('calibration-flow-content');
|
|
if (!content) return;
|
|
|
|
const formattedValue = formatSuggestedValue(settingKey, suggestion.value);
|
|
|
|
content.innerHTML = `
|
|
<div class="calibration-step">
|
|
<div class="calibration-icon">✨</div>
|
|
<h3>Here's my recommendation</h3>
|
|
<div class="calibration-suggestion">
|
|
<div class="suggestion-value">
|
|
<span class="suggestion-label">Suggested ${formatSettingName(settingKey)}:</span>
|
|
<span class="suggestion-number">${formattedValue}</span>
|
|
</div>
|
|
<div class="suggestion-reason">${suggestion.reason}</div>
|
|
</div>
|
|
<div class="calibration-metrics">
|
|
<h4>Based on your space:</h4>
|
|
${suggestion.metrics.map(m => `
|
|
<div class="calibration-metric">
|
|
<span class="metric-label">${m.label}:</span>
|
|
<span class="metric-value">${m.value}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
<div class="calibration-actions">
|
|
<button class="calibration-btn calibration-btn-primary" onclick="Proactive.applySuggestedValue('${settingKey}', ${suggestion.value})">
|
|
Apply Suggested Value
|
|
</button>
|
|
<button class="calibration-btn calibration-btn-secondary" onclick="Proactive.closeCalibrationFlow()">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Format a suggested value for display
|
|
*/
|
|
function formatSuggestedValue(settingKey, value) {
|
|
if (settingKey === 'tau_s') {
|
|
return value + ' seconds';
|
|
} else if (settingKey === 'n_subcarriers') {
|
|
return value + ' subcarriers';
|
|
} else if (settingKey === 'fresnel_decay') {
|
|
return value.toFixed(1);
|
|
}
|
|
return value.toFixed(3);
|
|
}
|
|
|
|
/**
|
|
* Apply the suggested value to the setting
|
|
*/
|
|
function applySuggestedValue(settingKey, value) {
|
|
// Show loading state
|
|
const applyBtn = document.querySelector('.calibration-btn-primary');
|
|
if (applyBtn) {
|
|
applyBtn.disabled = true;
|
|
applyBtn.textContent = 'Applying...';
|
|
}
|
|
|
|
// Build settings payload
|
|
const settings = {};
|
|
settings[settingKey] = value;
|
|
|
|
// Send to API
|
|
fetch('/api/settings', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(settings)
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
// Show success message
|
|
showCalibrationSuccess(settingKey, value);
|
|
|
|
// Notify other components of settings change
|
|
window.dispatchEvent(new CustomEvent('spaxel:settings-changed', {
|
|
detail: { key: settingKey, value: value }
|
|
}));
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to apply setting:', err);
|
|
showCalibrationError();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show calibration success message
|
|
*/
|
|
function showCalibrationSuccess(settingKey, value) {
|
|
const content = document.getElementById('calibration-flow-content');
|
|
if (!content) return;
|
|
|
|
const formattedValue = formatSuggestedValue(settingKey, value);
|
|
|
|
content.innerHTML = `
|
|
<div class="calibration-step">
|
|
<div class="calibration-icon calibration-success">✓</div>
|
|
<h3>Value applied!</h3>
|
|
<p>Your <strong>${formatSettingName(settingKey)}</strong> has been set to <strong>${formattedValue}</strong>.</p>
|
|
<p class="calibration-note">The system will now use this new value for detection. If you experience issues, you can adjust it again in Settings.</p>
|
|
<div class="calibration-actions">
|
|
<button class="calibration-btn calibration-btn-primary" onclick="Proactive.closeCalibrationFlow()">
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Show calibration error message
|
|
*/
|
|
function showCalibrationError() {
|
|
const content = document.getElementById('calibration-flow-content');
|
|
if (!content) return;
|
|
|
|
content.innerHTML = `
|
|
<div class="calibration-step">
|
|
<div class="calibration-icon calibration-error">⚠</div>
|
|
<h3>Couldn't apply the value</h3>
|
|
<p>There was a problem saving the setting. Please try again or adjust it manually in Settings.</p>
|
|
<div class="calibration-actions">
|
|
<button class="calibration-btn calibration-btn-primary" onclick="Proactive.closeCalibrationFlow()">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Close the calibration flow modal
|
|
*/
|
|
function closeCalibrationFlow() {
|
|
const modal = document.getElementById('calibration-flow-modal');
|
|
if (modal) {
|
|
modal.classList.add('calibration-closing');
|
|
setTimeout(() => modal.remove(), 300);
|
|
}
|
|
// Clean up test data
|
|
if (window.calibrationTestData) {
|
|
delete window.calibrationTestData;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save setting change history to localStorage
|
|
*/
|
|
function saveSettingHistory() {
|
|
try {
|
|
localStorage.setItem('spaxel_setting_changes', JSON.stringify(settingChangeHistory));
|
|
} catch (e) {
|
|
console.warn('Failed to save setting history:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save setting hint shown timestamps
|
|
*/
|
|
function saveSettingHintShown() {
|
|
try {
|
|
localStorage.setItem('spaxel_setting_hints_shown', JSON.stringify(settingChangeHintShown));
|
|
} catch (e) {
|
|
console.warn('Failed to save hint shown state:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a setting key for display
|
|
*/
|
|
function formatSettingName(key) {
|
|
const names = {
|
|
'delta_rms_threshold': 'Motion Threshold',
|
|
'breathing_sensitivity': 'Breathing Sensitivity',
|
|
'tau_s': 'Baseline Time Constant',
|
|
'fresnel_decay': 'Fresnel Weight Decay',
|
|
'n_subcarriers': 'Subcarrier Count'
|
|
};
|
|
return names[key] || key;
|
|
}
|
|
|
|
// ===== Post-Feedback Explanations =====
|
|
|
|
/**
|
|
* Show explanation after false positive feedback
|
|
*/
|
|
function showFeedbackExplanation(eventData) {
|
|
if (!eventData || !eventData.explainability) {
|
|
return;
|
|
}
|
|
|
|
const explanation = eventData.explainability;
|
|
const contributingLinks = explanation.contributing_links || [];
|
|
const diagnosis = explanation.diagnosis || null;
|
|
|
|
// Find the primary contributing link
|
|
const primaryLink = contributingLinks.length > 0 ? contributingLinks[0] : null;
|
|
|
|
let explanationText = '';
|
|
|
|
if (primaryLink) {
|
|
const linkName = formatLinkID(primaryLink.link_id);
|
|
const deltaRMS = primaryLink.delta_rms?.toFixed(4) || 'N/A';
|
|
const threshold = 0.02; // Standard threshold
|
|
const ratio = (primaryLink.delta_rms / threshold).toFixed(1);
|
|
|
|
explanationText = `The system detected motion here because: <strong>${linkName}</strong>'s signal (deltaRMS: ${deltaRMS}) exceeded the motion threshold by <strong>${ratio}x</strong>.`;
|
|
|
|
// Add root cause from diagnostic if available
|
|
if (diagnosis) {
|
|
explanationText += `<br><br><strong>Possible cause:</strong> ${diagnosis.detail}`;
|
|
|
|
if (diagnosis.advice) {
|
|
explanationText += `<br><strong>What to do:</strong> ${diagnosis.advice}`;
|
|
}
|
|
explanationText += `<br><br><em>We've noted this and will apply corrections.</em>`;
|
|
} else {
|
|
explanationText += `<br><br><strong>Possible cause:</strong> Ambient RF interference or environmental changes. We've noted this and will apply corrections.`;
|
|
}
|
|
} else {
|
|
explanationText = 'The system detected motion based on signal patterns across multiple links. We\'ve noted this feedback to improve accuracy.';
|
|
}
|
|
|
|
// Create explanation element
|
|
const explanationDiv = document.createElement('div');
|
|
explanationDiv.className = 'feedback-explanation';
|
|
explanationDiv.innerHTML = `
|
|
<div class="explanation-header">
|
|
<span class="explanation-title">Why did this happen?</span>
|
|
</div>
|
|
<div class="explanation-content">
|
|
<p>${explanationText}</p>
|
|
${contributingLinks.length > 1 ? `<p class="explanation-additional-links">Contributing links: ${contributingLinks.map(l => formatLinkID(l.link_id)).join(', ')}</p>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
return explanationDiv;
|
|
}
|
|
|
|
/**
|
|
* Fetch diagnostic info for a link at a specific time
|
|
*/
|
|
function fetchDiagnosticForLink(linkID, timestamp) {
|
|
// Using the correct endpoint: /api/diagnostics/link/{linkID}?timestamp={ms}
|
|
return fetch(`/api/diagnostics/link/${encodeURIComponent(linkID)}?timestamp=${timestamp}`)
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
}
|
|
return res.json();
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to fetch diagnostic:', err);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
|
|
function formatLinkID(linkID) {
|
|
if (!linkID) {
|
|
return 'Unknown Link';
|
|
}
|
|
// Format MAC address pairs nicely
|
|
const parts = linkID.split(':');
|
|
if (parts.length === 2) {
|
|
const mac1 = parts[0].substring(0, 8); // First 8 chars of first MAC
|
|
const mac2 = parts[1].substring(0, 8); // First 8 chars of second MAC
|
|
return `${mac1} → ${mac2}`;
|
|
}
|
|
return linkID.substring(0, 16) + '...';
|
|
}
|
|
|
|
// ===== Public API =====
|
|
window.Proactive = {
|
|
// Quality monitoring
|
|
monitorLinkQuality: monitorLinkQuality,
|
|
dismissQualityPrompt: dismissQualityPrompt,
|
|
dismissQualityPromptForToday: dismissQualityPromptForToday,
|
|
diagnoseLink: diagnoseLink,
|
|
|
|
// Setting change tracking
|
|
trackSettingChange: trackSettingChange,
|
|
dismissSettingHint: dismissSettingHint,
|
|
startCalibrationFlow: startCalibrationFlow,
|
|
|
|
// Calibration flow
|
|
startCalibrationStep: renderCalibrationStep,
|
|
closeCalibrationFlow: closeCalibrationFlow,
|
|
applySuggestedValue: applySuggestedValue,
|
|
|
|
// Feedback explanations
|
|
showFeedbackExplanation: showFeedbackExplanation,
|
|
fetchDiagnosticForLink: fetchDiagnosticForLink,
|
|
|
|
// Initialization
|
|
init: function() {
|
|
loadDismissedPrompts();
|
|
|
|
// Listen for WebSocket messages with link health data
|
|
window.addEventListener('spaxel:update', (e) => {
|
|
if (e.detail && e.detail.links) {
|
|
monitorLinkQuality(e.detail.links);
|
|
}
|
|
});
|
|
|
|
// Listen for feedback submissions
|
|
window.addEventListener('spaxel:feedback', (e) => {
|
|
if (e.detail && e.detail.type === 'incorrect') {
|
|
const explanation = this.showFeedbackExplanation(e.detail);
|
|
if (explanation) {
|
|
// Show explanation in feedback confirmation
|
|
const feedbackPanel = document.getElementById('feedback-panel');
|
|
if (feedbackPanel) {
|
|
const contentArea = feedbackPanel.querySelector('.feedback-content');
|
|
if (contentArea) {
|
|
contentArea.appendChild(explanation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check for server-side repeated edit hint flag
|
|
checkServerEditHint();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check for server-side repeated edit hint flag
|
|
*/
|
|
function checkServerEditHint() {
|
|
fetch('/api/settings')
|
|
.then(res => res.json())
|
|
.then(settings => {
|
|
if (settings.repeated_edit_hint) {
|
|
// Server detected repeated edits. Find the most-changed qualifying
|
|
// setting from our local history to give a specific hint; fall back
|
|
// to delta_rms_threshold as the most common tuning target.
|
|
let bestKey = 'delta_rms_threshold';
|
|
let bestCount = 0;
|
|
for (const key of QUALIFYING_SETTINGS) {
|
|
const count = (settingChangeHistory[key] || []).length;
|
|
if (count > bestCount) {
|
|
bestCount = count;
|
|
bestKey = key;
|
|
}
|
|
}
|
|
showSettingChangeHint(bestKey);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
// Ignore errors - this is a nice-to-have feature
|
|
});
|
|
}
|
|
|
|
// Initialize on load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => window.Proactive.init());
|
|
} else {
|
|
window.Proactive.init();
|
|
}
|
|
|
|
// Add styles
|
|
const style = document.createElement('style');
|
|
style.id = 'proactive-styles';
|
|
style.textContent = `
|
|
.quality-prompt-card {
|
|
position: fixed;
|
|
bottom: 100px;
|
|
right: 20px;
|
|
width: 380px;
|
|
max-width: calc(100vw - 40px);
|
|
background: rgba(255, 152, 0, 0.15);
|
|
border: 1px solid rgba(255, 152, 0, 0.5);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
z-index: 200;
|
|
animation: qualityPromptSlideIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes qualityPromptSlideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.quality-prompt-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid rgba(255, 152, 0, 0.3);
|
|
}
|
|
|
|
.quality-prompt-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.quality-prompt-title {
|
|
flex: 1;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #ff9800;
|
|
}
|
|
|
|
.quality-prompt-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
|
|
.quality-prompt-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.quality-prompt-content {
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.quality-prompt-content p {
|
|
margin: 0 0 8px 0;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
color: #ddd;
|
|
}
|
|
|
|
.quality-prompt-detail {
|
|
color: #bbb;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.quality-prompt-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid rgba(255, 152, 0, 0.3);
|
|
}
|
|
|
|
.quality-prompt-btn {
|
|
flex: 1;
|
|
padding: 8px 14px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
border: none;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.quality-prompt-diagnose {
|
|
background: #ff9800;
|
|
color: #1a1a2e;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.quality-prompt-diagnose:hover {
|
|
background: #fb8c00;
|
|
}
|
|
|
|
.quality-prompt-dismiss {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #ccc;
|
|
}
|
|
|
|
.quality-prompt-dismiss:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.diagnostic-results-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 400px;
|
|
max-width: calc(100vw - 40px);
|
|
background: #1e1e3a;
|
|
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
|
|
z-index: 300;
|
|
overflow-y: auto;
|
|
animation: diagnosticSlideIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes diagnosticSlideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
.diagnostic-panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.diagnostic-panel-header h3 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
color: #eee;
|
|
}
|
|
|
|
.diagnostic-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
|
|
.diagnostic-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.diagnostic-panel-content {
|
|
padding: 20px;
|
|
}
|
|
|
|
.diagnostic-link-info {
|
|
margin-bottom: 16px;
|
|
padding: 12px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.diagnostic-link-label {
|
|
font-size: 11px;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.diagnostic-link-id {
|
|
margin-left: 8px;
|
|
font-size: 14px;
|
|
color: #4fc3f7;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.diagnostic-result {
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.diagnostic-severity-actionable {
|
|
background: rgba(244, 67, 54, 0.1);
|
|
border: 1px solid rgba(244, 67, 54, 0.3);
|
|
}
|
|
|
|
.diagnostic-severity-warning {
|
|
background: rgba(255, 152, 0, 0.1);
|
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
|
}
|
|
|
|
.diagnostic-severity-info {
|
|
background: rgba(76, 175, 80, 0.1);
|
|
border: 1px solid rgba(76, 175, 80, 0.3);
|
|
}
|
|
|
|
.diagnostic-result-title {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #eee;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.diagnostic-result-detail {
|
|
font-size: 13px;
|
|
color: #bbb;
|
|
line-height: 1.4;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.diagnostic-result-advice {
|
|
font-size: 12px;
|
|
color: #aaa;
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.diagnostic-confidence {
|
|
margin-top: 8px;
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
font-size: 11px;
|
|
color: #888;
|
|
}
|
|
|
|
.diagnostic-health-metrics {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.diagnostic-repositioning {
|
|
margin-top: 16px;
|
|
padding: 12px;
|
|
background: rgba(79, 195, 247, 0.1);
|
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.diagnostic-repositioning h4 {
|
|
margin: 0 0 8px 0;
|
|
font-size: 12px;
|
|
color: #4fc3f7;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.diagnostic-repositioning p {
|
|
margin: 0 0 8px 0;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.repositioning-coords {
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
color: #4fc3f7;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.diagnostic-health-metrics h4 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 13px;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.health-metric {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.health-metric:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.metric-label {
|
|
color: #888;
|
|
}
|
|
|
|
.metric-value {
|
|
color: #4fc3f7;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.setting-change-hint {
|
|
position: fixed;
|
|
bottom: 100px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 400px;
|
|
max-width: calc(100vw - 40px);
|
|
background: rgba(79, 195, 247, 0.15);
|
|
border: 1px solid rgba(79, 195, 247, 0.5);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
z-index: 200;
|
|
animation: hintSlideIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes hintSlideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
}
|
|
|
|
.hint-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid rgba(79, 195, 247, 0.3);
|
|
}
|
|
|
|
.hint-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.hint-title {
|
|
flex: 1;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #4fc3f7;
|
|
}
|
|
|
|
.hint-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
|
|
.hint-content {
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.hint-content p {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
color: #ddd;
|
|
}
|
|
|
|
.hint-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid rgba(79, 195, 247, 0.3);
|
|
}
|
|
|
|
.hint-btn {
|
|
flex: 1;
|
|
padding: 8px 14px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.hint-btn-primary {
|
|
background: #4fc3f7;
|
|
color: #1a1a2e;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.hint-btn-secondary {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #ccc;
|
|
}
|
|
|
|
.feedback-explanation {
|
|
margin-top: 12px;
|
|
padding: 12px;
|
|
background: rgba(79, 195, 247, 0.1);
|
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.explanation-header {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.explanation-title {
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
color: #4fc3f7;
|
|
}
|
|
|
|
.explanation-content {
|
|
font-size: 12px;
|
|
color: #bbb;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.explanation-content strong {
|
|
color: #ddd;
|
|
}
|
|
|
|
.explanation-additional-links {
|
|
margin-top: 8px;
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
font-size: 11px;
|
|
color: #888;
|
|
}
|
|
|
|
/* Calibration Flow Modal */
|
|
.calibration-flow-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
animation: modalFadeIn 0.2s ease-out;
|
|
}
|
|
|
|
.calibration-flow-modal.calibration-closing {
|
|
animation: modalFadeOut 0.3s ease-in forwards;
|
|
}
|
|
|
|
@keyframes modalFadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes modalFadeOut {
|
|
from { opacity: 1; }
|
|
to { opacity: 0; }
|
|
}
|
|
|
|
.calibration-flow-backdrop {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.calibration-flow-container {
|
|
position: relative;
|
|
width: 500px;
|
|
max-width: calc(100vw - 40px);
|
|
max-height: calc(100vh - 40px);
|
|
background: #1e1e3a;
|
|
border-radius: 16px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calibration-flow-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.calibration-flow-header h2 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
color: #eee;
|
|
}
|
|
|
|
.calibration-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.calibration-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.calibration-flow-content {
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.calibration-step {
|
|
text-align: center;
|
|
}
|
|
|
|
.calibration-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.calibration-icon.calibration-success {
|
|
color: #4caf50;
|
|
}
|
|
|
|
.calibration-icon.calibration-error {
|
|
color: #f44336;
|
|
}
|
|
|
|
.calibration-step h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 20px;
|
|
color: #eee;
|
|
}
|
|
|
|
.calibration-step p {
|
|
margin: 0 0 16px 0;
|
|
font-size: 14px;
|
|
color: #bbb;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.calibration-steps-overview {
|
|
margin: 20px 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.overview-step {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 0;
|
|
}
|
|
|
|
.overview-number {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: rgba(79, 195, 247, 0.2);
|
|
color: #4fc3f7;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.overview-text {
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.calibration-progress {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 6px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #4fc3f7, #29b6f6);
|
|
border-radius: 3px;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.progress-text {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
.calibration-timer {
|
|
font-size: 48px;
|
|
font-weight: 700;
|
|
color: #4fc3f7;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.calibration-status {
|
|
font-size: 14px;
|
|
color: #bbb;
|
|
}
|
|
|
|
.calibration-spinner {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid rgba(79, 195, 247, 0.2);
|
|
border-top-color: #4fc3f7;
|
|
border-radius: 50%;
|
|
margin: 20px auto;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.calibration-suggestion {
|
|
background: rgba(79, 195, 247, 0.1);
|
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.suggestion-value {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.suggestion-label {
|
|
font-size: 14px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.suggestion-number {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: #4fc3f7;
|
|
}
|
|
|
|
.suggestion-reason {
|
|
font-size: 13px;
|
|
color: #bbb;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.calibration-metrics {
|
|
margin: 20px 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.calibration-metrics h4 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 12px;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.calibration-metric {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.calibration-metric:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.calibration-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.calibration-btn {
|
|
flex: 1;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: none;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.calibration-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.calibration-btn-primary {
|
|
background: #4fc3f7;
|
|
color: #1a1a2e;
|
|
}
|
|
|
|
.calibration-btn-primary:hover:not(:disabled) {
|
|
background: #29b6f6;
|
|
}
|
|
|
|
.calibration-btn-secondary {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #ccc;
|
|
}
|
|
|
|
.calibration-btn-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.calibration-note {
|
|
font-size: 12px;
|
|
color: #888;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.calibration-step-active {
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
|
|
/* Feature Discovery Notifications */
|
|
.feature-discovery-container {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
z-index: 250;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.feature-discovery-notification {
|
|
pointer-events: auto;
|
|
width: 380px;
|
|
max-width: calc(100vw - 40px);
|
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15), rgba(76, 175, 80, 0.05));
|
|
border: 1px solid rgba(76, 175, 80, 0.4);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
animation: featureDiscoverySlideIn 0.4s ease-out;
|
|
overflow: hidden;
|
|
}
|
|
|
|
@keyframes featureDiscoverySlideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(100%) scale(0.9);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0) scale(1);
|
|
}
|
|
}
|
|
|
|
.feature-discovery-dismissed {
|
|
animation: featureDiscoverySlideOut 0.3s ease-in forwards;
|
|
}
|
|
|
|
@keyframes featureDiscoverySlideOut {
|
|
to {
|
|
opacity: 0;
|
|
transform: translateX(100%);
|
|
margin-bottom: -100px;
|
|
}
|
|
}
|
|
|
|
.feature-discovery-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid rgba(76, 175, 80, 0.3);
|
|
background: rgba(76, 175, 80, 0.1);
|
|
}
|
|
|
|
.feature-discovery-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.feature-discovery-title {
|
|
flex: 1;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #81c784;
|
|
}
|
|
|
|
.feature-discovery-close {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.feature-discovery-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.feature-discovery-content {
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.feature-discovery-content p {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: #ddd;
|
|
}
|
|
|
|
.feature-discovery-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid rgba(76, 175, 80, 0.3);
|
|
background: rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.feature-discovery-btn {
|
|
flex: 1;
|
|
padding: 8px 14px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
border: none;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.feature-discovery-primary {
|
|
background: #4caf50;
|
|
color: #1a1a2e;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.feature-discovery-primary:hover {
|
|
background: #43a047;
|
|
}
|
|
|
|
.feature-discovery-secondary {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: #ccc;
|
|
}
|
|
|
|
.feature-discovery-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.feature-discovery-sparkle {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
font-size: 16px;
|
|
animation: sparkle 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes sparkle {
|
|
0%, 100% { opacity: 0.3; transform: scale(1); }
|
|
50% { opacity: 1; transform: scale(1.2); }
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
|
|
// ===== Feature Discovery Notifications =====
|
|
|
|
// State for feature notifications
|
|
let featureNotificationCheckInterval = null;
|
|
let pendingFeatureNotifications = [];
|
|
let displayedNotificationIDs = new Set();
|
|
|
|
/**
|
|
* Start polling for feature discovery notifications
|
|
*/
|
|
function startFeatureNotificationPolling() {
|
|
// Check every 30 seconds
|
|
if (featureNotificationCheckInterval) {
|
|
clearInterval(featureNotificationCheckInterval);
|
|
}
|
|
|
|
// Initial check
|
|
checkFeatureNotifications();
|
|
|
|
featureNotificationCheckInterval = setInterval(() => {
|
|
checkFeatureNotifications();
|
|
}, 30000); // 30 seconds
|
|
}
|
|
|
|
/**
|
|
* Stop polling for feature notifications
|
|
*/
|
|
function stopFeatureNotificationPolling() {
|
|
if (featureNotificationCheckInterval) {
|
|
clearInterval(featureNotificationCheckInterval);
|
|
featureNotificationCheckInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for new feature discovery notifications
|
|
*/
|
|
async function checkFeatureNotifications() {
|
|
try {
|
|
const response = await fetch('/api/help/notifications');
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const notifications = data.notifications || [];
|
|
|
|
// Filter out already displayed notifications
|
|
const newNotifications = notifications.filter(n => !displayedNotificationIDs.has(n.event_id));
|
|
|
|
if (newNotifications.length > 0) {
|
|
// Show each new notification
|
|
for (const notification of newNotifications) {
|
|
showFeatureNotification(notification);
|
|
displayedNotificationIDs.add(notification.event_id);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[FeatureDiscovery] Failed to check notifications:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a feature discovery notification card
|
|
*/
|
|
function showFeatureNotification(notification) {
|
|
// Create container if it doesn't exist
|
|
let container = document.querySelector('.feature-discovery-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.className = 'feature-discovery-container';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'feature-discovery-notification';
|
|
card.dataset.eventId = notification.event_id;
|
|
|
|
const actionButton = notification.action_label ? `
|
|
<button class="feature-discovery-btn feature-discovery-primary" data-action="${notification.action_url || ''}">
|
|
${notification.action_label}
|
|
</button>
|
|
` : '';
|
|
|
|
card.innerHTML = `
|
|
<div class="feature-discovery-sparkle">✨</div>
|
|
<div class="feature-discovery-header">
|
|
<span class="feature-discovery-icon">🎉</span>
|
|
<span class="feature-discovery-title">${notification.title}</span>
|
|
<button class="feature-discovery-close" onclick="Proactive.dismissFeatureNotification('${notification.event_id}')">×</button>
|
|
</div>
|
|
<div class="feature-discovery-content">
|
|
<p>${notification.message}</p>
|
|
</div>
|
|
${actionButton ? `
|
|
<div class="feature-discovery-actions">
|
|
${actionButton}
|
|
<button class="feature-discovery-btn feature-discovery-secondary" onclick="Proactive.dismissFeatureNotification('${notification.event_id}')">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
` : `
|
|
<div class="feature-discovery-actions">
|
|
<button class="feature-discovery-btn feature-discovery-secondary" onclick="Proactive.dismissFeatureNotification('${notification.event_id}')">
|
|
Got it
|
|
</button>
|
|
</div>
|
|
`}
|
|
`;
|
|
|
|
// Add click handler for action button
|
|
if (notification.action_label && notification.action_url) {
|
|
const actionBtn = card.querySelector('.feature-discovery-primary');
|
|
if (actionBtn) {
|
|
actionBtn.addEventListener('click', () => {
|
|
// Navigate to the action URL
|
|
if (notification.action_url.startsWith('#')) {
|
|
// Internal navigation
|
|
if (window.SpaxelRouter) {
|
|
window.SpaxelRouter.navigate(notification.action_url.substring(2));
|
|
} else {
|
|
window.location.hash = notification.action_url;
|
|
}
|
|
} else {
|
|
// External link
|
|
window.open(notification.action_url, '_blank');
|
|
}
|
|
// Dismiss after action
|
|
dismissFeatureNotification(notification.event_id);
|
|
});
|
|
}
|
|
}
|
|
|
|
container.appendChild(card);
|
|
|
|
// Auto-dismiss after 30 seconds if not interacted with
|
|
setTimeout(() => {
|
|
if (card.parentNode) {
|
|
dismissFeatureNotification(notification.event_id);
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
/**
|
|
* Dismiss a feature discovery notification
|
|
*/
|
|
async function dismissFeatureNotification(eventID) {
|
|
// Remove from UI
|
|
const card = document.querySelector(`.feature-discovery-notification[data-event-id="${eventID}"]`);
|
|
if (card) {
|
|
card.classList.add('feature-discovery-dismissed');
|
|
setTimeout(() => {
|
|
if (card.parentNode) {
|
|
card.remove();
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
// Acknowledge on server
|
|
try {
|
|
await fetch(`/api/help/notifications/${encodeURIComponent(eventID)}/acknowledge`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('[FeatureDiscovery] Failed to acknowledge notification:', err);
|
|
}
|
|
|
|
// Remove from displayed set
|
|
displayedNotificationIDs.delete(eventID);
|
|
}
|
|
|
|
// Update public API to include feature discovery methods
|
|
window.Proactive.startFeatureNotificationPolling = startFeatureNotificationPolling;
|
|
window.Proactive.stopFeatureNotificationPolling = stopFeatureNotificationPolling;
|
|
window.Proactive.dismissFeatureNotification = dismissFeatureNotification;
|
|
|
|
// Start feature notification polling on init
|
|
const originalInit = window.Proactive.init;
|
|
window.Proactive.init = function() {
|
|
if (originalInit) {
|
|
originalInit.call(this);
|
|
}
|
|
startFeatureNotificationPolling();
|
|
};
|
|
|
|
})();
|