/** * VoxDoc - Voice Symptom Intake & Documentation Assistant * Dark Theme Edition - JavaScript Controller * * Features: * - Real-time streaming transcription via WebSocket * - Text input fallback for manual symptom entry * - SOAP note generation with MedGemma */ // ===================================================== // DOM ELEMENTS // ===================================================== const elements = { // Sidebar sidebar: document.getElementById('sidebar'), sidebarOverlay: document.getElementById('sidebarOverlay'), openSidebarBtn: document.getElementById('openSidebar'), closeSidebarBtn: document.getElementById('closeSidebar'), userAvatar: document.getElementById('userAvatar'), userName: document.getElementById('userName'), userRole: document.getElementById('userRole'), // Voice Recording recordBtn: document.getElementById('recordBtn'), recordRipple: document.getElementById('recordRipple'), micIcon: document.getElementById('micIcon'), stopIcon: document.getElementById('stopIcon'), waveformContainer: document.getElementById('waveformContainer'), voiceStatus: document.getElementById('voiceStatus'), durationDisplay: document.getElementById('durationDisplay'), recordingTime: document.getElementById('recordingTime'), // Text Input textInput: document.getElementById('textInput'), // Submit submitBtn: document.getElementById('submitBtn'), // Transcript Panel transcriptCard: document.getElementById('transcriptCard'), transcriptTitle: document.getElementById('transcriptTitle'), liveIndicator: document.getElementById('liveIndicator'), emptyState: document.getElementById('emptyState'), followupState: document.getElementById('followupState'), followupQuestions: document.getElementById('followupQuestions'), followupSubmitBtn: document.getElementById('followupSubmitBtn'), followupSkipBtn: document.getElementById('followupSkipBtn'), loadingState: document.getElementById('loadingState'), resultsContainer: document.getElementById('resultsContainer'), // Live Transcript (Streaming) liveTranscriptState: document.getElementById('liveTranscriptState'), liveTranscriptText: document.getElementById('liveTranscriptText'), // Results transcriptionText: document.getElementById('transcriptionText'), chiefComplaint: document.getElementById('chiefComplaint'), symptomDetails: document.getElementById('symptomDetails'), audioPlaybackSection: document.getElementById('audioPlaybackSection'), resultAudioPlayer: document.getElementById('resultAudioPlayer'), textInputIndicator: document.getElementById('textInputIndicator'), // SOAP Section Cards soapS: document.getElementById('soapS'), soapO: document.getElementById('soapO'), soapA: document.getElementById('soapA'), soapP: document.getElementById('soapP'), visualFindings: document.getElementById('visualFindings'), imageFindingsCard: document.getElementById('imageFindingsCard'), imageFindingsThumbnailContainer: document.getElementById('imageFindingsThumbnailContainer'), soapStatusS: document.getElementById('soapStatusS'), soapStatusO: document.getElementById('soapStatusO'), soapStatusA: document.getElementById('soapStatusA'), soapStatusP: document.getElementById('soapStatusP'), soapStatusCC: document.getElementById('soapStatusCC'), soapStatusCD: document.getElementById('soapStatusCD'), soapStatusVI: document.getElementById('soapStatusVI'), soapOverallStatus: document.getElementById('soapOverallStatus'), // Image Upload imageDropZone: document.getElementById('imageDropZone'), imageFileInput: document.getElementById('imageFileInput'), imagePreviewContainer: document.getElementById('imagePreviewContainer'), imagePreview: document.getElementById('imagePreview'), removeImageBtn: document.getElementById('removeImageBtn'), imageFilename: document.getElementById('imageFilename'), imageFilesize: document.getElementById('imageFilesize'), dropZoneContent: document.getElementById('dropZoneContent'), imageAnalyzingState: document.getElementById('imageAnalyzingState'), // Actions copyBtn: document.getElementById('copyBtn'), exportBtn: document.getElementById('exportBtn'), exportPdfBtn: document.getElementById('exportPdfBtn'), batchExportBtn: document.getElementById('batchExportBtn'), // PWA offlineBadge: document.getElementById('offlineBadge'), installAppBtn: document.getElementById('installAppBtn'), // NER Entities nerEntitiesCard: document.getElementById('nerEntitiesCard'), nerEntityCount: document.getElementById('nerEntityCount'), nerConditionBadges: document.getElementById('nerConditionBadges'), nerMedicationBadges: document.getElementById('nerMedicationBadges'), // FHIR / EHR fhirExportBtn: document.getElementById('fhirExportBtn'), ehrPushBtn: document.getElementById('ehrPushBtn'), ehrModal: document.getElementById('ehrModal'), ehrModalClose: document.getElementById('ehrModalClose'), ehrModalCancel: document.getElementById('ehrModalCancel'), ehrModalSubmit: document.getElementById('ehrModalSubmit'), ehrServerUrl: document.getElementById('ehrServerUrl'), ehrAuthToken: document.getElementById('ehrAuthToken') }; // ===================================================== // STATE // ===================================================== const state = { isRecording: false, mediaRecorder: null, audioChunks: [], audioBlob: null, audioUrl: null, recordingStartTime: null, recordingInterval: null, currentDocumentation: null, // WebSocket streaming state websocket: null, liveTranscript: '', wsConnected: false, streamingMode: true, // true = use WebSocket streaming, false = fallback to upload // SOAP section approval state (with edit history) soapApprovals: { subjective: { status: 'pending', edited: false, originalText: '', history: [] }, objective: { status: 'pending', edited: false, originalText: '', history: [] }, assessment: { status: 'pending', edited: false, originalText: '', history: [] }, plan: { status: 'pending', edited: false, originalText: '', history: [] }, chief_complaint: { status: 'pending', edited: false, originalText: '', history: [] }, clinical_details: { status: 'pending', edited: false, originalText: '', history: [] }, visual_findings: { status: 'pending', edited: false, originalText: '', history: [] } }, // Image Upload State uploadedImageFile: null, imageAnalysis: null, // Session History sessionHistory: [], // User (no auth) currentUser: { id: 'system', username: 'local_operator', full_name: 'Local Operator', role: 'admin', is_active: true }, // Auth (Phase 4) accessToken: null, refreshToken: null, authEnabled: false, mfaEnabled: false, consentTrackingEnabled: false, sessionTimeoutMinutes: 15, inactivityTimer: null, inactivityWarningTimer: null, tokenRefreshTimer: null, // PWA State deferredInstallPrompt: null, isOffline: !navigator.onLine }; const ROLE_LABELS = { admin: 'Admin', clinician: 'Clinician', intake_staff: 'Intake Staff' }; const LOCAL_USER = { id: 'system', username: 'local_operator', full_name: 'Local Operator', role: 'admin', is_active: true }; function getRoleLabel(role) { return ROLE_LABELS[role] || role || 'Unknown'; } function getInitials(name) { if (!name) return '--'; const parts = String(name).trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return '--'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); } function updateAuthUI(message = null) { const currentUser = state.currentUser || LOCAL_USER; if (elements.userAvatar) { elements.userAvatar.textContent = getInitials(currentUser.full_name || currentUser.username); } if (elements.userName) { elements.userName.textContent = currentUser.full_name || currentUser.username; } if (elements.userRole) { elements.userRole.textContent = getRoleLabel(currentUser.role); } updateSubmitButton(); } async function apiFetch(url, options = {}) { const headers = new Headers(options.headers || {}); // Bypass ngrok browser-warning interstitial when running via tunnel headers.set('ngrok-skip-browser-warning', '1'); // Inject Bearer token when auth is enabled (Phase 4) if (state.accessToken) { headers.set('Authorization', `Bearer ${state.accessToken}`); } const response = await fetch(url, { ...options, headers }); // Auto-refresh token on 401 if (response.status === 401 && state.refreshToken && url !== '/api/auth/refresh') { const refreshed = await tryRefreshToken(); if (refreshed) { headers.set('Authorization', `Bearer ${state.accessToken}`); return fetch(url, { ...options, headers }); } showLoginOverlay(); throw new Error('Session expired. Please log in again.'); } if (response.status === 429) { const errorData = await response.json().catch(() => ({})); const detail = errorData.detail || {}; const retryAfter = detail.retry_after_seconds || parseInt(response.headers.get('Retry-After') || '30', 10); const queueLength = detail.queue_length; const msg = detail.error || 'Rate limit exceeded. Please wait before retrying.'; showRateLimitToast(msg, retryAfter); throw new Error(msg); } return response; } function showRateLimitToast(message, retryAfter) { const existing = document.querySelector('.rate-limit-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = 'rate-limit-toast'; toast.textContent = `${message} (retry in ${retryAfter}s)`; document.body.appendChild(toast); setTimeout(() => toast.remove(), Math.min(retryAfter * 1000, 10000)); } let queuePollInterval = null; function showQueueStatus(position, waitSeconds) { const banner = document.getElementById('queueBanner'); const posEl = document.getElementById('queuePosition'); const waitEl = document.getElementById('queueWait'); if (!banner) return; banner.classList.remove('hidden'); posEl.textContent = `Position in queue: ${position}`; waitEl.textContent = `Estimated wait: ${Math.ceil(waitSeconds)}s`; } function hideQueueStatus() { const banner = document.getElementById('queueBanner'); if (banner) banner.classList.add('hidden'); if (queuePollInterval) { clearInterval(queuePollInterval); queuePollInterval = null; } } function startQueuePolling() { hideQueueStatus(); queuePollInterval = setInterval(async () => { try { const resp = await apiFetch('/api/queue/status'); if (!resp.ok) return; const data = await resp.json(); if (data.queue_length > 0) { showQueueStatus(data.queue_length, data.queue_length * data.avg_inference_seconds); } else { hideQueueStatus(); const msgEl = document.getElementById('loadingMessage'); if (msgEl) msgEl.textContent = 'Processing and generating documentation...'; } } catch { /* ignore polling errors */ } }, 3000); } // ===================================================== // MONITORING DASHBOARD // ===================================================== let monitoringAutoRefresh = null; async function loadMonitoringDashboard() { try { const resp = await apiFetch('/api/monitoring/dashboard'); if (!resp.ok) { if (resp.status === 403) { const mv = document.getElementById('monitoringView'); if (mv) mv.innerHTML = '

Admin access required for monitoring dashboard.

'; } return; } const data = await resp.json(); renderMonitoringData(data); // Auto-refresh every 10s while on the monitoring tab if (monitoringAutoRefresh) clearInterval(monitoringAutoRefresh); monitoringAutoRefresh = setInterval(async () => { const mv = document.getElementById('monitoringView'); if (mv && !mv.classList.contains('hidden')) { try { const r = await apiFetch('/api/monitoring/dashboard'); if (r.ok) renderMonitoringData(await r.json()); } catch { /* ignore */ } } else { clearInterval(monitoringAutoRefresh); monitoringAutoRefresh = null; } }, 10000); } catch (e) { console.error('Failed to load monitoring data:', e); } } function renderMonitoringData(data) { // Uptime const uptimeEl = document.getElementById('monitoringUptime'); if (uptimeEl) { const h = Math.floor(data.uptime_seconds / 3600); const m = Math.floor((data.uptime_seconds % 3600) / 60); uptimeEl.textContent = `Uptime: ${h}h ${m}m`; } // Model cards const modelMap = { medasr: { req: 'medasrRequests', err: 'medasrErrorRate', lat: 'medasrLatency', p95: 'medasrP95', dot: 'statusDotMedasr', card: 'monitorCardMedasr' }, medgemma: { req: 'medgemmaRequests', err: 'medgemmaErrorRate', lat: 'medgemmaLatency', p95: 'medgemmaP95', dot: 'statusDotMedgemma', card: 'monitorCardMedgemma' }, medgemma_vision: { req: 'visionRequests', err: 'visionErrorRate', lat: 'visionLatency', p95: 'visionP95', dot: 'statusDotVision', card: 'monitorCardVision' }, ner: { req: 'nerRequests', err: 'nerErrorRate', lat: 'nerLatency', p95: 'nerP95', dot: 'statusDotNer', card: 'monitorCardNer' }, }; for (const [model, ids] of Object.entries(modelMap)) { const stats = data.models?.[model]; if (!stats) continue; const setTxt = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; setTxt(ids.req, stats.total_requests); setTxt(ids.err, (stats.error_rate * 100).toFixed(1) + '%'); setTxt(ids.lat, stats.latency?.avg ? stats.latency.avg.toFixed(2) + 's' : '--'); setTxt(ids.p95, stats.latency?.p95 ? stats.latency.p95.toFixed(2) + 's' : '--'); const dot = document.getElementById(ids.dot); if (dot) { dot.className = 'monitor-status-dot ' + (stats.ready ? 'ready' : 'not-ready'); } const card = document.getElementById(ids.card); if (card) { card.classList.remove('alert-warning', 'alert-critical'); if (stats.error_rate >= 0.25) card.classList.add('alert-critical'); else if (stats.error_rate >= 0.1) card.classList.add('alert-warning'); } } // Queue if (data.queue) { const setTxt = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; setTxt('queueActive', data.queue.active_inferences); setTxt('queueWaiting', data.queue.queue_length); setTxt('queueMaxConc', data.queue.max_concurrent); setTxt('queueAvgTime', data.queue.avg_inference_seconds + 's'); } // Connections const setTxt = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; setTxt('connHttp', data.active_http_connections || 0); setTxt('connWs', data.active_websockets || 0); // Alerts const alertsBanner = document.getElementById('alertsBanner'); if (alertsBanner) { if (data.alerts && data.alerts.length > 0) { alertsBanner.classList.remove('hidden'); alertsBanner.innerHTML = data.alerts.map(a => { const sev = escapeHTML(String(a.severity || '')); const desc = escapeHTML(String(a.description || '')); return `
${sev} ${desc}
`; }).join(''); } else { alertsBanner.classList.add('hidden'); alertsBanner.innerHTML = ''; } } } function requireAuthentication() { if (!state.authEnabled) return true; if (!state.accessToken) { showLoginOverlay(); return false; } return true; } async function refreshCurrentUser() { if (!state.authEnabled || !state.accessToken) { state.currentUser = { ...LOCAL_USER }; updateAuthUI(); return true; } try { const resp = await apiFetch('/api/auth/me'); if (resp.ok) { state.currentUser = await resp.json(); updateAuthUI(); return true; } } catch (e) { console.warn('Failed to refresh user:', e); } state.currentUser = { ...LOCAL_USER }; updateAuthUI(); return false; } async function setupAuth() { try { const resp = await fetch('/api/auth/status', { headers: { 'ngrok-skip-browser-warning': '1' } }); if (resp.ok) { const data = await resp.json(); state.authEnabled = data.auth_enabled; state.mfaEnabled = data.mfa_enabled; state.sessionTimeoutMinutes = data.session_timeout_minutes || 15; state.consentTrackingEnabled = data.consent_tracking_enabled; } } catch (e) { console.warn('Auth status check failed, assuming disabled:', e); state.authEnabled = false; } if (state.authEnabled) { showLoginOverlay(); setupInactivityTimer(); } else { hideLoginOverlay(); } // Logout button const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) { logoutBtn.addEventListener('click', handleLogout); logoutBtn.classList.toggle('hidden', !state.authEnabled); } updateAuthUI(); } // --- Login overlay --- function showLoginOverlay() { const overlay = document.getElementById('loginOverlay'); if (overlay) overlay.classList.remove('hidden'); } function hideLoginOverlay() { const overlay = document.getElementById('loginOverlay'); if (overlay) overlay.classList.add('hidden'); } async function handleLogin() { const username = document.getElementById('loginUsername')?.value?.trim(); const password = document.getElementById('loginPassword')?.value; const totpCode = document.getElementById('loginTotp')?.value?.trim(); const mfaToken = document.getElementById('loginOverlay')?.dataset?.mfaToken; const errorEl = document.getElementById('loginError'); if (!username || !password) { if (errorEl) errorEl.textContent = 'Username and password required'; return; } try { const body = { username, password }; if (mfaToken && totpCode) { body.mfa_token = mfaToken; body.totp_code = totpCode; } else if (totpCode) { body.totp_code = totpCode; } const resp = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await resp.json(); if (!resp.ok) { if (errorEl) errorEl.textContent = data.detail || 'Login failed'; return; } // MFA challenge if (data.mfa_required) { const mfaField = document.getElementById('mfaField'); if (mfaField) mfaField.classList.remove('hidden'); const overlay = document.getElementById('loginOverlay'); if (overlay) overlay.dataset.mfaToken = data.mfa_token; if (errorEl) errorEl.textContent = 'Enter your authenticator code'; return; } // Success state.accessToken = data.access_token; state.refreshToken = data.refresh_token; state.currentUser = data.user; hideLoginOverlay(); updateAuthUI(); scheduleTokenRefresh(); resetInactivityTimer(); // Clear form if (document.getElementById('loginPassword')) document.getElementById('loginPassword').value = ''; if (document.getElementById('loginTotp')) document.getElementById('loginTotp').value = ''; if (errorEl) errorEl.textContent = ''; } catch (e) { if (errorEl) errorEl.textContent = 'Connection error'; console.error('Login error:', e); } } async function handleLogout() { if (state.refreshToken) { try { await apiFetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: state.refreshToken }), }); } catch (e) { console.warn('Logout request failed:', e); } } state.accessToken = null; state.refreshToken = null; state.currentUser = { ...LOCAL_USER }; clearTimeout(state.tokenRefreshTimer); updateAuthUI(); if (state.authEnabled) showLoginOverlay(); } async function tryRefreshToken() { if (!state.refreshToken) return false; try { const resp = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: state.refreshToken }), }); if (resp.ok) { const data = await resp.json(); state.accessToken = data.access_token; scheduleTokenRefresh(); return true; } } catch (e) { console.warn('Token refresh failed:', e); } return false; } function scheduleTokenRefresh() { clearTimeout(state.tokenRefreshTimer); // Refresh 2 minutes before expiry const refreshMs = Math.max((state.sessionTimeoutMinutes - 2) * 60 * 1000, 60000); state.tokenRefreshTimer = setTimeout(async () => { const ok = await tryRefreshToken(); if (!ok && state.authEnabled) showLoginOverlay(); }, refreshMs); } // --- Inactivity timeout --- function setupInactivityTimer() { const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click']; events.forEach(evt => document.addEventListener(evt, resetInactivityTimer, { passive: true }) ); resetInactivityTimer(); } function resetInactivityTimer() { clearTimeout(state.inactivityTimer); clearTimeout(state.inactivityWarningTimer); hideTimeoutWarning(); if (!state.authEnabled || !state.accessToken) return; const timeoutMs = state.sessionTimeoutMinutes * 60 * 1000; const warningMs = Math.max(timeoutMs - 120000, 0); // Warn 2 min before state.inactivityWarningTimer = setTimeout(showTimeoutWarning, warningMs); state.inactivityTimer = setTimeout(() => { hideTimeoutWarning(); handleLogout(); }, timeoutMs); } function showTimeoutWarning() { const banner = document.getElementById('timeoutWarning'); if (banner) banner.classList.remove('hidden'); } function hideTimeoutWarning() { const banner = document.getElementById('timeoutWarning'); if (banner) banner.classList.add('hidden'); } // --- Consent dialog --- async function checkAndRecordConsent(sessionId) { if (!state.consentTrackingEnabled) return true; // Check existing consent try { const resp = await apiFetch(`/api/auth/consent/${sessionId}`); if (resp.ok) { const data = await resp.json(); if (data.has_consent) return true; } } catch (e) { /* proceed to ask */ } // Show consent dialog return new Promise((resolve) => { const dialog = document.getElementById('consentDialog'); if (!dialog) { resolve(true); return; } dialog.classList.remove('hidden'); const yesBtn = document.getElementById('consentYes'); const noBtn = document.getElementById('consentNo'); const cleanup = () => { dialog.classList.add('hidden'); yesBtn?.removeEventListener('click', onYes); noBtn?.removeEventListener('click', onNo); }; const onYes = async () => { cleanup(); try { await apiFetch('/api/auth/consent/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, consent_type: 'verbal' }), }); resolve(true); } catch (e) { console.error('Consent recording failed:', e); resolve(false); } }; const onNo = () => { cleanup(); resolve(false); }; yesBtn?.addEventListener('click', onYes); noBtn?.addEventListener('click', onNo); }); } // ===================================================== // WEBSOCKET STREAMING // ===================================================== /** * Build the WebSocket URL from current page location. * Handles both local dev (ws://) and ngrok/production (wss://). */ function getWebSocketUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/ws/transcribe`; } /** * Connect to the WebSocket streaming endpoint. * Returns a promise that resolves when the connection is ready. */ function connectWebSocket() { return new Promise((resolve, reject) => { const url = getWebSocketUrl(); console.log(`[WS] Connecting to ${url}`); const ws = new WebSocket(url); let resolved = false; ws.onopen = () => { console.log('[WS] Connection opened'); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); // Resolve the promise on first "connected" message if (data.type === 'connected' && !resolved) { resolved = true; state.wsConnected = true; resolve(ws); } } catch (e) { console.error('[WS] Failed to parse message:', e); } }; ws.onerror = (error) => { console.error('[WS] Error:', error); state.wsConnected = false; if (!resolved) { resolved = true; reject(error); } }; ws.onclose = (event) => { console.log(`[WS] Connection closed (code: ${event.code})`); state.wsConnected = false; state.websocket = null; }; // Timeout after 5 seconds setTimeout(() => { if (!resolved) { resolved = true; ws.close(); reject(new Error('WebSocket connection timeout')); } }, 5000); }); } /** * Handle incoming WebSocket messages (partial/final transcripts). */ function handleWebSocketMessage(data) { switch (data.type) { case 'connected': console.log('[WS] Server ready'); break; case 'partial': // Update live transcript with new words state.liveTranscript = data.full_text || ''; updateLiveTranscript(data.text, data.full_text); break; case 'final': // Final transcript received state.liveTranscript = data.full_text || data.text || ''; updateLiveTranscriptFinal(state.liveTranscript); console.log(`[WS] Final transcript: ${state.liveTranscript.length} chars`); break; case 'error': console.error('[WS] Server error:', data.message); elements.voiceStatus.textContent = `Error: ${data.message}`; break; default: console.log('[WS] Unknown message type:', data.type); } } /** * Update the live transcript display with new words (animated). */ function updateLiveTranscript(deltaText, fullText) { if (!elements.liveTranscriptText) return; // Remove placeholder if present const placeholder = elements.liveTranscriptText.querySelector('.live-placeholder'); if (placeholder) { placeholder.remove(); } if (deltaText && deltaText.trim()) { // Add new words with animation const words = deltaText.trim().split(/\s+/); words.forEach((word, i) => { const span = document.createElement('span'); span.className = 'live-word'; span.textContent = (elements.liveTranscriptText.textContent.trim() ? ' ' : '') + word; span.style.animationDelay = `${i * 0.05}s`; elements.liveTranscriptText.appendChild(span); }); } else if (fullText) { // Full replacement (correction case) — no animation elements.liveTranscriptText.textContent = fullText; } // Auto-scroll to bottom elements.liveTranscriptText.scrollTop = elements.liveTranscriptText.scrollHeight; } /** * Set the final transcript text (removes animations). */ function updateLiveTranscriptFinal(text) { if (!elements.liveTranscriptText) return; // Replace with plain text (no more animation) elements.liveTranscriptText.textContent = text || 'No speech detected.'; elements.liveTranscriptText.style.borderLeftColor = '#10b981'; // Green = finalized } /** * Show the live transcript panel. */ function showLiveTranscript() { elements.emptyState?.classList.add('hidden'); elements.loadingState?.classList.add('hidden'); elements.resultsContainer?.classList.add('hidden'); elements.liveTranscriptState?.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Live Transcript'; // Reset the live transcript text if (elements.liveTranscriptText) { elements.liveTranscriptText.innerHTML = 'Listening... speak clearly into your microphone'; elements.liveTranscriptText.style.borderLeftColor = '#ef4444'; // Red = live } } /** * Hide the live transcript panel. */ function hideLiveTranscript() { elements.liveTranscriptState?.classList.add('hidden'); } // ===================================================== // SETTINGS & NEURAL CONFIG // ===================================================== // Theme cycle order for header toggle const THEME_ORDER = ['glass', 'light', 'neon', 'midnight', 'aurora', 'high-contrast']; // Meta theme-color mapping per theme const THEME_META_COLORS = { glass: '#0f111a', neon: '#050505', midnight: '#000000', light: '#f0f4f8', aurora: '#141020', 'high-contrast': '#000000' }; function setupSettings() { // Theme Switching const themeCards = document.querySelectorAll('.theme-card'); // Determine initial theme: saved > OS preference > default (glass) let initialTheme = localStorage.getItem('voxdoc_theme'); if (!initialTheme) { // First visit — detect OS preference if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { initialTheme = 'light'; } else { initialTheme = 'glass'; } } applyTheme(initialTheme); // Listen for OS theme changes (live) if (window.matchMedia) { window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => { // Only auto-switch if user hasn't explicitly picked a theme const userPicked = localStorage.getItem('voxdoc_theme'); if (!userPicked) { applyTheme(e.matches ? 'light' : 'glass'); } }); } const savedSound = localStorage.getItem('voxdoc_sound') === 'true'; const soundToggle = document.getElementById('soundToggle'); if (soundToggle) soundToggle.checked = savedSound; themeCards.forEach(card => { const activateTheme = () => { const theme = card.dataset.theme; applyTheme(theme); saveSettings('voxdoc_theme', theme); }; card.addEventListener('click', activateTheme); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activateTheme(); } }); }); // Header quick-toggle setupThemeToggle(); // Neural Graph Animation setupNeuralGraph(); // Sliders setupSliders(); } function applyTheme(theme) { // Remove all theme classes THEME_ORDER.forEach(t => { if (t !== 'glass') document.body.classList.remove(`theme-${t}`); }); // Apply new theme class (glass = no class, uses :root defaults) if (theme !== 'glass') { document.body.classList.add(`theme-${theme}`); } // Update const metaTheme = document.querySelector('meta[name="theme-color"]'); if (metaTheme) { metaTheme.setAttribute('content', THEME_META_COLORS[theme] || '#0f111a'); } // Update settings panel theme cards active state document.querySelectorAll('.theme-card').forEach(c => { c.classList.toggle('active', c.dataset.theme === theme); }); // Store current theme for toggle cycling window._currentTheme = theme; } function setupThemeToggle() { const toggleBtn = document.getElementById('themeToggle'); if (!toggleBtn) return; toggleBtn.addEventListener('click', () => { const current = window._currentTheme || 'glass'; const currentIndex = THEME_ORDER.indexOf(current); const nextIndex = (currentIndex + 1) % THEME_ORDER.length; const nextTheme = THEME_ORDER[nextIndex]; applyTheme(nextTheme); saveSettings('voxdoc_theme', nextTheme); // Subtle spin animation on click toggleBtn.style.transform = 'rotate(360deg)'; setTimeout(() => { toggleBtn.style.transform = ''; }, 400); }); } function saveSettings(key, value) { localStorage.setItem(key, value); } function setupSliders() { const sliders = [ { id: 'depthSlider', display: 'depthValue', unit: '%' }, { id: 'empathySlider', display: 'empathyValue', map: { 1: 'Low', 2: 'Medium', 3: 'High' } }, { id: 'particleSlider', display: null } // Just visual ]; sliders.forEach(s => { const el = document.getElementById(s.id); const display = s.display ? document.getElementById(s.display) : null; if (el) { el.addEventListener('input', (e) => { if (display) { if (s.map) { display.textContent = s.map[e.target.value]; } else { display.textContent = e.target.value + (s.unit || ''); } } }); } }); // Sound toggle document.getElementById('soundToggle').addEventListener('change', (e) => { saveSettings('voxdoc_sound', e.target.checked); }); } function setupNeuralGraph() { const canvas = document.getElementById('neuralGraph'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Resize handling const resizeObserver = new ResizeObserver(() => { canvas.width = canvas.parentElement.clientWidth; canvas.height = canvas.parentElement.clientHeight; }); resizeObserver.observe(canvas.parentElement); // Animation loop const dataPoints = new Array(50).fill(0); function draw() { if (document.getElementById('settingsView').classList.contains('hidden')) { requestAnimationFrame(draw); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); // Update data dataPoints.shift(); dataPoints.push(Math.random() * 0.8 + 0.1); // Random activity // Draw line ctx.beginPath(); const step = canvas.width / (dataPoints.length - 1); dataPoints.forEach((val, i) => { const x = i * step; const y = canvas.height - (val * canvas.height * 0.8) - 10; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = getComputedStyle(document.body).getPropertyValue('--violet-500'); ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); // Add glow ctx.shadowBlur = 10; ctx.shadowColor = ctx.strokeStyle; ctx.stroke(); ctx.shadowBlur = 0; // Reset requestAnimationFrame(draw); } draw(); } // ===================================================== // INITIALIZATION // ===================================================== async function init() { setupNavigation(); await setupAuth(); setupRecording(); setupTextInput(); setupImageUpload(); setupSubmit(); setupActions(); setupSOAPActions(); setupSettings(); await refreshCurrentUser(); await loadSessionHistory(); setupPDFExport(); setupPWA(); setupLoginForm(); } function setupLoginForm() { const loginBtn = document.getElementById('loginBtn'); if (loginBtn) loginBtn.addEventListener('click', handleLogin); // Allow Enter key in login form const loginPassword = document.getElementById('loginPassword'); if (loginPassword) { loginPassword.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleLogin(); }); } const loginTotp = document.getElementById('loginTotp'); if (loginTotp) { loginTotp.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleLogin(); }); } } // ===================================================== // PWA & OFFLINE SETUP // ===================================================== function setupPWA() { // 1. Register Service Worker if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js') .then(reg => console.log('SW registered!', reg)) .catch(err => console.error('SW registration failed', err)); }); } // 2. Handle Network Status const updateOnlineStatus = () => { state.isOffline = !navigator.onLine; if (state.isOffline) { elements.offlineBadge?.classList.remove('hidden'); console.warn("App is offline. API calls may fail."); } else { elements.offlineBadge?.classList.add('hidden'); console.log("App is back online. Attempting to sync..."); syncOfflineQueue(); } }; window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); updateOnlineStatus(); // Set initial // 3. Handle Install Prompt window.addEventListener('beforeinstallprompt', (e) => { // Prevent Chrome 67 and earlier from automatically showing the prompt e.preventDefault(); // Stash the event so it can be triggered later. state.deferredInstallPrompt = e; // Update UI to notify the user they can add to home screen elements.installAppBtn?.classList.remove('hidden'); }); elements.installAppBtn?.addEventListener('click', async () => { if (!state.deferredInstallPrompt) return; // Show the prompt state.deferredInstallPrompt.prompt(); // Wait for the user to respond to the prompt const { outcome } = await state.deferredInstallPrompt.userChoice; console.log(`User response to the install prompt: ${outcome}`); // We've used the prompt, and can't use it again, throw it away state.deferredInstallPrompt = null; elements.installAppBtn.classList.add('hidden'); }); window.addEventListener('appinstalled', () => { // Hide the app-provided install promotion elements.installAppBtn?.classList.add('hidden'); // Clear the deferredPrompt so it can be garbage collected state.deferredInstallPrompt = null; console.log('PWA was installed'); }); } // Simple IndexedDB wrapper for Offline Queue const dbPromise = (() => { return new Promise((resolve, reject) => { const request = indexedDB.open('VoxDocDB', 1); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('offlineQueue')) { db.createObjectStore('offlineQueue', { keyPath: 'id' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); })(); async function saveToOfflineQueue(type, data) { try { const db = await dbPromise; const tx = db.transaction('offlineQueue', 'readwrite'); const store = tx.objectStore('offlineQueue'); const item = { id: Date.now().toString(), timestamp: new Date().toISOString(), type: type, // 'audio' or 'text' data: data }; await new Promise((resolve, reject) => { const req = store.add(item); req.onsuccess = resolve; req.onerror = reject; }); alert(`You are offline. ${type === 'audio' ? 'Recording' : 'Text'} saved locally and will sync when reconnected.`); } catch (e) { console.error("Failed to save to offline queue", e); } } async function syncOfflineQueue() { try { const db = await dbPromise; const tx = db.transaction('offlineQueue', 'readonly'); const store = tx.objectStore('offlineQueue'); const req = store.getAll(); req.onsuccess = async () => { const items = req.result; if (!items || items.length === 0) return; console.log(`Syncing ${items.length} offline items...`); for (const item of items) { try { if (item.type === 'audio') { // Re-submit audio const formData = new FormData(); formData.append('audio', item.data.blob, "offline_recording.webm"); // Assuming a submitAudio function exists or re-implementing the logic here const response = await apiFetch('/api/voice-intake', { method: 'POST', body: formData }); if (!response.ok) throw new Error('Failed to re-submit audio'); // Process response if needed, for now just delete from queue } else if (item.type === 'text') { // Re-submit text // Assuming a submitText function exists or re-implementing the logic here const response = await apiFetch('/api/document', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: item.data.text }) }); if (!response.ok) throw new Error('Failed to re-submit text'); // Process response if needed } // On success, clean from queue const delTx = db.transaction('offlineQueue', 'readwrite'); delTx.objectStore('offlineQueue').delete(item.id); } catch (e) { console.error("Sync failed for item", item.id, e); // Leave in queue } } console.log("Offline sync complete"); }; } catch (e) { console.error("Failed to access offline queue for sync", e); } } // ===================================================== // NAVIGATION & SIDEBAR // ===================================================== function setupNavigation() { // Sidebar Toggles elements.openSidebarBtn?.addEventListener('click', openSidebar); elements.closeSidebarBtn?.addEventListener('click', closeSidebar); elements.sidebarOverlay?.addEventListener('click', closeSidebar); // Navigation Tabs const dashboardBtn = document.querySelector('[data-tab="dashboard"]'); const settingsBtn = document.querySelector('[data-tab="settings"]'); const historyBtn = document.querySelector('[data-tab="history"]'); const monitoringBtn = document.querySelector('[data-tab="monitoring"]'); const hipaaBtn = document.querySelector('[data-tab="hipaa"]'); // Views const dashboardView = document.getElementById('dashboardView'); const settingsView = document.getElementById('settingsView'); const historyView = document.getElementById('historyView'); const monitoringView = document.getElementById('monitoringView'); const hipaaView = document.getElementById('hipaaView'); const allViews = [dashboardView, settingsView, historyView, monitoringView, hipaaView]; const allBtns = [dashboardBtn, settingsBtn, historyBtn, monitoringBtn, hipaaBtn]; function setActiveTab(activeBtn) { allBtns.forEach(btn => { if (btn) btn.classList.remove('active'); }); if (activeBtn) activeBtn.classList.add('active'); closeSidebar(); } function hideAllViews() { allViews.forEach(v => { if (v) v.classList.add('hidden'); }); } function handleNavKeydown(e, actionObj) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); actionObj.click(); } } if (dashboardBtn) { dashboardBtn.addEventListener('click', () => { setActiveTab(dashboardBtn); hideAllViews(); if (dashboardView) dashboardView.classList.remove('hidden'); }); dashboardBtn.addEventListener('keydown', (e) => handleNavKeydown(e, dashboardBtn)); } if (historyBtn) { historyBtn.addEventListener('click', () => { setActiveTab(historyBtn); hideAllViews(); if (historyView) historyView.classList.remove('hidden'); renderHistoryView(); }); historyBtn.addEventListener('keydown', (e) => handleNavKeydown(e, historyBtn)); } if (settingsBtn) { settingsBtn.addEventListener('click', () => { setActiveTab(settingsBtn); hideAllViews(); if (settingsView) settingsView.classList.remove('hidden'); setupNeuralGraph(); }); settingsBtn.addEventListener('keydown', (e) => handleNavKeydown(e, settingsBtn)); } if (monitoringBtn) { monitoringBtn.addEventListener('click', () => { setActiveTab(monitoringBtn); hideAllViews(); if (monitoringView) monitoringView.classList.remove('hidden'); loadMonitoringDashboard(); }); monitoringBtn.addEventListener('keydown', (e) => handleNavKeydown(e, monitoringBtn)); } if (hipaaBtn) { hipaaBtn.addEventListener('click', () => { setActiveTab(hipaaBtn); hideAllViews(); if (hipaaView) hipaaView.classList.remove('hidden'); loadHipaaDashboard(); }); hipaaBtn.addEventListener('keydown', (e) => handleNavKeydown(e, hipaaBtn)); } // Refresh button in monitoring view const refreshBtn = document.getElementById('refreshMonitoring'); if (refreshBtn) { refreshBtn.addEventListener('click', loadMonitoringDashboard); } // HIPAA refresh and purge buttons const refreshHipaaBtn = document.getElementById('refreshHipaa'); if (refreshHipaaBtn) { refreshHipaaBtn.addEventListener('click', loadHipaaDashboard); } const purgeBtn = document.getElementById('hipaaPurgeBtn'); if (purgeBtn) { purgeBtn.addEventListener('click', runHipaaPurge); } } function renderHistoryView() { const historyGrid = document.getElementById('historyGrid'); const emptyState = document.getElementById('historyEmptyState'); if (!historyGrid || !emptyState) return; if (!state.sessionHistory || state.sessionHistory.length === 0) { historyGrid.innerHTML = ''; historyGrid.classList.add('hidden'); emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); historyGrid.classList.remove('hidden'); historyGrid.innerHTML = state.sessionHistory.map(session => { const date = new Date(session.timestamp); const displayDate = date.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); const displayTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return `

${escapeHTML(truncate(session.chiefComplaint, 40))}

${displayDate} at ${displayTime}

Subjective: ${escapeHTML(truncate(session.soapS || '', 80))}

Assessment: ${escapeHTML(truncate(session.soapA || '', 80))}

`; }).join(''); // Attach click listeners to load session document.querySelectorAll('.history-card').forEach(card => { const id = card.getAttribute('data-id'); card.addEventListener('click', (e) => loadSessionIntoDashboard(id)); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadSessionIntoDashboard(id); } }); }); } async function loadSessionIntoDashboard(sessionId) { try { if (!requireAuthentication()) return; const response = await apiFetch(`/api/sessions/${sessionId}`, { method: 'GET' }); if (!response.ok) throw new Error('Failed to fetch session'); const session = await response.json(); // Populate UI elements.transcriptionText.textContent = session.transcript || 'N/A'; // Mock documentation object shape for displayResults const mockData = { transcript: session.transcript || 'N/A', detected_language: session.detected_language || 'en', documentation: { chief_complaint: session.chief_complaint || 'N/A', symptom_details: { symptoms_mentioned: ["Loaded from history"], onset: "N/A", duration: "N/A", location: "N/A", aggravating_factors: "N/A", severity_description: "N/A" }, field_confidence: { chief_complaint: { score: 0.45, color: "yellow", verification_text: "Needs quick verification" }, symptom_details: { symptoms_mentioned: { score: 0.45, color: "yellow", verification_text: "Needs quick verification" }, onset: { score: 0.30, color: "red", verification_text: "Needs verification" }, duration: { score: 0.30, color: "red", verification_text: "Needs verification" }, location: { score: 0.30, color: "red", verification_text: "Needs verification" }, aggravating_factors: { score: 0.30, color: "red", verification_text: "Needs verification" }, severity_description: { score: 0.25, color: "red", verification_text: "Needs verification" } } }, soap_note_subjective: session.soap_subjective || '', soap_note_objective: session.soap_objective || '', soap_note_assessment: session.soap_assessment || '', soap_note_plan: session.soap_plan || '' }, extracted_entities: { conditions: [], medications: [] }, compliance_metadata: { hipaa: { minimum_necessary_mode: true }, medgemma_terms: { acknowledged: true } } }; state.currentDocumentation = mockData; displayResults(mockData); // Switch to dashboard view const dashboardBtn = document.querySelector('[data-tab="dashboard"]'); if (dashboardBtn) dashboardBtn.click(); } catch (e) { console.error("Failed to load session into dashboard", e); alert("Failed to load session details."); } } function openSidebar() { elements.sidebar?.classList.add('open'); elements.sidebarOverlay?.classList.add('active'); elements.openSidebarBtn?.setAttribute('aria-expanded', 'true'); // Focus management for accessibility document.querySelector('[data-tab="dashboard"]')?.focus(); } function closeSidebar() { elements.sidebar?.classList.remove('open'); elements.sidebarOverlay?.classList.remove('active'); elements.openSidebarBtn?.setAttribute('aria-expanded', 'false'); // Return focus if on mobile if (window.innerWidth < 1024) { elements.openSidebarBtn?.focus(); } } // ===================================================== // RECORDING (with WebSocket Streaming) // ===================================================== function setupRecording() { elements.recordBtn?.addEventListener('click', toggleRecording); } async function toggleRecording() { if (state.isRecording) { stopRecording(); } else { await startRecording(); } } async function startRecording() { try { if (!requireAuthentication()) return; const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Try to connect WebSocket first let wsConnected = false; try { state.websocket = await connectWebSocket(); wsConnected = true; state.streamingMode = true; console.log('[Recording] WebSocket streaming mode enabled'); } catch (wsErr) { console.warn('[Recording] WebSocket failed, falling back to upload mode:', wsErr); state.streamingMode = false; } state.mediaRecorder = new MediaRecorder(stream); state.audioChunks = []; state.liveTranscript = ''; state.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { state.audioChunks.push(e.data); // Stream audio chunk via WebSocket (if connected) if (state.streamingMode && state.websocket && state.websocket.readyState === WebSocket.OPEN) { e.data.arrayBuffer().then(buffer => { state.websocket.send(buffer); }).catch(err => { console.warn('[WS] Failed to send chunk:', err); }); } } }; state.mediaRecorder.onstop = () => { state.audioBlob = new Blob(state.audioChunks, { type: 'audio/webm' }); state.audioUrl = URL.createObjectURL(state.audioBlob); stream.getTracks().forEach(track => track.stop()); updateSubmitButton(); }; // Use 500ms timeslice for streaming chunks state.mediaRecorder.start(500); state.isRecording = true; state.recordingStartTime = Date.now(); // UI Updates elements.recordBtn?.classList.add('recording'); elements.recordBtn?.setAttribute('aria-pressed', 'true'); elements.micIcon?.classList.add('hidden'); elements.stopIcon?.classList.remove('hidden'); elements.waveformContainer?.classList.add('recording'); elements.recordRipple?.classList.add('active'); elements.durationDisplay?.classList.remove('hidden'); // Show live transcript if streaming if (state.streamingMode) { elements.voiceStatus.textContent = 'Listening and transcribing live...'; showLiveTranscript(); } else { elements.voiceStatus.textContent = 'Recording... (will transcribe after stop)'; } // Start timer state.recordingInterval = setInterval(updateRecordingTime, 1000); } catch (error) { console.error('Microphone access denied:', error); alert('Microphone access is required for voice recording.'); } } function stopRecording() { if (state.mediaRecorder && state.isRecording) { state.mediaRecorder.stop(); state.isRecording = false; // UI Updates elements.recordBtn?.classList.remove('recording'); elements.recordBtn?.setAttribute('aria-pressed', 'false'); elements.micIcon?.classList.remove('hidden'); elements.stopIcon?.classList.add('hidden'); elements.waveformContainer?.classList.remove('recording'); elements.recordRipple?.classList.remove('active'); // Stop timer clearInterval(state.recordingInterval); // Send stop signal via WebSocket and wait for final transcript if (state.streamingMode && state.websocket && state.websocket.readyState === WebSocket.OPEN) { elements.voiceStatus.textContent = 'Finalizing transcript...'; state.websocket.send(JSON.stringify({ action: 'stop' })); // Wait a moment for the final transcript, then close setTimeout(() => { if (state.websocket && state.websocket.readyState === WebSocket.OPEN) { state.websocket.close(); } elements.voiceStatus.textContent = 'Recording complete. Ready to generate documentation.'; }, 3000); } else { elements.voiceStatus.textContent = 'Recording complete. Ready to generate documentation.'; } } } function updateRecordingTime() { if (!state.recordingStartTime) return; const elapsed = Math.floor((Date.now() - state.recordingStartTime) / 1000); const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0'); const seconds = (elapsed % 60).toString().padStart(2, '0'); if (elements.recordingTime) { elements.recordingTime.textContent = `${ minutes }: ${ seconds }`; } } // ===================================================== // TEXT INPUT // ===================================================== function setupTextInput() { elements.textInput?.addEventListener('input', () => { updateSubmitButton(); }); } // ===================================================== // IMAGE UPLOAD // ===================================================== function setupImageUpload() { if (!elements.imageDropZone || !elements.imageFileInput) return; // Click to open file dialog elements.imageDropZone.addEventListener('click', (e) => { // Prevent click if clicking the remove button if (!e.target.closest('.remove-image-btn')) { elements.imageFileInput.click(); } }); // Keyboard space/enter to open file dialog elements.imageDropZone.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!e.target.closest('.remove-image-btn')) { elements.imageFileInput.click(); } } }); // File input change elements.imageFileInput.addEventListener('change', (e) => { if (e.target.files && e.target.files.length > 0) { handleImageSelection(e.target.files[0]); } }); // Drag and Drop elements.imageDropZone.addEventListener('dragover', (e) => { e.preventDefault(); elements.imageDropZone.classList.add('drag-over'); }); elements.imageDropZone.addEventListener('dragleave', () => { elements.imageDropZone.classList.remove('drag-over'); }); elements.imageDropZone.addEventListener('drop', (e) => { e.preventDefault(); elements.imageDropZone.classList.remove('drag-over'); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleImageSelection(e.dataTransfer.files[0]); } }); // Remove Image elements.removeImageBtn?.addEventListener('click', (e) => { e.stopPropagation(); clearImageSelection(); }); } function handleImageSelection(file) { // Validate file type const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; if (!validTypes.includes(file.type)) { alert('Please select a valid image file (JPEG, PNG, WebP).'); return; } // Validate file size (10MB max) const maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { alert(`File is too large(${(file.size / (1024 * 1024)).toFixed(1)}MB). Max allowed size is 10MB.`); return; } state.uploadedImageFile = file; // Update UI const reader = new FileReader(); reader.onload = (e) => { elements.imagePreview.src = e.target.result; elements.imageFilename.textContent = file.name; elements.imageFilesize.textContent = formatBytes(file.size); elements.dropZoneContent.classList.add('hidden'); elements.imagePreviewContainer.classList.remove('hidden'); updateSubmitButton(); }; reader.readAsDataURL(file); } function clearImageSelection() { state.uploadedImageFile = null; state.imageAnalysis = null; // Reset file input elements.imageFileInput.value = ''; // Update UI elements.dropZoneContent.classList.remove('hidden'); elements.imagePreviewContainer.classList.add('hidden'); elements.imagePreview.src = ''; updateSubmitButton(); } function formatBytes(bytes, decimals = 1) { if (!+bytes) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${ parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) } ${ sizes[i] } `; } function updateSubmitButton() { const hasAudio = state.audioBlob !== null; const textContent = elements.textInput?.value.trim(); const hasLiveTranscript = state.liveTranscript && state.liveTranscript.trim().length > 0; const hasImage = state.uploadedImageFile !== null; if (elements.submitBtn) { elements.submitBtn.disabled = !(hasAudio || textContent || hasLiveTranscript || hasImage); } } // ===================================================== // SUBMIT & PROCESSING // ===================================================== function setupSubmit() { elements.submitBtn?.addEventListener('click', processInput); // Follow-up questions: submit answers elements.followupSubmitBtn?.addEventListener('click', async () => { const qa = collectFollowupAnswers(); const payload = { ...state.pendingDocPayload }; if (qa.length > 0) { payload.followup_qa = qa; } await generateDocumentation(payload, state.pendingHasAudio); }); // Follow-up questions: skip elements.followupSkipBtn?.addEventListener('click', async () => { await generateDocumentation(state.pendingDocPayload, state.pendingHasAudio); }); } async function processInput() { const hasAudio = state.audioBlob !== null; const textContent = elements.textInput?.value.trim(); const hasLiveTranscript = state.liveTranscript && state.liveTranscript.trim().length > 0; const hasImage = state.uploadedImageFile !== null; if (!hasAudio && !textContent && !hasLiveTranscript && !hasImage) return; if (!requireAuthentication()) return; // Hide live transcript and show loading hideLiveTranscript(); showLoading(); // Update loading text if (hasImage) { elements.imagePreviewContainer.classList.add('hidden'); elements.imageAnalyzingState.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Analyzing Image...'; } try { let response; let imageFindingsData = null; // 1. Process image first if present if (hasImage) { const imageFormData = new FormData(); imageFormData.append('image', state.uploadedImageFile); const imgResponse = await apiFetch('/api/analyze-image', { method: 'POST', body: imageFormData }); if (!imgResponse.ok) { const errorData = await imgResponse.json(); throw new Error(`Image analysis failed: ${ errorData.detail || 'Unknown error' } `); } const imgResult = await imgResponse.json(); imageFindingsData = imgResult.image_analysis; state.imageAnalysis = imageFindingsData; elements.imageAnalyzingState.classList.add('hidden'); elements.imagePreviewContainer.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Generating Documentation...'; } // 2. Determine what transcript to use for documentation let documentPayload = {}; if (hasLiveTranscript && state.streamingMode) { documentPayload = { transcript: state.liveTranscript }; } else if (hasAudio && !state.streamingMode) { // Transcribe audio first via voice-intake const formData = new FormData(); formData.append('audio', state.audioBlob, 'recording.webm'); try { response = await apiFetch('/api/voice-intake', { method: 'POST', body: formData }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.detail || 'Processing failed'); } const data = await response.json(); documentPayload = { transcript: data.transcript }; } catch (error) { console.error('Audio processing error:', error); if (state.isOffline || error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { await saveToOfflineQueue('audio', { blob: state.audioBlob }); showError('You are offline. Audio saved locally and will sync when reconnected.'); return; } else { throw error; } } } else if (textContent) { documentPayload = { transcript: textContent }; } else if (hasImage) { // Image-only: use image findings as the transcript context const findingsText = imageFindingsData?.visual_findings_text || ''; documentPayload = { transcript: `Patient uploaded a medical image. Visual findings: ${findingsText}` }; } // Add image findings to the document payload if available if (imageFindingsData) { documentPayload.image_findings = imageFindingsData.visual_findings_text; } // 3. Ask follow-up questions before generating documentation if (Object.keys(documentPayload).length > 0) { // Store payload for later use by follow-up handlers state.pendingDocPayload = documentPayload; state.pendingHasAudio = hasAudio; // Build transcript context for follow-up questions // Include image findings so questions are relevant to visual findings too let followupTranscript = documentPayload.transcript; if (imageFindingsData?.visual_findings_text && !followupTranscript.includes(imageFindingsData.visual_findings_text)) { followupTranscript += ` Visual findings from uploaded image: ${imageFindingsData.visual_findings_text}`; } try { const fqResponse = await apiFetch('/api/intake/questions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: followupTranscript }) }); if (fqResponse.ok) { const fqData = await fqResponse.json(); if (fqData.questions && fqData.questions.length > 0) { renderFollowupQuestions(fqData.questions); showFollowup(); return; // Wait for user to submit or skip } } } catch (err) { console.warn('Follow-up questions unavailable, proceeding directly:', err.message); } // If follow-up questions failed or returned none, generate directly await generateDocumentation(documentPayload, hasAudio); } } catch (error) { console.error('Processing error:', error); // Reset image UI on error if (state.uploadedImageFile) { elements.imageAnalyzingState?.classList.add('hidden'); elements.imagePreviewContainer?.classList.remove('hidden'); } showError(error.message); } } function renderFollowupQuestions(questions) { if (!elements.followupQuestions) return; elements.followupQuestions.innerHTML = questions.map((q, i) => `
`).join(''); } function collectFollowupAnswers() { const items = elements.followupQuestions?.querySelectorAll('.followup-question-item') || []; const qa = []; items.forEach((item, i) => { const question = item.querySelector('label')?.textContent?.replace(/^\d+\.\s*/, '') || ''; const answer = item.querySelector('textarea')?.value?.trim() || ''; if (question) { qa.push({ question, answer }); } }); return qa; } async function generateDocumentation(payload, hasAudio) { showLoading(); try { const response = await apiFetch('/api/document', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const errorData = await response.json(); throw new Error(`Documentation generation failed: ${errorData.detail || 'Unknown error'}`); } let data = await response.json(); data.transcript = payload.transcript; data.duration_seconds = state.recordingStartTime && hasAudio ? (Date.now() - state.recordingStartTime) / 1000 : 0; state.currentDocumentation = data; displayResults(data); } catch (error) { console.error('Documentation generation error:', error); if (state.isOffline || error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { showError('You are offline. Could not generate documentation. Please try again when online.'); } else { showError(error.message); } } } // ===================================================== // UI STATE MANAGEMENT // ===================================================== function hideAllStates() { elements.emptyState?.classList.add('hidden'); elements.resultsContainer?.classList.add('hidden'); elements.liveTranscriptState?.classList.add('hidden'); elements.loadingState?.classList.add('hidden'); elements.followupState?.classList.add('hidden'); } function showLoading() { hideAllStates(); elements.loadingState?.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Processing...'; const msgEl = document.getElementById('loadingMessage'); if (msgEl) msgEl.textContent = 'Processing and generating documentation...'; startQueuePolling(); } function showFollowup() { hideAllStates(); elements.followupState?.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Follow-up Questions'; hideQueueStatus(); } function showResults() { hideAllStates(); elements.resultsContainer?.classList.remove('hidden'); elements.transcriptTitle.textContent = 'Documentation Results'; hideQueueStatus(); } function showError(message) { hideAllStates(); elements.transcriptTitle.textContent = 'Error'; hideQueueStatus(); // Create error display const errorHtml = `
Error: ${message}
`; if (elements.resultsContainer) { elements.resultsContainer.innerHTML = errorHtml; elements.resultsContainer.classList.remove('hidden'); } } function displayResults(data) { showResults(); // Transcription if (elements.transcriptionText) { elements.transcriptionText.textContent = data.transcript; } // Detected Language Badge const langBadge = document.getElementById('detectedLanguageBadge'); if (langBadge) { if (data.detected_language && data.detected_language !== 'en') { langBadge.textContent = 'Detected: ' + data.detected_language.toUpperCase(); langBadge.classList.remove('hidden'); } else { langBadge.classList.add('hidden'); } } // Audio Playback (only for voice input) if (state.audioUrl && elements.audioPlaybackSection && elements.resultAudioPlayer) { elements.resultAudioPlayer.src = state.audioUrl; elements.audioPlaybackSection.classList.remove('hidden'); elements.textInputIndicator?.classList.add('hidden'); } else { elements.audioPlaybackSection?.classList.add('hidden'); elements.textInputIndicator?.classList.remove('hidden'); } // Documentation fields const doc = data.documentation || {}; renderComplianceNotice(data, doc); if (elements.chiefComplaint) { elements.chiefComplaint.textContent = doc.chief_complaint || 'N/A'; } // Symptom Details with field-level extraction confidence if (elements.symptomDetails && doc.symptom_details) { elements.symptomDetails.innerHTML = buildSymptomDetailsHtml(doc); } // SOAP Notes + CC/CD/VI — populate and store originals let soapMap = { soapS: doc.soap_note_subjective || 'Patient describes symptoms.', soapO: doc.soap_note_objective || 'Pending clinician assessment.', soapA: doc.soap_note_assessment || 'Pending clinician assessment.', soapP: doc.soap_note_plan || 'Pending clinician assessment.', chiefComplaint: doc.chief_complaint || 'N/A', symptomDetails: elements.symptomDetails?.innerHTML || 'N/A', }; // Handle Image Analysis Display if (state.imageAnalysis && elements.imageFindingsCard && elements.visualFindings) { const viText = state.imageAnalysis.visual_findings_text || state.imageAnalysis.description; elements.imageFindingsCard.classList.remove('hidden'); elements.visualFindings.textContent = viText; soapMap.visualFindings = viText; // Show thumbnail if (elements.imageFindingsThumbnailContainer && state.uploadedImageFile) { const reader = new FileReader(); reader.onload = (e) => { elements.imageFindingsThumbnailContainer.innerHTML = ` Uploaded finding
Uploaded Document Body Area: ${state.imageAnalysis.body_area || 'Not specified'}
Size: ${formatBytes(state.uploadedImageFile.size)}
`; elements.imageFindingsThumbnailContainer.classList.add('active'); }; reader.readAsDataURL(state.uploadedImageFile); } } else if (elements.imageFindingsCard) { elements.imageFindingsCard.classList.add('hidden'); if (elements.imageFindingsThumbnailContainer) { elements.imageFindingsThumbnailContainer.classList.remove('active'); } } for (const [id, text] of Object.entries(soapMap)) { if (elements[id] && id !== 'visualFindings') { if (id === 'symptomDetails') { // Already rendered formatted HTML for Symptom Details } else { elements[id].textContent = text; } } } // Reset approval states and store original AI text state.soapApprovals = { subjective: { status: 'pending', edited: false, originalText: soapMap.soapS, history: [] }, objective: { status: 'pending', edited: false, originalText: soapMap.soapO, history: [] }, assessment: { status: 'pending', edited: false, originalText: soapMap.soapA, history: [] }, plan: { status: 'pending', edited: false, originalText: soapMap.soapP, history: [] }, chief_complaint: { status: 'pending', edited: false, originalText: soapMap.chiefComplaint, history: [] }, clinical_details: { status: 'pending', edited: false, originalText: soapMap.symptomDetails, history: [] }, visual_findings: { status: 'pending', edited: false, originalText: soapMap.visualFindings || '', history: [] } }; // Log initial generation event for (const section of Object.keys(state.soapApprovals)) { logSOAPHistory(section, 'generated', 'AI-generated content'); } resetSOAPCardStates(); // Display NER Extracted Entities displayNEREntities(data.extracted_entities); } function renderComplianceNotice(data, doc) { const complianceEl = document.getElementById('complianceMetaNotice'); if (!complianceEl) return; const metadata = data.compliance_metadata || doc.compliance_metadata || {}; const hipaa = metadata.hipaa || {}; const medgemmaTerms = metadata.medgemma_terms || {}; const hipaaText = hipaa.minimum_necessary_mode === false ? 'PHI persistence is enabled. Validate BAA, encryption, and audit controls.' : 'HIPAA minimum-necessary mode active (PHI persistence disabled).'; const termsText = medgemmaTerms.acknowledged ? 'MedGemma terms acknowledged.' : 'MedGemma terms acknowledgement pending.'; complianceEl.textContent = `${hipaaText} ${termsText}`; } function buildSymptomDetailsHtml(doc) { const details = doc.symptom_details || {}; const confidenceMap = doc.field_confidence?.symptom_details || {}; const chiefComplaintConfidence = doc.field_confidence?.chief_complaint; const rows = [ { label: 'Chief Complaint', value: doc.chief_complaint || 'not specified', confidence: chiefComplaintConfidence }, { label: 'Symptoms', value: formatSymptomFieldValue(details.symptoms_mentioned), confidence: confidenceMap.symptoms_mentioned }, { label: 'Onset', value: formatSymptomFieldValue(details.onset), confidence: confidenceMap.onset }, { label: 'Duration', value: formatSymptomFieldValue(details.duration), confidence: confidenceMap.duration }, { label: 'Location', value: formatSymptomFieldValue(details.location), confidence: confidenceMap.location }, { label: 'Aggravating Factors', value: formatSymptomFieldValue(details.aggravating_factors), confidence: confidenceMap.aggravating_factors }, { label: 'Severity Description', value: formatSymptomFieldValue(details.severity_description), confidence: confidenceMap.severity_description } ]; return ``; } function formatSymptomFieldValue(value) { if (Array.isArray(value)) { return value.length ? value.join(', ') : 'not specified'; } if (value === null || value === undefined) { return 'not specified'; } const strValue = String(value).trim(); return strValue.length > 0 ? strValue : 'not specified'; } function renderConfidenceRow(row) { const confidence = normalizeConfidenceRecord(row.confidence); const badge = buildConfidenceBadge(confidence); return `
  • ${escapeHTML(row.label)}: ${escapeHTML(row.value)}
    ${badge}
  • `; } function normalizeConfidenceRecord(record) { const defaultScore = 0.30; const rawScore = typeof record?.score === 'number' ? record.score : defaultScore; const score = Math.max(0, Math.min(1, rawScore)); let color = record?.color; if (!color) { color = score >= 0.8 ? 'green' : (score >= 0.55 ? 'yellow' : 'red'); } const verificationText = record?.verification_text || (color === 'green' ? 'High confidence' : color === 'yellow' ? 'Needs quick verification' : 'Needs verification'); return { score, color, verificationText, rationale: record?.rationale || 'No confidence metadata provided.' }; } function buildConfidenceBadge(confidence) { const percent = Math.round(confidence.score * 100); const colorClass = confidence.color === 'green' ? 'confidence-green' : confidence.color === 'yellow' ? 'confidence-yellow' : 'confidence-red'; return ` ${escapeHTML(confidence.verificationText)} (${percent}%) `; } /** * Render extracted NER entities as styled badges. * @param {Object} entities - { conditions: [...], medications: [...] } */ function displayNEREntities(entities) { if (!entities || (!entities.conditions?.length && !entities.medications?.length)) { // Hide the card if no entities elements.nerEntitiesCard?.classList.add('hidden'); return; } elements.nerEntitiesCard?.classList.remove('hidden'); const totalCount = (entities.conditions?.length || 0) + (entities.medications?.length || 0); if (elements.nerEntityCount) { elements.nerEntityCount.textContent = `${ totalCount } ${ totalCount === 1 ? 'entity' : 'entities' } `; } // Render Conditions if (elements.nerConditionBadges) { if (entities.conditions && entities.conditions.length > 0) { elements.nerConditionBadges.innerHTML = entities.conditions.map(ent => { const text = escapeHTML(String(ent.text || '')); const system = escapeHTML(String(ent.system || '')); const code = escapeHTML(String(ent.code || '')); return ` ${text} ${system}: ${code} `; }).join(''); document.getElementById('nerConditions')?.classList.remove('hidden'); } else { elements.nerConditionBadges.innerHTML = 'None detected'; } } // Render Medications if (elements.nerMedicationBadges) { if (entities.medications && entities.medications.length > 0) { elements.nerMedicationBadges.innerHTML = entities.medications.map(ent => { const text = escapeHTML(String(ent.text || '')); const system = escapeHTML(String(ent.system || '')); const code = escapeHTML(String(ent.code || '')); return ` ${text} ${system}: ${code} `; }).join(''); document.getElementById('nerMedications')?.classList.remove('hidden'); } else { elements.nerMedicationBadges.innerHTML = 'None detected'; } } } // ===================================================== // ACTIONS (Copy, Export) // ===================================================== function setupActions() { elements.copyBtn?.addEventListener('click', copyToClipboard); elements.exportBtn?.addEventListener('click', exportJSON); elements.fhirExportBtn?.addEventListener('click', downloadFHIRBundle); elements.ehrPushBtn?.addEventListener('click', () => { elements.ehrModal?.classList.remove('hidden'); }); elements.ehrModalClose?.addEventListener('click', () => { elements.ehrModal?.classList.add('hidden'); }); elements.ehrModalCancel?.addEventListener('click', () => { elements.ehrModal?.classList.add('hidden'); }); elements.ehrModal?.querySelector('.ehr-modal-backdrop')?.addEventListener('click', () => { elements.ehrModal?.classList.add('hidden'); }); elements.ehrModalSubmit?.addEventListener('click', pushToEHR); // EHR preset buttons document.querySelectorAll('.ehr-preset-btn').forEach(btn => { btn.addEventListener('click', () => { if (elements.ehrServerUrl) { elements.ehrServerUrl.value = btn.dataset.url; } }); }); } async function copyToClipboard() { if (!state.currentDocumentation) return; const doc = state.currentDocumentation.documentation; // Get current SOAP text (may have been edited by clinician) const soapS = elements.soapS?.textContent || doc.soap_note_subjective || 'N/A'; const soapO = elements.soapO?.textContent || doc.soap_note_objective || 'N/A'; const soapA = elements.soapA?.textContent || doc.soap_note_assessment || 'N/A'; const soapP = elements.soapP?.textContent || doc.soap_note_plan || 'N/A'; const text = ` CHIEF COMPLAINT: ${ doc.chief_complaint } SYMPTOM DETAILS: - Symptoms: ${ Array.isArray(doc.symptom_details?.symptoms_mentioned) ? doc.symptom_details.symptoms_mentioned.join(', ') : (doc.symptom_details?.symptoms || 'N/A') } - Onset: ${ doc.symptom_details?.onset || 'N/A' } - Duration: ${ doc.symptom_details?.duration || 'N/A' } - Location: ${ doc.symptom_details?.location || 'N/A' } SOAP NOTE: S(Subjective)[${ state.soapApprovals.subjective.status }]: ${ soapS } O(Objective)[${ state.soapApprovals.objective.status }]: ${ soapO } A(Assessment)[${ state.soapApprovals.assessment.status }]: ${ soapA } P(Plan)[${ state.soapApprovals.plan.status }]: ${ soapP } `.trim(); try { await navigator.clipboard.writeText(text); // Visual feedback const originalSvg = elements.copyBtn.innerHTML; elements.copyBtn.innerHTML = ` `; elements.copyBtn.style.color = '#10b981'; setTimeout(() => { elements.copyBtn.innerHTML = originalSvg; elements.copyBtn.style.color = ''; }, 2000); } catch (error) { console.error('Copy failed:', error); } } function exportJSON() { if (!state.currentDocumentation) return; // Enrich export data with approval status, edit history, and current content const exportData = JSON.parse(JSON.stringify(state.currentDocumentation)); exportData.soap_approvals = state.soapApprovals; exportData.soap_edit_history = {}; for (const [section, data] of Object.entries(state.soapApprovals)) { exportData.soap_edit_history[section] = { original_ai_text: data.originalText, current_status: data.status, was_edited: data.edited, timeline: data.history }; } // Include current (possibly edited) SOAP text if (exportData.documentation) { exportData.documentation.soap_note_subjective = elements.soapS?.textContent || exportData.documentation.soap_note_subjective; exportData.documentation.soap_note_objective = elements.soapO?.textContent || exportData.documentation.soap_note_objective; exportData.documentation.soap_note_assessment = elements.soapA?.textContent || exportData.documentation.soap_note_assessment; exportData.documentation.soap_note_plan = elements.soapP?.textContent || exportData.documentation.soap_note_plan; } const dataStr = JSON.stringify(exportData, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `voxdoc_${ new Date().toISOString().slice(0, 10) }.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } async function downloadFHIRBundle() { if (!state.currentDocumentation) return; if (!requireAuthentication()) return; try { elements.fhirExportBtn.classList.add('loading'); const response = await apiFetch('/api/fhir/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentation: state.currentDocumentation.documentation, extracted_entities: state.currentDocumentation.extracted_entities }) }); if (!response.ok) throw new Error('FHIR export failed'); const bundle = await response.json(); const dataStr = JSON.stringify(bundle, null, 2); const blob = new Blob([dataStr], { type: 'application/fhir+json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `fhir_bundle_${ new Date().toISOString().slice(0, 10) }.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('FHIR Download error:', error); alert('Failed to generate FHIR Bundle. Please check the console.'); } finally { elements.fhirExportBtn.classList.remove('loading'); } } async function pushToEHR() { if (!state.currentDocumentation) return; if (!requireAuthentication()) return; const ehrUrl = elements.ehrServerUrl?.value; if (!ehrUrl) { alert("Please enter a valid FHIR Server URL."); return; } try { elements.ehrModalSubmit.disabled = true; elements.ehrModalSubmit.innerHTML = '
    Pushing...'; const response = await apiFetch('/api/fhir/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentation: state.currentDocumentation.documentation, extracted_entities: state.currentDocumentation.extracted_entities, ehr_url: ehrUrl, auth_token: elements.ehrAuthToken?.value || null }) }); const result = await response.json(); if (result.success) { alert(`Success! Bundle pushed to EHR(Status: ${ result.status_code })`); elements.ehrModal?.classList.add('hidden'); } else { console.error(result.error); alert(`Failed to push to EHR: ${ result.error } `); } } catch (error) { console.error("EHR Push error:", error); alert("Network error occurred while pushing to EHR."); } finally { elements.ehrModalSubmit.disabled = false; elements.ehrModalSubmit.innerHTML = ` Push Bundle `; } } // ===================================================== // SOAP SECTION EDIT / APPROVE / REJECT / HISTORY // ===================================================== /** * Set up click handlers for all SOAP Edit, Reject, Approve, and History buttons. */ function setupSOAPActions() { document.querySelectorAll('.soap-edit-btn').forEach(btn => { btn.addEventListener('click', () => handleSOAPEdit(btn.dataset.section)); }); document.querySelectorAll('.soap-reject-btn').forEach(btn => { btn.addEventListener('click', () => handleSOAPReject(btn.dataset.section)); }); document.querySelectorAll('.soap-approve-btn').forEach(btn => { btn.addEventListener('click', () => handleSOAPApprove(btn.dataset.section)); }); document.querySelectorAll('.soap-history-toggle').forEach(btn => { btn.addEventListener('click', () => toggleSOAPHistory(btn.dataset.section)); }); } /** * Handle Edit button click for a SOAP section. * Toggles contenteditable on the body, changes button text. */ function handleSOAPEdit(section) { const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const editBtn = card?.querySelector('.soap-edit-btn'); const statusBadge = getSOAPStatusEl(section); if (!bodyEl || !editBtn) return; const isEditing = bodyEl.getAttribute('contenteditable') === 'true'; if (isEditing) { // Save bodyEl.setAttribute('contenteditable', 'false'); card?.classList.remove('editing'); editBtn.querySelector('span').textContent = 'Edit'; editBtn.classList.remove('saving'); // Mark as edited and log history const currentText = bodyEl.textContent; state.soapApprovals[section].edited = true; state.soapApprovals[section].status = 'edited'; logSOAPHistory(section, 'edited', currentText); if (statusBadge) { statusBadge.textContent = 'Edited'; statusBadge.className = 'soap-status badge info'; } // Clear rejected state if re-editing card?.classList.remove('rejected', 'just-rejected'); const rejectBtn = card?.querySelector('.soap-reject-btn'); if (rejectBtn) { rejectBtn.classList.remove('rejected'); rejectBtn.disabled = false; rejectBtn.querySelector('span').textContent = 'Reject'; } updateSOAPOverallStatus(); } else { // Enter edit mode bodyEl.setAttribute('contenteditable', 'true'); card?.classList.add('editing'); editBtn.querySelector('span').textContent = 'Save'; editBtn.classList.add('saving'); bodyEl.focus(); // Un-approve if was approved if (state.soapApprovals[section].status === 'approved') { state.soapApprovals[section].status = 'edited'; card?.classList.remove('approved'); const approveBtn = card?.querySelector('.soap-approve-btn'); if (approveBtn) { approveBtn.classList.remove('approved'); approveBtn.disabled = false; approveBtn.querySelector('span').textContent = 'Approve'; } } } } /** * Handle Approve button click for a SOAP section. * Locks the section and marks it approved. */ function handleSOAPApprove(section) { const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const approveBtn = card?.querySelector('.soap-approve-btn'); const editBtn = card?.querySelector('.soap-edit-btn'); const statusBadge = getSOAPStatusEl(section); if (!bodyEl || !approveBtn) return; // Close edit mode if open bodyEl.setAttribute('contenteditable', 'false'); card?.classList.remove('editing'); if (editBtn) { editBtn.querySelector('span').textContent = 'Edit'; editBtn.classList.remove('saving'); } // Mark approved and log state.soapApprovals[section].status = 'approved'; card?.classList.add('approved'); card?.classList.remove('rejected', 'just-rejected'); approveBtn.classList.add('approved'); approveBtn.querySelector('span').textContent = 'Approved'; logSOAPHistory(section, 'approved', bodyEl.textContent); // Reset reject button const rejectBtn = card?.querySelector('.soap-reject-btn'); if (rejectBtn) { rejectBtn.classList.remove('rejected'); rejectBtn.disabled = false; rejectBtn.querySelector('span').textContent = 'Reject'; } if (statusBadge) { statusBadge.textContent = '✓ Approved'; statusBadge.className = 'soap-status badge success'; } // Flash animation card?.classList.add('just-approved'); setTimeout(() => card?.classList.remove('just-approved'), 900); updateSOAPOverallStatus(); } /** * Reset all SOAP card states to initial "Pending Review". */ function resetSOAPCardStates() { ['subjective', 'objective', 'assessment', 'plan', 'visual_findings'].forEach(section => { const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const statusBadge = getSOAPStatusEl(section); const editBtn = card?.querySelector('.soap-edit-btn'); const approveBtn = card?.querySelector('.soap-approve-btn'); bodyEl?.setAttribute('contenteditable', 'false'); card?.classList.remove('editing', 'approved', 'just-approved', 'rejected', 'just-rejected'); if (statusBadge) { statusBadge.textContent = 'Pending Review'; statusBadge.className = 'soap-status badge warning'; } if (editBtn) { editBtn.querySelector('span').textContent = 'Edit'; editBtn.classList.remove('saving'); editBtn.disabled = false; } if (approveBtn) { approveBtn.classList.remove('approved'); approveBtn.disabled = false; approveBtn.querySelector('span').textContent = 'Approve'; } const rejectBtn = card?.querySelector('.soap-reject-btn'); if (rejectBtn) { rejectBtn.classList.remove('rejected'); rejectBtn.disabled = false; rejectBtn.querySelector('span').textContent = 'Reject'; } // Hide and clear history panel const historyEl = getSOAPHistoryEl(section); if (historyEl) { historyEl.classList.add('hidden'); historyEl.innerHTML = ''; } const historyToggle = card?.querySelector('.soap-history-toggle'); historyToggle?.classList.remove('active'); }); updateSOAPOverallStatus(); } /** * Update the overall SOAP status badge. */ function updateSOAPOverallStatus() { if (!elements.soapOverallStatus) return; const sections = Object.values(state.soapApprovals); const approvedCount = sections.filter(s => s.status === 'approved').length; const rejectedCount = sections.filter(s => s.status === 'rejected').length; const total = sections.length; if (approvedCount === total) { elements.soapOverallStatus.textContent = 'All sections approved ✓'; elements.soapOverallStatus.className = 'badge success'; } else if (rejectedCount > 0) { elements.soapOverallStatus.textContent = `${ rejectedCount } rejected · ${ total - approvedCount - rejectedCount } pending`; elements.soapOverallStatus.className = 'badge warning'; } else { elements.soapOverallStatus.textContent = `${ total - approvedCount } of ${ total } pending review`; elements.soapOverallStatus.className = 'badge info'; } } /** * Handle Reject button click for a SOAP section. * Prompts for optional reason, strikes-through the content. */ function handleSOAPReject(section) { const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const rejectBtn = card?.querySelector('.soap-reject-btn'); const approveBtn = card?.querySelector('.soap-approve-btn'); const editBtn = card?.querySelector('.soap-edit-btn'); const statusBadge = getSOAPStatusEl(section); if (!bodyEl || !rejectBtn) return; // Prompt for optional reason const reason = prompt('Reason for rejection (optional):') || ''; // Close edit mode if open bodyEl.setAttribute('contenteditable', 'false'); card?.classList.remove('editing', 'approved', 'just-approved'); if (editBtn) { editBtn.querySelector('span').textContent = 'Edit'; editBtn.classList.remove('saving'); } if (approveBtn) { approveBtn.classList.remove('approved'); approveBtn.querySelector('span').textContent = 'Approve'; } // Mark rejected state.soapApprovals[section].status = 'rejected'; card?.classList.add('rejected'); rejectBtn.classList.add('rejected'); rejectBtn.querySelector('span').textContent = 'Rejected'; logSOAPHistory(section, 'rejected', reason ? `Rejected: ${ reason } ` : 'Rejected by clinician'); if (statusBadge) { statusBadge.textContent = '✗ Rejected'; statusBadge.className = 'soap-status badge danger'; } // Flash animation card?.classList.add('just-rejected'); setTimeout(() => card?.classList.remove('just-rejected'), 900); updateSOAPOverallStatus(); } /** * Toggle the history panel for a SOAP section. */ function toggleSOAPHistory(section) { const historyEl = getSOAPHistoryEl(section); const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const toggleBtn = card?.querySelector('.soap-history-toggle'); if (!historyEl) return; const isVisible = !historyEl.classList.contains('hidden'); if (isVisible) { historyEl.classList.add('hidden'); toggleBtn?.classList.remove('active'); } else { renderSOAPHistory(section); historyEl.classList.remove('hidden'); toggleBtn?.classList.add('active'); } } /** * Log a timestamped entry in a SOAP section's history. */ function logSOAPHistory(section, action, text) { if (!state.soapApprovals[section]) return; state.soapApprovals[section].history.push({ action, text: text || '', timestamp: new Date().toISOString() }); } /** * Render the history panel for a SOAP section. */ function renderSOAPHistory(section) { const historyEl = getSOAPHistoryEl(section); const data = state.soapApprovals[section]; if (!historyEl || !data) return; const entries = data.history; // Build original AI text block const isSymptomDetails = section === 'clinical_details'; const originalBlock = data.originalText ? `
    Original AI Output
    ${escapeHTML(data.originalText)}
    ` : ''; // Build timeline let timeline = ''; if (entries.length === 0) { timeline = '

    No history yet.

    '; } else { const items = entries.map(e => { const time = new Date(e.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const dotClass = e.action; const actionLabel = e.action.charAt(0).toUpperCase() + e.action.slice(1); let detail = ''; if (e.text) { // Use DOMParser to extract text safely — avoids incomplete regex stripping const parsed = new DOMParser().parseFromString(e.text, 'text/html'); const plainText = parsed.body.textContent || ''; detail = ` — ${ escapeHTML(truncate(plainText, 120)) } `; } return `
  • ${time} ${actionLabel}${detail}
  • `; }).join(''); timeline = ``; } historyEl.innerHTML = originalBlock + timeline; } /** * Restore a section to its original AI-generated text. */ function restoreOriginalSOAP(section) { const data = state.soapApprovals[section]; if (!data || !data.originalText) return; if (!confirm(`Are you sure you want to restore the original ${ section.replace('_', ' ') }? Current edits will be lost.`)) { return; } const bodyEl = getSOAPBodyEl(section); const card = bodyEl?.closest('.soap-section-card'); const statusBadge = getSOAPStatusEl(section); if (!bodyEl) return; // Restore text bodyEl.textContent = data.originalText; // Reset state data.status = 'pending'; data.edited = false; logSOAPHistory(section, 'restored', 'Restored to original AI output'); // UI Reset card?.classList.remove('approved', 'rejected', 'editing', 'just-approved', 'just-rejected'); if (statusBadge) { statusBadge.textContent = 'Pending Review'; statusBadge.className = 'soap-status badge warning'; } const approveBtn = card?.querySelector('.soap-approve-btn'); if (approveBtn) { approveBtn.classList.remove('approved'); approveBtn.disabled = false; approveBtn.querySelector('span').textContent = 'Approve'; } const rejectBtn = card?.querySelector('.soap-reject-btn'); if (rejectBtn) { rejectBtn.classList.remove('rejected'); rejectBtn.disabled = false; rejectBtn.querySelector('span').textContent = 'Reject'; } const editBtn = card?.querySelector('.soap-edit-btn'); if (editBtn) { editBtn.querySelector('span').textContent = 'Edit'; editBtn.classList.remove('saving'); } bodyEl.setAttribute('contenteditable', 'false'); // Re-render history to show "Restored" entry renderSOAPHistory(section); updateSOAPOverallStatus(); } /** Escape HTML entities. */ function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** Truncate a string to maxLen characters. */ function truncate(str, maxLen) { return str.length > maxLen ? str.slice(0, maxLen) + '…' : str; } /** Get the body element for a SOAP section. */ function getSOAPBodyEl(section) { const map = { subjective: 'soapS', objective: 'soapO', assessment: 'soapA', plan: 'soapP', chief_complaint: 'chiefComplaint', clinical_details: 'symptomDetails', visual_findings: 'visualFindings' }; return elements[map[section]] || null; } /** Get the status badge element for a SOAP section. */ function getSOAPStatusEl(section) { const map = { subjective: 'soapStatusS', objective: 'soapStatusO', assessment: 'soapStatusA', plan: 'soapStatusP', chief_complaint: 'soapStatusCC', clinical_details: 'soapStatusCD', visual_findings: 'soapStatusVI' }; return elements[map[section]] || null; } /** Get the history panel element for a SOAP section. */ function getSOAPHistoryEl(section) { const map = { subjective: 'soapHistoryS', objective: 'soapHistoryO', assessment: 'soapHistoryA', plan: 'soapHistoryP', chief_complaint: 'soapHistoryCC', clinical_details: 'soapHistoryCD', visual_findings: 'soapHistoryVI' }; return document.getElementById(map[section]) || null; } // ===================================================== // SESSION HISTORY & PDF EXPORT // ===================================================== async function loadSessionHistory() { try { const response = await apiFetch('/api/sessions', { method: 'GET' }); if (response.ok) { const data = await response.json(); // Map the DB fields to what the UI expects state.sessionHistory = data.map(session => ({ id: session.id, timestamp: session.created_at, patientName: session.patient_name || "Patient", chiefComplaint: session.chief_complaint || "N/A", clinicalDetails: "N/A", // This is not stored directly, could extract from SOAP visualFindings: "", soapS: session.soap_subjective || "", soapO: session.soap_objective || "", soapA: session.soap_assessment || "", soapP: session.soap_plan || "" })); updateRecentDocsUI(); if (typeof renderHistoryView === 'function') { renderHistoryView(); } } else { state.sessionHistory = []; updateRecentDocsUI(); renderHistoryView(); } } catch (e) { console.error('Failed to load session history', e); } } async function saveSessionToHistory() { if (!state.currentDocumentation) return; const doc = state.currentDocumentation.documentation; const sessionData = { patient_name: "Patient " + Math.floor(Math.random() * 1000), // Placeholder transcript: elements.transcriptionText ? elements.transcriptionText.textContent : 'N/A', detected_language: state.currentDocumentation.detected_language || 'en', chief_complaint: doc.chief_complaint || 'N/A', soap_subjective: elements.soapS ? elements.soapS.textContent : doc.soap_note_subjective, soap_objective: elements.soapO ? elements.soapO.textContent : doc.soap_note_objective, soap_assessment: elements.soapA ? elements.soapA.textContent : doc.soap_note_assessment, soap_plan: elements.soapP ? elements.soapP.textContent : doc.soap_note_plan }; try { const response = await apiFetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sessionData) }); if (response.ok) { await loadSessionHistory(); } } catch (e) { console.error('Failed to save session to history', e); } } function updateRecentDocsUI() { const listEl = document.getElementById('recentDocsList'); if (!listEl) return; if (state.sessionHistory.length === 0) { listEl.innerHTML = `
    No recent sessions
    `; return; } listEl.innerHTML = state.sessionHistory.map(session => { const date = new Date(session.timestamp); const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const dateStr = date.toLocaleDateString(); const displayTime = date.toDateString() === new Date().toDateString() ? `Today, ${ timeStr } ` : `${ dateStr }, ${ timeStr } `; return `
    ${truncate(session.chiefComplaint, 25)}
    ${displayTime}
    `; }).join(''); } function setupPDFExport() { if (elements.exportPdfBtn) { elements.exportPdfBtn.addEventListener('click', () => { if (!state.currentDocumentation) return; // Update the session in history before exporting to capture any edits saveSessionToHistory(); const sessionToExport = state.sessionHistory[0]; if (!sessionToExport) { alert('No session available to export.'); return; } exportSinglePDF(sessionToExport); }); } if (elements.batchExportBtn) { const handleBatchExport = () => { if (state.sessionHistory.length === 0) { alert('No sessions found in history to export.'); return; } batchExportPDF(); }; elements.batchExportBtn.addEventListener('click', handleBatchExport); elements.batchExportBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleBatchExport(); } }); } } function populatePDFTemplate(session) { const d = new Date(session.timestamp); document.getElementById('pdfDate').textContent = d.toLocaleDateString(); document.getElementById('pdfTime').textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); document.getElementById('pdfChiefComplaint').textContent = session.chiefComplaint; document.getElementById('pdfClinicalDetails').textContent = session.clinicalDetails; if (session.visualFindings) { document.getElementById('pdfVisualFindingsContainer').style.display = 'block'; document.getElementById('pdfVisualFindings').textContent = session.visualFindings; } else { document.getElementById('pdfVisualFindingsContainer').style.display = 'none'; } document.getElementById('pdfSubjective').textContent = session.soapS; document.getElementById('pdfObjective').textContent = session.soapO; document.getElementById('pdfAssessment').textContent = session.soapA; document.getElementById('pdfPlan').textContent = session.soapP; } function exportSinglePDF(session) { const template = document.getElementById('pdfPrintTemplate'); if (!template) return; populatePDFTemplate(session); template.parentElement.style.display = 'block'; const opt = { margin: 0, filename: `VoxDoc_Report_${ session.id }.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2 }, jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' } }; html2pdf().set(opt).from(template).save().then(() => { template.parentElement.style.display = 'none'; }); } function batchExportPDF() { const containerItem = document.createElement('div'); const template = document.getElementById('pdfPrintTemplate'); // We clone the template for each session state.sessionHistory.forEach((session, index) => { populatePDFTemplate(session); const clone = template.cloneNode(true); clone.id = ''; // remove id duplicate containerItem.appendChild(clone); // Add page break if it's not the last item if (index < state.sessionHistory.length - 1) { const pageBreak = document.createElement('div'); pageBreak.className = 'html2pdf__page-break'; containerItem.appendChild(pageBreak); } }); // Temporarily attach to DOM for html2pdf to render containerItem.style.display = 'none'; document.body.appendChild(containerItem); const opt = { margin: 0, filename: `VoxDoc_Batch_Reports_${ Date.now() }.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2 }, jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' } }; html2pdf().set(opt).from(containerItem).save().then(() => { document.body.removeChild(containerItem); }); } // ===================================================== // HIPAA COMPLIANCE DASHBOARD // ===================================================== let hipaaAutoRefresh = null; async function loadHipaaDashboard() { try { const [summaryResp, exportResp] = await Promise.all([ apiFetch('/api/hipaa/audit-summary'), apiFetch('/api/hipaa/export-logs?limit=20'), ]); if (summaryResp.ok) { const data = await summaryResp.json(); renderHipaaSummary(data); } else if (summaryResp.status === 403) { const hv = document.getElementById('hipaaView'); if (hv) hv.innerHTML = '

    Admin access required for HIPAA dashboard.

    '; return; } if (exportResp.ok) { const logs = await exportResp.json(); renderExportLogs(logs); } // Auto-refresh every 15s while on hipaa tab if (hipaaAutoRefresh) clearInterval(hipaaAutoRefresh); hipaaAutoRefresh = setInterval(async () => { const hv = document.getElementById('hipaaView'); if (hv && !hv.classList.contains('hidden')) { try { const r = await apiFetch('/api/hipaa/audit-summary'); if (r.ok) renderHipaaSummary(await r.json()); } catch { /* ignore */ } } else { clearInterval(hipaaAutoRefresh); hipaaAutoRefresh = null; } }, 15000); } catch (e) { console.error('Failed to load HIPAA dashboard:', e); } } function renderHipaaSummary(data) { const setTxt = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; // Audit trail setTxt('hipaaTotalAudit', data.total_audit_logs || 0); setTxt('hipaaPhiAccess', data.phi_access_count || 0); setTxt('hipaaPhiRecent', data.recent_phi_accesses_24h || 0); setTxt('hipaaExports', data.total_exports || 0); // Encryption status const encStatus = document.getElementById('hipaaEncryptionStatus'); if (encStatus) { encStatus.className = 'monitor-status-dot ' + (data.encryption_at_rest ? 'ready' : 'not-ready'); } setTxt('hipaaEncryptionAtRest', data.encryption_at_rest ? 'Enabled' : 'Disabled'); const meta = data.compliance_metadata || {}; const hipaa = meta.hipaa || {}; setTxt('hipaaPhiPersistence', hipaa.phi_persistence_enabled ? 'Enabled' : 'Disabled'); setTxt('hipaaPhiLogging', hipaa.phi_logging_enabled ? 'Enabled' : 'Disabled'); setTxt('hipaaMinNecessary', hipaa.minimum_necessary_mode ? 'Active' : 'Inactive'); // Retention stats const ret = data.retention || {}; const sessions = ret.sessions || {}; const audits = ret.audit_logs || {}; setTxt('hipaaSessionsTotal', sessions.total || 0); setTxt('hipaaSessionsExpired', sessions.expired || 0); setTxt('hipaaSessionRetention', sessions.retention_days > 0 ? sessions.retention_days : 'Forever'); setTxt('hipaaAutoPurge', ret.auto_purge_enabled ? 'Enabled' : 'Disabled'); setTxt('hipaaAuditTotal', audits.total || 0); setTxt('hipaaAuditExpired', audits.expired || 0); setTxt('hipaaAuditRetention', audits.retention_days > 0 ? audits.retention_days : 'Forever'); setTxt('hipaaPurgeInterval', ret.purge_interval_hours ? ret.purge_interval_hours + 'h' : '--'); } function renderExportLogs(logs) { const container = document.getElementById('hipaaExportLogs'); if (!container) return; if (!logs || logs.length === 0) { container.innerHTML = '

    No export logs found.

    '; return; } let html = `
    UserTypeDestinationTimestampStatus
    `; for (const log of logs) { const ts = log.timestamp ? new Date(log.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '--'; const dest = log.destination ? (log.destination.length > 15 ? log.destination.substring(0, 15) + '...' : log.destination) : '--'; const logUser = escapeHTML(String(log.username || 'system')); const logType = escapeHTML(String(log.export_type || '--')); const logDest = escapeHTML(String(log.destination || '')); const logDestTr = escapeHTML(dest); const logStatus = escapeHTML(String(log.status || 'success')); html += `
    ${logUser} ${logType} ${logDestTr} ${ts} ${logStatus}
    `; } container.innerHTML = html; } async function runHipaaPurge() { const resultEl = document.getElementById('hipaaPurgeResult'); const btn = document.getElementById('hipaaPurgeBtn'); if (!confirm('Run data purge based on retention policies? This will permanently delete expired records.')) { return; } if (btn) btn.disabled = true; if (resultEl) resultEl.textContent = 'Running purge...'; try { const resp = await apiFetch('/api/hipaa/retention/purge', { method: 'POST' }); if (resp.ok) { const data = await resp.json(); const msg = `Purged ${data.sessions_purged} sessions, ${data.audit_logs_purged} audit logs.`; if (resultEl) resultEl.textContent = msg; // Refresh dashboard after purge loadHipaaDashboard(); } else { if (resultEl) resultEl.textContent = 'Purge failed: ' + resp.status; } } catch (e) { if (resultEl) resultEl.textContent = 'Purge error: ' + e.message; } finally { if (btn) btn.disabled = false; } } // ===================================================== // AI VOICE ASSISTANT INTEGRATION // ===================================================== function initConversationMode() { const toggleBtn = document.getElementById('convToggleBtn'); const panel = document.getElementById('conversationPanel'); const modeSelect = document.getElementById('convModeSelect'); const endBtn = document.getElementById('convEndBtn'); if (!toggleBtn || !panel) return; // Initialize conversation manager if (typeof conversationManager !== 'undefined') { conversationManager.init(); } let conversationActive = false; toggleBtn.addEventListener('click', () => { conversationActive = !conversationActive; if (conversationActive) { toggleBtn.classList.add('active'); panel.classList.add('active'); modeSelect.style.display = 'block'; // Start conversation const mode = modeSelect.value; if (typeof conversationManager !== 'undefined') { conversationManager.start(mode); } } else { toggleBtn.classList.remove('active'); panel.classList.remove('active'); modeSelect.style.display = 'none'; // Disconnect if (typeof conversationManager !== 'undefined') { conversationManager.disconnect(); } } }); if (modeSelect) { modeSelect.addEventListener('change', () => { if (conversationActive && typeof conversationManager !== 'undefined') { conversationManager.disconnect(); conversationManager.start(modeSelect.value); } }); } if (endBtn) { endBtn.addEventListener('click', () => { if (typeof conversationManager !== 'undefined') { conversationManager.endConversation(); } }); } } // ===================================================== // INITIALIZE // ===================================================== // ===================================================== // PHASE 6: KEYBOARD SHORTCUTS // ===================================================== function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Skip if typing in an input/textarea if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; // R — toggle recording if (e.key === 'r' || e.key === 'R') { e.preventDefault(); const btn = elements.recordBtn; if (btn) btn.click(); } // S — stop recording if (e.key === 's' || e.key === 'S') { if (state.isRecording) { e.preventDefault(); const btn = elements.recordBtn; if (btn) btn.click(); } } // D — generate documentation (submit) if (e.key === 'd' || e.key === 'D') { e.preventDefault(); const btn = elements.submitBtn; if (btn && !btn.disabled) btn.click(); } // E — export/copy if (e.key === 'e' || e.key === 'E') { e.preventDefault(); const btn = elements.exportBtn; if (btn) btn.click(); } // Escape — close modals if (e.key === 'Escape') { const ehrModal = elements.ehrModal; if (ehrModal && !ehrModal.classList.contains('hidden')) { ehrModal.classList.add('hidden'); } hideLoginOverlay(); } // ? — show shortcuts help if (e.key === '?') { e.preventDefault(); toggleShortcutsHelp(); } }); } function toggleShortcutsHelp() { let panel = document.getElementById('shortcutsHelp'); if (panel) { panel.remove(); return; } panel = document.createElement('div'); panel.id = 'shortcutsHelp'; panel.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:16px 20px;z-index:9000;min-width:200px;box-shadow:0 4px 20px rgba(0,0,0,0.3);'; panel.innerHTML = `
    Keyboard Shortcuts
    R Toggle recording
    S Stop recording
    D Generate documentation
    E Export
    Esc Close modals
    ? Toggle this help
    `; document.body.appendChild(panel); setTimeout(() => panel.remove(), 8000); } // ===================================================== // PHASE 6: PROGRESS INDICATORS // ===================================================== function showPipelineProgress(stage) { const stages = ['transcribing', 'extracting', 'documenting', 'complete']; const labels = { transcribing: 'Transcribing audio...', extracting: 'Extracting entities...', documenting: 'Generating documentation...', complete: 'Complete', }; const banner = document.getElementById('pipelineProgress'); if (!banner) return; banner.classList.remove('hidden'); const currentIdx = stages.indexOf(stage); banner.innerHTML = stages.map((s, i) => { let cls = 'pipeline-step'; if (i < currentIdx) cls += ' done'; if (i === currentIdx) cls += ' active'; return `${labels[s]}`; }).join(''); if (stage === 'complete') setTimeout(() => banner.classList.add('hidden'), 2000); } // ===================================================== // PHASE 6: SESSION SEARCH & FILTER // ===================================================== function setupSessionSearch() { const searchInput = document.getElementById('sessionSearchInput'); const dateFilter = document.getElementById('sessionDateFilter'); if (searchInput) { searchInput.addEventListener('input', () => filterSessions()); } if (dateFilter) { dateFilter.addEventListener('change', () => filterSessions()); } } function filterSessions() { const query = (document.getElementById('sessionSearchInput')?.value || '').toLowerCase().trim(); const dateFilter = document.getElementById('sessionDateFilter')?.value || ''; const cards = document.querySelectorAll('.history-card'); cards.forEach(card => { const text = card.textContent.toLowerCase(); const dateAttr = card.dataset.date || ''; let show = true; if (query && !text.includes(query)) show = false; if (dateFilter && dateAttr && !dateAttr.startsWith(dateFilter)) show = false; card.style.display = show ? '' : 'none'; }); } // ===================================================== // PHASE 6: ACCESSIBILITY // ===================================================== function setupAccessibility() { // Add ARIA labels to key interactive elements const ariaMap = { recordBtn: 'Toggle voice recording', submitBtn: 'Generate clinical documentation', copyBtn: 'Copy documentation to clipboard', exportBtn: 'Export documentation as JSON', exportPdfBtn: 'Export as PDF', openSidebar: 'Open navigation menu', closeSidebar: 'Close navigation menu', }; Object.entries(ariaMap).forEach(([id, label]) => { const el = document.getElementById(id); if (el && !el.getAttribute('aria-label')) { el.setAttribute('aria-label', label); } }); // Live region for status updates let liveRegion = document.getElementById('a11yLiveRegion'); if (!liveRegion) { liveRegion = document.createElement('div'); liveRegion.id = 'a11yLiveRegion'; liveRegion.setAttribute('role', 'status'); liveRegion.setAttribute('aria-live', 'polite'); liveRegion.className = 'sr-only'; liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);'; document.body.appendChild(liveRegion); } } function announceToScreenReader(message) { const region = document.getElementById('a11yLiveRegion'); if (region) { region.textContent = message; setTimeout(() => { region.textContent = ''; }, 3000); } } // ===================================================== // INITIALIZE // ===================================================== document.addEventListener('DOMContentLoaded', () => { init().catch((error) => { console.error('Initialization failed', error); }); initConversationMode(); setupKeyboardShortcuts(); setupSessionSearch(); setupAccessibility(); });