Three concurrent themes from this session:
═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════
UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):
- Each generated stage stays on screen as its own editable card with
its own embedded "Don't Miss" panel. No more single rolling note
element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
before any operation (advance, finalize, persist) so inline edits
flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
background after Add-more before generation; "Stage N" with gray
after generation. Fixes the bug where the badge flipped to Stage 2
the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
1. edConsolidate (new prompt) → polished single final note that
integrates every stage chronologically (HPI / ROS / PE / ED Course /
A&P with disposition).
2. edFinalize (rewritten with full inline 2023 AMA E/M element
rubric — problems / data / risk definitions, level mapping with
concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
finalized} so resume re-renders the full state.
Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.
Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).
═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════
Multiple iterations based on Daniel's feedback:
- Layout: align-items:flex-start so action buttons stay pinned top-right
when long numbers wrap (was align-items:center → buttons drifted into
the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
so long numbers wrap within their column instead of pushing under the
buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
at-a-glance type signal (replacing an earlier 3px left stripe that
Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
-0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.
Files: public/js/extensions.js, public/css/styles.css.
═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════
Document moves (preserving git history via git mv):
BROWSER_WHISPER_SETUP.md → docs/browser-whisper-setup.md
BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
DEVELOPER_GUIDE.md → docs/developer-guide-extended.md
EMBEDDINGS_SETUP.md → docs/embeddings-setup.md
FEATURES_EXPLAINED.md → docs/features-explained.md
IMPROVEMENTS.md → docs/improvements.md
OPENID_SETUP.md → docs/openid-setup.md
TRANSCRIPTION_OPTIONS.md → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.
New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.
docs/logic/README.md — index + recommended reading order
docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
load, backend route convention,
schema, encryption, deployment
docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
pocket + calculators + PE Guide
+ suture selector
docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
admin panel + Learning Hub
(Quiz engine logic at sub-detail
only — TODO follow-up)
docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
prompts, voice/STT, helper trio
docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
session's worked example)
Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
the tree as JSON; /file?path=X validates path stays inside docs/ and
renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
revealed in auth.js when user.role==='admin' (same pattern as the
existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
activation, renders collapsible folders, persists expanded state and
last-opened path via UIState. Server-rendered HTML so no client
markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
with router.use(authMiddleware) at /api accidentally 401s every other
/api/* path (caught and fixed during testing — /api/health was 401'ing).
Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).
═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════
- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
tracking + progress dashboard) is covered at the architectural level
in docs/logic/auth-admin-learning.md but not drilled into the quiz
data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
the consolidated finalNote in the error payload, but client doesn't
surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
645 lines
28 KiB
JavaScript
645 lines
28 KiB
JavaScript
// ============================================================
|
|
// ED ENCOUNTER TAB — multi-stage emergency note with per-stage
|
|
// don't-miss tooltips and 2023 E/M MDM finalize.
|
|
//
|
|
// State model:
|
|
// _state = {
|
|
// stage: 1|2|..., // current stage number (what the recorder is for)
|
|
// stages: [ // per-stage history — one entry per generated stage
|
|
// { transcript, note, dontMiss, model, generatedAt }
|
|
// ],
|
|
// finalized: false,
|
|
// finalNote: null, // consolidated final note from /finalize
|
|
// mdm: null
|
|
// }
|
|
//
|
|
// UX rules:
|
|
// - Every generated stage stays on screen as its own editable card with
|
|
// its own don't-miss panel. The physician can edit any stage at any
|
|
// time; gatherCurrentNotes() reads the live DOM into _state.stages
|
|
// before any operation that depends on the current text.
|
|
// - The badge under the patient label reflects exactly where you are:
|
|
// "Stage N (recording)" — advanced to stage N but no note yet
|
|
// "Stage N" — stage N's note has been generated
|
|
// "Finalized" — Save & Done done
|
|
// - Save & Done sends every stage's current text to the server, which
|
|
// consolidates them into one polished final note and then generates
|
|
// MDM from that. Stages become read-only after finalize.
|
|
//
|
|
// Persistence: localStorage on every input (debounced) + auto-save
|
|
// draft row to saved_encounters on stage transitions.
|
|
// ============================================================
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
var LS_KEY = 'ped_ed_draft_v1';
|
|
|
|
var _state = freshState();
|
|
var _saveTimer = null;
|
|
var _recorder = null;
|
|
var _timer = null;
|
|
var _recording = false;
|
|
var _paused = false;
|
|
var _recognition = null;
|
|
var _liveTranscript = '';
|
|
|
|
function freshState() {
|
|
return { stage: 1, stages: [], finalized: false, finalNote: null, mdm: null };
|
|
}
|
|
|
|
// ── DOM helpers ──────────────────────────────────────────────────────
|
|
function getVal(id) { var el = document.getElementById(id); return el ? el.value : ''; }
|
|
function setVal(id, v) { var el = document.getElementById(id); if (el) el.value = v; }
|
|
function getText(id) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return '';
|
|
return (el.innerText || el.textContent || '').trim();
|
|
}
|
|
function setText(id, v) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = v;
|
|
}
|
|
function showEl(id) { var el = document.getElementById(id); if (el) el.classList.remove('hidden'); }
|
|
function hideEl(id) { var el = document.getElementById(id); if (el) el.classList.add('hidden'); }
|
|
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
|
|
function stageTextElId(idx) { return 'ed-stage-text-' + idx; }
|
|
|
|
// Read the current contenteditable text from each rendered stage card
|
|
// and write it back into _state.stages. Call before any operation
|
|
// (advance, finalize, persist) that needs the latest user edits.
|
|
function gatherCurrentNotes() {
|
|
for (var i = 0; i < _state.stages.length; i++) {
|
|
var el = document.getElementById(stageTextElId(i));
|
|
if (el) {
|
|
var t = (el.innerText || el.textContent || '').trim();
|
|
if (t) _state.stages[i].note = t;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Persistence ──────────────────────────────────────────────────────
|
|
function persistLocal() {
|
|
if (_saveTimer) clearTimeout(_saveTimer);
|
|
_saveTimer = setTimeout(function() {
|
|
try {
|
|
gatherCurrentNotes();
|
|
var snap = {
|
|
state: _state,
|
|
label: getVal('ed-label'),
|
|
age: getVal('ed-age'),
|
|
gender: getVal('ed-gender'),
|
|
cc: getVal('ed-cc'),
|
|
currentTranscript: getText('ed-transcript')
|
|
};
|
|
localStorage.setItem(LS_KEY, JSON.stringify(snap));
|
|
} catch (e) {}
|
|
}, 300);
|
|
}
|
|
|
|
function loadLocal() {
|
|
try {
|
|
var raw = localStorage.getItem(LS_KEY);
|
|
if (!raw) return;
|
|
var snap = JSON.parse(raw);
|
|
if (!snap || !snap.state) return;
|
|
_state = snap.state;
|
|
// Backfill new fields if a snapshot pre-dates this version
|
|
if (typeof _state.finalNote === 'undefined') _state.finalNote = null;
|
|
setVal('ed-label', snap.label || '');
|
|
setVal('ed-age', snap.age || '');
|
|
setVal('ed-gender', snap.gender || '');
|
|
setVal('ed-cc', snap.cc || '');
|
|
setText('ed-transcript', snap.currentTranscript || '');
|
|
renderStages();
|
|
if (_state.finalNote) renderFinalNote(_state.finalNote);
|
|
if (_state.finalized && _state.mdm) renderMdm(_state.mdm);
|
|
updateBadge();
|
|
} catch (e) {}
|
|
}
|
|
|
|
function clearLocal() {
|
|
try { localStorage.removeItem(LS_KEY); } catch (e) {}
|
|
}
|
|
|
|
// ── Stage rendering ──────────────────────────────────────────────────
|
|
function renderStages() {
|
|
var container = document.getElementById('ed-stages-container');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
if (_state.stages.length === 0) {
|
|
hideEl('ed-tail-controls');
|
|
return;
|
|
}
|
|
for (var i = 0; i < _state.stages.length; i++) {
|
|
container.appendChild(buildStageCard(i, _state.stages[i]));
|
|
}
|
|
// Tail controls visible once at least one stage exists.
|
|
// Hidden when finalized — encounter is locked.
|
|
if (_state.finalized) hideEl('ed-tail-controls');
|
|
else showEl('ed-tail-controls');
|
|
}
|
|
|
|
function buildStageCard(idx, stage) {
|
|
var card = document.createElement('div');
|
|
card.className = 'card output-card stage-card';
|
|
card.dataset.stageIdx = String(idx);
|
|
card.style.marginBottom = '10px';
|
|
var modelShort = (stage.model || '').split('/').pop();
|
|
var editable = _state.finalized ? 'false' : 'true';
|
|
var dontMissHtml = '';
|
|
if (stage.dontMiss && stage.dontMiss.length) {
|
|
dontMissHtml =
|
|
'<div style="border-top:1px solid var(--g100);padding:10px 14px;background:#fffbeb;">' +
|
|
'<div style="font-size:12px;font-weight:700;color:#92400e;margin-bottom:6px;">' +
|
|
'<i class="fas fa-triangle-exclamation" style="color:#f59e0b;margin-right:6px;"></i>' +
|
|
'Don\'t Miss — Stage ' + (idx + 1) +
|
|
'</div>' +
|
|
stage.dontMiss.map(function(it) {
|
|
var why = it.why ? '<div style="font-size:11px;color:var(--g500);margin-top:1px;">' + escHtml(it.why) + '</div>' : '';
|
|
return '<div style="padding:4px 0;font-size:13px;color:var(--g800);">' +
|
|
'<i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:10px;margin-right:6px;"></i>' +
|
|
escHtml(it.point) +
|
|
why +
|
|
'</div>';
|
|
}).join('') +
|
|
'</div>';
|
|
}
|
|
card.innerHTML =
|
|
'<div class="card-header output-header">' +
|
|
'<h3>' +
|
|
'<i class="fas fa-file-medical"></i> Stage ' + (idx + 1) + ' Note' +
|
|
(modelShort ? ' <span class="model-tag" style="margin-left:6px;">' + escHtml(modelShort) + '</span>' : '') +
|
|
'</h3>' +
|
|
'<div class="output-actions">' +
|
|
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + stageTextElId(idx) + '"><i class="fas fa-copy"></i> Copy</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div id="' + stageTextElId(idx) + '" class="output-text" contenteditable="' + editable + '"></div>' +
|
|
dontMissHtml;
|
|
// Set the note text via textContent so contenteditable shows the correct content
|
|
// (and so any HTML in the model output is treated as plain text, not parsed).
|
|
var textEl = card.querySelector('#' + stageTextElId(idx));
|
|
if (textEl) textEl.textContent = stage.note || '';
|
|
return card;
|
|
}
|
|
|
|
function renderFinalNote(note) {
|
|
var container = document.getElementById('ed-final-note-card');
|
|
if (!container) {
|
|
// Create the final-note card lazily and insert before the MDM card
|
|
container = document.createElement('div');
|
|
container.id = 'ed-final-note-card';
|
|
container.className = 'card output-card';
|
|
container.style.cssText = 'margin-top:10px;border-left:3px solid #2563eb;';
|
|
var mdm = document.getElementById('ed-mdm-card');
|
|
if (mdm && mdm.parentNode) mdm.parentNode.insertBefore(container, mdm);
|
|
else document.body.appendChild(container);
|
|
}
|
|
container.innerHTML =
|
|
'<div class="card-header output-header">' +
|
|
'<h3><i class="fas fa-file-medical-alt" style="color:#2563eb;"></i> Final Consolidated Note</h3>' +
|
|
'<div class="output-actions">' +
|
|
'<button class="btn-sm btn-primary" data-action="copy" data-target="ed-final-note-text"><i class="fas fa-copy"></i> Copy</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div id="ed-final-note-text" class="output-text"></div>';
|
|
var t = container.querySelector('#ed-final-note-text');
|
|
if (t) t.textContent = note;
|
|
container.classList.remove('hidden');
|
|
container.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
function renderMdm(mdm) {
|
|
var card = document.getElementById('ed-mdm-card');
|
|
var text = document.getElementById('ed-mdm-text');
|
|
var tag = document.getElementById('ed-mdm-level-tag');
|
|
if (!card || !text || !mdm) return;
|
|
if (tag) tag.textContent = mdm.suggestedLevel || '';
|
|
text.innerHTML =
|
|
'<div style="margin-bottom:6px;"><strong>Problems Addressed</strong> (' + escHtml(mdm.problemsAddressed) + ')<br>' + escHtml(mdm.problemsNarrative) + '</div>' +
|
|
'<div style="margin-bottom:6px;"><strong>Data Reviewed</strong> (' + escHtml(mdm.dataReviewed) + ')<br>' + escHtml(mdm.dataNarrative) + '</div>' +
|
|
'<div style="margin-bottom:6px;"><strong>Risk</strong> (' + escHtml(mdm.risk) + ')<br>' + escHtml(mdm.riskNarrative) + '</div>' +
|
|
'<div style="padding-top:8px;border-top:1px solid var(--g100);"><strong>Suggested Level: ' + escHtml(mdm.suggestedLevel) + '</strong><br>' +
|
|
'<span style="font-size:12px;color:var(--g600);">' + escHtml(mdm.levelRationale) + '</span></div>' +
|
|
'<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestion only. Verify against your institution\'s coding guidelines.</p>';
|
|
card.classList.remove('hidden');
|
|
}
|
|
|
|
// Badge accurately reflects state.
|
|
// - "Stage N (recording)" — advanced past last generation; no note yet for stage N
|
|
// - "Stage N" — stage N's note has been generated
|
|
// - "Finalized" — Save & Done complete
|
|
function updateBadge() {
|
|
var badge = document.getElementById('ed-stage-badge');
|
|
var rec = document.getElementById('ed-rec-stage-num');
|
|
var gen = document.getElementById('ed-gen-stage-num');
|
|
if (rec) rec.textContent = String(_state.stage);
|
|
if (gen) gen.textContent = String(_state.stage);
|
|
if (!badge) return;
|
|
if (_state.finalized) {
|
|
badge.textContent = 'Finalized';
|
|
badge.style.background = '#d1fae5';
|
|
badge.style.color = '#047857';
|
|
return;
|
|
}
|
|
var generatedHere = _state.stages.length >= _state.stage;
|
|
if (generatedHere) {
|
|
badge.textContent = 'Stage ' + _state.stage;
|
|
badge.style.background = 'var(--g100)';
|
|
badge.style.color = 'var(--g600)';
|
|
} else {
|
|
badge.textContent = 'Stage ' + _state.stage + ' (recording)';
|
|
badge.style.background = '#fef3c7';
|
|
badge.style.color = '#92400e';
|
|
}
|
|
}
|
|
|
|
// ── Recording ────────────────────────────────────────────────────────
|
|
function initRecording() {
|
|
var recordBtn = document.getElementById('ed-record-btn');
|
|
var pauseBtn = document.getElementById('ed-pause-btn');
|
|
var indicator = document.getElementById('ed-rec-indicator');
|
|
var timerEl = document.getElementById('ed-timer');
|
|
var transcriptEl = document.getElementById('ed-transcript');
|
|
if (!recordBtn) return;
|
|
|
|
_timer = createTimer(timerEl);
|
|
_recognition = createSpeechRecognition();
|
|
if (_recognition) {
|
|
_recognition.onresult = function(e) {
|
|
var interim = '';
|
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
|
if (e.results[i].isFinal) _liveTranscript += e.results[i][0].transcript + ' ';
|
|
else interim = e.results[i][0].transcript;
|
|
}
|
|
if (transcriptEl) transcriptEl.innerHTML = _liveTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
|
};
|
|
_recognition.onend = function() { if (_recording && !_paused) try { _recognition.start(); } catch(e) {} };
|
|
}
|
|
|
|
recordBtn.addEventListener('click', function() {
|
|
if (!_recording) {
|
|
_liveTranscript = '';
|
|
_recorder = new AudioRecorder();
|
|
_recorder.start().then(function() {
|
|
_recording = true; _paused = false;
|
|
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
|
|
recordBtn.classList.add('recording');
|
|
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
|
indicator.classList.remove('hidden');
|
|
_timer.start();
|
|
if (_recognition) try { _recognition.start(); } catch(e) {}
|
|
showToast('ED recording started — Stage ' + _state.stage, 'info');
|
|
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'ed' } }));
|
|
}).catch(function() { showToast('Microphone denied', 'error'); });
|
|
} else {
|
|
_recording = false; _paused = false;
|
|
var dur = _timer.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 (_recognition) try { _recognition.stop(); } catch(e) {}
|
|
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'ed' } }));
|
|
|
|
showBusy('Transcribing...');
|
|
_recorder.stop().then(function(blob) {
|
|
if (!blob || blob.size === 0) {
|
|
hideBusy();
|
|
if (_liveTranscript.trim() && transcriptEl) transcriptEl.textContent = _liveTranscript.trim();
|
|
persistLocal();
|
|
return;
|
|
}
|
|
return transcribeAudio(blob).then(function(data) {
|
|
hideBusy();
|
|
if (data && data.success) {
|
|
if (transcriptEl) transcriptEl.textContent = data.text;
|
|
showToast('Transcribed ' + dur + 's', 'success');
|
|
} else if (_liveTranscript.trim() && transcriptEl) {
|
|
transcriptEl.textContent = _liveTranscript.trim();
|
|
}
|
|
persistLocal();
|
|
});
|
|
}).catch(function() { hideBusy(); });
|
|
}
|
|
});
|
|
|
|
if (pauseBtn) {
|
|
pauseBtn.addEventListener('click', function() {
|
|
if (!_recording || !_recorder || !_recorder.mediaRecorder) return;
|
|
if (!_paused) {
|
|
try { if (_recorder.mediaRecorder.state === 'recording') _recorder.mediaRecorder.pause(); } catch(e) {}
|
|
_timer.stop(); _paused = true;
|
|
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
|
if (_recognition) try { _recognition.stop(); } catch(e) {}
|
|
} else {
|
|
try {
|
|
if (_recorder.mediaRecorder.state === 'paused') { _recorder.mediaRecorder.resume(); }
|
|
else if (_recorder.mediaRecorder.state === 'inactive' && _recorder.stream && _recorder.stream.active) {
|
|
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
|
_recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _recorder.chunks.push(e.data); };
|
|
_recorder.mediaRecorder.start(1000);
|
|
}
|
|
} catch(e) {}
|
|
_timer.resume(); _paused = false;
|
|
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
|
if (_recognition) try { _recognition.start(); } catch(e) {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Generate (per-stage) ─────────────────────────────────────────────
|
|
function generateStage() {
|
|
if (_state.finalized) { showToast('Encounter is finalized — start a new one', 'error'); return; }
|
|
var cc = getVal('ed-cc');
|
|
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
|
|
var transcript = getText('ed-transcript');
|
|
if (!transcript) { showToast('No transcript — record or type the dictation first', 'error'); return; }
|
|
|
|
// Capture any in-place edits to earlier stages so the AI sees them as the
|
|
// baseline for the new stage.
|
|
gatherCurrentNotes();
|
|
|
|
var modelEl = document.getElementById('ed-model-select');
|
|
var selectedModel = modelEl ? modelEl.value : '';
|
|
|
|
showBusy('Generating ED note (Stage ' + _state.stage + ')...');
|
|
|
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
|
|
|
memoriesPromise.then(function(memCtx) {
|
|
var prevNote = _state.stages.length > 0 ? _state.stages[_state.stages.length - 1].note : '';
|
|
return fetch('/api/ed-encounters/generate', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({
|
|
stage: _state.stage,
|
|
transcript: transcript,
|
|
chiefComplaint: cc,
|
|
patientAge: getVal('ed-age'),
|
|
patientGender: getVal('ed-gender'),
|
|
previousNote: prevNote || undefined,
|
|
physicianMemories: memCtx || undefined,
|
|
model: selectedModel || undefined
|
|
})
|
|
});
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideBusy();
|
|
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
|
|
|
// Place this stage at index (_state.stage - 1). If the user is regenerating
|
|
// an existing stage (shouldn't happen via UI, but defensively), overwrite.
|
|
var idx = _state.stage - 1;
|
|
_state.stages[idx] = {
|
|
transcript: transcript,
|
|
note: data.note || '',
|
|
dontMiss: data.dontMiss || [],
|
|
model: data.model || '',
|
|
generatedAt: new Date().toISOString()
|
|
};
|
|
|
|
renderStages();
|
|
updateBadge();
|
|
persistLocal();
|
|
autoSaveDraft();
|
|
// Scroll to the newly added card
|
|
var card = document.querySelector('.stage-card[data-stage-idx="' + idx + '"]');
|
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
showToast('Stage ' + _state.stage + ' generated. Edit, add more, or Save & Done.', 'success');
|
|
})
|
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
|
}
|
|
|
|
// ── Add another stage — DOES NOT change displayed cards ─────────────
|
|
function advanceStage() {
|
|
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
|
|
if (_state.stages.length < _state.stage) {
|
|
showToast('Generate the current stage before advancing', 'error');
|
|
return;
|
|
}
|
|
gatherCurrentNotes();
|
|
_state.stage += 1;
|
|
setText('ed-transcript', '');
|
|
_liveTranscript = '';
|
|
updateBadge();
|
|
persistLocal();
|
|
showToast('Ready for Stage ' + _state.stage + ' — record or type additional findings.', 'info');
|
|
var rec = document.getElementById('ed-record-btn');
|
|
if (rec) rec.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
// ── Finalize → consolidate + MDM + persist as final ──────────────────
|
|
function finalize() {
|
|
if (_state.finalized) { showToast('Already finalized', 'info'); return; }
|
|
if (_state.stages.length === 0) { showToast('Generate at least one stage first', 'error'); return; }
|
|
var label = getVal('ed-label');
|
|
if (!label.trim()) { showToast('Enter a patient label before finalizing', 'error'); return; }
|
|
|
|
gatherCurrentNotes();
|
|
|
|
var modelEl = document.getElementById('ed-model-select');
|
|
var selectedModel = modelEl ? modelEl.value : '';
|
|
|
|
showBusy('Consolidating note + generating MDM...');
|
|
fetch('/api/ed-encounters/finalize', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({
|
|
stages: _state.stages.map(function(s) {
|
|
return { transcript: s.transcript || '', note: s.note || '' };
|
|
}),
|
|
chiefComplaint: getVal('ed-cc'),
|
|
patientAge: getVal('ed-age'),
|
|
patientGender: getVal('ed-gender'),
|
|
model: selectedModel || undefined
|
|
})
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { hideBusy(); showToast(data.error || 'Finalize failed', 'error'); return; }
|
|
_state.finalNote = data.finalNote || '';
|
|
_state.mdm = data.mdm;
|
|
_state.finalized = true;
|
|
renderStages(); // re-render with read-only stage cards
|
|
renderFinalNote(_state.finalNote);
|
|
renderMdm(_state.mdm);
|
|
updateBadge();
|
|
|
|
var fullTranscript = _state.stages.map(function(s, i) {
|
|
return '--- Stage ' + (i + 1) + ' ---\n' + (s.transcript || '');
|
|
}).join('\n\n');
|
|
var partial = { stages: _state.stages, finalNote: _state.finalNote, mdm: _state.mdm, finalized: true };
|
|
|
|
if (typeof saveEncounter === 'function') {
|
|
saveEncounter({
|
|
id: window._savedEncId_ed || null,
|
|
label: label,
|
|
enc_type: 'ed',
|
|
transcript: fullTranscript,
|
|
generated_note: composeFinalNoteForSave(_state.finalNote, _state.mdm),
|
|
partial_data: partial,
|
|
status: 'final',
|
|
idempotency_key: 'ed-final-' + Date.now(),
|
|
onSaved: function(id) {
|
|
window._savedEncId_ed = id;
|
|
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
|
clearLocal();
|
|
hideBusy();
|
|
showToast('Encounter finalized and saved.', 'success');
|
|
}
|
|
});
|
|
} else {
|
|
hideBusy();
|
|
}
|
|
})
|
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function composeFinalNoteForSave(noteText, mdm) {
|
|
if (!mdm) return noteText;
|
|
return [
|
|
noteText,
|
|
'',
|
|
'Medical Decision Making (2023 E/M):',
|
|
'Problems Addressed (' + (mdm.problemsAddressed || '') + '): ' + (mdm.problemsNarrative || ''),
|
|
'Data Reviewed (' + (mdm.dataReviewed || '') + '): ' + (mdm.dataNarrative || ''),
|
|
'Risk (' + (mdm.risk || '') + '): ' + (mdm.riskNarrative || ''),
|
|
'Suggested Level: ' + (mdm.suggestedLevel || '') + ' — ' + (mdm.levelRationale || '')
|
|
].join('\n');
|
|
}
|
|
|
|
// ── Save draft (manual / auto on stage transition) ──────────────────
|
|
function autoSaveDraft() {
|
|
var label = getVal('ed-label');
|
|
if (!label.trim()) return;
|
|
if (typeof saveEncounter !== 'function') return;
|
|
gatherCurrentNotes();
|
|
var fullTranscript = _state.stages.map(function(s) { return s.transcript || ''; }).join('\n\n');
|
|
var partial = { stages: _state.stages, finalized: false };
|
|
var lastNote = _state.stages.length ? _state.stages[_state.stages.length - 1].note : '';
|
|
saveEncounter({
|
|
id: window._savedEncId_ed || null,
|
|
label: label,
|
|
enc_type: 'ed',
|
|
transcript: fullTranscript,
|
|
generated_note: lastNote,
|
|
partial_data: partial,
|
|
status: 'draft',
|
|
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
|
|
onSaved: function(id) {
|
|
window._savedEncId_ed = id;
|
|
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
function manualSaveDraft() {
|
|
if (!getVal('ed-label').trim()) { showToast('Enter a patient label first', 'error'); return; }
|
|
autoSaveDraft();
|
|
showToast('Draft saved', 'success');
|
|
}
|
|
|
|
// ── Reset / new patient ──────────────────────────────────────────────
|
|
function resetEncounter() {
|
|
_state = freshState();
|
|
setVal('ed-label', '');
|
|
setVal('ed-age', '');
|
|
setVal('ed-gender', '');
|
|
setVal('ed-cc', '');
|
|
setText('ed-transcript', '');
|
|
_liveTranscript = '';
|
|
var container = document.getElementById('ed-stages-container');
|
|
if (container) container.innerHTML = '';
|
|
var finalCard = document.getElementById('ed-final-note-card');
|
|
if (finalCard) finalCard.remove();
|
|
hideEl('ed-tail-controls');
|
|
hideEl('ed-mdm-card');
|
|
window._savedEncId_ed = null;
|
|
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
|
|
clearLocal();
|
|
updateBadge();
|
|
showToast('ED encounter cleared for new patient', 'info');
|
|
}
|
|
|
|
// ── Refine the latest stage ──────────────────────────────────────────
|
|
function refineLatestStage() {
|
|
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
|
|
if (_state.stages.length === 0) { showToast('Generate a stage first', 'error'); return; }
|
|
var idx = _state.stages.length - 1;
|
|
var elId = stageTextElId(idx);
|
|
if (typeof refineDocument === 'function') refineDocument(elId, 'ed-refine-input');
|
|
}
|
|
|
|
function shortenLatestStage() {
|
|
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
|
|
if (_state.stages.length === 0) { showToast('Generate a stage first', 'error'); return; }
|
|
var idx = _state.stages.length - 1;
|
|
var elId = stageTextElId(idx);
|
|
if (typeof shortenDocument === 'function') shortenDocument(elId);
|
|
}
|
|
|
|
// ── Wire click handlers ──────────────────────────────────────────────
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('#btn-ed-generate')) generateStage();
|
|
if (e.target.closest('#btn-ed-add-more')) advanceStage();
|
|
if (e.target.closest('#btn-ed-finalize')) finalize();
|
|
if (e.target.closest('#btn-ed-new')) resetEncounter();
|
|
if (e.target.closest('#btn-ed-save')) manualSaveDraft();
|
|
if (e.target.closest('#ed-refine-btn')) refineLatestStage();
|
|
if (e.target.closest('#ed-shorten-btn')) shortenLatestStage();
|
|
});
|
|
|
|
// Persist on input — capture stage edits via gatherCurrentNotes inside persistLocal
|
|
document.addEventListener('input', function(e) {
|
|
if (!e.target) return;
|
|
var t = e.target;
|
|
if (t.id === 'ed-label' || t.id === 'ed-age' || t.id === 'ed-gender' || t.id === 'ed-cc' || t.id === 'ed-transcript') {
|
|
persistLocal();
|
|
return;
|
|
}
|
|
if (t.id && t.id.indexOf('ed-stage-text-') === 0) {
|
|
persistLocal();
|
|
}
|
|
});
|
|
|
|
// ── Load handler (resume from saved encounters list) ────────────────
|
|
if (typeof registerEncounterLoadHandler === 'function') {
|
|
registerEncounterLoadHandler('ed', function(enc) {
|
|
_state = freshState();
|
|
setVal('ed-label', enc.label || '');
|
|
var partial = enc.partial_data;
|
|
try { if (typeof partial === 'string') partial = JSON.parse(partial); } catch (e) { partial = null; }
|
|
if (partial && partial.stages) {
|
|
_state.stages = partial.stages;
|
|
_state.stage = partial.stages.length || 1;
|
|
_state.finalized = !!partial.finalized;
|
|
_state.finalNote = partial.finalNote || null;
|
|
_state.mdm = partial.mdm || null;
|
|
}
|
|
renderStages();
|
|
if (_state.finalNote) renderFinalNote(_state.finalNote);
|
|
if (_state.mdm) renderMdm(_state.mdm);
|
|
if (enc.transcript && _state.stages.length === 0) setText('ed-transcript', enc.transcript);
|
|
updateBadge();
|
|
});
|
|
}
|
|
|
|
// ── Init on tab activation ───────────────────────────────────────────
|
|
var _inited = false;
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'ed' || _inited) return;
|
|
_inited = true;
|
|
initRecording();
|
|
loadLocal();
|
|
updateBadge();
|
|
});
|
|
|
|
console.log('ED encounters module loaded');
|
|
})();
|