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.
41 KiB
ED Encounters — Application Logic
Multi-stage emergency department documentation with per-stage AI generation, "don't miss" tooltips per stage, and a final consolidate→MDM pipeline at Save & Done. Lives in its own tab between Dictation HPI and the Notes sidebar group.
This is the deepest, freshest doc in the logic/ series — the feature was
built and revised across one focused session in late April 2026 and most of
the architectural decisions are explicitly motivated below. Read this first if
you want to understand how a clinical workflow gets composed in this codebase.
1. What this is
An ED encounter is structurally different from every other clinical note in the app:
- A sick visit, well visit, SOAP note, or HPI is one transcript → one generated note. The physician records, clicks Generate, edits, saves.
- An ED encounter is multiple successive recordings → multiple successive notes → one final consolidated note + MDM. The physician dictates the initial assessment, generates a note. Labs come back, they record more, generate again. After consult, more dictation, generate again. When done (could be after 1, 2, 3, or N stages), they click Save & Done and the server consolidates everything into one polished note plus a 2023 AMA E/M Medical Decision-Making block for billing.
The user-visible model: each 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. Whatever's on screen at finalize time is what gets sent to the consolidate step.
Physicians can also include direct asides in their dictation ("include normal cardiac exam", "assessment is viral URI") and the AI is explicitly instructed to route those to the right note section instead of quoting them.
2. The user flow, end to end
- Open the tab. Sidebar → ED Encounter. Tab is lazy-loaded (the
<section id="ed-tab" data-component="ed-encounter">placeholder inpublic/index.htmltriggers a fetch ofpublic/components/ed-encounter.htmlon first activation). - Enter patient info. Label (required for save), age, gender, chief
complaint (required for generation), and a model dropdown (the same
class="tab-model-select"pattern every clinical tab uses; auto-populated byapp.jsagainst the admin's allowed-models list). - Record or type Stage 1 dictation. Standard recorder (
AudioRecorderfrompublic/js/audioBackup.js) + browser SpeechRecognition for live transcript preview + final server STT pass on stop. Same plumbing as every other clinical tab. - Click "Generate Stage 1 Note". Frontend POSTs to
/api/ed-encounters/generatewith{stage: 1, transcript, chiefComplaint, patientAge, patientGender, physicianMemories, model}. Server returns{success, note, dontMiss[], model}. Note appears as Stage 1 card. Don't-miss items appear as a yellow/orange section embedded in that same card. - Edit if needed. Each stage's note element is
contenteditable. Type freely; edits persist to localStorage on each input event. - Refine the latest stage (optional). The "Refine latest" textarea +
button at the bottom of the stage list calls
/api/refinewith the latest stage's text + your instruction. The latest stage's text is replaced inline with the refined version. - Add another stage (optional). Click "Add more (next stage)". Stage 1 card stays visible. Transcript box clears. Badge changes to "Stage 2 (recording)" — yellow background — meaning we've advanced but no Stage 2 note has been generated yet.
- Repeat for as many stages as you need.
- Click "Save & Done (with MDM)" at any point.
- Server runs
edConsolidate→ produces one polished final note that integrates every stage chronologically. - Server then runs
edFinalize→ produces a 2023 E/M MDM block as JSON. - Both come back in one HTTP response.
- Frontend renders a "Final Consolidated Note" card (blue left border) and a "Medical Decision Making (2023 E/M)" card (green left border) below the stage cards.
- Stage cards become read-only.
- The whole thing persists to
saved_encounterswithstatus='final'. - Local draft cleared.
- Server runs
3. State model
Lives in a closure variable in public/js/ed-encounters.js:
_state = {
stage: 1, // 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 // 2023 E/M MDM block from /finalize
}
Invariants
_state.stageincrements monotonically. It only goes up via the user clicking "Add more". It is the target stage of the recorder/generate button._state.stages.lengthis the number of stages already generated. The array is indexed 0..N-1; stage 1 is at index 0, stage 2 at index 1, etc.- The relationship
_state.stages.length === _state.stagemeans "the current stage has been generated, ready to finalize or advance." - The relationship
_state.stages.length === _state.stage - 1means "we're recording into a new stage that hasn't been generated yet" (the yellowStage N (recording)badge state). _state.finalizedflips totrueonly when finalize succeeds. Once true, Add more / Generate / Refine all reject with a toast.
Why this shape
Earlier iterations stored a single currentNote string and rotated stages
out of view on each generation. Daniel's clarification was explicit: every
stage's note must remain visible and editable; the final MDM should reflect
whatever's on screen, including any inline physician edits to earlier stages.
The stages[] array is the source of truth and gatherCurrentNotes()
re-syncs it from the DOM before any operation that needs current text.
4. The badge — accurate state communication
Top-right of the save bar. Exactly four states:
| Condition | Text | Background |
|---|---|---|
_state.stages.length >= _state.stage (current stage has been generated) |
Stage N |
Gray |
_state.stages.length < _state.stage (advanced past last generation; no note for stage N yet) |
Stage N (recording) |
Yellow |
_state.finalized && !_state.mdm (transient) |
(not reached — finalize is atomic) | — |
_state.finalized |
Finalized |
Green |
This badge was the source of the most confusing UX bug in v1: clicking
"Add more" used to immediately flip the badge to "Stage 2" even though
no Stage 2 note existed yet. The fix isn't subtle — updateBadge() derives
the label from the relationship between _state.stages.length and
_state.stage, with explicit color coding so the difference is
unmistakable.
5. Frontend file map
public/components/ed-encounter.html
The static markup. Roughly:
- Save bar (
#ed-save-bar) — label input, badge (#ed-stage-badge), Save draft / Load / New buttons, plus a Load popover for saved drafts. - Patient Info card — age (
#ed-age), gender (#ed-gender), chief complaint (#ed-cc), and the model select (#ed-model-selectwith thetab-model-selectclass). - Recording card — header showing
Stage <span id="ed-rec-stage-num">N</span> Recording / Dictation, the Listen In / Pause buttons, the recording indicator with timer, and a contenteditable transcript box (#ed-transcript). - Generate button (
#btn-ed-generate) —Generate Stage <span id="ed-gen-stage-num">N</span> Note. #ed-stages-container— empty div. JS appends one card per stage here.#ed-tail-controls— refine bar (textarea + Refine latest + Shorter)- stage-control row (Add more, Save & Done). Hidden until at least one stage exists. Hidden again after finalize (encounter is locked).
#ed-mdm-card— the green-bordered MDM card. Hidden until finalize. The blue-bordered "Final Consolidated Note" card is created dynamically byrenderFinalNote()and inserted before the MDM card.
public/js/ed-encounters.js
Single IIFE module. Key functions:
| Function | What it does |
|---|---|
freshState() |
Returns a clean _state object — used at module load and resetEncounter() |
gatherCurrentNotes() |
Walks the DOM stage cards (#ed-stage-text-N elements) and writes their current text back into _state.stages[N].note. Called before persist, advance, finalize, generate (the last because the AI prompt for stage N+1 needs the latest text of stage N as previousNote). |
persistLocal() |
Debounced 300ms localStorage save under key ped_ed_draft_v1. Snapshot includes _state plus the current label/age/gender/CC/transcript box content. |
loadLocal() |
Reverse of persistLocal() — restores state on tab open if a draft exists. |
renderStages() |
Rebuilds #ed-stages-container from _state.stages[]. Each card gets a unique id ed-stage-text-N. Cards become contenteditable=false after finalize. |
buildStageCard(idx, stage) |
Constructs one card's DOM. Includes the editable note + an embedded yellow "Don't Miss — Stage N" section if stage.dontMiss is non-empty. |
renderFinalNote(note) |
Lazily creates #ed-final-note-card (blue border) and inserts it before the MDM card. |
renderMdm(mdm) |
Fills #ed-mdm-card with structured MDM HTML (problems / data / risk paragraphs + suggested level + rationale + disclaimer). |
updateBadge() |
Sets #ed-stage-badge text + background color based on state. |
initRecording() |
Wires the record / pause buttons, browser SpeechRecognition, AudioRecorder, transcribe-on-stop. Identical pattern to sickVisit.js — copy-pasted because the recording paths are sacred and shouldn't be factored into a shared helper. |
generateStage() |
Validates inputs, calls gatherCurrentNotes(), fetches user templates via getUserMemoryContext(), POSTs /api/ed-encounters/generate, pushes the result into _state.stages[stage-1], re-renders, autoSaves a draft to the DB. |
advanceStage() |
Validates current stage exists, gathers edits, increments _state.stage, clears the transcript box, updates the badge to "(recording)", scrolls to the recorder. Does NOT touch any displayed cards. |
finalize() |
Validates label + at least one stage, gathers edits, POSTs /api/ed-encounters/finalize with the full stages array, on success renders Final Note + MDM cards, marks finalized, calls saveEncounter with status='final', clears localStorage. |
composeFinalNoteForSave(note, mdm) |
Concatenates the final note + MDM block into a single text blob written to saved_encounters.generated_note (so the saved encounter has a single coherent stored note for any downstream consumer). |
autoSaveDraft() |
Best-effort — saves a status='draft' row to saved_encounters if a label is set. Called after each stage generation. Silent if no label. |
resetEncounter() |
"New" button — wipes state, removes stage cards, hides Final Note + MDM, clears localStorage, drops the saved-encounter id. |
refineLatestStage() / shortenLatestStage() |
Resolve the latest stage's text element id (stageTextElId(stages.length - 1)) and call the global refineDocument / shortenDocument helpers from app.js. |
How the file integrates with encounters.js
public/js/encounters.js is sacred (per the project memory file —
don't refactor without per-change approval, especially the save/idempotency
logic). ED encounters needed exactly two minimal touches to it:
- Line 98:
'ed'added to the sessionStorage restore array so the_savedEncId_edvalue survives page refresh. - Lines 305-310:
ed: 'ed'added to thetabMapinresumeEncounterso the saved-encounters list can navigate to the ED tab when a user clicks a saved ED row.
That's it. Save logic, idempotency, optimistic versioning — all reused
unchanged via window.saveEncounter(). ED rows store with enc_type='ed'
and partial_data containing the full {stages, finalNote, mdm, finalized}
JSON for resume.
A registerEncounterLoadHandler('ed', fn) call near the bottom of
ed-encounters.js registers the resume handler with encounters.js. When
a user clicks an ED row in the Load popover, encounters.js invokes that
handler with the decrypted row, and the handler restores _state and
re-renders.
6. Backend file map
src/routes/edEncounters.js
Two endpoints, both auth-gated.
POST /api/ed-encounters/generate
Per-stage note generation. Body:
| Field | Type | Notes |
|---|---|---|
stage |
number | Informational; the prompt is told this is "Stage N" |
transcript |
string | Required. This stage's raw dictation. |
chiefComplaint |
string | Required. Same as in other tabs. |
patientAge |
string | Optional but strongly preferred — drives "don't miss" tailoring |
patientGender |
string | Optional |
previousNote |
string | Stage 2+ only. The previous stage's current text (after edits). |
physicianMemories |
string | Concatenated user templates from /api/memories/context |
model |
string | Optional override. Validated by callAI's allowlist. |
Returns:
{ "success": true, "note": "<plain-text note>", "dontMiss": [{"point","why"}], "model": "<id>" }
The route assembles a structured user message:
ED ENCOUNTER — STAGE N
Patient: <age>, <gender>
Chief Complaint: <wrapped>
CURRENT STAGE TRANSCRIPT (may include direct physician asides — preserve and route them per the prompt rules):
<wrapped transcript>
PREVIOUS-STAGE NOTE (baseline to integrate on top of — do not start fresh):
<wrapped previous note> [only stage 2+]
PHYSICIAN TEMPLATES AND PREFERENCES: [if any]
<wrapped templates>
Calls callAI with PROMPTS.edEncounterStaged + INJECTION_GUARD as system
and the assembled user message. Parses the response with extractJson.
Recovery logic: if the model returns plain prose instead of JSON (model occasionally shortcuts), the route treats the entire response as the note and returns an empty don't-miss list rather than 500-ing. The physician still gets a usable note.
dontMiss is filtered to entries with non-empty point and trimmed.
Audit + apiCall logs are written.
POST /api/ed-encounters/finalize
Two-call server-side pipeline. Body:
| Field | Type | Notes |
|---|---|---|
stages |
[{transcript, note}] |
Required. Array in chronological order. Empty stages are filtered. |
chiefComplaint |
string | Optional but strongly preferred |
patientAge |
string | Optional |
patientGender |
string | Optional |
model |
string | Optional override |
The route:
-
Step 1 — consolidate. Builds a context with chief complaint, demographics, and a labeled
=== STAGE N ===block for each stage (transcript + working note). System prompt isPROMPTS.edConsolidate. Returns the model's plain-text response asfinalNote. -
Step 2 — MDM. Builds a context with the consolidated
finalNoteplus the full transcript across all stages. System prompt isPROMPTS.edFinalize. Parses JSON for{mdm: {...}}.
Returns:
{
"success": true,
"finalNote": "<plain-text consolidated note>",
"mdm": {
"problemsAddressed": "minimal|low|moderate|high",
"problemsNarrative": "...",
"dataReviewed": "minimal|limited|moderate|extensive",
"dataNarrative": "...",
"risk": "minimal|low|moderate|high",
"riskNarrative": "...",
"suggestedLevel": "99281|99282|99283|99284|99285",
"levelRationale": "..."
},
"model": "<id>"
}
Why two calls instead of one combined prompt: each task has a focused rubric (the consolidate prompt enforces section structure and chronological integration; the MDM prompt enforces the 2023 AMA element definitions). Asking for both in one JSON tends to make the model shortcut one or the other. Two calls cost ~2x latency at the very end of the encounter — acceptable since finalize is a one-time terminal action.
If the MDM step's JSON parse fails, the route returns 502 but still includes the finalNote in the error payload so the client doesn't lose work. (The client doesn't currently surface this case to the user — TODO to render the partial result with a "MDM failed, retry" affordance.)
Token usage from both calls is summed for the apiCall log.
src/utils/prompts.js — the three ED prompts
edEncounterStaged
System prompt for per-stage generation. Returns strict JSON {note, dontMiss[]}.
Key instructions:
- Note structure is fixed: Chief Complaint, HPI (OLDCARTS, historian noted), ROS (per ROS_PE_RULES), PE (per ROS_PE_RULES), ED Course (only when present), Assessment and Plan.
- Don't-miss list is uncapped for ED (unlike the global
dontMissTooltipprompt which hard-caps at 5 for sick visit / encounter HPI). Quality over quantity. Tailored strictly to age + chief complaint. - PRESERVE INSTRUCTIONS WITHIN DICTATION — explicit instruction that physician asides like "include normal cardiac exam" or "assessment is viral URI" are first-class clinical input. Route exam findings to PE, assessment statements to A&P, plan statements to A&P. Never echo as quoted speech.
- Templates — the user's templates (especially
template_ed, but also matchingtemplate_hpi/template_soap/template_sickvisit) are delivered in the user message as PHYSICIAN TEMPLATES AND PREFERENCES. Apply matching template sections; never copy clinical content from a template — only formatting/structure. - Previous-stage note — explicit instruction: integrate the new transcript on top of the previous note, do not start fresh. Drop don't-miss items that have been addressed.
The prompt is appended with INJECTION_GUARD from promptSafe.js to
defend against prompt-injection attempts inside the dictation.
edConsolidate
System prompt for the consolidate step at finalize. Plain text output (no JSON wrapper).
Key instructions:
- Same fixed note structure as the staged prompt.
- Integration rules: use the latest stage as the structural baseline (it already integrates earlier stages); use earlier stages and transcripts to fill gaps. ED Course should reflect chronological progression. Resolve contradictions by using the later value AND noting the change in ED Course (e.g., "now afebrile after antipyretic").
- Preserve every clinical fact; never drop information; never invent.
edFinalize
System prompt for the MDM step. Returns strict JSON {mdm: {...}}.
Includes a full inline rubric of the 2023 AMA E/M MDM table so the model has clear definitions to score against:
- Element 1 — Problems addressed: minimal / low / moderate / high with explicit definitions (e.g., "high = chronic illness with severe exacerbation, OR acute illness/injury that poses threat to life or bodily function").
- Element 2 — Data reviewed: categories (tests reviewed, tests ordered, independent interpretation, discussion with another physician, external records, independent historian) and counting rules for minimal / limited / moderate / extensive.
- Element 3 — Risk: minimal / low / moderate / high with concrete examples per level (drug therapy requiring intensive monitoring, decision regarding hospitalization, etc.).
- Level mapping (2 of 3 elements must meet the level): 99281 through 99285 with descriptions of the typical encounter at each level (99284 = "MODERATE complexity MDM, most common ED visit with workup, labs, or imaging and prescription decisions"; 99285 = "HIGH complexity MDM, admission for high-acuity care").
Critical rules at the bottom:
- Use only information present in the note (and transcript if provided).
- Never invent.
- Conservative when ambiguous — pick the lower level.
levelRationalemust reference specific elements actually documented.
This prompt is the most important to get right — it's what determines the suggested billing level. Daniel called this out explicitly: "make sure mdm is configured well." The full element rubric is inline so the model isn't relying on its training to remember the 2023 guidelines correctly.
7. Persistence — three layers
Layer 1 — localStorage (ped_ed_draft_v1)
Debounced 300ms after every input event. Snapshot includes the entire
_state plus the current label / age / gender / CC / transcript box
content. Survives page refresh, browser restart, signing out + back in.
Cleared on resetEncounter() and on successful finalize.
This is the fast layer — captures every keystroke without round-trips.
Layer 2 — saved_encounters DB row, status='draft'
Best-effort auto-save after each stage generation. Requires a label to be
set; silent no-op otherwise. Uses window.saveEncounter from
encounters.js with:
enc_type: 'ed'status: 'draft'generated_note: the latest stage's note (so the saved-encounters list has something to preview)partial_data: encrypted JSON containing{stages, finalized: false}idempotency_key: 'ed-draft-' + savedId
The same row gets updated on each subsequent generation (by passing
the saved id back to saveEncounter).
This is the durable layer — survives device loss because it's on the server, encrypted at rest with the app key.
Layer 3 — saved_encounters DB row, status='final'
Written exactly once on successful finalize. Includes:
generated_note: the final consolidated note + MDM block combined viacomposeFinalNoteForSave(), so any downstream consumer (export, copy, print) gets a single coherent text.partial_data:{stages, finalNote, mdm, finalized: true}— full fidelity for resume / audit / future re-render.idempotency_key: 'ed-final-' + Date.now()— unique per finalize attempt so a network retry doesn't create a duplicate row.
After finalize, localStorage is cleared. The encounter is locked from
further edits in-app (stage cards become contenteditable=false, tail
controls hidden). The user can still load the row later for review;
editing requires loading then unlocking by some manual workflow that
doesn't yet exist (TODO if requested).
8. The "Don't Miss" tooltip — per-stage
Each stage's response from /api/ed-encounters/generate includes a
dontMiss[] array of {point, why} objects. Same JSON call as the
note — no second AI request. The stage card embeds these as a
yellow/orange section beneath the note text:
┌─────────────────────────────────────────┐
│ Stage 2 Note [model] [Copy] │
├─────────────────────────────────────────┤
│ Chief Complaint: ... │
│ HPI: ... │
│ ... │
├─────────────────────────────────────────┤ ← yellow background
│ ⚠ Don't Miss — Stage 2 │
│ • Document hydration status │
│ (tachycardia + 4-day vomiting) │
│ • Consider DKA workup │
│ (polyuria + weight loss in HPI) │
│ • ... │
└─────────────────────────────────────────┘
ED don't-miss is uncapped by design — Daniel wanted no limit ("just like
remember to ask this, do this, keep this in mind etc."). Compare with
/api/dont-miss (used by sick visit + encounter HPI) which caps at 5
both in the prompt and via server-side .slice(0, 5).
The same-call design (note + don't-miss in one JSON) was a deliberate choice to keep per-stage latency down. The risk (model occasionally shortcuts the don't-miss list) is acceptable per Daniel since don't-miss is informational, not load-bearing.
9. Templates — template_ed
When a physician saves a template under category template_ed
(Settings → Templates), it gets included in the physicianMemories string
that the frontend fetches via getUserMemoryContext() and passes to
/api/ed-encounters/generate.
The flow:
- User saves a template named e.g. "ED Pearls" with category
template_ed. - Server stores it in
user_memories(encrypted name + content). - On next ED note generation,
ed-encounters.jscallsgetUserMemoryContext()(defined inpublic/js/memories.js). - That function fetches
/api/memories/contextwhich returns a single string containing every active template (correction_* rows are filtered out at the SQL level — the AI corrections feature was removed in late April 2026). - The string is included in the user message under the "PHYSICIAN TEMPLATES AND PREFERENCES" header.
- The system prompt's "PHYSICIAN TEMPLATES" section instructs the model
to apply matching sections from any template (especially
template_ed, but also matching HPI/SOAP/sickvisit templates).
template_ed was added to VALID_CATEGORIES in src/routes/memories.js
and to the dropdown in public/components/settings.html.
10. Why the design looks like this
Each major decision, with the constraint that motivated it.
Why per-stage cards (vs. one rolling note element)
Daniel's clarification. The first build used one #ed-note-text
element that got replaced on each generation. After demo: "every stage
note should be shown, if AI is told to modify that particular note then
the modified version is used in final mdm." The cards model is the
direct response — every stage stays visible, every stage is editable,
edits flow into finalize.
Why the badge has an explicit "(recording)" state
Bug from first user test. Clicking "Add more" used to flip the badge
to "Stage 2" immediately, before any Stage 2 note existed. Daniel: "the
title changes to stage 2 even without a new recording and generate being
hit." The fix isn't subtle — updateBadge() derives state from the
relationship between stages.length and _state.stage, with explicit
color coding so the difference is unmistakable.
Why finalize is two server-side AI calls instead of one
Quality concern. A single combined "produce finalNote AND mdm in one JSON" prompt makes the model cut corners on one of the two tasks (usually the MDM rubric gets compressed). Two focused calls each get their own dedicated system prompt with no competing pressure. Cost: ~2x latency at finalize. Justification: finalize is a one-time terminal action, not a per-stage hot path.
Why edConsolidate returns plain text instead of JSON
Reliability + simplicity. The consolidate step produces ONE thing —
a clinical note. JSON wrapping adds parse-failure surface area for zero
benefit. The text is rendered directly into a contenteditable element.
The MDM step does need JSON because it has structured fields the UI
displays in a table layout.
Why the MDM prompt has the full 2023 AMA rubric inline
Daniel's directive: "make sure mdm is configured well." Models' training data includes pre-2023 guidelines mixed with 2023; relying on "you know the AMA E/M MDM table" produces drift. The prompt now includes element-by-element definitions (problems / data / risk), counting rules for data, and concrete examples per level. The level rationale must reference specific findings.
Why the recorder code is copy-pasted from sickVisit.js
Project memory: "Voice/STT is sacred — Don't refactor recorder/transcribe plumbing; fix only named bugs in smallest diff." The recording paths in every clinical tab look almost identical; refactoring to a shared helper is a textbook clean-code move that has burned this project before (silent breakage of recording when the abstraction ate an edge case). The deliberate non-DRY duplication is the safer choice.
Why finalize sends the whole stages array instead of just stage texts
The consolidate prompt benefits from seeing each stage's transcript (physician's actual dictation) AND each stage's note (which may include physician edits). The transcripts let the AI catch facts that didn't make it into the working notes; the notes show physician interpretation. Both together produce a more faithful consolidation.
Why previousNote is sent during stage 2+ generation, not just the transcript
The per-stage AI is told to "integrate the new transcript on top of the previous note as baseline, do not start fresh." Without the previous note as input, stage 2 would have to regenerate everything from transcripts alone (slower, less faithful to physician edits made between stages).
11. Sacred / fragile zones
These are not refactor-without-permission lines.
public/js/encounters.js
Per project memory: don't touch without per-change approval; even
pre-approved changes get rejected if they refactor save/idempotency. The
ED feature touched it in exactly two places (sessionStorage array and
tabMap) and that's it. All ED save/load goes through window.saveEncounter
and registerEncounterLoadHandler — established interfaces. Do not
add new methods to encounters.js or modify the save body shape.
Recorder + transcribe paths
public/js/audioBackup.js (the AudioRecorder class), the
transcribeAudio global function, the SpeechRecognition wrapper from
public/js/speechRecognition.js. The ED tab's recording logic in
initRecording() was copied from sickVisit.js deliberately. Don't
factor it out into a shared record-and-transcribe-helper.js.
The MDM prompt rubric
The 2023 AMA E/M element definitions and level mapping in
PROMPTS.edFinalize are load-bearing for billing accuracy. Don't trim
them to "save tokens" — the cost of a miscoded encounter to a real
practice is much higher than the prompt overhead. Update only with
explicit billing/coding source citation.
12. How to extend — concrete recipes
Add a new prompt key
- Add the entry to
PROMPTSinsrc/utils/prompts.js. Use the same${CORE_RULES}and (if relevant)${ROS_PE_RULES}preambles other prompts use. - The DB-override system (
loadFromDbin the same file) auto-picks up the new key on next startup, so admins can override it from the Admin → Prompts UI without code changes.
Tweak the MDM rubric
Edit PROMPTS.edFinalize in src/utils/prompts.js. Cite the source
(2023 AMA E/M Office or Other Outpatient guideline updates, or AMA
errata) in the commit message. The rubric structure is stable —
changes are usually wording refinements, not category rewrites.
Add a new field to the stage card (e.g., timestamp)
- The data is already in
_state.stages[i].generatedAt. - In
buildStageCard(idx, stage)inpublic/js/ed-encounters.js, add a small<div>next to the model tag in the card header. - No backend change needed;
generatedAtis already saved inpartial_data.
Add per-stage refine (instead of "refine latest")
- In
buildStageCard(), render a refine input + button for every stage card. - Update the refine button click handler to read
data-stage-idxfrom the clicked button and passstageTextElId(idx)torefineDocument. - Be aware: physicians editing earlier stages then refining them then regenerating later stages creates a complex causality chain. Daniel's current call is "refine targets the latest stage" to avoid this.
Add a new ED-specific output (e.g., a discharge instructions card)
- Decide if it's per-stage or once-per-encounter. Per-encounter is simpler — generate it on finalize.
- Add a third server-side AI call in
/api/ed-encounters/finalizebetween consolidate and MDM. Add the result to the response payload. - Add a new card to
ed-encounter.html(or create dynamically likerenderFinalNote). - Render it in the finalize success handler.
Unlock a finalized encounter for editing (TODO — not implemented)
Currently no UI for this. Would require:
- A new endpoint
POST /api/ed-encounters/:id/unlockthat flipsstatusfrom'final'back to'draft'and clearspartial_data.finalized. - An "Unlock for editing" button on the load popover for finalized rows.
- UI logic in
ed-encounters.jsto handle the unlocked state (re-enable editing on stage cards, re-show tail controls).
Add a fourth stage type (currently the prompt is generic)
The current design treats all stages identically — same prompt, same structure. If there's a value in distinguishing "initial assessment" vs "post-workup" vs "post-consult" stages with different prompts, that's a meaningful shift. Probable plan:
- Add a
stageTypefield to each stage entry. - Branch on
stageTypein the route to pick a prompt variant. - UI: dropdown next to the recorder that defaults to "Initial / Workup / Consult / Disposition" based on stage number.
This isn't currently planned — the generic stage works because the physician's dictation is what differentiates stages, not a metadata tag.
13. Testing pointers
There are currently no Playwright e2e tests for ED encounters — flagged as TODO in the session that built the feature. A reasonable first batch:
- Stage 1 happy path. Open tab, fill label/age/gender/CC, type transcript, click Generate, assert Stage 1 card appears with note text and don't-miss section.
- Multi-stage flow. Stage 1 → Add more → badge says "Stage 2 (recording)" with yellow background → type Stage 2 transcript → Generate → both Stage 1 and Stage 2 cards visible.
- Edit-then-finalize. Generate Stage 1 → edit the note text inline
→ Save & Done → assert the consolidate step received the edited text
(mock
/api/ed-encounters/finalize, inspect the request body). - Finalize renders both cards. Mock
/finalizeto return{finalNote, mdm}→ assert blue Final Note card AND green MDM card appear → assert stage cards become read-only. - Resume from saved. Save a draft, reload, click Load, pick the ED row → all stages reappear with their don't-miss sections.
The mocking pattern is in e2e/fixtures.js — mockAI(page, overrides).
Add '**/api/ed-encounters/generate' and '**/api/ed-encounters/finalize'
to the routes table with canned responses.
14. Known issues / TODOs
- No e2e coverage (above).
- No "unlock" UI for finalized encounters.
- The MDM-step partial-success case (consolidate succeeded, MDM failed)
returns 502 with
finalNotein the error payload, but the client doesn't render the partial result. Currently the user sees a generic error toast and loses the consolidate work. - Per-stage refine isn't supported — only "refine latest." If the user wants to refine an earlier stage, they edit it inline (works) but don't get an AI-assisted refine for that specific stage.
- The "(recording)" badge color (yellow) might be confusing in the dark theme if one is added — currently the app is light-only.
- The consolidate step uses the configured default model unless the user picks a specific one in the dropdown. There's no way to use one model for per-stage generation and a different model for finalize. Probably fine; flag if a user wants this.
extractJsonis defined locally insrc/routes/edEncounters.jsand duplicated fromnotes.js. Candidate forsrc/utils/jsonRecover.jsif a third route ever needs it.
15. Quick reference — the ED encounter at a glance
┌───────────────────────────────────────────────────────────────────┐
│ ED ENCOUNTER TAB │
│ [Patient label] [Stage N badge] │
│ Age | Gender | Chief Complaint | Model dropdown │
├───────────────────────────────────────────────────────────────────┤
│ Stage N Recording — [Listen In] [Pause] [00:23 indicator] │
│ [contenteditable transcript box] │
├───────────────────────────────────────────────────────────────────┤
│ [✨ Generate Stage N Note] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 1 Note [model] [Copy] │ │
│ │ [editable note text] │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ ⚠ Don't Miss — Stage 1 │ │
│ │ • point 1 │ │
│ │ • point 2 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 2 Note [model] [Copy] │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ [Refine input] [Refine latest] [Shorter] │
│ [+ Add more (next stage)] [✓ Save & Done (with MDM)] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ (after │
│ │ 📋 Final Consolidated Note [Copy] │ finalize) │
│ │ [polished single note from edConsolidate] │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 💵 Medical Decision Making (2023 E/M) [99284] │ │
│ │ Problems Addressed (moderate): ... │ │
│ │ Data Reviewed (moderate): ... │ │
│ │ Risk (moderate): ... │ │
│ │ Suggested Level: 99284 — rationale... │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
Files involved (so a reader can map this to the codebase):
| Path | Role |
|---|---|
public/components/ed-encounter.html |
Tab markup |
public/js/ed-encounters.js |
All client logic (~500 lines) |
public/js/encounters.js |
Sacred — saveEncounter, sessionStorage, tabMap (2-string touch only) |
src/routes/edEncounters.js |
/generate + /finalize endpoints |
src/utils/prompts.js |
edEncounterStaged, edConsolidate, edFinalize keys |
src/utils/promptSafe.js |
wrapUserText + INJECTION_GUARD |
src/routes/encounters.js |
Generic save infrastructure (saved_encounters table) |
src/routes/memories.js |
template_ed category in VALID_CATEGORIES |
public/js/memories.js |
template_ed: 'ED Template' label + getUserMemoryContext |
public/components/settings.html |
<option value="template_ed"> in the category dropdown |
public/index.html |
Tab button, lazy-load section, script tag |
server.js |
Mounts edEncounters route on /api |