pediatric-ai-scribe-v3/public/js/sickVisit.js
Daniel Onyejesi 6c0c911dbd Fix module init: defer all tab scripts until component HTML is loaded
All JS modules were running immediately (IIFE/DOMContentLoaded) but
tab HTML is lazy-loaded from /components/*.html, causing null errors
on every addEventListener call. Each module now listens for tabChanged
with its tab name and initializes once after the DOM is ready.

Also: quiz question/explanation boxes changed to textarea for
multi-line support; quiz display uses sanitizeHtml() so formatted
text (bold, lists, etc.) renders correctly.
2026-03-23 22:03:54 -04:00

426 lines
22 KiB
JavaScript

// ============================================================
// SICK VISIT TAB — Recording, ROS/PE inference, Note generation
// ============================================================
(function() {
'use strict';
// ── 15 ROS systems (same keys as shadess.js) ─────────────────────────────
var ROS_SYSTEMS = [
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
];
// ── 17 PE systems ─────────────────────────────────────────────────────────
var PE_SYSTEMS = [
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
];
// ── Inference rules (CC keyword → ROS and PE system keys) ─────────────────
var INFERENCE_RULES = [
{ keywords: ['ear', 'hearing', 'otalgia', 'otitis'],
ros: ['constitutional', 'ent'],
pe: ['general', 'ears', 'nose', 'mouthThroat', 'neck'] },
{ keywords: ['throat', 'strep', 'pharyn', 'tonsil'],
ros: ['constitutional', 'ent'],
pe: ['general', 'mouthThroat', 'neck', 'ears', 'lymphatic'] },
{ keywords: ['cough', 'breath', 'asthma', 'wheez', 'respiratory', 'bronch'],
ros: ['constitutional', 'respiratory', 'cardiovascular', 'ent'],
pe: ['general', 'chestLungs', 'peCv', 'ears', 'nose'] },
{ keywords: ['stomach', 'vomit', 'nausea', 'diarr', 'abdomin', 'constip', 'belly'],
ros: ['constitutional', 'gastrointestinal', 'genitourinary'],
pe: ['general', 'abdomen', 'chestLungs', 'peCv', 'peSkin'] },
{ keywords: ['fever', 'temp', 'hot'],
ros: ['constitutional', 'ent', 'respiratory', 'gastrointestinal'],
pe: ['general', 'ears', 'mouthThroat', 'chestLungs', 'abdomen', 'peSkin', 'lymphatic'] },
{ keywords: ['rash', 'skin', 'itch', 'eczema', 'hives', 'dermat'],
ros: ['constitutional', 'skin', 'allergic'],
pe: ['general', 'peSkin', 'chestLungs', 'peCv', 'lymphatic'] },
{ keywords: ['headache', 'head', 'migrain'],
ros: ['constitutional', 'neurological', 'eyes', 'ent'],
pe: ['general', 'peNeuro', 'peEyes', 'ears', 'neck'] },
{ keywords: ['urin', 'uti', 'dysuria', 'bladder', 'kidney'],
ros: ['constitutional', 'genitourinary', 'gastrointestinal'],
pe: ['general', 'abdomen', 'guTanner', 'chestLungs', 'peCv'] },
{ keywords: ['joint', 'knee', 'limb', 'leg', 'arm', 'ankle', 'musculo', 'back', 'pain'],
ros: ['constitutional', 'musculoskeletal', 'neurological'],
pe: ['general', 'msk', 'extremities', 'peNeuro', 'peSkin'] },
{ keywords: ['anxi', 'mood', 'behav', 'depress', 'mental', 'school'],
ros: ['constitutional', 'psychiatric', 'neurological', 'developmental'],
pe: ['general', 'pePsych', 'peNeuro', 'peSkin'] },
];
var DEFAULT_ROS = ['constitutional', 'ent', 'respiratory', 'gastrointestinal'];
var DEFAULT_PE = ['general', 'ears', 'chestLungs', 'peCv', 'abdomen', 'peSkin', 'peNeuro'];
function inferSystems(cc) {
var lc = (cc || '').toLowerCase();
var rosSet = {};
var peSet = {};
var matched = false;
INFERENCE_RULES.forEach(function(rule) {
if (rule.keywords.some(function(kw) { return lc.indexOf(kw) !== -1; })) {
matched = true;
rule.ros.forEach(function(k) { rosSet[k] = true; });
rule.pe.forEach(function(k) { peSet[k] = true; });
}
});
if (!matched) {
DEFAULT_ROS.forEach(function(k) { rosSet[k] = true; });
DEFAULT_PE.forEach(function(k) { peSet[k] = true; });
}
// Return top 4 ROS, top 7 PE
var rosKeys = Object.keys(rosSet).slice(0, 4);
var peKeys = Object.keys(peSet).slice(0, 7);
// Ensure we always have at least the default counts
DEFAULT_ROS.forEach(function(k) { if (rosKeys.length < 4 && rosKeys.indexOf(k) === -1) rosKeys.push(k); });
DEFAULT_PE.forEach(function(k) { if (peKeys.length < 7 && peKeys.indexOf(k) === -1) peKeys.push(k); });
return {
ros: rosKeys.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
pe: peKeys.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
};
}
// ── Module state ─────────────────────────────────────────────────────────
var _sickRosData = {};
var _sickPeData = {};
var _sickSelectedDx = [];
var _sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
var _sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
// ── Recording ────────────────────────────────────────────────────────────
var _sickRecorder = null;
var _sickTimer = null;
var _sickRecording = false;
var _sickPaused = false;
var _sickRecognition = null;
var _sickTranscript = '';
function initRecording() {
var recordBtn = document.getElementById('sick-record-btn');
var pauseBtn = document.getElementById('sick-pause-btn');
var indicator = document.getElementById('sick-rec-indicator');
var timerEl = document.getElementById('sick-timer');
var transcriptEl = document.getElementById('sick-transcript');
if (!recordBtn) return;
_sickTimer = createTimer(timerEl);
_sickRecognition = createSpeechRecognition();
if (_sickRecognition) {
_sickRecognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) _sickTranscript += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
if (transcriptEl) transcriptEl.innerHTML = _sickTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
};
_sickRecognition.onend = function() { if (_sickRecording && !_sickPaused) try { _sickRecognition.start(); } catch(e) {} };
}
recordBtn.addEventListener('click', function() {
if (!_sickRecording) {
_sickTranscript = '';
_sickRecorder = new AudioRecorder();
_sickRecorder.start().then(function() {
_sickRecording = true; _sickPaused = false;
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
recordBtn.classList.add('recording');
if (pauseBtn) pauseBtn.classList.remove('hidden');
indicator.classList.remove('hidden');
_sickTimer.start();
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
showToast('Sick visit recording started', 'info');
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'sick' } }));
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
_sickRecording = false; _sickPaused = false;
var dur = _sickTimer.stop();
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
recordBtn.classList.remove('recording');
if (pauseBtn) pauseBtn.classList.add('hidden');
indicator.classList.add('hidden');
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'sick' } }));
showLoading('Transcribing...');
_sickRecorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideLoading(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); }
});
}).catch(function() { hideLoading(); });
}
});
if (pauseBtn) {
pauseBtn.addEventListener('click', function() {
if (!_sickRecording || !_sickRecorder || !_sickRecorder.mediaRecorder) return;
if (!_sickPaused) {
_sickRecorder.mediaRecorder.pause(); _sickTimer.stop(); _sickPaused = true;
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
} else {
_sickRecorder.mediaRecorder.resume(); _sickTimer.start(); _sickPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
}
});
}
}
// ── ROS/PE rendering ──────────────────────────────────────────────────────
function populateAddDropdowns() {
var rosAddEl = document.getElementById('sick-ros-add');
var peAddEl = document.getElementById('sick-pe-add');
if (rosAddEl) {
rosAddEl.innerHTML = '<option value="">+ Add system</option>' +
ROS_SYSTEMS.map(function(s) {
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
}).join('');
rosAddEl.addEventListener('change', function() {
var key = this.value;
if (!key) return;
this.value = '';
var sys = ROS_SYSTEMS.find(function(s) { return s.key === key; });
if (!sys) return;
if (_sickRosSystems.some(function(s) { return s.key === key; })) return;
_sickRosSystems.push(sys);
renderRosPe();
});
}
if (peAddEl) {
peAddEl.innerHTML = '<option value="">+ Add system</option>' +
PE_SYSTEMS.map(function(s) {
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
}).join('');
peAddEl.addEventListener('change', function() {
var key = this.value;
if (!key) return;
this.value = '';
var sys = PE_SYSTEMS.find(function(s) { return s.key === key; });
if (!sys) return;
if (_sickPeSystems.some(function(s) { return s.key === key; })) return;
_sickPeSystems.push(sys);
renderRosPe();
});
}
}
function renderRosPe() {
var rosContainer = document.getElementById('sick-ros-container');
var peContainer = document.getElementById('sick-pe-container');
if (rosContainer && typeof window.renderRosRows === 'function') {
rosContainer.innerHTML = window.renderRosRows(_sickRosSystems, _sickRosData, 'sros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
window.wireRosContainer(rosContainer, _sickRosData);
}
if (peContainer && typeof window.renderRosRows === 'function') {
peContainer.innerHTML = window.renderRosRows(_sickPeSystems, _sickPeData, 'spe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
window.wireRosContainer(peContainer, _sickPeData);
}
}
function inferAndRender() {
var cc = (document.getElementById('sick-cc') || {}).value || '';
var inferred = inferSystems(cc);
_sickRosSystems = inferred.ros;
_sickPeSystems = inferred.pe;
_sickRosData = {};
_sickPeData = {};
renderRosPe();
if (cc) showToast('ROS/PE updated for: ' + cc, 'info');
}
// ── Diagnoses ─────────────────────────────────────────────────────────────
function initDx() {
var dxContainer = document.getElementById('sick-dx-container');
if (dxContainer && typeof window.renderDxComponent === 'function') {
window.renderDxComponent(dxContainer, _sickSelectedDx, 'sick');
}
}
// ── Generate note ─────────────────────────────────────────────────────────
function generateSickNote() {
var age = (document.getElementById('sick-age') || {}).value || '';
var gender = (document.getElementById('sick-gender') || {}).value || '';
var cc = (document.getElementById('sick-cc') || {}).value || '';
if (!cc) { showToast('Enter a chief complaint first', 'error'); return; }
var transcriptEl = document.getElementById('sick-transcript');
var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '';
var rosText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickRosSystems, _sickRosData, 'Review of Systems') : '';
var peText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickPeSystems, _sickPeData, 'Physical Examination') : '';
var dxText = typeof window.formatDxForAI === 'function' ? window.formatDxForAI(_sickSelectedDx, 'sick-dx-freetext') : '';
showLoading('Generating sick visit note...');
var modelEl = document.getElementById('sick-model-select');
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
return fetch('/api/sick-visit/note', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
patientAge: age,
patientGender: gender,
chiefComplaint: cc,
transcript: transcript || null,
ros: rosText || null,
physicalExam: peText || null,
diagnoses: dxText || null,
physicianMemories: memCtx || null,
model: modelEl ? modelEl.value : getSelectedModel()
})
});
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
var noteEl = document.getElementById('sick-note-text');
var outCard = document.getElementById('sick-note-output');
var tag = document.getElementById('sick-note-model-tag');
if (noteEl) setOutputText(noteEl, data.note);
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Sick visit note generated!', 'success');
} else {
showToast(data.error || 'Generation failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
}
// ── Reset ─────────────────────────────────────────────────────────────────
function resetSickVisit() {
var fields = ['sick-age', 'sick-gender', 'sick-cc', 'sick-label'];
fields.forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; });
var transcriptEl = document.getElementById('sick-transcript');
if (transcriptEl) transcriptEl.textContent = '';
_sickTranscript = '';
_sickRosData = {};
_sickPeData = {};
_sickSelectedDx.length = 0;
_sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
_sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
renderRosPe();
initDx();
var outCard = document.getElementById('sick-note-output');
var noteEl = document.getElementById('sick-note-text');
if (outCard) outCard.classList.add('hidden');
if (noteEl) noteEl.textContent = '';
window._savedEncId_sickvisit = null;
showToast('Sick visit cleared for new patient', 'info');
}
// ── Click handler ─────────────────────────────────────────────────────────
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-sick-generate')) generateSickNote();
if (e.target.closest('#btn-sick-infer')) inferAndRender();
if (e.target.closest('#btn-sick-new-patient')) resetSickVisit();
if (e.target.closest('#sick-refine-btn')) refineDocument('sick-note-text', 'sick-refine-input');
if (e.target.closest('#sick-shorten-btn')) shortenDocument('sick-note-text');
if (e.target.closest('#sick-ros-all-wnl')) {
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = 'wnl'; });
renderRosPe();
showToast('All ROS set to WNL', 'success');
}
if (e.target.closest('#sick-pe-all-normal')) {
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = 'wnl'; });
renderRosPe();
showToast('All PE set to Normal', 'success');
}
if (e.target.closest('#btn-sick-save')) {
var labelEl = document.getElementById('sick-label');
var transcriptEl = document.getElementById('sick-transcript');
var noteEl = document.getElementById('sick-note-text');
if (typeof saveEncounter === 'function') {
saveEncounter({
id: window._savedEncId_sickvisit || null,
label: labelEl ? labelEl.value.trim() : '',
enc_type: 'sickvisit',
transcript: transcriptEl ? (transcriptEl.innerText || '') : '',
generated_note: noteEl ? (noteEl.innerText || '') : '',
onSaved: function(id) { window._savedEncId_sickvisit = id; }
});
}
}
});
// ── Load handler ──────────────────────────────────────────────────────────
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('sickvisit', function(enc) {
var transcriptEl = document.getElementById('sick-transcript');
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
if (enc.generated_note) {
var noteEl = document.getElementById('sick-note-text');
var outCard = document.getElementById('sick-note-output');
if (noteEl) noteEl.textContent = enc.generated_note;
if (outCard) outCard.classList.remove('hidden');
}
});
}
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Init ──────────────────────────────────────────────────────────────────
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'sickvisit' || _inited) return;
_inited = true;
initRecording();
populateAddDropdowns();
renderRosPe();
initDx();
});
console.log('SickVisit module loaded');
})();