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.
8 new spec files covering sections previously only smoke-tested:
- ai-endpoints-contract.spec.js — hits 8 real AI endpoints via request
context and fails if the response leaks TypeError / ReferenceError /
'Cannot read properties of undefined' / 'is not defined' / 'is not a
function'. This is the class of bug that shipped the PE-narrative
regression to prod because every page-level mock prevented the real
handler from running.
- encounter-workflow.spec.js — generate HPI, refine, clear transcript.
- encounter-save-load.spec.js — save draft, load popover, repopulate.
- wellvisit-workflow.spec.js — byvisit, milestones, SSHADESS (12+
reveal), visit note.
- vaxschedule-content.spec.js — schedule + catch-up panels populate
beyond "Loading".
- chart-review-workflow.spec.js — generate + load popover.
- learning-tab.spec.js — search filter, category pills, feed.
- settings-faq-dictation.spec.js — voice/password/2FA/Nextcloud
sections, FAQ expand/collapse, dictation generate flow.
Baseline fixes:
- Added CORS_ORIGINS + API_RATE_LIMIT_MAX env overrides so the e2e
container accepts the browser's Origin header and can absorb the
full suite's API traffic without tripping the 200/min guard.
- Server's /api/ rate limit is now configurable via
API_RATE_LIMIT_MAX (default stays 200).
- extensions-crud: replaced native page.on('dialog') listeners with
#confirm-modal-ok clicks (we moved off native confirm()).
- pe-guide-smoke + extensions-crud: mobile viewport opens the hamburger
before clicking sidebar tabs.
- fixtures.js: /api/refine mock uses 'refined' (real API shape), not
'content'. /api/chart-review replaced with /api/generate-chart-review.
Suite: 230 passed / 0 failed in 4m36s.
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.
The route destructured PROMPTS/INJECTION_GUARD/wrapUserText from
../utils/prompts, but PROMPTS is the module's default export and the
other two live in ../utils/promptSafe. All three resolved to undefined,
so every /api/generate-pe-narrative request crashed with
"Cannot read properties of undefined (reading 'peGuideNarrative')"
and the client surfaced "Request failed" for PE Guide narrative and
summary generation. Split the require into the two-line form that
every other AI route already uses.
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.
The previous entrypoint unconditionally exported every key from
kv/ped-ai/prod. This broke the e2e container, which needs
TURNSTILE_SECRET_KEY="" and SMTP_HOST="" set via docker-compose
environment block so login works without bot challenge and register
auto-verifies. OpenBao's real values were overriding those empties,
re-enabling Turnstile and email on e2e.
Fix: before the OpenBao fetch, snapshot every env var name already
defined (env_file + environment: block). During the export loop,
skip any OpenBao key that's already in the snapshot. Docker-compose
wins, OpenBao fills in the rest.
Impact:
- Prod container: no change (env_file only has OPENBAO_* bootstrap
vars, which aren't in the KV payload anyway)
- E2e container: TURNSTILE_SECRET_KEY="" and SMTP_HOST="" preserved
even when the image is rebuilt from the current source tree
- Any future per-container override via docker-compose environment:
block just works
Log line now reports counts: "applied N secrets; M already set by
docker (kept override)".
Uses the shared fixture so pageerror + console.error fail the test —
would catch any regression of the bug-class that shipped the SSO
ReferenceError. All tests log in as the seeded e2e user and drive the
real UI.
Coverage:
- tab loads with empty-state before age selected
- MSK (default) renders with scales + steps
- switch to Neuro shows MRC scale + teaching pearl
- Respiratory shows 8-sound library with play buttons + RR scale
- Cardiovascular shows APTM image (naturalWidth > 0 — actual network
fetch confirmed), all 5 landmark letters, innocent-murmur panel,
7 "S" criteria footer
- parametrised: every age group × {resp, cv} must NOT show "no data",
must have ≥ 1 step (catches the regression Daniel just flagged)
- step toggle cycles Normal → Abnormal → Skip with visual state change
and note-field show/hide
- grading scales <details> is collapsible and expands on click
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').
Two additions:
1. e2e/fixtures.js — shared test infrastructure
- Custom `test` extending @playwright/test with two auto-fixtures on
every page: page.on('pageerror') and page.on('console') of type
'error'. Any uncaught JS error fails the test. This is the SSO-
bug-class safety net: if we'd had this earlier, the silent
admin.js ReferenceError would have failed CI instead of shipping.
- `authedPage` fixture — logs in via API, injects session cookie,
provides pre-authed page ready to drive.
- `mockAI(page)` helper — intercepts generate-* and transcribe
endpoints with canned JSON responses. Enables fast deterministic
CI runs. Opt-out via E2E_USE_REAL_AI=1 to hit real LiteLLM.
- Console-error allowlist for known noise (favicon 404, lazy-loaded
Whisper models, etc.).
2. e2e/tests/extensions-crud.spec.js — 11 tests covering the new
Pagers & Extensions feature end-to-end:
- empty state, add extension, add pager (correct grouping)
- edit persists, search by location + by number
- soft-delete with confirm → moves to trash
- restore from trash → reappears in active
- purge from trash → permanent
- cancel dialog keeps item, cancel form keeps nothing, validation
on required fields
Known follow-up: the e2e container (pediatric-ai-scribe-e2e) is still
on the pre-entrypoint image from before the OpenBao migration, so it
doesn't have the Extensions routes yet. Rebuilding it needs a small
entrypoint enhancement to honor docker-compose-level env overrides
vs OpenBao-fetched values (e.g. TURNSTILE_SECRET_KEY="" for the e2e
instance). That's separate work — this commit just lays in the tests.
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 optional secret-fetch step at container boot. When OPENBAO_ADDR,
OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint
authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports
each key as a process env var before exec'ing node. When OPENBAO_ADDR
is unset the entrypoint is a no-op — the legacy .env flow continues to
work unchanged (e2e container, local dev, rollback).
Changes:
- docker-entrypoint.sh: new — AppRole login + KV fetch + env inject +
exec. Fails fast on missing/invalid creds; unsets bootstrap vars
before launching node so they don't linger in the process env.
- Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3
(multi-arch handled automatically by buildx manifest-list resolution).
Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD
remains ["node", "server.js"].
- .env.example: documents the three vault-bootstrap variables at the
top and notes that everything below is vault-sourced when OPENBAO_ADDR
is set.
Rollout is two-phase for safety: rebuild image with unchanged .env
(proves no regression in legacy mode), then add the three OpenBao vars
and restart to cut over to vault-sourced secrets. Rollback at any point
is blanking OPENBAO_ADDR in .env + restart.
Two independent PHI-leak hardenings folded together:
1. forgot-password timing oracle
The hit path previously did SELECT + token gen + UPDATE + SMTP send
before responding; the miss path returned after the SELECT. An
attacker could distinguish registered emails by response latency
(SMTP RTT is hundreds of ms). Response is now sent immediately after
Turnstile, with the DB and email work fired-and-forgotten in a
background async block. Hit and miss take identical wall-clock time.
Also hardened req.body.email to tolerate missing/non-string input
instead of throwing 500.
2. logger.file redaction
logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
without going through redact(). Current callers are metadata-only and
safe, but any future caller writing logger.error('boom', req.body)
would silently drop PHI to disk. Route both message and optional data
through redact() — same helper the audit path already uses. Benign
startup messages pass through unchanged; SSN/phone/email/DOB patterns
are tokenised, long note-body-shaped text is truncated.
Extends the existing crypto helper (already used for audio backups and the
Nextcloud token) to cover every column that can hold PHI:
- saved_encounters.transcript, .generated_note, .partial_data
- user_memories.content (templates + Dragon-style corrections)
- user_memories.name (auto-derived from original snippet on corrections,
so effectively PHI)
Reads decrypt transparently. Legacy plaintext rows continue to work —
decryptString passes non-enc1: values through unchanged — so no migration
is required; rows re-encrypt on their next save.
The encounters list query previously used LEFT(transcript, 200) for a
preview. With ciphertext that slice is meaningless, so the route now
fetches the full columns, decrypts in Node, then slices. At 7-day auto-
delete the row count is bounded and the cost is a handful of GCM
decrypts per list call.
user_memories ORDER BY moved from (category, name) to (category, id)
since SQL can no longer order on encrypted names.
Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
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.
Adds a second containerized instance of the app with Turnstile + SMTP
disabled so Playwright can log in without a bot challenge.
- docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares
postgres + pgdata with main so seeded test users (*@ped-ai.test) persist.
- Test user: e2e-user@ped-ai.test (created once via /api/auth/register
against the e2e container — SMTP is off so register auto-verifies).
- Tests log in once per worker via /api/auth/login (module-scoped token
cache) then inject the ped_auth cookie into each test's browser context.
This avoids the 10-per-15-min login rate-limit.
- Mobile viewport opens the sidebar via #btn-menu-toggle before clicking
tab buttons (which are hidden behind the hamburger <=768px).
Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning,
dictation, settings, calculators, faq + landing-page-after-login. Each
test clicks the tab, waits for the lazy component to render (>100 chars),
and asserts a known anchor string is present.
Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs +
11 desktop + 11 mobile auth-gated).