Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.
Server (src/routes/notes.js):
• New toHtmlBody() helper. If the AI returned real HTML (any
block tag), pass through. Otherwise run through `marked` so
markdown becomes <p>/<h*>/<strong>/etc.
• Strips ```json / ```html code fences before JSON parsing.
• Stricter JSON-recovery: only accepts {title|body} parsed shape;
falls back to wrapping the AI's full reply via toHtmlBody().
• Final guard: if body would be empty after sanitisation, wrap
the raw transcript so the user can at least edit it manually.
Client (public/js/notes.js):
• applyGeneratedNote prefers _editor.commands.setContent over a
full remount — avoids the toolbar-reattach flicker + the brief
window where the body looked empty.
• Logs to console when the editor target is missing or Tiptap
setContent throws, so a future regression is greppable.
Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:
• src/db/database.js: cleanup interval handle exposed; server
shutdown now clearInterval()s it before pool.end(). Removes
the SIGTERM → 9-second-hang → Docker SIGKILL race.
• src/routes/audioBackups.js: switch multer.memoryStorage() to
diskStorage with cleanup. 10 concurrent 25 MB uploads no
longer pin 250 MB of RAM. Identical user-visible perf since
upload is wire-bound.
New dep: marked@latest (used server-side only in toHtmlBody).
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
1. flushAutosave dropped brand-new unsaved notes.
The guard was `if (_dirty && _activeId != null)` but _activeId is
null until the first POST succeeds. So tap-New-Note → type → tap-
Back went through flushAutosave → skipped → closeToList → lost
the typed content. saveNote() already handles isNew via POST;
the guard only needs to check _dirty.
2. beforeunload sendBeacon used wrong HTTP method.
navigator.sendBeacon is POST-only by spec, but my code used it to
target PUT /api/notes/:id. Beacons silently hit a 404 route and
the note didn't save. For brand-new notes the URL resolved to
/api/notes/undefined. Replaced with fetch({ keepalive: true })
which browsers queue + ship even across unload, supports PUT,
and handles POST for new notes correctly.
3. Mobile: empty-state card stacked below the sidebar in list view.
The mobile media-query hid .notes-reader + .notes-editor when
data-view="list" but didn't include .notes-empty-state, so the
big "new note" card appeared below the list on phones. Added
the missing selector.
4. Delete fallback used window.confirm.
The `else if (window.confirm(...))` branch in deleteActive was
a rule violation even as a fallback — feedback_no_native_dialogs
says no native dialogs anywhere in frontend. Dropped it;
showConfirm is loaded by app.js before any tab activates so the
fallback path is unreachable in practice. Also upgraded the
confirm call to use the danger + confirmText options so the
modal renders red "Delete" button instead of neutral "Confirm".
5. Recorder kept running after switching tabs or notes.
Dictating, then tapping another clinical-tools tab (or opening
another note) left the MediaRecorder active and the mic stream
open — wasted battery, privacy surprise. Added stopRecording
(silent=true) to the non-notes branch of tabChanged, to
startCreate, and to openReader so any in-flight recording
cancels cleanly when the user moves on.
Minor:
• Dead "'Saving…' : 'Saving…'" ternary in saveNote simplified.
SW cache bumped to pedscribe-v12-notes4.
Removed the "your note saves as you type" / "encrypted at rest" /
tip-list filler that telegraphs AI-generated code. UI now reads
the way a real app reads — names where names go, no paragraphs
explaining what a button does.
• Module header: "Notes" — removed the paragraph tagline.
• Empty state: icon + "New note" button only — removed the
"Hello 👋" heading, the description, and the three-tip list.
• Placeholders: "Title" / "Search" — removed the "…" ellipses
and the "Note title" / "Search notes" verbosity.
• Status + meta: emptied the "New note — will save automatically
once you type" meta string and the "This note is empty. Tap
Edit to add content" reader placeholder.
• Empty-list copy trimmed to just "No matches" when the search
filter is active.
SW cache bumped to pedscribe-v12-notes3.
Four changes landing together so the UX flows properly.
1. Fix voice-generation model error.
/api/notes/from-voice was passing `req.body.model` (undefined)
through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
for LiteLLM deployments with no explicit default) → `model=""` →
LiteLLM 400 "Invalid model name". Server now reads the admin-
configured `models.default` setting, falls back to the
LITELLM_DEFAULT_MODEL env var, and only passes a model if one
resolved. Empty string never reaches the provider.
2. Read mode as the default post-save.
Opening an existing note or saving a new one now lands on a
clean, read-only rendering of the body (sanitized HTML with an
allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
Click the "Edit" button to switch to the Tiptap editor. Matches
the mental model of "notes are documents I review, not drafts I'm
always editing."
3. Autosave during edit.
Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
In-flight saves coalesce: if the user types more while a save is
in progress, a follow-up fires right after it lands so the last
keystroke never gets stranded. beforeunload uses navigator.
sendBeacon to best-effort flush on tab close. The manual Save
button is kept as a "save + switch to reader" shortcut. Ctrl/
Cmd+S still works in the editor.
4. Mobile layout.
The two-pane layout collapses to a single-pane view below 900px,
driven by a data-view attribute on .notes-layout. Back buttons
appear in the reader + editor heads on mobile to return to the
list. Desktop layout unchanged (sidebar + right pane always
visible).
Also:
• CSS specificity fix — .hidden{display:none} was losing to
.notes-rec-indicator{display:inline-flex} on source order, so
the "Recording" indicator was showing when idle. Added explicit
.notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
overrides at higher specificity.
• Reader-body sanitizer — allowlist of safe tags + attributes;
<a> links get target=_blank + rel=noopener.
• SW cache bumped to pedscribe-v12-notes2 so clients pick up the
new module / component / CSS.
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.
scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.
Running it on the current tree surfaced two real problems:
1. shadess.js:917 reached for #wv-note-transcript when collecting
refine source context. The actual id is #wv-transcript (no -note-
infix — the transcript element is shared across well-visit sub-
panels). The bug meant a generated well-visit note's Refine call
silently missed the original transcript as source context; the AI
still "worked" but with less signal and no error. Fixed.
2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
were removed a year+ ago in commit 3173ce6 ("Remove milestone
admin UI, add CMS content refresh button"). That commit dropped
the HTML but forgot the JS, which has been dead-loaded on every
page view since. All its addEventListener calls are guarded with
optional chaining, so nothing errored — just silent cruft.
Deleted the file and dropped the <script defer> tag from
index.html.
scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.
Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.
Suite: 294 passed / 0 failed.
PVCs are an ECG / rhythm finding, not a routine auscultation sample —
what you actually hear on the stethoscope is an irregular rhythm with a
compensatory pause, which depends on the underlying rate and is not
teachable from a canned audio clip. The card was also backed by a
synthesized sound, not a real recording. Removing both the card and
the pvc.ogg asset.
open from the Bedside tab
The #img-lightbox overlay markup was sitting at the bottom of
calculators.html. Before the reorg it was fine — the calculators tab
was always the only home for bedside, so by the time a user clicked
the seizure or NRP pathway button the lightbox HTML was guaranteed to
be in the DOM. After promoting Bedside to its own tab, a user can
open Bedside -> Seizures without having visited Calculators first;
the lightbox JS's getElementById('img-lightbox') then returns null
and the click silently no-ops.
Moved the overlay markup to the bottom of index.html so it exists
from page load regardless of which tab has been lazy-loaded. The e2e
harness gets a duplicate copy so the existing lightbox smoke test
keeps working.
Added a regression test for NRP (bedside-smoke.spec.js:141) to catch
any future breakage — the prior seizure-only test didn't exercise the
second pathway button and so the neonatal/NRP path had never been
clicked in CI.
Suite: 252 passed / 0 failed.
Tab-level choice (ped_last_tab) already survived sign-out/in via
localStorage, but sub-pill and sub-tab selections inside a loaded tab
lived only in memory — they reset to defaults after a reload or
browser restart. Now the following are persisted under the ped_ui/
namespace:
- Calculators nav pill (BP / BMI / GCS / …)
- Bedside sub-pill (neonatal / airway / …)
- Well Visit sub-tab (byvisit / milestones / shadess / note)
- Physical Exam Guide age group + system
Implementation:
- Added public/js/ui-state.js — a ~30-line window.UIState wrapper
around localStorage with a ped_ui/ prefix and try/catch around both
read and write (Safari private mode + quota errors silently no-op).
- Each tab's click handler now also calls UIState.set; each tab's
init path calls UIState.get and replays the saved value through
the same function a click would call — so there is exactly one
code path for "show this selection", whether it came from the user
or from a restore. For Bedside, the restore additionally listens
for tabChanged so the lazy-loaded HTML is guaranteed to exist by
the time we re-activate the pill.
Tests:
- e2e/tests/ui-state-persistence.spec.js — 5 specs × 2 viewports =
10 tests. Each clicks the feature, reloads the page, and asserts
the same pill / subtab / dropdown value is still active. Catches
any future regression in the persistence wiring.
- e2e/tests/soap-hospital-workflow.spec.js — fills SOAP transcript,
generates via mocked AI, clears, opens/closes load popovers; also
smoke-tests the Hospital Course save-bar.
Suite: 250 passed / 0 failed (+ 20 over the last run).
Bedside is now a top-level tab instead of a sub-pill inside Calculators —
it's the highest-traffic emergency reference in the app and deserves a
one-click entry. Same DOM structure and JS modules; only the container
moved.
Sidebar reorg:
- Notes: Hospital Course, Chart Review, SOAP Note, Well Visit, Sick Visit
(Well Visit + Sick Visit relocated from the old "Pediatric" group —
they're clinical note workflows, not pure reference tools).
- Section rename: "Pediatric" → "Clinical Tools". The section now holds
Vaccine Schedule, Catch-Up Schedule, Physical Exam Guide, Bedside,
Calculators, Pagers & Extensions, Learning Hub, Content Manager — mix
of reference tables + active calculators + utilities, none strictly
pediatric. "Clinical Tools" reads naturally for the combined set.
Layout changes:
- calculators.html: dropped 480 lines of bedside panel + the Bedside
nav-pill. The shared age→weight estimator moved with it.
- bedside.html: new component file, contains the full bedside card +
the age→weight estimator prepended.
- index.html: added bedside-tab section, sidebar restructured.
- e2e-harness.html: renders calculators + bedside side-by-side (not the
old calc-tab→bedside-pill dance) so the bedside smoke suite still
works without auth. e2e-bootstrap fetches both with a cache-buster.
- bedside-smoke.spec.js: removed the now-obsolete calc-nav-pill click
from each test.
Tab persistence is unchanged — ped_last_tab already survives sign-out,
and the lazy-load cache keeps sub-pill state across navigation within a
session. Persistence across browser restart for sub-pills is a separate
follow-up.
drop grunting + dead synth module
Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):
- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg
Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.
All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
User-visible changes:
- Removed the REAL / SYNTH badge from each sound card. User feedback:
"ridiculous". Cards now just show the sound title + description +
player, no distinction beyond the player type (native <audio controls>
for recordings, play/stop/progress bar for synth).
- Removed the "real recordings; synth labelled SYNTH" subtitle from
the sounds library header.
- Single-playback policy across the whole PE Guide: when any sound
starts (audio or synth), every other playing sound stops. Covers:
audio → audio (pause the previous), audio → synth, synth → audio,
synth → synth. Listeners attach on each .pe-audio 'play' event and
on every synth play-button click.
E2E infrastructure fixes (for the test failures we hit):
- auth-gated-smoke.spec.js now imports test + loginAs from the shared
fixtures.js so the token cache is unified across every spec. Without
this, each spec file's module-scoped _tokenCache multiplied logins
and hit the 10/15min rate limit.
- server.js: /api/auth/login rate limit is now configurable via
LOGIN_RATE_LIMIT_MAX env var (default 10, prod unchanged).
- docker-compose.e2e.yml: LOGIN_RATE_LIMIT_MAX="500" so Playwright's
two-project (chromium + mobile-chrome) multi-worker runs can do
their logins without tripping the cap. Prod container unaffected.
- fixtures.js console.error allowlist expanded to suppress known
non-bugs: Cross-Origin-Opener-Policy warnings on http:// e2e
server, transient 401/403/404/503 resource loads, ERR_BLOCKED_BY_CLIENT.
Three user-flagged fixes:
1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
heart-sounds + heart-murmurs subcategories). All real recordings:
- Normal (61 bpm) [existing]
- Infant heartbeat [new — pediatric reference]
- VSD [existing]
- Mitral valve prolapse [existing]
- Still's murmur in a toddler [new — classic innocent murmur]
- Functional murmur (adult female) [new — benign flow murmur]
- PVCs [new — arrhythmia]
All with native <audio controls> (play/pause/seek/elapsed/total
work on desktop AND mobile for free).
2. APTM diagram is now full-width on every device, no longer sharing
row space with the legend on mobile:
- Image always on its own row, max-width:420px, centered
- Wrapped in an <a href target="_blank"> — tap/click opens the PNG
full-size in a new tab where browser pinch-zoom works natively
- "Tap to open full-size" hint line under the image
- Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
on narrow viewports without cramping
3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
Web-Audio-API generated, no native controls) now have:
- Play button → kicks off playback and hides itself
- Stop button appears, lets user terminate early
- Progress bar animates over 3.2 s, resets at end
- Only one synth sound plays at a time (clicking another stops the
previous)
Three user-flagged issues fixed together:
1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
Now each sound card has a REAL or SYNTH badge and the library header
reads: "real recordings where available; synthesised approximations
labelled SYNTH".
2. No murmur sounds in the CV section. Added a Cardiac sounds library
between the APTM diagram and the innocent-murmur panel:
- Normal heart sounds (S1, S2) — 61 bpm reference
- Ventricular septal defect (VSD) — harsh holosystolic at LLSB
- Mitral valve prolapse (MVP) — mid-systolic click + late systolic
All 3 are real recordings from Wikimedia Commons.
3. No pause / stop / duration controls. Replaced the synth-only play
button with a native <audio controls> element for every real
recording — gives play, pause, seek, elapsed/total time, volume
for free on desktop AND mobile (browser-native, accessibility-
compliant, consistent with platform conventions). Synth sounds
(rhonchi, pleural rub, grunting, normal vesicular) keep the one-
shot play button since they\'re Web-Audio-API generated and don\'t
support seeking.
Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
which could overflow narrow screens. Switched to repeat(auto-fit,
minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
Four changes rolled together:
1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
stays as fallback for sounds not available from Wikimedia.
respiratorySounds.js now tries the real OGG first; falls back to Web
Audio synthesis if file missing.
2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
/public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
Kept the side legend with A/P/E/T/M colour-coded points.
3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
bruit, PPS) — age, location, character, confirming maneuver — plus
the 7 "S" criteria summary.
4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
has age-appropriate resp + cv content (newborn through adolescent).
Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
Toddler: croup/FB/epiglottitis, innocent murmur peak age.
Preschool + school-age: adult-pattern transition, sports screening.
Fourth PE system added. Adolescent cardiovascular exam at the same
teaching-focused depth as respiratory/neuro: five components with
significance + pearls + detailed step methods + watch-for blocks.
APTM auscultation diagram (new, inline SVG, no image file):
- Stylised anterior chest with sternum, clavicles, ICS level lines,
left mid-clavicular line
- Five colour-coded landmarks:
A Aortic — 2nd ICS right sternal border
P Pulmonic — 2nd ICS left sternal border
E Erb's pt — 3rd ICS left sternal border
T Tricuspid — 4th ICS left sternal border
M Mitral — 5th ICS mid-clavicular (apex)
- Side legend: location + what to listen for at each point
- Patient's-left / patient's-right labels to prevent mirror-image
confusion
CV-specific grading scales (3 new entries in SCALES):
- Murmur grade Levine 1–6
- Pulse amplitude 0–4+
- Capillary refill time thresholds
CV components:
1. Inspection (general appearance, central/peripheral cyanosis,
clubbing with Schamroth sign, precordial bulge, visible apex, JVP)
2. Palpation (apex position + character, parasternal heave, thrills
at all 5 points, peripheral pulses upper + lower, radio-femoral
delay for coarctation)
3. Auscultation — approach (positioning, diaphragm vs bell, systematic
walk through all 5 points, left lateral decub for MS, leaning
forward for AR)
4. Auscultation — heart sounds + murmurs (S1, S2 split, S3/S4 gallops,
murmur characterisation by timing/location/radiation/character,
Levine grading, dynamic maneuvers, innocent-murmur "7 S" pearl)
5. Peripheral vascular (four-limb BP for coarctation, radio-femoral
delay, bounding pulse differential)
UI wiring:
- renderSystem() emits APTM diagram card at the top of cv system,
before scales. Two-column layout: SVG on left, legend on right.
- accentMap/iconMap/labelMap extended with cv = rose accent,
heart-pulse icon, "Cardiovascular" label
- New sub-tab pill in pe-guide.html
Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.
New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
- Normal vesicular
- Wheeze (two-partial + vibrato, filtered sawtooth)
- Stridor (inspiratory, bandpass-filtered sawtooth sweep)
- Fine crackles (dense brief high-freq noise bursts, late inspiration)
- Coarse crackles (sparser, longer, lower-freq bursts)
- Rhonchi (low-pitched warbled sawtooth, expiratory)
- Pleural friction rub (bandpass noise, biphasic)
- Expiratory grunting (square-wave short grunts)
Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.
Respiratory-specific grading scales:
- RR by age (WHO tachypnea cutoffs)
- Pulse ox (SpO2) with hypoxemia thresholds
- Silverman–Andersen (neonatal retractions, 0–10)
- Westley croup severity score
Components in adolescent respiratory:
1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
chest shape, colour, clubbing with Schamroth sign)
2. Palpation (trachea, expansion symmetry, tactile fremitus,
tenderness, subcutaneous emphysema)
3. Percussion (technique + systematic zones + cardiac/hepatic
dullness + diaphragmatic excursion)
4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
bronchial) with systematic side-to-side comparison
5. Auscultation — adventitious sounds with per-sound listen buttons
linking directly to the sounds library
6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy
Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.
UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
User feedback: the exam steps were too generic (e.g. "Shoulder abduction
— 5/5 bilaterally" never explained what 5/5 means or HOW to test it).
The guide needs to serve as a teaching tool, not just a checkbox list.
Three changes:
1. GRADING SCALES reference card (new). Collapsible panel at the top of
each system showing the relevant scales:
- Neuro: MRC strength (0-5), DTR (0-4+), Plantar response
- MSK: Scoliometer ATR, Beighton hypermobility score
Each scale shows the grade AND its clinical meaning in a compact
table. No more orphan "5/5 bilaterally" without definition.
2. Per-component SIGNIFICANCE + PEARL fields (optional). Adolescent
neuro components enriched with:
- Significance: one-line clinical relevance (what this component is
actually for — what pathologies it detects)
- Teaching pearl: a Hutchison/Bates/Nelson-style tip that helps the
learner see past the mechanics to the reasoning
Visually distinct — pearl gets a warm amber accent, significance is
a crosshair icon under the name.
3. Method strings REWRITTEN for every adolescent strength step. Before:
"Shoulder abduction — 5/5 bilaterally". After: "Patient abducts both
arms to 90°. Examiner pushes down on each arm just above the elbow
while patient resists. Compare sides. — Holds against full resistance
— MRC 5/5 bilaterally". Same treatment for all 14 strength steps,
all 8 DTR steps, and tone/pronator-drift.
UI redesign:
- Accent bars on cards (cyan for MSK, purple for neuro) for visual
anchor
- Numbered step circles instead of "1." prefix
- HOW / NORMAL label badges on each step
- Watch-for block with red left-border for red-flag grouping
- System-level header with icon (bone for MSK, brain for neuro)
Other age groups (newborn through school-age) keep the old data shape
(steps without pearls) — they still render correctly, just without the
pearl/significance blocks. Enriching them is an incremental follow-up.
Daniel flagged the native browser confirm() dialogs in Extensions as ugly
and incompatible with the app's design. There was also a stray alert()
in calculators.js resus-meds weight validation.
Replaced:
- extensions.js: confirmDelete → showConfirm(..., {confirmText: 'Move to trash'})
- extensions.js: confirmPurge → showConfirm(..., {danger: true, confirmText: 'Delete permanently'})
- calculators.js:2060 alert() → showToast(..., 'error')
All three helpers (showConfirm, showToast) are already defined as globals
in public/js/app.js. The design already had a modal — I should have used
it from the start.
Audit confirmation: `grep -rnE '\b(alert|confirm|prompt)\s*\(' public/`
now returns only comment references and the showConfirm definition
itself. No native dialogs remain anywhere in the frontend.
Playwright test updated to click the in-app modal's #confirm-modal-ok
and #confirm-modal-cancel buttons instead of intercepting page.on('dialog').
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.
Data:
- New table user_phone_extensions (id, user_id, location, name, number,
type CHECK (extension|pager), notes, trashed_at, timestamps).
Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.
API (all user-scoped, all params validated):
- GET /api/extensions?trash=1&q=text — list active or trash, optional search
- POST /api/extensions — create
- PUT /api/extensions/:id — update (requires all three core fields)
- DELETE /api/extensions/:id — soft-delete (sets trashed_at)
- POST /api/extensions/:id/restore — un-trash
- DELETE /api/extensions/:id/purge — hard-delete (only if trashed)
All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.
UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.
Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.
Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.
Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.
Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
components with technique, expected normal finding, and abnormal-
feature watch-list.
2. Documentation generator — physician marks each component
Normal / Abnormal (with free-text detail) / Skip; AI produces a
two-section report (Technique + Findings), narrative or structured
list format.
Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.
Files:
- src/routes/peGuide.js — POST /api/generate-pe-narrative (mirrors
milestone-narrative pattern: AppRole-level
injection guard, clinical audit category,
PHI redaction upstream already in place)
- src/utils/prompts.js — peGuideNarrative + peGuideList prompts,
structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js — embedded PE_DATA (all clinical content),
render + state + AI call
- public/index.html — tab button, section, script include
- server.js — mount route at /api
No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
The dropdown was labeled just "Risk Factors" with option "None (lower risk)"
— ambiguous because AAP 2022 uses "risk factors" in two distinct senses:
(a) risk factors for developing hyperbilirubinemia (screening-only, do not
change thresholds) and (b) neurotoxicity risk factors (do change thresholds).
Only (b) belongs on the threshold nomogram, and a clinician glancing at the
form could easily pick wrong.
Changes:
- Label: "Neurotoxicity risk factors"
- Options: "Absent" / "Present (any one qualifies)" — removes the misleading
"None (lower risk)" phrasing (no-risk curve actually has HIGHER thresholds)
- Expandable details listing the 6 specific AAP 2022 neurotoxicity risk
factors (isoimmune hemolysis, G6PD, other hemolysis, sepsis, albumin <3.0,
clinical instability <24h) with explicit note that GA <38w is handled by
the per-week curve, not by this checkbox — prevents double-counting.
No data changes. Cite: Kemper et al., Pediatrics 2022;150(3):e2022058859, Box 2.
The previous tables grouped all ≥38-week infants into one table (using
38w values) and 36-37 into another. AAP 2022 actually has separate
phototherapy curves per completed week for 35, 36, 37, 38, 39, 40+.
Worst real-world impact: a 40-week infant at 72h of life got the 38w
threshold (18.8 mg/dL) instead of the correct 40w threshold
(19.8 mg/dL) — a 1.0 mg/dL error at exactly the clinical decision point.
Borderline infants could be started on phototherapy unnecessarily, or
the reverse (miss a true threshold) depending on the direction.
Replaced the block with 18 distinct per-week tables extracted directly
from peditools.org/bili2022 API:
- Phototherapy no-risk: 35, 36, 37, 38, 39, 40+ (all differ)
- Phototherapy with-risk: 35, 36, 37, 38+ (38-41 identical)
- Exchange (both risk states): 35, 36, 37, 38+ (38-41 identical)
Selection logic updated to map gaNum → correct table. HTML dropdown
label clarified to "completed weeks" with a one-line note that days
don't change the curve per AAP 2022.
Validation: exhaustive roundtrip — 7 GA weeks × 85 hours × 2 risk
profiles = 1190 cases, all match peditools within 0.01 mg/dL (zero
mismatches). See commit message footer.
The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.
New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.
Example — 40 5/7 wk male, 3070 g:
BEFORE: z = -1.38 (matched only one of three external sources)
AFTER: z = -1.42 (matches Epic -1.43 and third-source -1.44)
Peditools at integer 40w: z = -1.10 (exact match with new table at
integer-week input)
LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
Adds an age-appropriate reflex reference after the Expected Growth /
Feeding block for each well-visit age. Each entry shows the reflex
name, an expected-status chip (Present / Fading / Integrated /
Up-going / Down-going), and a short clinical note covering how to
elicit it, when it should fade, and what abnormal persistence means.
Covers primitive reflexes for newborn-6mo (rooting, sucking, Moro,
palmar/plantar grasp, tonic neck, stepping, Galant, tongue thrust,
Babinski), the transition at 6-18mo (protective extension, parachute,
plantar-response switch), and adult-pattern DTRs/frontal-release
screening from age 2 through 21.
loadOidcConfig() was called from the first IIFE in admin.js but declared
in the second IIFE. Function declarations don't cross IIFE boundaries,
so the call threw ReferenceError and left the SSO form empty with
"Disabled" selected — even when SSO was configured and working. Moved
the call into the tabChanged handler of the IIFE where the function
lives.
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.
Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.
Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
Playwright container (no host Node needed). Covers every Bedside sub-pill,
the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
component without the SPA auth wall (scripts external to satisfy CSP).
Server:
- server.js now re-reads public/index.html on mtime change instead of
caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
enforces HTTPS at the reverse-proxy layer in production.
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.
Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
- Add neonatal assessment calculator: GA classification (extremely preterm through
post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.
Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).
Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.
User's reference case (41 h of life, TSB 9.7 mg/dL):
p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
→ Low-Intermediate Zone ✓ (matches clinician expectation)
Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.
New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html
Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.
Spot-check: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → High-Intermediate Zone (wrong)
After: p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓
No interpolation or decision logic changed — only the lookup data
and its citation.
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.
Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.
Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.
Example: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
After: p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
(matches the published nomogram)
No code-path changes — only the lookup data.
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.
AAP chart now has three-zone shading matching BiliTool's convention:
- Faint green tint below the phototherapy curve (safe zone)
- Amber band between phototherapy and exchange (treatment zone)
- Unshaded above the dashed crimson exchange line (danger)
Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.
renderBiliChart common improvements:
- Right-edge label on each threshold line ("Phototherapy", "Exchange",
or "95th (High-Risk)" for Bhutani) with white halo — identifiable
without a legend, like PediTools
- Legend removed (replaced by the inline labels)
- X-axis auto-ranges to fit the actual data with tick every 12 h
- Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
- 40 px right padding so labels don't clip
- Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
redrawn on top of every label + its TSB value printed next to it
- Bhutani chart inherits all the same improvements without changing
its own zone/fill setup
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.
Fills updated for new indexes (3↔97, 10↔90, 25↔75).
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
- Forward pass pushes items DOWN when crowded from above
- Backward pass pulls items UP when still crowded from below
- Min gap bumped 13 → 15 px for breathing room
- Labels clamped to stay inside chart top/bottom
- Thin leader line drawn from native curve position to the label
when the two diverge by more than 2 px, so you can still see
which line a nudged label belongs to
- Patient dot redrawn on top of all labels at the very end of
the plugin so a top-region label can never cover the dot
(Chart.js's afterDatasetsDraw fires after dataset rendering,
so the native dot was being painted over).