# Clinical notes — application logic

This doc covers every "note-producing" tab in the ped-ai app: how each
one collects input, what AI prompt it uses, which endpoint it hits, what
DOM elements it touches, and how the shared save/resume infrastructure
ties them together.

It is written for a developer who has never opened the codebase. File
paths are absolute. Where a fact depends on a specific line, the line
number is named so you can jump straight to it.

The covered tabs are:

- **Encounter HPI** — live mic recording → HPI
- **Dictation HPI** — physician dictation → HPI
- **Sick Visit** — chief complaint + auto-inferred ROS/PE + dictation → sick visit note
- **Well Visit** — multi-pane Bright Futures workflow (by-visit, milestones, SSHADESS, visit note)
- **SOAP** — transcript or dictation → SOAP note (full or subjective only)
- **Hospital Course** — pasted progress notes / H&P / ED note → admission summary
- **Chart Review** — pasted outpatient / subspecialty / ED notes → AI summary
- **Personal Notes** — free-form rich-text editor with optional voice → AI cleanup

ED Encounter is referenced in passing because it shares the recorder and
the `enc_type='ed'` save path, but it has its own separate logic doc and
is not the focus here.

---

## 1. Overview

Each clinical-notes tab is independent: its own DOM IDs, its own JS
module (an IIFE), its own `/api/...` endpoint, and its own AI prompt.
But every tab sits on top of the same scaffolding:

1. A small header bar with patient demographics (age, gender, sometimes
   setting / chief complaint / PMH).
2. A **save bar** at the top with `Save / Load / New` buttons and a
   patient-label input.
3. A **recorder** (most tabs) that writes into a `contenteditable`
   transcript box.
4. A **Generate** button that POSTs the assembled context to a route in
   `src/routes/`.
5. An **output card** that hides until generation succeeds, then
   reveals the AI's note plus a `Refine` and `Shorten` bar.
6. After generation, a few of the tabs auto-call **billing-codes** and
   **don't-miss** helpers that mount additional cards beneath the note.

Why a tab per note type?
Each note type has different inputs (e.g. Sick Visit needs CC + ROS +
PE, Hospital Course needs many dated notes + H&P + labs, Personal Notes
needs neither demographics nor a transcript), and a different AI system
prompt. A "one-size note generator" would have an unwieldy form and a
prompt full of conditionals. Splitting them gives each tab a focused UI
and a clean prompt.

The shared lifecycle — recorder → transcribe → generate → save — is the
same skeleton in every recording tab; only the per-tab payload, prompt,
and post-generation UI differ.

---

## 2. The shared lifecycle

### 2.1 Lazy tab loading

`public/js/app.js` (lines 26–104) implements the SPA tab system. The
sidebar buttons are `<button class="tab-btn" data-tab="encounter">…`
and the tab panes are `<section id="encounter-tab" class="tab-content"
data-component="encounter"></section>`. When the user clicks a tab,
`activateTab(name)`:

1. Removes `.active` from every tab button and pane.
2. Adds `.active` to the clicked one.
3. Calls `loadComponent(tabEl)` which `fetch('/components/' +
   component + '.html')`s the per-tab HTML fragment, caches it in
   `_componentCache`, and writes it into `tabEl.innerHTML`.
4. After the fragment is in the DOM, dispatches a `tabChanged`
   `CustomEvent` with `detail: { tab: tabName }`.
5. Persists the last opened tab to `localStorage['ped_last_tab']`.

Each tab's JS module listens for `tabChanged` and runs its `init`
exactly once:

```
document.addEventListener('tabChanged', function(e) {
  if (e.detail.tab !== 'encounter' || _inited) return;
  _inited = true;
  // wire DOM, attach listeners
});
```

This is why every JS module starts with the same one-shot guard —
without it, hot-loading the same tab twice would double-wire all the
buttons.

### 2.2 The recorder

`AudioRecorder` is defined in `public/js/app.js:659–683`:

- `start()` calls `getUserMedia({ audio: { channelCount: 1, sampleRate:
  16000, echoCancellation: true, noiseSuppression: true } })`, picks
  `audio/webm;codecs=opus` (fallback `audio/webm`), runs at 32 kbps
  Opus, and pushes 1-second chunks into `this.chunks`.
- `stop()` finalises a `Blob`, stops the mic tracks, and resolves the
  Blob.

Each recording tab (Encounter, Dictation, Sick, Well-Visit, SHADESS,
SOAP) holds **its own** `AudioRecorder` instance. They also each
instantiate `createSpeechRecognition()` (`app.js:1007`) — the browser's
`webkitSpeechRecognition` if available — for live preview text while
the recorder buffers audio.

The browser's interim transcript is shown greyed-out as the user talks;
when the user stops, the buffered audio is uploaded to the server for a
proper transcription. Two reasons for the dual-track approach:

- The browser's STT is good enough to give live feedback so the
  physician knows the mic is working.
- Server STT (Whisper / Gemini / Amazon Transcribe Medical / etc.) is
  much more accurate, especially for medical jargon, but only returns
  after the recording ends.

If the recording exceeds 24 MB, the audio upload is skipped and the
browser-STT transcript is used as-is (e.g. `liveEncounter.js:101`).

Each tab also dispatches `recording-started` and `recording-stopped`
`CustomEvent`s with `detail.module = 'enc' | 'dict' | 'soap' | 'sick'`
so other modules (currently encounters.js' pause-button helper) can
react.

### 2.3 The transcribe call

`transcribeAudio(blob)` in `public/js/app.js:739–795` is the single
entry point for STT:

- If browser-Whisper is enabled in user settings (and supported), it
  runs locally — no network call, no PHI leaves the device.
- Otherwise it POSTs the Blob to `/api/transcribe` (handled by
  `src/routes/transcribe.js` — out of scope here). The server picks
  whichever provider the admin configured: Gemini, AWS Transcribe
  Medical, OpenAI Whisper, Local Whisper, or LiteLLM.
- On any failure with a non-empty Blob, the audio is auto-saved via
  `saveAudioBackup(blob, 'failed-transcription')` (`audioBackup.js`)
  so the user can retry from the Settings page.

A startup probe (`checkTranscribeStatus`, `app.js:722`) queries
`/api/transcribe/status` and stores the result in
`window._transcribeAvailable`. If the server reports `false`, every tab
short-circuits the upload path and just uses the live-browser
transcript instead.

### 2.4 The generate call

Every tab assembles a payload and POSTs it to its own endpoint. The
shared parts:

- Demographics (`patientAge`, `patientGender`, sometimes `setting`).
- The transcript or dictation text (read from the contenteditable
  `<div>` via `.innerText`).
- An optional `physicianMemories` blob, fetched ahead of the call via
  `getUserMemoryContext()` (`memories.js:166`) which hits
  `/api/memories/context`.
- A `model` string from `getSelectedModel()` or the per-tab
  `<select class="tab-model-select">`.

The route handler builds the system + user messages and calls
`callAI()` (`src/utils/ai.js`, multi-provider router). Every route also
runs the user-derived text through `wrapUserText(label, text)` from
`src/utils/promptSafe.js`, which wraps it in
`<UNTRUSTED_LABEL>…</UNTRUSTED_LABEL>` and appends the standard
`INJECTION_GUARD` so the model treats the text as data, not
instructions.

The result is rendered into the output `<div>` via `setOutputText(el,
text)` (`app.js:342`). The model name is shown in a small `model-tag`
chip beside the output card.

### 2.5 Post-generation helpers

After the note is rendered, several tabs trigger optional follow-ups:

- `storeSourceContext(outputId, sourceText)` — stashes the original
  transcript onto `outputEl.dataset.sourceContext` so the Refine call
  can reference the original facts (not just the AI's rewrite).
- `suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
  (`app.js:804`) — POSTs to `/api/suggest-codes` and mounts a
  `.billing-codes-card` with ICD-10 + CPT chips.
- `suggestDontMiss(outputId, noteText, noteType, age, cc)`
  (`app.js:894`) — POSTs to `/api/dont-miss` and mounts an
  orange-bordered card with up to 5 high-yield "don't miss" items.

Refine and Shorten use the same pattern: `refineDocument(outputId,
inputId)` (`app.js:946`) and `shortenDocument(outputId)` (`app.js:971`)
read the output `<div>`'s `.innerText`, send it to `/api/refine` or
`/api/shorten`, and replace the output's content.

### 2.6 The save flow

Every tab (except Personal Notes, which has its own CRUD) wires its
Save button to `window.saveEncounter()` exposed by
`public/js/encounters.js:113`. The flow:

1. User types a `label` (e.g. "John D, 5y fever") in the tab's
   `*-label` input.
2. Save button click calls `saveFromTab(type, prefix)` in
   `encounters.js:431`, which reads the transcript and the generated
   note, attaches the cached `_savedEncId_<type>` (so updates hit the
   same row), generates / reuses an `idempotency_key` from
   sessionStorage, and POSTs `/api/encounters/saved`.
3. The server (`src/routes/encounters.js`) encrypts every text field
   with the AES-256-GCM helper, applies optimistic versioning, and
   returns the row ID + new version.
4. Client stashes the ID in both `window._savedEncId_<type>` and
   `sessionStorage['_savedEncId_' + type]` so a page refresh won't
   create a duplicate row.

Resume works in reverse: the load popover hits `/api/encounters/saved`
to list, then `/api/encounters/saved/:id` for the full row. The client
navigates to the right tab via `activateTab()`, then calls the
tab-specific load handler registered through
`registerEncounterLoadHandler(type, fn)`.

### 2.7 End-to-end example: Sick Visit

To make this concrete, here is one full Sick Visit cycle, with the
files and lines involved:

1. **User clicks Sick Visit tab.**
   `app.js:97` → `activateTab('sickvisit')` →
   `loadComponent(tabEl)` fetches
   `/components/sickvisit.html` → injects HTML → fires `tabChanged`.
   `sickVisit.js:439` catches the event, runs `initRecording()`,
   `populateAddDropdowns()`, `renderRosPe()`, `initDx()`.

2. **User types the chief complaint** (`#sick-cc`) and clicks
   `#btn-sick-infer`. `inferAndRender()` (`sickVisit.js:272`) walks
   the `INFERENCE_RULES` array (lines 49–80) — e.g. "fever" matches
   `['constitutional', 'ent', 'respiratory', 'gastrointestinal']`
   ROS systems and 7 PE systems — and re-renders the rows.

3. **User clicks "Listen In"** (`#sick-record-btn`). The handler at
   `sickVisit.js:155` instantiates a fresh `AudioRecorder`, starts
   the browser `SpeechRecognition`, and shows the live transcript in
   `#sick-transcript`. The `pulse-dot` indicator goes red and the
   `00:00` timer starts.

4. **User clicks "Stop"**. `sickVisit.js:170` stops the recorder,
   gets the Blob, calls `transcribeAudio(blob)`. On success, the
   server transcript replaces the live one in `#sick-transcript`.

5. **User clicks each ROS row's WNL/Abnormal button.** The shared
   `wireRosContainer()` (`shadess.js:460`) is what handles these —
   it lives in the SHADESS module because both sick visit and well
   visit share the row-renderer pair `renderRosRows` /
   `wireRosContainer` (both exposed on `window`).

6. **User clicks Generate.** `generateSickNote()` (`sickVisit.js:292`)
   reads everything: transcript, ROS data, PE data, selected
   diagnoses (via `formatDxForAI` in `shadess.js`), picks up the
   user's saved templates from `getUserMemoryContext()`, and POSTs
   `/api/sick-visit/note`.

7. **Server route** (`src/routes/sickVisit.js:14`) builds the user
   message:
   - `Patient: 5 years, Female`
   - `Chief Complaint: <UNTRUSTED_CHIEF_COMPLAINT>fever</UNTRUSTED_CHIEF_COMPLAINT>`
   - `ENCOUNTER TRANSCRIPT/DICTATION: <UNTRUSTED_TRANSCRIPT>…</UNTRUSTED_TRANSCRIPT>`
   - `<UNTRUSTED_ROS>…</UNTRUSTED_ROS>`
   - `<UNTRUSTED_PHYSICAL_EXAM>…</UNTRUSTED_PHYSICAL_EXAM>`
   - `<UNTRUSTED_DIAGNOSES>…</UNTRUSTED_DIAGNOSES>`
   - And, if present, a low-priority STYLE HINTS block from the
     user's memories.

   System prompt = `PROMPTS.sickVisitNote + INJECTION_GUARD`. Calls
   `callAI()`. Logs an audit row.

8. **Client gets the response.** `sickVisit.js:329`:
   - `setOutputText(noteEl, data.note)` writes the note into
     `#sick-note-text`.
   - `storeSourceContext('sick-note-text', transcript)` saves the
     transcript on the dataset for future Refine calls.
   - `outputCard.classList.remove('hidden')` reveals the card.
   - `suggestBillingCodes('sick-note-text', data.note, 'sickvisit',
     age)` mounts the billing card.
   - `suggestDontMiss('sick-note-text', data.note, 'sickvisit', age,
     cc)` mounts the don't-miss card.

9. **User types a label** (`#sick-label = "John D 5y fever"`) and
   clicks Save. `sickVisit.js:401` calls
   `saveEncounter({id, label, enc_type: 'sickvisit',
   transcript, generated_note, onSaved})`.
   Server encrypts and stores; client caches the ID in
   `_savedEncId_sickvisit`.

10. **User comes back tomorrow**, clicks Load, picks the saved entry.
    `encounters.js:287 resumeEncounter(id, 'sickvisit')` fetches the
    row, navigates to the Sick Visit tab, runs the registered load
    handler at `sickVisit.js:420` which restores transcript and note.

That's the full cycle. Every other tab does a variant of the same
sequence — the differences are listed below.

---

## 3. Per-tab deep dives

### 3.1 Encounter HPI — `liveEncounter.js`

**File:** `public/js/liveEncounter.js`
**Component:** `public/components/encounter.html`
**Tab name:** `encounter`
**Endpoint:** `POST /api/generate-hpi-encounter` →
`src/routes/hpi.js:10`
**Prompt:** `PROMPTS.hpiEncounter` (or `PROMPTS.hpiInpatient` if
`setting === 'inpatient'`) — both at `src/utils/prompts.js:53` and
`:70`.

**Purpose:** Live recording of a doctor-patient conversation. The AI
extracts an OLDCARTS HPI from the dialogue.

**User flow:**

1. Type age / gender / setting.
2. Click "Start Recording" — mic engages, live STT begins.
3. Use the visible Pause / Stop buttons mid-encounter as needed
   (Stop is available even when the record button is hidden — see
   `liveEncounter.js:119` for the dedicated stop path).
4. On stop, the audio is uploaded to `/api/transcribe`; the server
   transcript replaces the live one.
5. Click Generate → AI produces an HPI, mounted in `#enc-output`.

**DOM IDs:**

| Element | ID |
|---|---|
| Age / gender / setting | `enc-age`, `enc-gender`, `enc-setting` |
| Record / pause / stop | `enc-record-btn`, `enc-pause-btn`, `enc-stop-btn` |
| Recording indicator + timer | `enc-recording-indicator`, `enc-timer` |
| Transcript (contenteditable) | `enc-transcript` |
| Clear / Generate | `enc-clear`, `enc-generate-btn` |
| Output card / text / model tag | `enc-output`, `enc-hpi-text`, `enc-model-tag` |
| Save bar | `enc-label`, `btn-enc-save`, `btn-enc-load`, `btn-enc-new` |
| Refine / shorten | `enc-refine-input`, `enc-refine-btn`, `enc-shorten-btn` |

**Quirks:**

- Uses `nativeHaptic`, `nativeKeepAwake`, `nativeStartRecordingService`
  if running in the Capacitor app (lines 69–71). Web sessions skip
  these.
- The dedicated Stop button is visible only while recording; the main
  record button is hidden during recording so the user can't double-
  trigger the stop flow. Stop just `recordBtn.click()`s under the hood
  (line 124).
- After generation: calls `suggestBillingCodes` and `suggestDontMiss`
  (lines 216–217), unlike Dictation HPI which doesn't.
- `partial_data` is round-tripped through JSON in the load handler
  (line 235) to restore age + gender after resume.

**On save:** `enc_type='encounter'`, transcript = `#enc-transcript`,
generated note = `#enc-hpi-text`. Uses the generic `saveFromTab`
plumbing in `encounters.js:431`.

### 3.2 Dictation HPI — `voiceDictation.js`

**File:** `public/js/voiceDictation.js`
**Component:** `public/components/dictation.html`
**Tab name:** `dictation`
**Endpoint:** `POST /api/generate-hpi-dictation` (or
`/api/generate-soap` if user picks SOAP output) →
`src/routes/hpi.js:35` and `src/routes/soap.js:9`.
**Prompt:** `PROMPTS.hpiDictation` (`prompts.js:62`) or
`PROMPTS.hpiInpatient` if setting=inpatient. If user picks
`soap-full` / `soap-subjective`, switches to `PROMPTS.soapFull` /
`PROMPTS.soapSubjective`.

**Purpose:** The physician dictates a narrative monologue (no patient
present). AI cleans + restructures it into a polished HPI (or SOAP
note).

**User flow:**
Same shape as Encounter HPI, but the user is dictating in their own
voice. The output-type select at `#dict-output-type` lets them pick
HPI, full SOAP, or subjective-only — the Generate handler branches the
endpoint at `voiceDictation.js:157`.

**DOM IDs:** Mirror the Encounter tab with `dict-` prefix:
`dict-record-btn`, `dict-pause-btn`, `dict-transcript`,
`dict-generate-btn`, `dict-output`, `dict-hpi-text`, `dict-age`,
`dict-gender`, `dict-setting`, `dict-output-type`, `dict-label`, etc.

**Quirks:**

- No dedicated Stop button (unlike Encounter / SOAP). Stop is the
  same record button toggling state.
- After generation does NOT call `suggestBillingCodes` or
  `suggestDontMiss` — dictation HPIs are intentionally lean.
- The HPI prompt is a different system prompt from the Encounter HPI
  one. `hpiEncounter` says "Generate a professional HPI from the
  doctor-patient encounter transcript", while `hpiDictation` says
  "Restructure physician dictation into a polished HPI" — the latter
  is more aggressive about reorganising and polishing.

**On save:** `enc_type='dictation'`, transcript = `#dict-transcript`,
generated note = `#dict-hpi-text`.

### 3.3 Sick Visit — `sickVisit.js` + `shadess.js`

**File:** `public/js/sickVisit.js` (450 lines, biggest single-tab
module after shadess+wellVisit).
**Component:** `public/components/sickvisit.html`
**Tab name:** `sickvisit`
**Endpoint:** `POST /api/sick-visit/note` → `src/routes/sickVisit.js`
**Prompt:** `PROMPTS.sickVisitNote` (`prompts.js:364`).

**Purpose:** Quick sick visit documentation. The user types a chief
complaint, the app auto-suggests which ROS systems and PE systems are
worth reviewing (4 ROS + 7 PE by default), the user toggles each as
WNL / Abnormal / Not reviewed (typing a free-text descriptor for
abnormal), then records or dictates the encounter, picks ICD-10 codes,
and clicks Generate.

**The auto-inference logic** (the Sick Visit's distinguishing feature)
lives at `sickVisit.js:49–116`:

- `INFERENCE_RULES` is a list of `{ keywords, ros, pe }` rules.
- `inferSystems(cc)` lowercases the chief complaint and OR-matches
  each rule's keywords. All matched rules' `ros` + `pe` keys are
  unioned.
- If nothing matches, `DEFAULT_ROS` (4 systems) and `DEFAULT_PE`
  (7 systems) are used.
- The result is sliced to 4 ROS + 7 PE max so the UI doesn't explode
  for "fever" matching every rule. If under-filled, defaults are
  topped up.

Examples:

- "ear pain" → ROS: constitutional, ENT. PE: general, ears, nose,
  mouth/throat, neck.
- "cough" → ROS: constitutional, respiratory, cardiovascular, ENT.
  PE: general, chest/lungs, CV, ears, nose.
- "abdominal pain" → ROS: constitutional, GI, GU. PE: general,
  abdomen, chest/lungs, CV, skin.

The user can also Add (`#sick-ros-add`, `#sick-pe-add` selects) any of
the 15 ROS / 17 PE systems manually.

**The diagnosis component** (`shadess.js:582 renderDxComponent`) is
shared between Sick and Well Visit. It renders:

- A search box that hits the NLM Clinical Tables API
  (`https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search`) — no
  auth, CORS-enabled, free.
- A 28-entry chip row of common pediatric DX (`COMMON_DX` at
  `shadess.js:531`) for one-click adds.
- A pill list of selected diagnoses with × to remove.

**DOM IDs:**

| Element | ID |
|---|---|
| Demographics | `sick-age`, `sick-gender`, `sick-cc`, `sick-model-select` |
| Infer button | `btn-sick-infer` |
| Recording | `sick-record-btn`, `sick-pause-btn`, `sick-rec-indicator`, `sick-timer`, `sick-transcript` |
| ROS / PE | `sick-ros-container`, `sick-pe-container`, `sick-ros-add`, `sick-pe-add`, `sick-ros-all-wnl`, `sick-ros-clear`, `sick-pe-all-normal`, `sick-pe-clear` |
| Diagnoses | `sick-dx-container`, `sick-dx-search`, `sick-dx-tags`, `sick-dx-results`, `sick-dx-chips` |
| Generate / output | `btn-sick-generate`, `sick-note-output`, `sick-note-text`, `sick-note-model-tag` |
| Save bar | `sick-label`, `btn-sick-save`, `btn-sick-load`, `btn-sick-new`, `sick-load-popover` |
| Refine | `sick-refine-input`, `sick-refine-btn`, `sick-shorten-btn` |
| New patient | `btn-sick-new-patient` (in output card) |

**Other quirks:**

- `formatRosForAI(systems, dataObj, heading)` (`shadess.js:509`)
  serialises ROS/PE into text the AI can read. WNL systems are
  marked `"NORMAL [domain: …]"`, abnormals are `"ABNORMAL — <user
  note> [domain: …]"`. The PROMPTS' `ROS_PE_RULES` block (in
  `prompts.js:17–48`) tells the AI to expand each NORMAL into
  1–3 specific pertinent negatives instead of writing "WNL", and to
  expand ABNORMALs with the most common matching presentation.
- After generation, the tab calls **both** `suggestBillingCodes` and
  `suggestDontMiss` (`sickVisit.js:341–342`). Sick Visit is
  intentionally the canonical "don't miss" tab — the prompt is tuned
  for this kind of single-complaint visit.
- Reset (`#btn-sick-new-patient`) is implemented in
  `resetSickVisit()` at `sickVisit.js:351` — it explicitly clears the
  ROS/PE/Dx state in addition to the form fields. The shared
  `clearTab()` in `encounters.js:459` is also wired via the save
  bar's New button, but the in-output reset button uses the more
  exhaustive `resetSickVisit`.

**On save:** `enc_type='sickvisit'`. The save bar's button calls the
generic `saveFromTab('sickvisit', 'sick')`; the in-output Save (also
wired) calls `saveEncounter` directly with the transcript +
generated_note.

### 3.4 Well Visit — `wellVisit.js` + `shadess.js`

This is the largest tab (`wellVisit.js`: 729 lines + `shadess.js`:
1061 lines, of which ~700 are the well-visit / SHADESS UI). It's
also the only tab with sub-tabs.

**Files:** `public/js/wellVisit.js` (the by-visit/catch-up/schedule
panes + state) and `public/js/shadess.js` (the SHADESS form, the
shared ROS/PE/DX renderers, the recording for SHADESS and Visit
Note panes, and the Visit Note generation).
**Component:** `public/components/wellvisit.html` (308 lines).
**Tab name:** `wellvisit`. Two siblings — `vaxschedule` (the full
periodicity grid) and `catchup` — share data and renderers but live
in their own top-level tabs.

**Sub-tabs** (rendered as `.wv-subtab-btn` in
`wellvisit.html:7–20`):

- **By Visit Age** (`wv-panel-byvisit`) — pick a visit from the
  AAP/Bright Futures schedule (`VISIT_AGES` from
  `pediatricScheduleData.js`) and see all the recommended
  measurements, vaccines, screenings, procedures, oral health, expected
  growth, expected reflexes, BMI classification, billing codes for
  that visit. Each item has a Done/Refused/Not Due/Given/Refused/
  Deferred/Already Done button row whose state is persisted to
  `localStorage['ped_visit_statuses']`.
  A "Copy to Visit Note" button (`#btn-wv-copy-to-note`) flattens
  the per-visit statuses into the Visit Note's `#wv-vaccines` and
  `#wv-screenings` textareas.

- **Milestones** (`wv-panel-milestones`) — handled by
  `public/js/milestones.js` (out of scope for this doc but cited for
  completeness). The "Copy to Note" button fills `#wv-milestones-text`
  via the handler at `encounters.js:374`.

- **SSHADESS** (`wv-panel-shadess`) — only visible for visits in
  `SSHADESS_VISITS = ['12y' … '21y']` (`wellVisit.js:82`). When a
  younger visit is selected, the sub-tab button is hidden in
  `onVisitChange()` (line 192–201) and the user is bumped back to the
  by-visit pane.

- **Visit Note** (`wv-panel-note`) — the actual note generator: vitals
  textarea, encounter recording / dictation, screenings textarea,
  vaccines textarea, SSHADESS carry-over textarea, milestones
  carry-over textarea, ROS card, PE card, ICD-10 diagnosis component,
  Generate button, output card with refine/shorten.

**Endpoints:**

- `POST /api/well-visit/shadess` (`src/routes/wellVisit.js:63`) —
  produces the SSHADESS narrative.
  Prompt: `PROMPTS.shadessAssessment` (`prompts.js:311`).
- `POST /api/well-visit/note` (`src/routes/wellVisit.js:135`) —
  produces the full visit note. Prompt: `PROMPTS.wellVisitNote`
  (`prompts.js:321`) for full encounter, or `PROMPTS.wellVisitShort`
  (`prompts.js:354`) for brief SOAP — selected by `noteStyle` from
  `#wv-note-style`.

**The SSHADESS form** (`shadess.js:9–119, 124–192`) is 8 domains —
Strengths, School, Home, Activities, Drugs/Substances, Emotions/
Eating, Sexuality, Safety — each with 3–6 yes/no or text questions.
Some questions have `concern_if: true` (auto-flag the domain on YES,
e.g. household violence, suicidal ideation). Each domain has an
`Additional notes` textarea, a Skip checkbox, and a manual flag
toggle. State lives in the module-local `_shadessAnswers` object.

**The recording paths** in this module:

- `initShadessRecording()` (`shadess.js:240`) wires the SSHADESS
  "Listen In" mic. Audio goes to `transcribeAudio` and the result is
  stored in `_shadessTranscript`, which is sent in the
  `dictationText` field on the SSHADESS endpoint.
- `initWvRecording()` (`shadess.js:712`) wires the Visit Note pane's
  recorder. The transcript ends up in `#wv-transcript` (and module
  local `_wvTranscript`), and is sent in the `transcript` field on
  the Visit Note endpoint.

**Server-side enrichment** (`wellVisit.js:135–215`): The Visit Note
route takes the patient age, looks up `GROWTH_REFERENCE` (also from
`pediatricScheduleData.js`) via `getGrowthRefForAge()`, and appends the
expected weight gain, length gain, head circumference gain, feeding
guidance bullets, and the AAP 2023 BMI Classification table to the
user message. The prompt then incorporates this as anticipatory
guidance.

**DOM IDs (Visit Note pane only):**

| Element | ID |
|---|---|
| Visit details | `wv-note-age`, `wv-note-gender`, `wv-note-style` |
| Vitals | `wv-vitals` |
| Recording | `wv-record-btn`, `wv-pause-btn`, `wv-rec-indicator`, `wv-timer`, `wv-transcript` |
| Screenings / vaccines | `wv-screenings`, `wv-vaccines` |
| Carry-over textareas | `wv-shadess-text`, `wv-milestones-text` |
| ROS / PE | `wv-ros-container`, `wv-pe-container`, `wv-ros-all-wnl`, `wv-ros-clear`, `wv-pe-all-normal`, `wv-pe-clear` |
| Diagnoses | `wv-dx-container` (id-prefix `wv` for the shared component) |
| Generate / output | `btn-wv-generate`, `wv-note-output`, `wv-note-text`, `wv-note-model-tag` |
| Save bar | `wv-label`, `btn-wv-save`, `btn-wv-load`, `btn-wv-new`, `wv-load-popover` |
| Refine / shorten | `wv-refine-input`, `wv-refine-btn`, `wv-shorten-btn` |
| New patient | `btn-wv-new-patient` |

**Other quirks:**

- The SHADESS pane has its own age/gender inputs (`#shadess-age`,
  `#shadess-gender`) separate from the Visit Note's
  (`#wv-note-age`, `#wv-note-gender`). They don't auto-sync.
- After SHADESS generation, the assessment is auto-copied into
  `#wv-shadess-text` (see `shadess.js:382`).
- After Visit Note generation, only `suggestBillingCodes` is called
  (`shadess.js:924`). No don't-miss for well visits — by design, well
  visits aren't the right context for "don't miss" alerts.
- The full periodicity grid (vaxschedule tab) and catch-up grid
  (catchup tab) share `wellVisit.js`'s `renderFullSchedule` and
  `renderCatchUp` — they are siblings, not sub-tabs (see
  `wellVisit.js:722–727` for the per-tab init guard).
- `localStorage['ped_visit_statuses']` keys are `<visitId>.<itemKey>`,
  e.g. `12y.vax_HPV_d1` → `{status: 'Given', note: 'Lot ABC123'}`.
  This survives reloads but is intentionally per-browser, not synced
  to the server.

**On save:** `enc_type='wellvisit'`, transcript = `#wv-transcript`,
generated note = `#wv-note-text`. The save bar wires the standard
flow; the output card's Save button (separately wired in
`shadess.js:963`) does the same thing.

### 3.5 SOAP — `soap.js`

**File:** `public/js/soap.js`
**Component:** `public/components/soap.html`
**Tab name:** `soap`
**Endpoint:** `POST /api/generate-soap` → `src/routes/soap.js:9`
**Prompt:** `PROMPTS.soapFull` (`prompts.js:168`) or
`PROMPTS.soapSubjective` (`prompts.js:176`), selected by
`#soap-type`.

**Purpose:** Generate a full SOAP note (or just the Subjective
section) from a transcript or dictated text. There's a free-form
"additional instructions" field that gets concatenated to the system
prompt as **trusted operator-supplied** instructions (i.e. NOT wrapped
in `UNTRUSTED_*` — the user is operating their own scribe, so their
free-text is treated as a prompt extension; see `soap.js:181`,
`soap.js:21–23` in the route).

**User flow:**

1. Type age, gender, pick "Full SOAP" or "Subjective Only".
2. Click Dictate → mic engages. Stop when done. Transcript fills
   `#soap-transcript`.
3. (Optional) Type additional AI instructions in `#soap-instructions`.
4. Click Generate → `#soap-text` populates.

**DOM IDs:** `soap-age`, `soap-gender`, `soap-type`,
`soap-instructions`, `soap-record-btn`, `soap-pause-btn`,
`soap-stop-btn`, `soap-recording-indicator`, `soap-timer`,
`soap-transcript`, `soap-clear`, `soap-generate-btn`, `soap-output`,
`soap-text`, `soap-model-tag`, `soap-label`, `soap-refine-btn`,
`soap-shorten-btn`, `soap-refine-input`.

**Quirks:**

- Has the same dedicated Stop button as Encounter HPI (`soap.js:113`).
- After generation calls `suggestBillingCodes` only — no don't-miss
  card.
- `additionalInstructions` is the only "trusted" free-text injection
  in the clinical-notes routes; everything else is wrapped. This is
  fine because it's the physician's own text in a single-user app.
- Load handler (`soap.js:207`) restores age, gender, AND type from
  `partial_data`.

**On save:** `enc_type='soap'`, transcript = `#soap-transcript`,
generated note = `#soap-text`.

### 3.6 Hospital Course — `hospitalCourse.js`

**File:** `public/js/hospitalCourse.js`
**Component:** `public/components/hospital.html`
**Tab name:** `hospital`
**Endpoint:** `POST /api/generate-hospital-course` →
`src/routes/hospitalCourse.js:10`
**Prompt:** picked by setting + LOS + `formatPreference`:

| Format | Prompt key | When |
|---|---|---|
| `prose` | `hospitalCourseShort` | default; LOS ≤ 3 |
| `dayByDay` | `hospitalCourseLong` | LOS > 3, format=auto |
| `organSystem` | `hospitalCourseICU` | setting=picu/nicu |
| `psych` | `hospitalCoursePsych` | setting=psych |

User can override via `#hc-format`. Selected at
`src/routes/hospitalCourse.js:34–51`.

**Purpose:** Build an organised hospital course from a stack of
admission documents.

**User flow:**

1. Patient demographics + setting (floor/PICU/NICU/psych) + LOS +
   format.
2. (Optional) Paste an ED note in the dedicated card with a date.
3. (Optional) Paste an H&P with a date.
4. Add as many Progress Notes as needed (each with date and type:
   attending / resident / NP / consult / procedure / discharge).
   Each card has its own contenteditable body and its own Dictate
   button (sets up its own per-card `AudioRecorder` via
   `setupDictateButton()` at `hospitalCourse.js:52`).
5. Add labs as date-stamped entries.
6. Optional free-text additional instructions.
7. Generate → AI produces the course in the chosen format.

The generate POST sends a structured payload: `notes` (array),
`edNote`, `hAndP`, `labs` (array), demographics, setting, LOS,
formatPreference, additionalInstructions, model, physicianMemories.
The server sorts the notes by date before assembling the user
message.

**Extras:**

- `Clarify` button (`#hc-clarify-btn`) hits `/api/clarify` (handled
  in `src/routes/refine.js:67`) which uses `PROMPTS.askClarification`
  to surface what's missing from the draft.
- `additionalInstructions` is treated as trusted, like SOAP.
- After generation calls `suggestBillingCodes` only.

**DOM IDs:** `hc-age`, `hc-gender`, `hc-pmh`, `hc-setting`, `hc-los`,
`hc-format`, `hc-instructions`, `hc-add-note`, `hc-notes-container`,
`hc-add-lab`, `hc-labs-container`, `hc-ed-content`, `hc-ed-date`,
`hc-ed-labs`, `hc-hp-content`, `hc-hp-date`, `hc-generate-btn`,
`hc-output`, `hc-course-text`, `hc-format-tag`, `hc-model-tag`,
`hc-clarify-btn`, `hc-clarify-output`, `hc-refine-btn`,
`hc-shorten-btn`, `hc-refine-input`.

**On save:** `enc_type='hospital'`. Note: this tab does NOT save the
transcript (there isn't one) — only `generated_note`. The pasted
notes themselves are not persisted; if the user resumes the encounter,
they get the AI's hospital course back but not the per-note source
material. This is intentional — pasted PHI shouldn't sit in the saved
encounter row indefinitely.

### 3.7 Chart Review — `chartReview.js`

**File:** `public/js/chartReview.js`
**Component:** `public/components/chart.html`
**Tab name:** `chart`
**Endpoint:** `POST /api/generate-chart-review` →
`src/routes/chartReview.js:10`
**Prompt:** picked by `cr-type`:

| Type | Prompt key |
|---|---|
| `outpatient` | `chartReviewOutpatient` (`prompts.js:136`) |
| `subspecialty` | `chartReviewSubspecialty` (`prompts.js:148`) |
| `ed` | `chartReviewED` (`prompts.js:158`) |

The prompt-selection key is the **top-level review type**, not the
per-visit type — see the comment at `src/routes/chartReview.js:27`.
This means an outpatient chart review can include subspecialty and
ED visits (and they'll all be rolled into one outpatient-style
summary).

**Purpose:** Summarise a stack of pasted notes for precharting. No
recording — pure paste-in.

**User flow:**

1. Patient demographics + PMH + review type.
2. Add as many Visit cards as needed. Each has a date, a per-visit
   type (outpatient / subspecialty / ED), specialist name + specialty
   (only relevant if subspecialty), pasted content, and per-visit
   labs.
3. Add additional date-stamped labs not tied to a visit.
4. Optional additional instructions.
5. Generate.

**Client-side guards** (`chartReview.js:127–135`):

- Warns if estimated payload >8 MB (the express `bodyParser.json` cap
  is 10 MB).
- Toasts a "this may take a moment" notice if >30 notes.

**Quirks:**

- After generation calls `suggestBillingCodes` only (no don't-miss).
- Does NOT call `getUserMemoryContext()` — chart reviews are summaries
  of others' notes, not original documents that should follow the
  user's templates.
- `resetChartReview()` is exposed as `window.resetChartReview` so the
  shared `clearTab('chart')` can call it (encounters.js:502).

**DOM IDs:** `cr-age`, `cr-gender`, `cr-pmh`, `cr-type`,
`cr-add-visit`, `cr-visits-container`, `cr-add-lab`,
`cr-labs-container`, `cr-instructions`, `cr-generate-btn`, `cr-output`,
`cr-review-text`, `cr-model-tag`, `chart-label`, `cr-refine-btn`,
`cr-shorten-btn`, `cr-refine-input`. Note: `cr-` for content vs `chart-`
for the save label — historical naming inconsistency.

**On save:** `enc_type='chart'`, generated note = `#cr-review-text`. As
with Hospital Course, no transcript is persisted; the source notes
have to be re-pasted on resume.

### 3.8 Personal Notes — `notes.js`

This tab is structurally different from all the others — see Section 7
for the deep dive. Briefly: it is a notes-list / reader / editor (CRUD
against `personal_notes` table), not a record-and-generate tab.
Voice → AI is an optional input that produces a clean note via
`/api/notes/from-voice`.

---

## 4. Encounters.js — the save / load infrastructure

**File:** `public/js/encounters.js` (516 lines).
**Server route:** `src/routes/encounters.js` (175 lines).
**Table:** `saved_encounters` (encrypted-at-rest columns).

### 4.1 The `saveEncounter` API

`window.saveEncounter(opts)` (`encounters.js:113`) is the single
entry point. Options:

| Field | What |
|---|---|
| `id` | Existing row ID (or null for new) |
| `label` | Human-readable label, must be non-empty |
| `enc_type` | One of `encounter`, `dictation`, `hospital`, `chart`, `wellvisit`, `sickvisit`, `soap`, `ed`, `milestones` |
| `transcript` | The transcript text |
| `generated_note` | The AI-generated note text |
| `partial_data` | (optional) JSON string with extra fields like age/gender/type |
| `idempotency_key` | (optional) UUID v4 to detect double-clicks |
| `expected_version` | (optional) the version number from the last GET |
| `onSaved(id)` | Callback fired after successful save |

The function:

1. Validates a non-empty label client-side.
2. De-dupes double-clicks via `_savingInProgress[type]` flag.
3. POSTs `/api/encounters/saved`.
4. Handles 409 (concurrent edit detected) with a toast asking the user
   to reload.
5. On success, stashes the new version in
   `window._encounterVersions[id]` and the new ID in both
   `window._savedEncId_<type>` and `sessionStorage['_savedEncId_' +
   type]`.
6. Calls `loadSavedEncountersList()` to refresh the saved-encounters
   UI (settings page + any open load-popover).

### 4.2 Optimistic versioning

Server side (`src/routes/encounters.js:75–104`):

- Every saved encounter row has an integer `version` column.
- On update, if the client sends `expected_version` in the body, the
  server compares it to the current `version`. Mismatch → HTTP 409
  with `{currentVersion, yourVersion}`.
- The UPDATE itself is also conditioned on the version (`AND
  (version = $9 OR $9::int IS NULL)`) so even concurrent writers
  between the SELECT and UPDATE get caught (`upd.changes === 0` →
  409).
- New version on success = previous + 1, returned to the client which
  caches it in `_encounterVersions`.

Backwards-compat: if the client doesn't send `expected_version`, the
server falls back to last-write-wins.

### 4.3 Idempotency keys

Each `enc_type` gets a UUID v4 (`generateUUID()`,
`encounters.js:68`), generated lazily and cached in
`sessionStorage['_idempKey_' + type]` (`getIdempotencyKey`,
`encounters.js:77`).

Server side (`src/routes/encounters.js:119–139`):

- On a new-row POST (no `id`), if an `idempotency_key` is supplied,
  the server checks for an existing row with the same key for this
  user. If found, it UPDATEs that row instead of inserting a new one
  and returns the existing ID.

The idempotency key is reset by `resetIdempotencyKey(type)`
(`encounters.js:90`) which is called from `clearTab(type)` whenever
the user clicks "New" — so each new patient gets a fresh key that
won't collide with any prior save.

### 4.4 sessionStorage restore

`encounters.js:97–104` runs at module load:

```
['encounter','dictation','ed','hospital','chart','wellvisit',
 'sickvisit','soap'].forEach(function(t) {
  try {
    var id = sessionStorage.getItem('_savedEncId_' + t);
    if (id) window['_savedEncId_' + t] = id;
  } catch(e) {}
});
```

So if the user refreshes mid-encounter, the next Save click will
update the existing row instead of creating a duplicate. This is the
session-scoped (not localStorage) layer that prevents the
"page-refresh-creates-a-new-encounter" footgun.

### 4.5 Load + resume

- `loadSavedEncountersList()` (`encounters.js:159`) GETs
  `/api/encounters/saved`, refreshes any open popovers and the
  settings-page list.
- `resumeEncounter(id, type)` (`encounters.js:287`) GETs
  `/api/encounters/saved/:id` (full decrypted row), navigates to the
  right tab via `tabBtn.click()`, and after a 200 ms `setTimeout`
  (giving the lazy-loaded component HTML time to mount), calls the
  registered load handler from `_encLoadHandlers[enc_type]`.
- Each tab module registers its handler via
  `registerEncounterLoadHandler('sickvisit', function(enc) {...})`.

The 200 ms delay is a best-effort wait — see the comment at
`encounters.js:330`. If the component is already cached, this is
mostly a no-op; on first-load, it covers the fetch + parse.

### 4.6 SACRED — do not refactor

Per the project memory, `public/js/encounters.js` is sacred. Even
pre-approved changes get rejected if they refactor save/idempotency.
The reason: the save flow is the one place every clinical tab
converges, the idempotency + versioning logic is subtle, and silent
regressions here would cost clinical work. Any change must be
narrowly scoped, named, and approved per-change.

Concretely off-limits without explicit approval:

- The save POST body shape.
- The 409 handling.
- The idempotency-key generation / storage.
- The sessionStorage restore array (extending it for a new note type
  is fine, but only as an additive change).
- The `tabMap` and `domPfxMap` and `noteIdMap` in
  `resumeEncounter()`.
- The dual-listener wiring in `clearTab` / save bar / output-card
  buttons.

---

## 5. Helper trio — refine, billing-codes, don't-miss

These are post-generation features. Each lives in `app.js`, posts to
its own endpoint, and renders into the same `.output-card` container
as the note.

### 5.1 Refine + Shorten

`refineDocument(outputId, inputId)` (`app.js:946`):

1. Reads the output `<div>`'s `.innerText` as `currentDocument`.
2. Reads the user's instructions from the textarea.
3. If `outputEl.dataset.sourceContext` exists (set by
   `storeSourceContext()` after generation), includes it as
   `sourceContext`.
4. POSTs `/api/refine` with `{currentDocument, instructions,
   sourceContext, model}`.
5. Server (`src/routes/refine.js:14`) wraps both `currentDocument` and
   `instructions` with `wrapUserText()`, prepends `sourceContext` as
   "ORIGINAL SOURCE MATERIAL" so the AI can do data lookups, runs
   `PROMPTS.refine`, and returns the rewritten text.
6. Client replaces the output content.

`shortenDocument(outputId)` (`app.js:971`) is simpler — POSTs to
`/api/shorten`, runs `PROMPTS.shortenDocument` (target ~50% shorter,
keep all clinical content), returns rewritten text.

`/api/clarify` (used only by Hospital Course's Clarify button) lives
in the same route file and uses `PROMPTS.askClarification`.

### 5.2 Billing codes

`suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
(`app.js:804`) is called by Encounter HPI, Sick Visit, Well Visit
(via `shadess.js:924`), SOAP, Hospital Course, Chart Review.

- Mounts a dynamically created `.billing-codes-card` directly after
  the output `<div>`.
- POSTs `/api/suggest-codes` with the note text, type, age, and
  visit type.
- Server (`src/routes/billing.js:291`) does NOT call an LLM — it
  parses the note text deterministically:
  1. **Diagnosis extraction** (`extractDiagnoses`, `billing.js:128`):
     extracts assessment-section text via regex, pulls out any
     existing ICD-10 code patterns (`[A-TV-Z]\d{2}(?:\.\d{1,4})?`),
     numbered items (`1. Otitis media`), bullet items, and "diagnosed
     with X" inline phrases.
  2. **ICD-10 lookup**: each extracted term is checked against
     `COMMON_ICD10` (a 47-entry pediatric local map at
     `billing.js:81`); on miss, it falls back to NLM Clinical Tables
     (`lookupICD10`, `billing.js:271`) with a 5 s timeout.
  3. **CPT selection**: based on `noteType`. `wellvisit` →
     age-bucketed `99391`–`99395`. `hospital` + admission →
     `99221`–`99223`, discharge → `99238/99239`, otherwise subsequent
     `99231`–`99233`. ED → `99281`–`99285`. Outpatient → new vs
     established detection.
  4. **E/M complexity** (`estimateEMLevel`, `billing.js:212`): counts
     ROS / PE systems documented, looks for high-risk keywords
     (admit, sepsis, intubated, etc.), looks for moderate-risk keywords
     (IV fluid, antibiotic, x-ray, etc.) and produces a level + MDM
     complexity.
- Result rendered as ICD-10 chips (red), CPT chips (blue), and an
  E/M assessment line. Every chip is click-to-copy.
- ReDoS guard: refuses note text > 20 000 chars.

### 5.3 Don't-miss

`suggestDontMiss(outputId, noteText, noteType, age, cc)`
(`app.js:894`) is currently called by Encounter HPI
(`liveEncounter.js:217`) and Sick Visit (`sickVisit.js:342`).

- Mounts a dynamically created card with a 3 px orange left border
  (`#f59e0b`) and a Triangle-Exclamation icon.
- POSTs `/api/dont-miss` with `{noteText, noteType, patientAge,
  chiefComplaint, model}`.
- Server (`src/routes/dontMiss.js`) runs `PROMPTS.dontMissTooltip`
  (`prompts.js:505`) which produces strict JSON
  `{points: [{point, why}, …]}`. The route hard-caps the result to 5
  items at line 61 (in addition to the prompt-level "HARD CAP: at
  most 5 points"). If the model returns 0–5 items the cap is a no-op;
  if it ignores the prompt and returns 12, the server slices to 5.

For the **ED Encounter** tab (out of scope but worth noting): its
note generation prompt itself produces `dontMiss[]` inline as part of
the JSON envelope, with **no fixed cap**. The standalone
`/api/dont-miss` endpoint is sick-visit and HPI only.

### 5.4 The orange-bordered card pattern

The cards use this insertion pattern (works for billing too):

1. Look up an existing container with id
   `<outputId-without-text> + '-dont-miss'` (e.g.
   `sick-note-dont-miss`).
2. If missing, create a `div`, set `.dont-miss-card` class + inline
   left-border style, and `parentNode.insertBefore` it directly after
   the output `<div>`.
3. Show a spinner placeholder.
4. After fetch, replace innerHTML with the final content.
5. On empty / error, hide the container.

Failures are silent (no toast) so the user isn't pestered if a model
is slow or returns junk.

---

## 6. User templates ("memories") flow

Daniel's saved templates live in the `user_memories` table (one
encrypted row per template). The UI is in
`public/js/memories.js` — Settings page → Physician Memory section.

The flow into the system prompt:

1. Each tab, just before its Generate POST, calls
   `getUserMemoryContext()` (`memories.js:166`):

   ```
   fetch('/api/memories/context', { headers: getAuthHeaders() })
     .then(r => r.json())
     .then(data => data.success ? data.context : '')
     .catch(() => '');
   ```

   — falling back to an empty string on any failure so the generation
   still works without templates.

2. The returned string is passed in the request body as
   `physicianMemories`.

3. Server side, the routes append it to the user message wrapped as a
   STYLE HINTS block:

   ```
   [STYLE HINTS (low priority — only apply if relevant to the current
   note, never copy content from these examples)]
   <UNTRUSTED_STYLE_HINTS>
   ...templates...
   </UNTRUSTED_STYLE_HINTS>
   [END STYLE HINTS]
   ```

   Always the LAST block before the call, marked low priority, double-
   wrapped (the brackets and the `UNTRUSTED_STYLE_HINTS` tags).

4. The system-prompt's `INJECTION_GUARD` ensures the model treats it
   as data, never instructions.

The "low priority — only apply if relevant" wording is deliberate.
Without it, models will sometimes paste a stored template's clinical
content into a new patient's note. The wording + the wrapper together
keep memories as STYLE/STRUCTURE hints, not content.

Where in each frontend module the fetch happens:

- `liveEncounter.js:190` — Encounter HPI
- `voiceDictation.js:154` — Dictation HPI
- `sickVisit.js:309` — Sick Visit
- `shadess.js:882` — Well Visit Note
- `soap.js:170` — SOAP
- `hospitalCourse.js:137` — Hospital Course
- (Chart Review intentionally does NOT fetch — see §3.7.)

---

## 7. Notes — the odd one out

Personal Notes (`public/js/notes.js`, 890 lines) breaks the pattern
because it isn't a record-and-generate tab; it's a per-user note
**editor** with optional voice → AI cleanup as one of several entry
methods.

### 7.1 Why it's structurally different

The other clinical-notes tabs are all single-form pages. You walk in,
fill it, click Generate, copy out, optionally Save. There's at most
one open "encounter" at a time, and the on-disk model is a row in
`saved_encounters` with a 7-day expiry.

Personal Notes is a Notion-style **list / reader / editor** with:

- A persistent list of notes (no expiry).
- Soft-delete to a Trash pane that the user can Restore from or
  Empty.
- A rich-text editor (Tiptap) instead of a contenteditable + AI-
  generated text.
- Autosave every 1.2 s (debounced) plus a `beforeunload` flush via
  `fetch({keepalive: true})`.
- Three view states (`list`, `reader`, `editor`) that on mobile (<900
  px) take turns occupying the screen via the layout's `data-view`
  attribute.
- The voice → AI flow targets the editor as an alternative entry
  method, not as the main UI.

The data model is also different — `personal_notes` (separate table
from `saved_encounters`), encrypted title + body, one row per note,
500-note cap per user, soft-delete via `deleted_at IS NOT NULL`.

### 7.2 The from-voice endpoint

When the user clicks the mic in the editor, `notes.js:536
startRecording()` engages a fresh `AudioRecorder`. On Stop,
`notes.js:589` calls `transcribeAudio(blob)` — same shared STT path —
and POSTs `/api/notes/from-voice` (`src/routes/notes.js:224`).

The route's system prompt has a deliberately wider scope than the
other tabs:

> "You turn a voice dictation into a clean, well-structured personal
> note. The note may be anything: a clinical observation, a shopping
> list, a reminder, a travel idea, a journal entry. Match the
> dictation — do not assume it is medical and do not impose clinical
> structure (assessment/plan, SOAP, etc.) on non-clinical content."

This is the "drop the medical scribe framing" change cited in the
recent commit history (`b8693ba: voice → AI note now produces HTML
the editor can render`). The previous prompt had the model treat
every note as clinical. Now Daniel can voice-record anything — a
grocery list, a paper outline — and get a clean note back without
the model bolting on assessments and plans.

The route returns strict JSON `{title, body}`. The `body` must be
HTML using only a small allowlist (`<p>`, `<h2>`, `<h3>`, `<strong>`,
`<em>`, `<u>`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`, `<code>`,
`<a href>`, `<br>`) — Tiptap can render this directly.

`toHtmlBody()` at `src/routes/notes.js:24` is a defensive normaliser:

- Strips ` ```html ` / ` ``` ` fences a model might wrap the body in.
- If the body has any real HTML tag, returns as-is.
- Otherwise runs through `marked.parse()` — covers the case where the
  model returned markdown despite the prompt.
- On marked failure, falls back to wrapping plain text in `<p>`.

The route also recovers from the model returning extra prose around
its JSON: line 263 strips fences, line 265 finds the first `{`, line
267 finds the last `}` and trims everything outside.

The client (`notes.js:600`) calls `applyGeneratedNote(title, body)`
which prefers `Tiptap.commands.setContent()` over a remount (avoids
toolbar flicker) and marks the note dirty so the autosave kicks in.

### 7.3 The recorder lifecycle in notes

Notes uses the same `AudioRecorder` class as the other tabs but with
its own UI lifecycle:

- Button states: `idle` → `recording` → `paused` (optional) →
  `processing` → `idle`. State changes are routed through
  `setRecUI(state)` (`notes.js:670`).
- Stop has TWO modes: `silent` (cancel cleanly without transcribing —
  used when the user navigates away) and `not silent` (the normal
  Stop button — transcribes and generates).
- The recent fix in `66f319e` (mentioned in the recent commits) wraps
  the click handler so the click `Event` doesn't get truthily passed
  as `silent: true` to `stopRecording`:

  ```
  if (recStop) recStop.addEventListener('click',
    function() { stopRecording(false); });
  ```

  Without the wrapper, `addEventListener` would pass the `Event` as
  the first arg, which is truthy, and silent-cancel would always be
  taken — i.e. Stop would do nothing.

### 7.4 Other notes-only details

- The pane state lives in module-locals
  (`_notes`, `_trash`, `_pane`, `_activeId`, `_editor`, `_dirty`,
  etc.). The note-list always pulls both active and trash in parallel
  (`refreshList()`, `notes.js:216`) so the trash count badge stays
  current.
- Sanitisation of saved HTML on render uses `DOMPurify` with a strict
  allowlist (`notes.js:861`). If DOMPurify fails to load,
  `sanitizeHtml` refuses to render HTML and falls back to plain text
  rather than risking an XSS via a homegrown sanitiser.
- Save shortcuts: Ctrl/Cmd+S in the editor calls
  `saveNote({source: 'shortcut', returnToReader: true})`.
- Server-side, every title and body is AES-256-GCM encrypted at rest
  via the same `cryptoUtil.encryptString` used for saved encounters
  (`src/routes/notes.js:113, :138`).
- Trash mechanics: DELETE soft-deletes (sets `deleted_at = NOW()`),
  POST `/restore` clears `deleted_at`, DELETE with `?hard=1` only
  removes rows that are already in trash (so a UI bug can't
  accidentally erase active notes), POST `/trash/empty` deletes every
  trashed row for the user.

---

## 8. Sacred zones

Two areas of the codebase are explicitly off-limits for refactoring,
based on Daniel's stated rules:

### 8.1 `public/js/encounters.js` — save / idempotency

Already covered in §4.6. The summary: every change to encounters.js
must be:

- Per-change explicitly approved.
- Narrowly scoped — additive only when possible.
- Reviewed for impact on save POST shape, version handling,
  idempotency, sessionStorage restore, and the tabMap/domPfxMap/
  noteIdMap.

Refactors that "tidy up" encounters.js are rejected on principle,
even if the rewrite preserves behaviour. Reason: the save flow is
clinically load-bearing, and silent regressions are expensive.

### 8.2 Voice / STT plumbing

The recording-and-transcription flow is:

1. `AudioRecorder` class in `app.js:659–683` — `getUserMedia` +
   `MediaRecorder` setup.
2. `transcribeAudio(blob)` in `app.js:739–795` and the
   `_serverTranscribe(blob)` helper.
3. The per-tab recording handlers in `liveEncounter.js`,
   `voiceDictation.js`, `sickVisit.js`, `soap.js`, `wellVisit.js` /
   `shadess.js` (all the tab-specific record/pause/stop wiring).
4. `audioBackup.js` — the IndexedDB / server-side audio backup
   fallback.

These are sacred because:

- Voice is the primary input modality for the app. If a recording
  silently cuts off, drops audio, doesn't pause cleanly, or fails to
  fall back to the live transcript when the server STT is down, the
  user loses clinical work.
- The patterns differ slightly per tab (some have a Stop button,
  some don't; some dispatch `recording-started` events, some don't;
  Sick Visit's pause logic handles `inactive` MediaRecorder state by
  re-creating a MediaRecorder on the same stream, etc.). These are
  deliberate per-tab quirks, not inconsistencies to be tidied.
- The browser's `MediaRecorder.pause()` / `.resume()` support varies
  across browsers. Every tab has a fallback path for when `state`
  becomes `inactive` (the browser killed the recorder mid-pause)
  that re-instantiates the MediaRecorder on the same stream. That
  fallback is replicated (not factored out) in each tab on purpose.

Off-limits without explicit approval per change:

- `AudioRecorder.start` / `.stop` semantics.
- The dual-track live-STT + uploaded-audio flow.
- The per-tab pause/resume + MediaRecorder-state fallback.
- The `_transcribeAvailable` probe and the no-server-fallback path.
- The audio-backup-on-failure path.

OK to do narrowly: fixing a named bug with the smallest possible
diff. Recent example: the `66f319e` fix for "Stop button never
transcribed because silent-cancel was always taken" — a one-line
event-handler wrapper change in `notes.js`.

---

## 9. How to add a new note tab

Concrete checklist. Use Sick Visit (a non-trivial example) as the
reference shape.

1. **HTML component** — create `public/components/<name>.html`. Use
   the Sick Visit component as a template. Required structure:

   - `.module-header` with title.
   - Save bar with `<input id="<pfx>-label">`, Save / Load / New
     buttons (`btn-<pfx>-save`, `btn-<pfx>-load`, `btn-<pfx>-new`),
     and the load popover (`<pfx>-load-popover`,
     `<pfx>-load-search`, `<pfx>-pop-list`).
   - Demographics card with whichever fields you need
     (`<pfx>-age`, `<pfx>-gender`, `<pfx>-model-select`).
   - (If recording) Recording card with `<pfx>-record-btn`,
     `<pfx>-pause-btn`, `<pfx>-rec-indicator`, `<pfx>-timer`,
     `<pfx>-transcript` (contenteditable).
   - Whatever per-tab structured inputs (ROS/PE/Dx/etc.) you need.
   - Generate button: `btn-<pfx>-generate`.
   - Output card: `<pfx>-output` (initially `hidden`),
     `<pfx>-text` (or `<pfx>-note-text` etc — must match what you
     register in `noteIdMap` below), `<pfx>-model-tag`.
   - Refine bar: `<pfx>-refine-input`, `<pfx>-refine-btn`,
     `<pfx>-shorten-btn`.

2. **Tab pane in `public/index.html`** — add:

   ```
   <button class="tab-btn" data-tab="<name>">…</button>
   ```

   in the sidebar (around line 197) and:

   ```
   <section id="<name>-tab" class="tab-content"
            data-component="<name>"></section>
   ```

   in the tab-content area (around line 301). The `data-component`
   value is the filename (without `.html`) under `public/components/`.

3. **JS module** — create `public/js/<name>.js`. Use this skeleton:

   ```
   (function() {
     var _inited = false;
     document.addEventListener('tabChanged', function(e) {
       if (e.detail.tab !== '<name>' || _inited) return;
       _inited = true;
       init();
     });

     function init() {
       // Wire buttons. Read inputs. Call generate on click.
     }

     function generate() {
       var memoriesPromise = (typeof getUserMemoryContext === 'function')
         ? getUserMemoryContext() : Promise.resolve('');
       memoriesPromise.then(function(memCtx) {
         return fetch('/api/<your-endpoint>', {
           method: 'POST',
           headers: getAuthHeaders(),
           body: JSON.stringify({
             ...,
             physicianMemories: memCtx || null,
             model: getSelectedModel()
           })
         });
       }).then(...).then(function(data) {
         setOutputText(noteEl, data.note);
         storeSourceContext('<pfx>-text', sourceMaterial);
         outputCard.classList.remove('hidden');
         // Optionally:
         if (typeof suggestBillingCodes === 'function') suggestBillingCodes(...);
         if (typeof suggestDontMiss   === 'function') suggestDontMiss(...);
       });
     }

     // Register a load handler so resume works
     if (typeof registerEncounterLoadHandler === 'function') {
       registerEncounterLoadHandler('<enc_type>', function(enc) {
         // Restore transcript + generated_note + any per-tab state
       });
     }
   })();
   ```

   Add a `<script src="/js/<name>.js"></script>` to `public/index.html`
   (the script load order is fixed; check the existing block).

4. **Server route + prompt** — create `src/routes/<name>.js`:

   ```
   var express = require('express');
   var router = express.Router();
   var { callAI } = require('../utils/ai');
   var PROMPTS = require('../utils/prompts');
   var { authMiddleware } = require('../middleware/auth');
   var logger = require('../utils/logger');
   var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');

   router.post('/<your-endpoint>', authMiddleware, async function(req, res) {
     try {
       var { ..., physicianMemories, model } = req.body;
       // Validate inputs, build context with wrapUserText() per field.
       // Append style hints if physicianMemories.
       var result = await callAI([
         { role: 'system', content: PROMPTS.<yourPromptKey> + INJECTION_GUARD },
         { role: 'user',   content: context }
       ], { model });
       res.json({ success: true, note: result.content, model: result.model });
       logger.audit(req.user.id, 'generate_<name>', '...', req,
                    { category: 'clinical' });
     } catch (e) {
       logger.error('[<name>] failed', e.message);
       res.status(500).json({ error: 'Request failed' });
     }
   });

   module.exports = router;
   ```

   Add a new prompt key in `src/utils/prompts.js` near related ones,
   following the style — start with `${CORE_RULES}` and (if relevant)
   `${ROS_PE_RULES}`, list required sections, end with constraints.

5. **Mount in `server.js`** — add a line in the existing block
   (around line 280):

   ```
   app.use('/api', require('./src/routes/<name>'));
   ```

6. **Register in `encounters.js` for save/resume** (NB: this is the
   minimum sacred-file change; treat as "additive, narrow"):

   - Add the new `enc_type` to the sessionStorage restore list
     (`encounters.js:98`):

     ```
     ['encounter','dictation','ed','hospital','chart','wellvisit',
      'sickvisit','soap','<name>'].forEach(...);
     ```

   - Add to `tabMap`, `domPfxMap`, `noteIdMap` in
     `resumeEncounter()` (lines 305, 317, 322).

   - Add a click case in the document-level click handler (around
     line 398) for `#btn-<pfx>-save`, `#btn-<pfx>-load`,
     `#btn-<pfx>-new`.

   - Add to `pfxMap`, `noteElMap`, `outputElMap` in `clearTab()`
     (lines 460–473) so the New Patient button clears your tab.

   - If the type appears in load popovers, add the prefix to the
     popover-list refresher arrays at lines 166 and 256 and 299
     and 424.

7. **Test the round-trip**: tab loads, recording works (if used),
   generate hits the route, save persists, page-refresh + Save
   doesn't duplicate, Load → Resume restores the state, New clears
   everything, idempotency_key resets on New.

---

## Appendix — file map (quick reference)

Frontend:

| Tab | JS module | HTML component | Save type |
|---|---|---|---|
| Encounter HPI | `public/js/liveEncounter.js` | `public/components/encounter.html` | `encounter` |
| Dictation HPI | `public/js/voiceDictation.js` | `public/components/dictation.html` | `dictation` |
| Sick Visit | `public/js/sickVisit.js` (+ `shadess.js` for shared ROS/PE/Dx) | `public/components/sickvisit.html` | `sickvisit` |
| Well Visit | `public/js/wellVisit.js` + `public/js/shadess.js` | `public/components/wellvisit.html` | `wellvisit` |
| SOAP | `public/js/soap.js` | `public/components/soap.html` | `soap` |
| Hospital Course | `public/js/hospitalCourse.js` | `public/components/hospital.html` | `hospital` |
| Chart Review | `public/js/chartReview.js` | `public/components/chart.html` | `chart` |
| Personal Notes | `public/js/notes.js` | `public/components/notes.html` | n/a (own table) |

Shared frontend:

- `public/js/app.js` — tab loader, `AudioRecorder`, `transcribeAudio`,
  `setOutputText`, `storeSourceContext`, `suggestBillingCodes`,
  `suggestDontMiss`, `refineDocument`, `shortenDocument`,
  `createSpeechRecognition`, `deduplicateFinal`.
- `public/js/encounters.js` — save/load/resume infrastructure
  (sacred).
- `public/js/audioBackup.js` — IndexedDB + server audio backup
  (sacred-adjacent — touch only narrowly).
- `public/js/memories.js` — `getUserMemoryContext()`.

Backend:

| Endpoint | File |
|---|---|
| `POST /api/generate-hpi-encounter` | `src/routes/hpi.js:10` |
| `POST /api/generate-hpi-dictation` | `src/routes/hpi.js:35` |
| `POST /api/sick-visit/note` | `src/routes/sickVisit.js:14` |
| `POST /api/well-visit/shadess` | `src/routes/wellVisit.js:63` |
| `POST /api/well-visit/note` | `src/routes/wellVisit.js:135` |
| `POST /api/generate-soap` | `src/routes/soap.js:9` |
| `POST /api/generate-hospital-course` | `src/routes/hospitalCourse.js:10` |
| `POST /api/hospital-course-clarify` | `src/routes/hospitalCourse.js:100` |
| `POST /api/hospital-course-update` | `src/routes/hospitalCourse.js:116` |
| `POST /api/generate-chart-review` | `src/routes/chartReview.js:10` |
| `GET/POST/PUT/DELETE /api/notes` (+ `/trash`, `/restore`, `/empty`) | `src/routes/notes.js` |
| `POST /api/notes/from-voice` | `src/routes/notes.js:224` |
| `GET /api/encounters/saved` | `src/routes/encounters.js:23` |
| `GET /api/encounters/saved/:id` | `src/routes/encounters.js:54` |
| `POST /api/encounters/saved` | `src/routes/encounters.js:70` |
| `DELETE /api/encounters/saved/:id` | `src/routes/encounters.js:163` |
| `POST /api/refine` | `src/routes/refine.js:14` |
| `POST /api/shorten` | `src/routes/refine.js:47` |
| `POST /api/clarify` | `src/routes/refine.js:67` |
| `POST /api/suggest-codes` | `src/routes/billing.js:291` |
| `POST /api/dont-miss` | `src/routes/dontMiss.js:36` |

Prompts (all in `src/utils/prompts.js`):

| Use | Key | Line |
|---|---|---|
| Encounter HPI (outpatient) | `hpiEncounter` | 53 |
| Encounter / Dictation HPI (inpatient) | `hpiInpatient` | 70 |
| Dictation HPI | `hpiDictation` | 62 |
| Hospital course (short / prose) | `hospitalCourseShort` | 82 |
| Hospital course (day-by-day) | `hospitalCourseLong` | 94 |
| Hospital course (ICU) | `hospitalCourseICU` | 105 |
| Hospital course (psych) | `hospitalCoursePsych` | 124 |
| Chart review (outpatient) | `chartReviewOutpatient` | 136 |
| Chart review (subspecialty) | `chartReviewSubspecialty` | 148 |
| Chart review (ED) | `chartReviewED` | 158 |
| SOAP (full) | `soapFull` | 168 |
| SOAP (subjective only) | `soapSubjective` | 176 |
| Refine | `refine` | 287 |
| Shorten | `shortenDocument` | 295 |
| Clarify (gap-finder) | `askClarification` | 304 |
| SSHADESS assessment | `shadessAssessment` | 311 |
| Well visit (full) | `wellVisitNote` | 321 |
| Well visit (short SOAP) | `wellVisitShort` | 354 |
| Sick visit | `sickVisitNote` | 364 |
| Don't-miss tooltip | `dontMissTooltip` | 505 |

Cross-cutting:

- `src/utils/promptSafe.js` — `wrapUserText()` and `INJECTION_GUARD`
  used by every clinical-notes route.
- `src/utils/ai.js` — `callAI()` multi-provider router (OpenRouter,
  Bedrock, Azure, Vertex, LiteLLM).
- `src/utils/crypto.js` — AES-256-GCM helpers used to encrypt every
  encounter / note column at rest.
