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).
Adds a second Playwright project so every calculator test runs at both
Desktop Chrome and Pixel 5 (~375 px). Catches mobile layout regressions
automatically. 106 tests passing (53 × 2 viewports).
ES module script tags (<script type="module">) always send an Origin
header on fetch, even for same-origin requests. The global cors()
middleware was rejecting /js/bedside/index.js with 500 in the e2e
harness because the container's internal origin
(http://pediatric-ai-scribe:3000) is not in APP_URL/CORS_ORIGINS.
Production was unaffected (real users hit APP_URL, which is allowed),
but the fix is architecturally correct either way: CORS belongs on
the API boundary, not on static file serving. All protected routes
are under /api/*.
Unblocks the bedside smoke suite — now 26/26 green.
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 logger.audit/access calls to auth route (login, login_failed,
login_blocked, register, password_changed, 2fa_backup_code_used,
2fa_backup_codes_regenerated) — these previously only wrote to DB
via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
(Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
- 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
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
docker-publish.yml:
- Rewrote as matrix build + manifest merge.
- amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm (free for public
repos). No QEMU — argon2 and every other native dep compile on
their target CPU, no more SIGILL / exit 132.
- Per-arch GHA cache scopes so builds don't thrash each other.
- Final step merges both digests under one tag (vX.Y.Z + latest),
publishing a real multi-arch manifest. `docker pull` from either
arch gets the right variant automatically.
auto-version.yml, version-bump.yml:
- Checkout now uses `secrets.RELEASE_PAT || secrets.GITHUB_TOKEN`.
With RELEASE_PAT set, the tag push this workflow does DOES
trigger downstream (android-release, docker-publish). Without
it, falls back to GITHUB_TOKEN (no downstream trigger, what we
have today).
All workflows (auto-version, version-bump, android-release,
docker-publish):
- Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' so actions
still on Node 20 runtime (checkout/cache/setup-*) opt in to
Node 24 early. GitHub makes Node 24 default 2026-06-02 and
removes Node 20 2026-09-16.
To finish the chain (one-time user step): create a fine-grained
PAT with "Contents: Read and write" on this repo and add as
RELEASE_PAT secret. After that `feat:` / `fix:` commits auto-tag
AND auto-build with zero manual intervention.
mobile-build.md:
- Removed "PedsHub Quiz" sections. That app lives in a separate
repo (quiz/mobile/) and has its own build pipeline. Docs here
are PedScribe-only now.
- Reorganized around CI as the primary flow, local build as
fallback. Added explicit secret names, JDK requirement, single-
quote-password caveat, QEMU/argon2 note.
- File-map section at the end so the native sources are
discoverable without grepping.
CONTRIBUTING.md:
- Cut the narrative prose. Dev-facing tables + single-line
commands only. Decision-tree removed (the table suffices).
- Release pipeline and mobile build link out rather than
duplicating content.
Drops a cheat sheet (CONTRIBUTING.md) in repo root so anyone — you,
future maintainers — has the full commit-prefix table one glance
away. Covers which prefixes trigger a release and which don't.
Also adds .gitmessage that you can optionally wire into git as the
default commit template:
git config --local commit.template .gitmessage
Opens the cheat sheet in your editor every time you `git commit`
without -m. Remove it with `git config --local --unset commit.template`.
This commit uses `docs:` prefix so it does NOT trigger a release —
proving the auto-version workflow's filter works.
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:
feat: → minor bump (new feature, backward-compatible)
fix: → patch bump (bug fix)
feat!: → major bump (breaking change)
BREAKING CHANGE in body → major bump
docs/chore/refactor/style/test/ci → no release
If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.
You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
docker-publish.yml:
- Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
hosted runner builds arm64 under QEMU emulation, which fails at
native argon2 compile with SIGILL (exit 132). Your production
box is x86, so arm64 isn't needed. Add it back with a native
ARM runner the day you deploy to ARM hardware.
version-bump.yml (new):
- Manual Actions trigger. Click "Run workflow" → pick patch / minor /
major (or type a custom X.Y.Z). The workflow computes the next
semver from the current package.json version, updates all three
version sites (package.json, mobile/package.json, Android
versionName + versionCode), commits "Release vX.Y.Z", tags it,
and pushes. The tag push then fires android-release.yml and
docker-publish.yml automatically — APK + Docker image published
with no local commands required.
Typical flow now:
Actions → "Version bump & release" → Run workflow → patch
↓
Bump + tag in ~5 s
↓
Parallel: android APK build (~2 m), docker image push (~4 m)
↓
Both assets show up on the new release; Obtanium + docker-hub
subscribers see the update automatically.
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.
Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.
No logic changes — only stopped tracking files that are always
regenerated.
On every v*.*.* tag push the workflow:
1. Checks out the repo
2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
3. Runs npm install + npx cap sync android in mobile/
4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
5. Builds a signed release APK via gradle
6. Renames to pedscribe-X.Y.Z.apk
7. Creates/updates the matching GitHub release with the APK attached
and make_latest=true so the /releases/latest URL always points to
the newest build (Obtanium and the login-page link pick it up
automatically)
Required repo secrets (set via gh secret set ... or the GitHub UI):
ANDROID_KEYSTORE_BASE64 base64 -w0 of the .jks file
ANDROID_KEYSTORE_PASSWORD keystore password
ANDROID_KEY_ALIAS key alias (pedscribe)
ANDROID_KEY_PASSWORD key password (same as keystore in our setup)
Typical release flow after this lands:
scripts/release.sh 6.1.1 --push (laptop, 5 sec)
── Actions builds APK in ~8-10 min ──
── Release updates automatically with signed APK ──
── Obtanium clients notice on next poll ──
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).
Label plugin rewritten:
- Collect all percentile-line labels with their native screen Y
- Sort top-to-bottom (97th ... 3rd) so order is always correct
- Walk the sorted list and enforce 13px min vertical gap by
pushing down (never reordering)
- Second-pass pull-back if the stack would clip off the chart
bottom
Prior logic could nudge "10th" past "3rd" because it moved labels
independently without respecting their natural screen order.
Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.
Kept the mobile-readability improvements from the prior pass:
- Each percentile is a distinct hue (red / deep-orange / orange /
amber / green / blue / violet / purple / pink)
- Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
and 95th use a tighter dash; rest solid
- 50th remains bold green to anchor the center
- Label plugin's white halo + vertical nudge keeps 3/5/10 and
90/95/97 label stacks legible
- Patient dot still on top, white-ringed
Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).
Each percentile now gets a distinct hue:
3rd red 50th green (bold) 90th violet
10th orange 75th blue 97th pink
25th amber (3rd/97th dashed)
Label plugin improvements:
- Bolder 11px font (was 10px)
- White halo stroke behind text so labels stay legible when the
line they sit on is also colored
- Vertical nudge when two labels would overlap — keeps adjacent
percentiles readable on mobile aspect ratios
- Solid fill color (strips alpha from borderColor)
Patient dot:
- Moved to order: -1 (drawn on top of everything, including labels
and fill bands)
- White 2.5px border ring so it's visible even when it lands
exactly on a colored curve
- Slightly larger hover radius (11 → was 10)