// ============================================================ // APP.JS — Core utilities, tabs, model selector, helpers // ============================================================ // ── Client-side error logging ────────────────────────────── function sendError(data) { try { navigator.sendBeacon('/api/logs/client-error', new Blob( [JSON.stringify(data)], { type: 'application/json' } )); } catch(e) {} } window.onerror = function(msg, src, line, col, err) { // Ignore errors from browser extensions if (src && src.indexOf('moz-extension') !== -1) return; if (src && src.indexOf('chrome-extension') !== -1) return; sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack }); }; window.addEventListener('unhandledrejection', function(e) { var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection'; sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack }); }); document.addEventListener('DOMContentLoaded', function() { // --- COMPONENT LOADER (lazy-load tab HTML from /components/) --- function getComponentVersion() { try { var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]'); var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : ''; return version || 'dev'; } catch(e) { return 'dev'; } } var COMPONENT_VERSION = getComponentVersion(); window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION; var _componentCache = {}; var _componentLoading = {}; var _tabActivation = 0; function loadComponent(tabEl) { var component = tabEl.getAttribute('data-component'); if (!component || tabEl.dataset.loaded) return Promise.resolve(); if (_componentLoading[component]) return _componentLoading[component]; _componentLoading[component] = (_componentCache[component] ? Promise.resolve(_componentCache[component]) : fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION) .then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })) .then(function(html) { var template = document.createElement('template'); template.innerHTML = html; var styles = Array.from(template.content.querySelectorAll('link[rel="stylesheet"]')).map(function(link) { var url = new URL(link.getAttribute('href'), window.location.href); if (url.origin === window.location.origin) url.searchParams.set('v', COMPONENT_VERSION); link.href = url.href; return new Promise(function(resolve, reject) { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', function() { reject(new Error('Component stylesheet failed to load')); }, { once: true }); }); }); // CSS must load in its original cascade position, but controls must not work before initialization. if (styles.length) { Array.from(template.content.children).forEach(function(child) { if (!child.hasAttribute('inert')) { child.setAttribute('inert', ''); child.setAttribute('data-component-pending', ''); } }); var loading = document.createElement('p'); loading.setAttribute('role', 'status'); loading.setAttribute('data-component-status', ''); loading.textContent = 'Loading…'; tabEl.setAttribute('aria-busy', 'true'); tabEl.replaceChildren(loading, template.content); } else tabEl.replaceChildren(template.content); return Promise.all(styles).then(function() { _componentCache[component] = html; }); }) .then(function() { tabEl.dataset.loaded = '1'; // Re-attach per-tab model selectors tabEl.querySelectorAll('.tab-model-select').forEach(function(sel) { if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel); }); delete _componentLoading[component]; }) .catch(function(err) { console.warn('[Component] Failed to load ' + component + ':', err); tabEl.innerHTML = '
Failed to load. Please refresh or select this tab again.
'; tabEl.removeAttribute('aria-busy'); delete _componentLoading[component]; }); return _componentLoading[component]; } // Preload the first tab immediately var firstTab = document.querySelector('.tab-content[data-component]'); if (firstTab) loadComponent(firstTab); // --- TAB NAVIGATION --- function activateTab(tabName) { var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]'); var tabEl = document.getElementById(tabName + '-tab'); // The assistant is reached from the mode switch, not a sidebar button, so a // tab can legitimately have a section without one. Requiring a button here // silently sent every /assistant visit to the fallback tab instead. if (btn ? btn.classList.contains('hidden') : !tabEl) return false; var activation = ++_tabActivation; document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); }); if (btn) btn.classList.add('active'); if (tabEl) { tabEl.classList.add('active'); // Lazy-load component HTML, then fire tabChanged after DOM is ready loadComponent(tabEl).then(function() { if (activation !== _tabActivation || (tabEl.hasAttribute('data-component') && !tabEl.dataset.loaded)) return; document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } })); // Event listeners bind synchronously; only now allow native form interaction. tabEl.querySelectorAll('[data-component-pending]').forEach(function(child) { child.removeAttribute('inert'); child.removeAttribute('data-component-pending'); }); var loading = tabEl.querySelector('[data-component-status]'); if (loading) loading.remove(); tabEl.removeAttribute('aria-busy'); }); } else { document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } })); } // The assistant's location is the URL (/assistant), not ped_last_tab — // storing it there made "/" reopen the assistant and defeated its own page. if (tabName !== 'assistant') { try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {} } syncTabLocation(tabName); // Close whichever menu is open on mobile after a tab click if (window.innerWidth <= 768) closeMobileMenus(); return true; } // The assistant is served from its own path; every other tab lives at "/". // replaceState keeps the URL honest without stacking a history entry per tab. function syncTabLocation(tabName) { if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return; var target = tabName === 'assistant' ? '/assistant' : '/'; if (window.location.pathname === target) return; try { history.replaceState({ tab: tabName }, '', target + window.location.search + window.location.hash); } catch (e) {} } // Browser Back out of /assistant must actually leave the assistant. window.addEventListener('popstate', function() { var wanted = window.location.pathname === '/assistant' ? 'assistant' : null; if (wanted) { activateTab(wanted); return; } var active = document.querySelector('.tab-btn.active'); if (!active || active.getAttribute('data-tab') !== 'assistant') return; var fallback = null; try { fallback = localStorage.getItem('ped_last_tab'); } catch (e) {} activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter'); }); // A phone has two menus that never show together: the workspace list (the app // sidebar) and the assistant's chat history. Every way out closes both. function closeMobileMenus() { var appMenu = document.getElementById('sidebar'); if (appMenu) appMenu.classList.remove('open'); var layout = document.getElementById('assistant-layout'); if (layout) layout.classList.remove('mobile-chats-open'); } // Assistant / Workspace switch. The app IS workspace mode, so switching is // navigation between two pages rather than an in-page state machine — which // is also what keeps the two looking identical: the only difference is which // pill is highlighted. A reload is fine; each is its own document. document.addEventListener('click', function(event) { var pill = event.target.closest && event.target.closest('[data-assistant-mode]'); if (!pill) return; var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant'; var onAssistant = window.location.pathname === '/assistant'; if (!onAssistant) { // In the app you ARE the workspace, so only the Assistant pill moves. // This used to set window.location, which reloads the document and kills // a running recording. The assistant is a tab in this same page, and // activateTab already rewrites the URL to /assistant, so switching keeps // the recorder — and everything else — alive. if (!wantsAssistant) return; if (typeof window.activateTab === 'function') window.activateTab('assistant'); else window.location.href = '/assistant'; return; } // Inside the assistant, Workspace opens the launcher in place rather than // reloading into whichever tab happened to be last. if (typeof window.assistantShowWorkspaceLauncher === 'function') { window.assistantShowWorkspaceLauncher(!wantsAssistant); } }); // On a phone the switch is inside the open sheet, so the view it just changed // is behind that sheet — tapping Workspace looked like it did nothing. document.addEventListener('click', function(event) { if (!(event.target.closest && event.target.closest('[data-assistant-mode]'))) return; if (window.innerWidth <= 768) closeMobileMenus(); }); // Account card. Rendered in both menus from the same markup, so whichever view // you are in ends the same way. Settings and Log out used to be unlabelled // icon buttons in the header, which the slim bar no longer explains. function initials(name, email) { var source = String(name || email || '').trim(); var parts = source.split(/[\s@._-]+/).filter(Boolean); return ((parts[0] || '?')[0] + (parts.length > 1 ? parts[1][0] : '')).toUpperCase(); } function renderAccountCards() { var user = window.CURRENT_USER; if (!user) return; var name = user.name || user.email || ''; document.querySelectorAll('.account-card').forEach(function(card) { var avatar = card.querySelector('[id^="account-avatar"]'); var nameEl = card.querySelector('[id^="account-name"]'); var mailEl = card.querySelector('[id^="account-email"]'); if (avatar) avatar.textContent = initials(user.name, user.email); if (nameEl) nameEl.textContent = name; if (mailEl) mailEl.textContent = user.email || ''; // Admin is only reachable by admins, so only they are offered it. if (user.role === 'admin' && !card.querySelector('[data-account-tab="admin"]')) { var menu = card.querySelector('.account-menu'); var logout = menu && menu.querySelector('[data-account-logout]'); if (menu && logout) { var admin = document.createElement('button'); admin.type = 'button'; admin.className = 'account-menu-item'; admin.setAttribute('role', 'menuitem'); admin.setAttribute('data-account-tab', 'admin'); admin.innerHTML = ' Admin'; menu.insertBefore(admin, logout); } } }); } window.renderAccountCards = renderAccountCards; document.addEventListener('tabChanged', renderAccountCards); renderAccountCards(); document.addEventListener('click', function(event) { var toggle = event.target.closest && event.target.closest('.account-card-btn'); var menus = document.querySelectorAll('.account-menu'); if (toggle) { var mine = toggle.parentElement.querySelector('.account-menu'); var opening = mine && mine.hidden; menus.forEach(function(m) { m.hidden = true; }); document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); if (mine && opening) { mine.hidden = false; toggle.setAttribute('aria-expanded', 'true'); } return; } var item = event.target.closest && event.target.closest('.account-menu-item'); menus.forEach(function(m) { m.hidden = true; }); document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); if (!item) return; if (item.hasAttribute('data-account-logout')) { var logoutBtn = document.getElementById('btn-logout'); if (logoutBtn) logoutBtn.click(); return; } var tab = item.getAttribute('data-account-tab'); // Those tabs live in the app, so reaching one from the assistant leaves it. if (tab && window.location.pathname === '/assistant') { try { localStorage.setItem('ped_last_tab', tab); } catch (e) {} window.location.href = '/'; return; } if (tab) activateTab(tab); }); document.addEventListener('keydown', function(event) { if (event.key !== 'Escape') return; document.querySelectorAll('.account-menu').forEach(function(m) { m.hidden = true; }); document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); }); // ── Search ────────────────────────────────────────────────────────────────── // One palette; the view decides what it searches. In the workspace that is the // app menu, in the assistant it is the saved chats. Both sources are already // in memory, so this needs no new endpoint. var searchModal = document.getElementById('menu-search'); var searchInput = document.getElementById('menu-search-input'); var searchResults = document.getElementById('menu-search-results'); // Sub-navigation is only present once its component has loaded, so give it a // moment rather than clicking into an empty tab. function openSubItem(sub, tabName, attempts) { var host = tabName ? document.getElementById(tabName + '-tab') : document; var selector = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + '="' + sub + '"]'; }).join(', '); var target = host && host.querySelector(selector); if (target) { target.click(); return; } if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110); } // Sub-navigation inside a tab — the calculator pills, the well-visit ages, the // exam regions — is what people actually look for ("bili", not "Calculators"). // Read generically from whatever a loaded component exposes, so this covers // every tab that has sub-navigation rather than one hard-coded list. // One list, so what is listed, what is searched and what a result opens can // never drift apart. data-em is the bedside emergency sections. var SUB_NAV_ATTRS = ['data-calc', 'data-subtab', 'data-section', 'data-em']; var SUB_NAV_SELECTOR = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + ']'; }).join(', ') + ', .calc-nav-pill'; function subItemsFor(tabEl, tabName, tabTitle) { if (!tabEl || !tabEl.dataset.loaded) return []; // not loaded yet; nothing to read var seen = {}; return Array.prototype.map.call(tabEl.querySelectorAll(SUB_NAV_SELECTOR), function(node) { var key = SUB_NAV_ATTRS.reduce(function(found, attr) { return found || node.getAttribute(attr); }, null); var title = (node.textContent || '').trim(); if (!key || !title || title.length > 48 || seen[key + title]) return null; seen[key + title] = true; return { id: tabName, sub: key, title: title, meta: tabTitle, icon: 'fas fa-arrow-turn-up fa-rotate-90' }; }).filter(Boolean); } function searchSources() { // Search follows the view, not the address: the assistant's chat view // searches chats, and Workspace — including the workspace launcher shown // inside the assistant, which keeps the /assistant URL — searches the menu. var inChats = document.body.classList.contains('assistant-workspace') && !document.body.classList.contains('assistant-mode-workspace'); if (inChats && typeof window.assistantSearchableChats === 'function') { return { kind: 'chats', items: window.assistantSearchableChats() }; } var items = []; Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) { var name = tab.getAttribute('data-tab'); if (!name || tab.classList.contains('hidden')) return; var label = tab.querySelector('span'); var icon = tab.querySelector('i'); var title = label ? label.textContent : name; items.push({ id: name, title: title, icon: icon ? icon.className : 'fas fa-circle' }); items = items.concat(subItemsFor(document.getElementById(name + '-tab'), name, title)); }); return { kind: 'tabs', items: items }; } // A component only exposes its sub-navigation once loaded, so warm the tabs // that have any. Cheap: each is fetched once and cached by the loader. function warmSearchableTabs() { ['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'].forEach(function(name) { var el = document.getElementById(name + '-tab'); if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el); }); } function renderSearch(term) { if (!searchResults) return; var source = searchSources(); var needle = String(term || '').trim().toLowerCase(); // With nothing typed the palette lists the main destinations only; the // sub-items (individual calculators, exam sections, schedule tabs) would // bury them, so they appear once there is something to match. var matches = source.items.filter(function(item) { if (!needle) return !item.sub; return String(item.title || '').toLowerCase().indexOf(needle) !== -1; }).slice(0, 40); searchResults.innerHTML = ''; if (!matches.length) { var empty = document.createElement('p'); empty.className = 'menu-search-empty'; empty.textContent = needle ? 'Nothing matches “' + term + '”.' : (source.kind === 'chats' ? 'No saved chats yet.' : 'Nothing to search.'); searchResults.appendChild(empty); return; } matches.forEach(function(item, index) { var row = document.createElement('button'); row.type = 'button'; row.className = 'menu-search-item' + (index === 0 ? ' is-active' : ''); row.setAttribute('role', 'option'); row.setAttribute('data-search-kind', source.kind); row.setAttribute('data-search-id', item.id); if (item.sub) row.setAttribute('data-search-sub', item.sub); row.innerHTML = '' + (item.meta ? '' : ''); row.querySelector('span').textContent = item.title || ''; if (item.meta) row.querySelector('em').textContent = item.meta; searchResults.appendChild(row); }); } function openSearch() { if (!searchModal) return; searchModal.hidden = false; warmSearchableTabs(); if (searchInput) { searchInput.value = ''; searchInput.placeholder = searchSources().kind === 'chats' ? 'Search chats...' : 'Search the workspace...'; searchInput.focus(); } renderSearch(''); } function closeSearch() { if (searchModal) searchModal.hidden = true; } function runSearchItem(row) { if (!row) return; var kind = row.getAttribute('data-search-kind'); var id = row.getAttribute('data-search-id'); closeSearch(); if (kind === 'chats') { if (typeof window.assistantOpenChat === 'function') window.assistantOpenChat(id); return; } // A sub-item opens its tab and then activates the entry inside it. var sub = row.getAttribute('data-search-sub'); if (sub) { if (window.location.pathname === '/assistant') { try { localStorage.setItem('ped_last_tab', id); localStorage.setItem('ped_pending_sub', sub); } catch (e) {} window.location.href = '/'; return; } activateTab(id); // The component may still be loading, so retry briefly rather than // clicking into an empty tab. openSubItem(sub, id, 20); return; } // A workspace result lives in the app, so reaching one from the assistant // leaves it — the same rule the account menu follows. if (window.location.pathname === '/assistant') { try { localStorage.setItem('ped_last_tab', id); } catch (e) {} window.location.href = '/'; return; } activateTab(id); } document.addEventListener('click', function(event) { if (event.target.closest && event.target.closest('[data-menu-search]')) { openSearch(); return; } if (event.target.closest && event.target.closest('#menu-search-close')) { closeSearch(); return; } var row = event.target.closest && event.target.closest('.menu-search-item'); if (row) { runSearchItem(row); return; } if (searchModal && !searchModal.hidden && event.target === searchModal) closeSearch(); }); if (searchInput) searchInput.addEventListener('input', function() { renderSearch(searchInput.value); }); document.addEventListener('keydown', function(event) { var key = String(event.key || '').toLowerCase(); if ((event.metaKey || event.ctrlKey) && key === 'k') { event.preventDefault(); openSearch(); return; } if (!searchModal || searchModal.hidden) return; if (event.key === 'Escape') { closeSearch(); return; } var rows = Array.prototype.slice.call(searchResults ? searchResults.querySelectorAll('.menu-search-item') : []); if (!rows.length) return; var at = rows.findIndex(function(r) { return r.classList.contains('is-active'); }); if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); var next = event.key === 'ArrowDown' ? Math.min(at + 1, rows.length - 1) : Math.max(at - 1, 0); rows.forEach(function(r) { r.classList.remove('is-active'); }); rows[next].classList.add('is-active'); rows[next].scrollIntoView({ block: 'nearest' }); } else if (event.key === 'Enter') { event.preventDefault(); runSearchItem(rows[at === -1 ? 0 : at]); } }); // A sub-item chosen from the assistant navigates here first; open it once the // component it lives in is ready. try { var pendingSub = localStorage.getItem('ped_pending_sub'); if (pendingSub) { localStorage.removeItem('ped_pending_sub'); openSubItem(pendingSub, null, 30); } } catch (e) {} // Expose activateTab globally so auth.js can call it after login window.activateTab = activateTab; document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.addEventListener('click', function() { activateTab(btn.getAttribute('data-tab')); var subtab = btn.getAttribute('data-subtab'); if (subtab && typeof window.wvSwitchSubtab === 'function') { setTimeout(function() { window.wvSwitchSubtab(subtab); }, 0); } }); }); // --- SIDEBAR TOGGLE --- var sidebar = document.getElementById('sidebar'); var sidebarOverlay = document.getElementById('sidebar-overlay'); document.addEventListener('click', function(e) { if (e.target.closest('#btn-menu-toggle')) { // The menu follows the view, as on desktop: in the assistant it is the // chat history (the assistant's own drawer), everywhere else the // workspace list. On a phone the assistant used to open the workspace // list, so there was no way to reach a saved chat. var railLayout = document.getElementById('assistant-layout'); if (railLayout && window.innerWidth <= 640 && document.body.classList.contains('assistant-workspace')) { railLayout.classList.toggle('mobile-chats-open'); } else if (sidebar) { sidebar.classList.toggle('open'); } } if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) { if (sidebar) sidebar.classList.remove('open'); } }); if (sidebarOverlay) { sidebarOverlay.addEventListener('click', function() { if (sidebar) sidebar.classList.remove('open'); }); } // --- DESKTOP SIDEBAR COLLAPSE --- // One menu toggle, shared by the app sidebar and the assistant rail. It used // to be a pin button plus a separate floating expand button here, and a third // control inside the assistant — three affordances for one idea. function syncMenuToggles(hidden) { var label = hidden ? 'Show menu' : 'Hide menu'; document.querySelectorAll('[data-menu-toggle]').forEach(function(button) { button.setAttribute('aria-expanded', hidden ? 'false' : 'true'); button.setAttribute('title', label); button.setAttribute('aria-label', label); button.classList.toggle('is-collapsed', hidden); }); } var menuHidden = false; try { menuHidden = localStorage.getItem('ped_sidebar_collapsed') === '1'; } catch (e) {} document.body.classList.toggle('menu-hidden', menuHidden); syncMenuToggles(menuHidden); document.addEventListener('click', function(event) { if (!(event.target.closest && event.target.closest('[data-menu-toggle]'))) return; // On a phone the menu is a sheet, not a rail, so there is nothing to // collapse — the same control closes it instead. if (window.innerWidth <= 768) { closeMobileMenus(); return; } var nowHidden = document.body.classList.toggle('menu-hidden'); syncMenuToggles(nowHidden); try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {} }); // Load settings data when settings tab is activated document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'settings') { if (typeof load2FAStatus === 'function') load2FAStatus(); if (typeof loadSessions === 'function') loadSessions(); if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus(); if (typeof loadMemories === 'function') loadMemories(); if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList(); if (typeof renderAudioBackups === 'function') renderAudioBackups(); if (typeof loadDocuments === 'function') loadDocuments(); } if (e.detail && e.detail.tab === 'faq') { // Wire FAQ accordion after component loads setTimeout(function() { document.querySelectorAll('.faq-question').forEach(function(btn) { if (btn._faqWired) return; btn._faqWired = true; btn.addEventListener('click', function() { var item = btn.parentElement; var isOpen = item.classList.contains('open'); var section = item.closest('.faq-section'); if (section) section.querySelectorAll('.faq-item.open').forEach(function(i) { i.classList.remove('open'); }); if (!isOpen) item.classList.add('open'); }); }); }, 100); } }); // --- MODEL SELECTORS --- window._currentModels = []; window._currentProvider = 'openrouter'; window._defaultModelId = ''; fetch('/api/models') .then(function(r) { return r.json(); }) .then(function(data) { window._currentModels = data.models || []; window._currentProvider = data.provider || 'openrouter'; window._buildModelOptions = function buildModelOptions(selectEl) { selectEl.innerHTML = ''; window._currentModels.forEach(function(m) { var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; selectEl.appendChild(opt); }); if (window._defaultModelId) selectEl.value = window._defaultModelId; } // Determine default model (admin override or first model) var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : ''); window._defaultModelId = defaultModelId; // Populate all per-tab model selectors already in DOM document.querySelectorAll('.tab-model-select').forEach(function(sel) { window._buildModelOptions(sel); }); }) .catch(function(err) { console.warn('Models load failed:', err); }); console.log('✅ App.js DOM ready'); }); // end DOMContentLoaded // ============================================================ // GLOBAL FUNCTIONS (must be outside DOMContentLoaded) // ============================================================ // ── Set formatted text on output/contenteditable elements ── // Converts \n to
so line breaks survive in contenteditable divs // and are preserved when copying with innerText function setOutputText(el, text) { if (!el) return; if (typeof el === 'string') el = document.getElementById(el); if (!el) return; // Escape HTML, then convert newlines to
var safe = String(text || '').replace(/&/g, '&').replace(//g, '>'); el.innerHTML = safe.replace(/\n/g, '
'); } // ── Data-action event delegation (replaces inline onclick handlers) ── // Handles data-action="copy|speak|nc-export" on any element, // allowing removal of 'unsafe-inline' from the Content Security Policy. document.addEventListener('click', function(e) { var btn = e.target.closest('[data-action]'); if (!btn) return; var action = btn.getAttribute('data-action'); var targetId = btn.getAttribute('data-target'); if (!action) return; if (action === 'copy' && targetId) { if (typeof copyText === 'function') copyText(targetId); // Log PHI copy event (fire-and-forget, best-effort) try { if (window.AccountBoundary.active()) { fetch('/api/logs/client-event', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ action: 'copy_to_clipboard', target: targetId }) }).catch(function() {}); } } catch(e) {} return; } if (action === 'speak' && targetId) { if (typeof speakText === 'function') speakText(targetId); return; } if (action === 'nc-export' && targetId) { var label = btn.getAttribute('data-label') || 'export'; if (typeof exportToNextcloud === 'function') exportToNextcloud(targetId, label); return; } }); // Attributes also hide controls in subsequently lazy-loaded components. window.userFeatures = {}; function loadUserFeatures() { return fetch('/api/user/features', { headers: getAuthHeaders() }) .then(function(r) { if (!r.ok) throw new Error('Feature policy unavailable'); return r.json(); }) .then(function(data) { applyUserFeatures(data.features || {}); }) .catch(function() { applyUserFeatures({}); }); } function applyUserFeatures(features) { window.userFeatures = features; ['read_aloud', 'nextcloud', 'memories'].forEach(function(name) { document.documentElement.setAttribute('data-feature-' + name, features[name] === true ? 'true' : 'false'); }); if (!features.read_aloud) stopReading(); } document.addEventListener('tabChanged', loadUserFeatures); // ── Announcement banner ──────────────────────────────────── // The banner is admin-authored and shown on every page, so it renders // Markdown but only ever emits inline formatting and links — no images, no // headings, no block layout that could push the app around. Anything else, // and any failure to sanitise, falls back to the literal text. function renderAnnouncement(el, raw) { var markdown = String(raw == null ? '' : raw); if (!window.marked || typeof window.marked.parseInline !== 'function' || !window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') { el.textContent = markdown; return; } try { el.innerHTML = window.DOMPurify.sanitize(window.marked.parseInline(markdown, { gfm: true }), { ALLOWED_TAGS: ['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'br'], ALLOWED_ATTR: ['href', 'target', 'rel'], ADD_ATTR: ['target'], FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick', 'onmouseover'], ALLOW_DATA_ATTR: false }); } catch (e) { el.textContent = markdown; } } function loadAnnouncement() { loadUserFeatures(); fetch('/api/admin/config/announcement', { headers: getAuthHeaders() }) .then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); }) .then(function(data) { if (!data.success) return; var banner = document.getElementById('announcement-banner'); var text = document.getElementById('announcement-text'); var icon = document.getElementById('announcement-icon'); if (!banner || !text) return; if (data.enabled && data.text && data.text.trim()) { renderAnnouncement(text, data.text); // type → icon + class var icons = { info: 'fa-info-circle', warning: 'fa-triangle-exclamation', error: 'fa-circle-xmark', success: 'fa-circle-check' }; var type = data.type || 'info'; if (icon) { icon.className = 'fas ' + (icons[type] || icons.info); } banner.className = 'announcement-banner ann-' + type; } else { banner.className = 'announcement-banner hidden'; } }) .catch(function() {}); // silently ignore if endpoint not yet available } // Close button — dismiss for this page view only (reappears on refresh/re-login) var closeBtn = document.getElementById('announcement-close'); if (closeBtn) { closeBtn.addEventListener('click', function() { var banner = document.getElementById('announcement-banner'); if (banner) banner.classList.add('hidden'); }); } function getSelectedModel() { // Prefer the active tab's own model selector if present var activeTab = document.querySelector('.tab-content.active'); if (activeTab) { var tabSel = activeTab.querySelector('.tab-model-select'); if (tabSel && tabSel.value) return tabSel.value; } return undefined; } function showLoading(text) { var overlay = document.getElementById('loading-overlay'); var textEl = document.getElementById('loading-text'); if (textEl) textEl.textContent = text || 'Processing...'; if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); } } function hideLoading() { var overlay = document.getElementById('loading-overlay'); if (overlay) overlay.style.display = 'none'; } // Non-blocking busy bar — user can keep working while AI processes function showBusy(text) { var bar = document.getElementById('busy-bar'); var textEl = document.getElementById('busy-text'); if (textEl) textEl.textContent = text || 'Processing...'; if (bar) bar.classList.add('active'); } function hideBusy() { var bar = document.getElementById('busy-bar'); if (bar) bar.classList.remove('active'); } // Reusable confirmation modal — replaces browser confirm() and prompt() // Usage: showConfirm('Are you sure?', function() { doSomething(); }); // With input: showConfirm('Enter email:', function(value) { send(value); }, { input: true, placeholder: 'email@example.com', inputType: 'email' }); // With danger styling: showConfirm('Delete user?', function() { deleteUser(); }, { danger: true, confirmText: 'Delete' }); window.showConfirm = function(message, onConfirm, opts) { opts = opts || {}; var modal = document.getElementById('confirm-modal'); var text = document.getElementById('confirm-modal-text'); var okBtn = document.getElementById('confirm-modal-ok'); var cancelBtn = document.getElementById('confirm-modal-cancel'); var inputWrap = document.getElementById('confirm-modal-input-wrap'); var inputEl = document.getElementById('confirm-modal-input'); if (!modal || !text || !okBtn) return; text.textContent = message; okBtn.textContent = opts.confirmText || 'Confirm'; if (opts.danger) { okBtn.style.background = 'var(--red)'; okBtn.style.borderColor = 'var(--red)'; } else { okBtn.style.background = ''; okBtn.style.borderColor = ''; } if (opts.input) { inputWrap.style.display = 'block'; inputEl.type = opts.inputType || 'text'; inputEl.placeholder = opts.placeholder || ''; inputEl.value = opts.defaultValue || ''; setTimeout(function() { inputEl.focus(); }, 50); } else { inputWrap.style.display = 'none'; } modal.classList.remove('hidden'); function cleanup() { modal.classList.add('hidden'); okBtn.onclick = null; cancelBtn.onclick = null; inputEl.value = ''; } cancelBtn.onclick = function() { cleanup(); }; okBtn.onclick = function() { if (opts.input) { var val = inputEl.value; if (opts.required && !val.trim()) { showToast(opts.requiredMsg || 'This field is required', 'error'); return; } cleanup(); if (onConfirm) onConfirm(val); } else { cleanup(); if (onConfirm) onConfirm(); } }; // Allow Enter key to confirm inputEl.onkeydown = function(e) { if (e.key === 'Enter') okBtn.click(); }; }; function showToast(message, type) { var container = document.getElementById('toast-container'); if (!container) { console.log('Toast:', type, message); return; } var toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'success'); var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle'; var iconEl = document.createElement('i'); iconEl.className = 'fas fa-' + icon; toast.appendChild(iconEl); toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message))); container.appendChild(toast); setTimeout(function() { toast.remove(); }, 3500); } function copyText(elementId) { var el = document.getElementById(elementId); if (!el) return; var text = el.innerText || el.textContent || ''; if (!text.trim()) { showToast('Nothing to copy', 'error'); return; } // Try modern clipboard API (requires HTTPS or localhost) if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function() { showToast('Copied!', 'success'); }).catch(function(err) { // Fallback: select + execCommand try { var range = document.createRange(); range.selectNodeContents(el); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); var ok = document.execCommand('copy'); window.getSelection().removeAllRanges(); showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error'); } catch (e) { showToast('Copy not supported in this browser', 'error'); } }); } else { // No clipboard API — use execCommand directly try { var range = document.createRange(); range.selectNodeContents(el); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); var ok = document.execCommand('copy'); window.getSelection().removeAllRanges(); showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error'); } catch (e) { showToast('Copy not supported in this browser', 'error'); } } } // Speak / Stop var currentlyReadingId = null; var currentAudio = null; var currentAudioURL = null; var playbackGeneration = 0; window.addEventListener('account-boundary', stopReading); function speakText(elementId) { var boundary = window.AccountBoundary; var ticket = boundary && boundary.capture(); if (!ticket || window.userFeatures.read_aloud !== true) return; if (currentlyReadingId === elementId) { stopReading(); return; } stopReading(); var el = document.getElementById(elementId); if (!el) return; var text = (el.innerText || el.textContent).trim(); if (!text) { showToast('Nothing to read', 'error'); return; } var playback = playbackGeneration; function current() { return playback === playbackGeneration && boundary.valid(ticket) && window.userFeatures.read_aloud === true; } currentlyReadingId = elementId; var btn = findReadButton(elementId); if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = ' Loading...'; } fetch('/api/text-to-speech', { method: 'POST', headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(r) { if (!current()) throw boundary.error(); if (!r.ok) { var error = new Error('TTS request failed (' + r.status + ')'); error.policyDenied = [401, 403, 503].includes(r.status); throw error; } var ttsProvider = r.headers.get('X-TTS-Provider') || 'server'; return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; }); }) .then(function(result) { if (!current()) throw boundary.error(); currentAudioURL = URL.createObjectURL(result.blob); currentAudio = new Audio(currentAudioURL); currentAudio.onended = function() { if (current()) stopReading(); }; currentAudio.onerror = function() { if (!current()) return; stopReading(); showToast('Audio playback error', 'error'); }; return Promise.resolve(currentAudio.play()).then(function() { if (!current()) return; if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = ' Stop'; } showToast('Reading aloud (' + result.provider + ')', 'info'); }); }) .catch(function(err) { if (!current()) return; // Stale completions must not stop newer playback either. stopReading(); playback = playbackGeneration; // Account/abort/policy failures must never speak the captured clinical text. if (err.name === 'AbortError') return; if (!err.policyDenied && current() && 'speechSynthesis' in window) { var utter = new SpeechSynthesisUtterance(text); utter.rate = 0.9; utter.onend = function() { if (current()) stopReading(); }; currentlyReadingId = elementId; if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = ' Stop'; } window.speechSynthesis.speak(utter); showToast('TTS unavailable — using browser voice', 'info'); } else { showToast('Read aloud failed: ' + err.message, 'error'); } }); } function stopReading() { playbackGeneration++; if ('speechSynthesis' in window) window.speechSynthesis.cancel(); if (currentAudio) { currentAudio.onended = currentAudio.onerror = null; currentAudio.pause(); currentAudio = null; } if (currentAudioURL) { URL.revokeObjectURL(currentAudioURL); currentAudioURL = null; } if (currentlyReadingId) { var btn = findReadButton(currentlyReadingId); if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = ' Read'; } currentlyReadingId = null; } } function findReadButton(elementId) { var el = document.getElementById(elementId); if (!el) return null; var card = el.closest('.output-card') || el.closest('.card'); if (!card) return null; var buttons = card.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var btn = buttons[i]; // data-action="speak" buttons (current approach) if (btn.getAttribute('data-action') === 'speak' && btn.getAttribute('data-target') === elementId) return btn; // legacy onclick fallback var oc = btn.getAttribute('onclick') || ''; if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return btn; } return null; } // Timer function createTimer(el) { var s = 0, iv = null; return { start: function() { s = 0; this.resume(); }, resume: function() { this.update(); var self = this; if (!iv) iv = setInterval(function() { s++; self.update(); }, 1000); }, stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; }, reset: function() { s = 0; this.update(); }, update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); } }; } // Audio Recorder // The screen going to sleep suspends a recording, so hold a wake lock for as // long as one is running. The browser drops the lock whenever the page is // hidden, so it has to be taken again when the page comes back; without that, // one glance away ends the lock for the rest of the session. The native app // keeps its own lock through nativeKeepAwake(). var _wakeLock = null; var _wakeLockHolders = 0; function _acquireWakeLock() { if (!_wakeLockHolders || _wakeLock) return Promise.resolve(null); if (!navigator.wakeLock || typeof navigator.wakeLock.request !== 'function') return Promise.resolve(null); if (document.visibilityState !== 'visible') return Promise.resolve(null); // the request would be rejected return navigator.wakeLock.request('screen').then(function(lock) { _wakeLock = lock; lock.addEventListener('release', function() { _wakeLock = null; }); return lock; }).catch(function() { return null; }); // denied or unsupported: recording continues regardless } function holdWakeLock() { _wakeLockHolders++; return _acquireWakeLock(); } function releaseWakeLock() { _wakeLockHolders = Math.max(0, _wakeLockHolders - 1); if (_wakeLockHolders > 0 || !_wakeLock) return; var lock = _wakeLock; _wakeLock = null; try { lock.release(); } catch (e) {} } document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'visible') _acquireWakeLock(); }); // Signing out must not leave the screen pinned awake for a recording that is // no longer anyone's. window.addEventListener('account-boundary', function() { _wakeLockHolders = 0; if (_wakeLock) { try { _wakeLock.release(); } catch (e) {} _wakeLock = null; } }); // Every running recorder, so a logout or a navigation can find one and save it // rather than discarding minutes of a consultation. var _activeRecorders = new Set(); window.activeRecordingCount = function() { return _activeRecorders.size; }; // Stops each running recording and stores it, tagged with the module it came // from, so it can be picked up and transcribed later. Resolves once every one // is stored (or has failed to store) — never rejects, because the caller is // usually on its way out of the app. window.rescueActiveRecordings = function() { var recorders = Array.from(_activeRecorders); if (!recorders.length) return Promise.resolve([]); return Promise.all(recorders.map(function(recorder) { var module = recorder._module || 'recording'; return recorder.stop().then(function(blob) { if (!blob || !blob.size || typeof saveAudioBackup !== 'function') return null; return saveAudioBackup(blob, module); }).then(function(id) { return { module: module, id: id }; }) .catch(function() { return { module: module, id: null }; }); })); }; function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; } // The one codec chain in the app: Opus webm, plain webm, then mp4, then whatever // the browser picks. Safari supports none of the webm types and throws // NotSupportedError when handed one, so anywhere that built a recorder with // "opus, else audio/webm" simply died there — which is what pause/resume did in // every module until they were routed through here. AudioRecorder.encoding = function() { var options = { audioBitsPerSecond: 32000 }; var candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; for (var i = 0; i < candidates.length; i++) { if (MediaRecorder.isTypeSupported(candidates[i])) { options.mimeType = candidates[i]; return options; } } return options; }; // Resume after a pause. A paused recorder resumes; one the browser stopped // outright is rebuilt on the same stream, so the chunks already captured are // kept and the recording continues into the same blob. AudioRecorder.prototype.resumeCapture = function() { var self = this; if (!self.mediaRecorder) return false; try { if (self.mediaRecorder.state === 'paused') { self.mediaRecorder.resume(); return true; } if (self.mediaRecorder.state !== 'inactive') return true; if (!self.stream || !self.stream.active) return false; self.mediaRecorder = new MediaRecorder(self.stream, AudioRecorder.encoding()); self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); }; self.mediaRecorder.start(1000); return true; } catch (e) { console.warn('[Rec] could not resume:', e.message); return false; } }; AudioRecorder.prototype.start = function() { var self = this; // Idempotent: a second start on a running recorder would replace the // MediaRecorder and silently drop everything captured so far. if (self.mediaRecorder && self.mediaRecorder.state === 'recording') return Promise.resolve(); self.chunks = []; return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } }) .then(function(stream) { self.stream = stream; self.mediaRecorder = new MediaRecorder(stream, AudioRecorder.encoding()); self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); }; // A recording can stop being a recording without anyone noticing: the // recorder can error, and the microphone can be taken away entirely (another // app claims it, a headset is unplugged, permission is revoked). Neither was // reported, so the tab kept showing "recording" while capturing nothing. // Whatever was captured up to that point is kept — the chunks are already in // self.chunks — so stop() still returns the audio so far. self.failure = null; self.mediaRecorder.onerror = function(event) { self.failure = (event && event.error && event.error.message) || 'The recorder stopped unexpectedly'; self.notifyFailure(); }; stream.getAudioTracks().forEach(function(track) { track.addEventListener('ended', function() { self.failure = 'The microphone became unavailable'; self.notifyFailure(); }); }); self.mediaRecorder.start(1000); _activeRecorders.add(self); self.heldWakeLock = true; holdWakeLock(); }); }; // Announced once per recorder, so a caller that is listening can stop cleanly // and the person is told rather than left recording silence. AudioRecorder.prototype.notifyFailure = function() { if (this.notified) return; this.notified = true; var message = this.failure || 'Recording stopped unexpectedly'; try { if (typeof showToast === 'function') showToast(message + '. Stop and check your microphone.', 'error'); } catch (e) {} try { // The recorder itself travels with the event. Without it a listener cannot // tell its own recording from someone else's, and the encounter tab would // stop a live consultation because the assistant's microphone had failed. document.dispatchEvent(new CustomEvent('audio-recorder-failed', { detail: { message: message, recorder: this } })); } catch (e) {} }; AudioRecorder.prototype.stop = function() { var self = this; _activeRecorders.delete(self); if (self.heldWakeLock) { self.heldWakeLock = false; releaseWakeLock(); } return new Promise(function(resolve) { if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; } self.mediaRecorder.onstop = function() { var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType }); if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); }); resolve(blob); }; self.mediaRecorder.stop(); }); }; // ── Native mobile helpers (Capacitor / Android bridge) ── // These only activate when running inside the native app window.nativeHaptic = function(style) { try { if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) { window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' }); } else if (navigator.vibrate) { navigator.vibrate(style === 'heavy' ? 100 : 50); } } catch(e) {} }; // Start/stop Android foreground service for background recording window.nativeStartRecordingService = function() { try { if (window.NativeRecording) window.NativeRecording.startForegroundService(); } catch(e) {} }; window.nativeStopRecordingService = function() { try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} }; // Keep screen awake during recording. // // Prefer the NativeRecording bridge (addJavascriptInterface, so it is present // on the remote origin the launcher navigates to). The Capacitor KeepAwake // plugin is kept as a fallback but is NOT installed in this project — relying // on it alone meant this function silently did nothing and the screen slept // mid-recording, killing the MediaRecorder. window.nativeKeepAwake = function(on) { try { if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') { window.NativeRecording.keepAwake(!!on); return; } if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) { if (on) window.Capacitor.Plugins.KeepAwake.keepAwake(); else window.Capacitor.Plugins.KeepAwake.allowSleep(); } } catch(e) {} }; // Detect if running in native app window.isNativeApp = function() { return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()); }; // Check if server-side transcription (Whisper/AWS) is available window._transcribeAvailable = null; // null = not checked yet, true/false after check function checkTranscribeStatus() { if (!window.AccountBoundary.active()) return; fetch('/api/transcribe/status', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { window._transcribeAvailable = !!data.available; window._transcribeProvider = data.provider || 'none'; if (!data.available) { console.log('[Transcribe] No server transcription configured — using browser speech recognition only'); } }) .catch(function() { window._transcribeAvailable = false; }); } function transcribeAudio(blob) { return _serverTranscribe(blob); } function _serverTranscribe(blob) { // If no server transcription is configured, skip upload entirely if (window._transcribeAvailable === false) { return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' }); } var startTime = Date.now(); var formData = new FormData(); formData.append('audio', blob, 'audio.webm'); var headers = getAuthHeaders(); delete headers['Content-Type']; // FormData supplies its own boundary. return fetch('/api/transcribe', { method: 'POST', headers: headers, body: formData }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') { console.log('[AudioBackup] Transcription failed, saving backup...'); saveAudioBackup(blob, 'failed-transcription').then(function(id) { console.log('[AudioBackup] Saved with id:', id); showToast('Audio backed up for retry', 'info'); }).catch(function(e) { console.error('[AudioBackup] Save failed:', e); }); } return data; }).catch(function(err) { if (blob.size > 0 && typeof saveAudioBackup === 'function') { console.log('[AudioBackup] Transcription error, saving backup...'); saveAudioBackup(blob, 'failed-transcription').then(function(id) { console.log('[AudioBackup] Saved with id:', id); showToast('Audio backed up for retry', 'info'); }).catch(function(e) { console.error('[AudioBackup] Save failed:', e); }); } return { success: false, error: err.message }; }); } // Store original source material on an output element (call after generation) function storeSourceContext(outputElementId, sourceText) { var el = document.getElementById(outputElementId); if (el && sourceText) el.dataset.sourceContext = sourceText; } // ── Billing code suggestions (called after note generation) ── function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, visitType) { // Find or create the billing codes container near the output element var outputEl = document.getElementById(outputElementId); if (!outputEl || !noteText) return; var card = outputEl.closest('.card, .output-card'); if (!card) return; var containerId = outputElementId.replace('-text', '') + '-billing-codes'; var container = document.getElementById(containerId); if (!container) { // Create container dynamically if not in HTML container = document.createElement('div'); container.id = containerId; container.className = 'billing-codes-card'; // Insert after the output text element outputEl.parentNode.insertBefore(container, outputEl.nextSibling); } container.className = 'billing-codes-card'; container.innerHTML = '
Analyzing billing codes...
'; fetch('/api/suggest-codes', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ noteText: noteText, noteType: noteType || 'soap', patientAge: patientAge || '', visitType: visitType || 'outpatient' }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || (!data.icd10.length && !data.cpt.length)) { container.classList.add('hidden'); return; } var html = '

Suggested Billing Codes

'; if (data.icd10 && data.icd10.length > 0) { html += '
ICD-10 Diagnoses
'; data.icd10.forEach(function(c) { var name = c.name ? ' ' + escHtml(c.name) + '' : ''; html += '' + escHtml(c.code) + name + ''; }); html += '
'; } if (data.cpt && data.cpt.length > 0) { html += '
CPT / E&M
'; data.cpt.forEach(function(c) { var desc = c.desc ? ' ' + escHtml(c.desc) + '' : ''; html += '' + escHtml(c.code) + desc + ''; }); html += '
'; } if (data.emLevel) { html += '
E/M Assessment
'; html += 'Level ' + escHtml(data.emLevel.level) + ''; html += 'MDM: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE'; html += '
'; } html += '

Suggestions only. Always verify codes against your institution\'s coding guidelines.

'; container.innerHTML = html; // Wire click-to-copy on chips container.querySelectorAll('.billing-code-chip').forEach(function(chip) { chip.addEventListener('click', function() { var code = chip.dataset.code; if (code && navigator.clipboard) { navigator.clipboard.writeText(code); showToast('Copied: ' + code, 'info'); } }); }); }) .catch(function() { container.classList.add('hidden'); }); } function escHtml(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Don't-miss tooltip (called after sick visit / encounter HPI generates) ── // Same insertion pattern as suggestBillingCodes — a side card next to the note // output. Hard-capped at 5 items by the server prompt. Silent on empty / failure. function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefComplaint) { var outputEl = document.getElementById(outputElementId); if (!outputEl || !noteText) return; var card = outputEl.closest('.card, .output-card'); if (!card) return; var containerId = outputElementId.replace('-text', '') + '-dont-miss'; var container = document.getElementById(containerId); if (!container) { container = document.createElement('div'); container.id = containerId; container.className = 'card dont-miss-card'; container.style.cssText = 'margin-top:10px;border-left:3px solid #f59e0b;'; outputEl.parentNode.insertBefore(container, outputEl.nextSibling); } container.innerHTML = '
Reviewing note for high-yield items...
'; fetch('/api/dont-miss', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ noteText: noteText, noteType: noteType || '', patientAge: patientAge || '', chiefComplaint: chiefComplaint || '' }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || !data.points || data.points.length === 0) { container.classList.add('hidden'); return; } var html = '

Don\'t Miss

' + 'High-yield items. Suggestions only.
' + '
'; data.points.forEach(function(p) { var why = p.why ? '
' + escHtml(p.why) + '
' : ''; html += '
' + '
' + escHtml(p.point) + '
' + why + '
'; }); html += '
'; container.classList.remove('hidden'); container.innerHTML = html; }) .catch(function() { container.classList.add('hidden'); }); } // ── Patient education handout helper ──────────────────────── // Adds a reusable "Handout" action beside generated clinical notes. The actual // handout is generated only when the physician clicks Generate in the panel. function attachPatientEducation(outputElementId, opts) { var outputEl = document.getElementById(outputElementId); if (!outputEl) return; opts = opts || {}; var card = outputEl.closest('.card, .output-card'); if (!card) return; var actions = card.querySelector('.output-actions'); var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, ''); if (actions && !document.getElementById(prefix + '-patient-ed-btn')) { var btn = document.createElement('button'); btn.id = prefix + '-patient-ed-btn'; btn.className = 'btn-sm btn-ghost'; btn.type = 'button'; btn.innerHTML = ' Handout'; btn.addEventListener('click', function() { var panel = ensurePatientEducationPanel(outputElementId, opts); panel.classList.remove('hidden'); panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); actions.appendChild(btn); } } function ensurePatientEducationPanel(outputElementId, opts) { var outputEl = document.getElementById(outputElementId); var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, ''); var panelId = prefix + '-patient-ed'; var panel = document.getElementById(panelId); if (panel) return panel; panel = document.createElement('div'); panel.id = panelId; panel.className = 'card patient-ed-card hidden'; panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;'; panel.innerHTML = '
' + '

Patient Education Handout

' + '
' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + 'Parent-facing draft. Verify before sharing.' + '
'; outputEl.parentNode.insertBefore(panel, outputEl.nextSibling); var gen = panel.querySelector('#' + prefix + '-patient-ed-generate'); if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); }); return panel; } function generatePatientEducation(outputElementId, opts) { var outputEl = document.getElementById(outputElementId); if (!outputEl) return; opts = opts || {}; var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, ''); var handoutEl = document.getElementById(prefix + '-patient-ed-text'); var langEl = document.getElementById(prefix + '-patient-ed-language'); var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis'); var medsEl = document.getElementById(prefix + '-patient-ed-meds'); var noteText = (outputEl.innerText || outputEl.textContent || '').trim(); if (!noteText) { showToast('No note for handout', 'error'); return; } if (handoutEl) handoutEl.innerHTML = ' Generating parent handout...'; fetch('/api/patient-education', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ noteText: noteText, diagnosis: dxEl ? dxEl.value : '', medications: medsEl ? medsEl.value : '', patientAge: opts.patientAge || '', language: langEl ? langEl.value : 'English', readingLevel: '6th grade plain language', model: getSelectedModel() }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { if (handoutEl) handoutEl.textContent = ''; showToast(data.error || 'Handout generation failed', 'error'); return; } setOutputText(handoutEl, data.handout || ''); showToast('Patient handout generated', 'success'); }) .catch(function(err) { if (handoutEl) handoutEl.textContent = ''; showToast(err.message || 'Handout generation failed', 'error'); }); } function refineDocument(outputElementId, inputElementId) { var doc = document.getElementById(outputElementId); var input = document.getElementById(inputElementId); if (!doc || !input) return; var docText = doc.innerText.trim(); var instructions = input.value.trim(); if (!docText) { showToast('No document', 'error'); return; } if (!instructions) { showToast('Enter instructions', 'error'); return; } showBusy('Refining...'); var body = { currentDocument: docText, instructions: instructions, model: getSelectedModel() }; // Include original source material so AI can reference the full input if (doc.dataset.sourceContext) body.sourceContext = doc.dataset.sourceContext; fetch('/api/refine', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { hideBusy(); if (data.success) { setOutputText(doc, data.refined); input.value = ''; showToast('Refined!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function shortenDocument(outputElementId) { var doc = document.getElementById(outputElementId); if (!doc) return; var text = doc.innerText.trim(); if (!text) { showToast('No document', 'error'); return; } showBusy('Shortening...'); fetch('/api/shorten', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ document: text, model: getSelectedModel() }) }) .then(function(r) { return r.json(); }) .then(function(data) { hideBusy(); if (data.success) { setOutputText(doc, data.shortened); showToast('Shortened!', 'success'); } else showToast(data.error || 'Failed', 'error'); }) .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } function exportToNextcloud(elementId, docType) { var el = document.getElementById(elementId); if (!el) return; var text = el.innerText.trim(); if (!text) { showToast('Nothing to export', 'error'); return; } fetch('/api/nextcloud/export', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) showToast(data.message, 'success'); else showToast(data.error || 'Export failed', 'error'); }) .catch(function() { showToast('Nextcloud not connected', 'error'); }); } function createSpeechRecognition() { if (window.WebSpeechRecognition && !window.WebSpeechRecognition.isEnabled()) return null; if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null; var SR = window.SpeechRecognition || window.webkitSpeechRecognition; var rec = new SR(); rec.continuous = true; rec.interimResults = true; rec.lang = 'en-US'; rec.maxAlternatives = 1; return rec; } // Deduplicate speech recognition finals — Chrome can repeat text across restarts function deduplicateFinal(newText, existingText) { if (!newText || !existingText) return newText; var trimmed = newText.trim(); if (!trimmed) return ''; // Check if the new text is already at the end of existing text if (existingText.trimEnd().endsWith(trimmed)) return ''; // Check for partial overlap (last sentence repeated) var words = trimmed.split(/\s+/); if (words.length >= 3) { var tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' '); if (tail === trimmed) return ''; // Check if first half of new text overlaps with end of existing var half = Math.ceil(words.length / 2); var firstHalf = words.slice(0, half).join(' '); if (existingText.trimEnd().endsWith(firstHalf)) { return words.slice(half).join(' ') + ' '; } } return newText; } // PWA Service Worker if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {}); } console.log('✅ App.js loaded');