Instead of shipping placeholder pages, added a new backend endpoint
so the React client can render the real AAP/CDC tables without
duplicating the 2000-line pediatricScheduleData module in the client
bundle:
GET /api/schedule-data (authed)
Returns { visitAges, periodicity, catchUpSchedule, vaccineFullNames }
— everything the vaccine table and catch-up views need from the
server-side pediatricScheduleData require(). VACCINE_FULL_NAMES
(which was inlined in public/js/wellVisit.js) now lives in the
route file so it's single-sourced.
client/src/pages/VaxSchedule.tsx
useQuery → /api/schedule-data. Renders the full vaccine × visit-age
grid with sticky header + sticky first column for long scrolling.
Each filled cell shows the dose number or bullet, with the original
note text on hover.
client/src/pages/Catchup.tsx
Card-per-vaccine layout with min-age / min-interval tables and
catch-up notes. Matches the vanilla layout.
Layout: vaccine + catch-up links now available in the sidebar.
Typecheck green both sides. Vite build 382 kB / 112 kB gzipped.
Three more Notes tabs:
/hospital → HospitalCourse.tsx
Textarea with blank-line-separated progress notes (one block =
one note) + H&P + format selector (auto / prose / day-by-day /
organ-system). Calls /api/generate-hospital-course.
/chart → ChartReview.tsx
Type selector (outpatient / subspecialty / ED) with a dynamic
array of visit entries — user can add/remove visits. Each
visit has date / content / labs. Calls /api/generate-chart-review.
/wellvisit → WellVisit.tsx
Vitals, measurements, parent concerns, transcript, screenings,
immunizations, note style. Minimum-viable Visit Note port —
the vanilla tab's milestone / SSHADESS / by-visit sub-panes
become their own sub-routes in a follow-up. Calls /api/well-visit/note.
All three reuse the established pattern: form state → Zod (where a
schema exists in shared/schemas.ts) → useMutation → display pane
with copy-to-clipboard.
Every Notes group item is now marked available in the Layout sidebar.
Build: 377 kB / 110 kB gzipped (+16 kB over previous).
Three more tabs behind /app/*:
/encounter → Encounter.tsx — POST /api/generate-hpi-encounter
/soap → Soap.tsx — POST /api/generate-soap (full or
subjective-only output type)
/sickvisit → SickVisit.tsx — POST /api/sick-visit/note (chief
complaint is the only required
field; transcript optional)
All three share the same skeleton: demographic triad + textarea +
Zod-validated submit + copy-to-clipboard output pane. Follow-up work
per tab (save/load, refine, audio capture) lands in subsequent
commits — this first pass proves the AI generate path for each.
Layout sidebar updated: Encounter HPI / SOAP Note / Sick Visit now
marked available. Pending stubs remain for Hospital Course, Chart
Review, Well Visit, and all Clinical Tools + Settings.
Build: 361 kB / 109 kB gzipped (+11 kB over previous, as expected
for three small pages using the existing lib/api + shared schemas).
Typecheck + vite build both green on the client side.
Three new pages behind the /app/* React router:
client/src/components/Layout.tsx
Sidebar + main content shell. NavLink-based nav with a single
NAV data structure mirroring the vanilla app's sidebar groups
(Encounters / Notes / Clinical Tools / Account). Items with
`available: false` render as greyed-out 'pending' stubs so the
future tab list is visible during migration without breaking
clicks. Vanilla-app fallback link is pinned at the top so anyone
needing a feature not yet ported can jump back to /.
client/src/pages/Faq.tsx
8 sections, 27 questions ported verbatim from
public/components/faq.html. Collapsible accordion pattern via
local useState — no Radix dependency yet. Content lives in
client/src/data/faq.ts (extracted from the HTML via a one-off
python parse, so re-extraction is reproducible if the vanilla
FAQ ever grows).
client/src/pages/Dictation.tsx
Minimum-viable port of Voice Dictation → HPI. Demographics
(age / gender / setting), transcript textarea, Zod-validated
submit to POST /api/generate-hpi-dictation, result pane with
copy-to-clipboard. Not yet ported from the vanilla tab:
MediaRecorder audio capture + /api/transcribe upload, save/load
popover, refine + shorten buttons, Nextcloud export. Each of
those is its own follow-up.
client/src/App.tsx
All routes now render inside <Layout />. New routes wired:
/, /extensions, /dictation, /faq. A catch-all Navigate redirects
any unknown /app/* path back to home.
Build check:
client: npx tsc -b → EXIT 0
client: npx vite build → 350 kB / 108 kB gzipped
Public bundle at public/app/index-BmpHzFRb.js replaces the previous
one; committed so the next prod rebuild ships it atomically.
Nothing on the backend changed. /api/generate-hpi-dictation and
/api/extensions already exist; the React pages just call them.
End-to-end proof that the full React + Vite + Tailwind + TypeScript
pipeline works against the existing Express API:
client/src/pages/Extensions.tsx
Minimum-viable port of the Extensions tab. Fetches /api/extensions
via the typed api wrapper; renders an add form backed by Zod
validation (ExtensionCreateSchema). Uses @tanstack/react-query for
server state (queryKey: ['extensions'], invalidate on mutate).
Full CRUD UI (trash / restore / purge / search) is a follow-up —
this ships just enough to prove the stack works.
client/src/lib/api.ts
Thin fetch wrapper. Every React page goes through apiFetch<T>(),
which narrows ApiResponse<T> to the success shape and throws
ApiError on failure. Central spot for future request/response
instrumentation, auth-token refresh, etc.
client/src/App.tsx
Replaced the Vite starter splash screen with a minimal router:
BrowserRouter basename='/app', routes for / (landing) and
/extensions. QueryClientProvider wraps the tree so every page can
use useQuery/useMutation.
client/src/shared/
Mirrored copy of /shared/types.ts + schemas.ts. Canonical source
stays at /shared/ (used by backend). A post-migration task is to
wire proper TypeScript project references so the client can import
straight from /shared — TS 6's cross-root bundler-mode paths
resolution isn't pulling it in cleanly. For now, the mirror is
header-annotated 'do not edit, mirror only'.
client/src/index.css
Tailwind v4 @theme block declaring the shadcn design tokens as
first-class CSS custom properties, which exposes the
bg-background / text-foreground / border-border utility classes
the page components use. v4 no longer uses @apply for these —
the theme block is the idiomatic form.
server.ts
Added:
app.get('/app/*splat', ...) → sendFile public/app/index.html
so React Router deep links (e.g. /app/extensions) resolve
client-side. Express static middleware below continues to serve
the hashed /app/assets/*.js + .css.
Build output (checked into public/app/ so the next prod docker
rebuild ships the React bundle without requiring a client/npm
install step in the Dockerfile — that's a day-8 refinement):
index.html 0.46 kB gzip 0.29 kB
index.css 9.51 kB gzip 2.69 kB
index.js 329.24 kB gzip 100.98 kB
Typecheck green on both sides:
server: npx tsc --noEmit → EXIT 0
client: npx tsc -b → EXIT 0
client: npx vite build → 150 modules, 219ms
How to see it live (after Daniel rebuilds prod):
https://<host>/app/ → React landing page
https://<host>/app/extensions → React-rendered Extensions list
https://<host>/ → unchanged vanilla JS app
Nothing destructive. The vanilla JS /extensions tab still works
identically. The React /app/extensions route talks to the same
/api/extensions backend endpoints. Both render from the same
PostgreSQL rows.
This closes out the 7-day migration scaffolding. The rest is a
port-one-tab-at-a-time grind that Codex (or anyone) can pick up
tab-by-tab with the Playwright suite as the safety net.
New client/ directory, standalone from the backend: Vite 8 + React
19.2 + TypeScript 6. Tailwind v4 via @tailwindcss/vite plugin (no
postcss config needed). shadcn/ui compatibility wired via
components.json + lib/utils.ts. @tanstack/react-query + react-router-dom
installed for server state + routing.
Vite config essentials:
base: '/app/' — Express mounts SPA at /app/*, vanilla JS stays at /
outDir: '../public/app/' — build lands next to existing static assets
@/* → ./src/* (client-local imports)
@shared/* → ../shared/* (typed wire protocol shared with backend)
server.proxy '/api' → localhost:3000 for `npm run dev`
tsconfig.app.json — added baseUrl + paths + include ../shared so the
shared/types.ts + schemas.ts are typechecked on the client side too.
src/index.css — Tailwind v4 @import, shadcn HSL design tokens (light +
dark schemes). Replaces the vite starter template's decorative styles.
No backend touched. Express route serving /app/* lands in Day 7.
Adds the three high-ROI tools the planning conversation identified:
vitest 4.x — fast TS-native unit test runner. Runs against pure
functions (calculators, validators, prompt builders). Playwright
stays for e2e. package.json `npm test` now runs Vitest;
`npm run test:node` preserves the old `node --test` runner
for the 3 legacy tests under test/.
zod 4.x — runtime request-body validation at API boundaries. The
new shared/schemas.ts exports a schema per endpoint request
(LoginRequestSchema, SoapRequestSchema, PeNarrativeRequestSchema,
ExtensionCreateSchema, etc.). Routes will adopt these one at a
time post-migration — usage pattern:
const body = SoapRequestSchema.parse(req.body);
Invalid input becomes a structured 400 instead of a silent
undefined-access crash.
knip 6.x — dead-code / unused-export detector. knip.json scopes
it to the backend (client/ excluded since it lives in its own
workspace). Run with `npm run lint:dead`. Catches the class of
bug that kept public/js/adminMilestones.js dead-loaded for a
year — a future orphaned file would fail the lint.
@vitest/coverage-v8 — coverage reporter backed by v8 profiler.
Shipped schemas.test.ts with 8 example cases to prove the toolchain
(`npx vitest run` green).
Not done on day 5 (punted to post-migration): flipping tsconfig to
`strict: true`. That cascade would light up hundreds of implicit-
any errors in handler signatures that would cost more commit bandwidth
than available in this pass. The Day 4 permissive mode is already
catching the big wins (wrong response shapes, orphan refs, undefined
destructures). Post-migration, Codex/vendor model can flip strict flags
one at a time and fix handler-by-handler.
All remaining backend files renamed:
src/middleware/auth.ts, logging.ts (2 files)
src/utils/*.ts (20 files: ai, auditQueue, config, crypto,
embeddings, errors, fileType, logger, models,
notify, passwords, platform, promptSafe,
prompts, redact, sessions, transcribe*,
ttsGoogle)
src/db/database.ts, migrate.ts (2 files)
Spot-fixes to satisfy tsc (all within the spirit of 'no behavior
change' — added `: any` annotations where the original JS relied on
duck typing that tsc's default inference narrows too aggressively):
utils/ai.ts — body, converseParams, request literals + fallback
result object + err.code/model/message casts. AI client has lots
of provider-specific ad-hoc object shapes; Day 5 will replace the
`any`s with proper provider-response interfaces.
utils/embeddings.ts — payload + request as `any`; generateEmbedding
call sites pass `undefined as any` for the now-required second
arg (model) until we refactor the signature.
utils/prompts.ts — PROMPTS typed as Record<string, any> so
.loadFromDb / .updatePrompt / .getAllPrompts attachments after
the const literal compile.
utils/transcribeLocal.ts — buildArgs() has two `var args = [...]`
in the same function scope (var-hoisted); both now typed as
any[] so they don't type-clash across conditionals.
Backend is now 54 of 54 TypeScript files, permissive mode.
`npm run typecheck` EXIT 0. Prod container still running the old
JS image — no Dockerfile change yet.
Next: Day 5 flips strict: true, fixes every error tsc surfaces, adds
Vitest + Zod + Knip tooling.
admin.ts adminConfig.ts adminMilestones.ts
auth.ts oidc.ts
learningHub.ts learningAI.ts learningAdmin.ts
These were the largest files (auth alone is ~600 lines). Minimum-
viable conversion: extension change + spot-fix the few places where
TypeScript's default inference caught genuine `unknown` escapes
from fetch().json() — patched with `: any` type annotations so the
compile passes. Day 5 strict-mode pass will replace those `any`s
with proper response type narrowing.
Fixes in this batch:
- adminConfig.ts: sttResp var shadowing (two declarations of same
name with different types); renamed to sttRespFetch / sttRespAxios
- auth.ts: turnstileData + tsData from fetch(...).json() now `: any`
- auth.ts: resp object for password change / reset now typed
{ success: boolean; message?: string; passwordWarning?: string }
- learningAI.ts: parseInt(questionCount) cast through `any`
All 29 of 29 routes now .ts. Backend progress: 30/54 files
migrated (server.ts + 29 routes). Remaining: 2 middleware + 20
utils + 2 db files. Day 4 handles those.
tsc --noEmit green (EXIT 0).
sickVisit.ts — /api/sick-visit/note
wellVisit.ts — /api/well-visit/{shadess,note}
peGuide.ts — /api/generate-pe-narrative (with typed PeStep)
milestones.ts — /api/{milestones-data, generate-milestone-narrative, generate-milestone-summary}
chartReview.ts — /api/generate-chart-review (typed VisitEntry etc.)
All five follow the established pattern. Added a handful of inline
interfaces (PeStep, MilestoneItem, VisitEntry) where the existing code
was juggling anonymous object shapes — these will migrate into
shared/types.ts during Day 5 if reused elsewhere.
14 of 29 routes converted. Progress: 14/54 backend files.
tsc --noEmit green.
Same CJS-compatible pattern as batch 1 (import express = require;
const {...} = require for internal utils; export = router). No
behavior change — TS only strips annotations during compile.
AI route touchpoints:
/api/generate-hpi-encounter, /generate-hpi-dictation → hpi.ts
/api/generate-soap → soap.ts
/api/refine, /shorten, /clarify → refine.ts
/api/text-to-speech → tts.ts
/api/transcribe, /transcribe/status → transcribe.ts
All five handlers retain identical request/response shapes; the
shared/types.ts contract was defined on day 2 from these very files.
One fetch() typing fix: the transcribe route's LiteLLM branch uses
DOM fetch, but the file imports Express's Response type, so the
`.then((r: Response) => ...)` annotation was shadowing the global
Response. Removed the explicit annotation — tsc infers correctly.
Progress: 9 of 54 files migrated. tsc --noEmit green.
Migrates the 4 smallest and best-understood routes to TypeScript:
src/routes/logs.ts
src/routes/userPreferences.ts
src/routes/sessions.ts
src/routes/extensions.ts (11 Playwright tests cover this one)
Pattern established for the remaining 25 routes:
import express = require('express'); // CJS-style, fully typed
import type { Request, Response } from 'express';
const db = require('../db/database'); // stays `any` until Day 4
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
router.get('/foo', async function (req: Request, res: Response) {
// req.user is typed via src/types/express.d.ts augmentation
});
export = router; // CJS-compatible export
Why `import = require()` instead of `import from`:
Express is imported via the namespace-import syntax so tsc preserves
`require("express")` in the emitted CJS. Using plain ES import would
emit `require("express").default` which doesn't exist on Express'
CommonJS default export. The pattern keeps the compiled dist/*.js
byte-identical to what vanilla Node expects.
Why `const { authMiddleware } = require(...)` for other imports:
The middleware + utils files are still .js and their module.exports
shape isn't fully typed yet (Day 4 work). Using `require` avoids
dragging Day 4 work forward; the destructuring still gives us the
variable name we want.
New file — src/types/express.d.ts:
Augments Express.Request with `user?: AuthUser` and `sessionId?: string`
(attached by authMiddleware). Route handlers now type-check against
`req.user!.id` instead of requiring a runtime cast.
`req.user!` uses non-null assertion because authMiddleware guarantees
user is set for every mounted route; strict mode on Day 5 will keep
the assertion but add a type-guard check inside authMiddleware itself
so it propagates.
Verification
- npm run typecheck → 0 errors
- scripts/lint-references.js → green
- Compiled dist/src/routes/logs.js diffs against the original .js by
whitespace + var→const only. Behavior preserved.
Nothing deployed. Prod + e2e containers still run the previous image.
Progress: 1/30 (server.ts) + 4/29 routes = 5 of 54 total files migrated.
Remaining batches go in subsequent Day 3 commits.
change)
Creates the wire-protocol contract every route and every client
component will import from. Renames server.js → server.ts as a pure
rename (zero bytes of logic changed) so the entry point becomes the
first file tsc type-checks against the real compilerOptions.
shared/types.ts — what's in it and why
- ApiResponse<T> envelope: (ApiOk<T> & T) | ApiErr. Every route
returns one of these; client code narrows on `r.success`.
- One typed "Ok" shape per endpoint, keyed by the actual res.json()
call in the current handler. Walked every src/routes/*.js file
and transcribed the literal keys: hpi, soap, note, hospitalCourse,
review, refined, shortened, questions, narrative+summary, etc.
No inventive renaming — wire stays identical, only the types are
new.
- Critical mismatches the types now prevent at compile time:
/api/refine returns `refined` (not `content` — a past bug)
/api/sick-visit/note (not /api/generate-sick-visit — past bug)
/api/generate-hospital-course returns `hospitalCourse` (not
`narrative` — past bug)
All three were caught and fixed earlier in Playwright; with the
shared types they become compile errors for any future regression.
server.ts
- Pure rename via `git mv`. Body unchanged.
- Compiled dist/server.js diffs against the original server.js by
two lines (TypeScript prepends `"use strict"` and the CommonJS
export marker). No semantic drift.
tsconfig.json tweak
- Include list adds server.ts alongside server.js so tsc doesn't
silently skip the entry point during the intermediate state
where `.js` entries might reappear.
- baseUrl removed (deprecated in TS 6); paths now uses './shared/*'.
package.json
- main: dist/server.js (post-compile entry)
- start: node dist/server.js
- prebuild: rm -rf dist (clean emit every time)
- dev: ts-node-dev for fast TS-aware reloads
The Dockerfile is still unchanged. The deployed prod and e2e
containers still run their baked-in server.js from the previous
image — this migration day has no effect on either until the final
rebuild at the end of day 7.
Next: day 3 renames the 29 route files one-by-one, each adding the
ApiResponse<T> type parameter to its res.json() calls.
Installs TypeScript toolchain and configures it to accept every
existing .js file untouched. tsc --noEmit is green; the app runs
identically and nothing is deployed or rebuilt yet.
Config choices:
- extends @tsconfig/node20 (matches the runtime version)
- allowJs: true, checkJs: false — existing files pass through
- strict: false — tightened progressively on day 5, not day 1
- skipLibCheck: true — node_modules .d.ts quality varies, not our
job on migration day
- paths: { "@shared/*": ["./shared/*"] } — pre-wired for the
shared/types.ts file landing on day 2
Dependency notes:
- typescript 6.0.3 (current stable, released late 2025)
- @types/express pinned to ^4 because the app uses Express 4.21;
the default npm install picked @types/express 5 which doesn't
match runtime shapes for Request/Response
- @tsconfig/node20 for the known-good strict / module / target
triple for Node 20 LTS
- ts-node-dev for the new `npm run dev` script — transpile-only
mode, keeps startup fast
package.json script additions:
- build: tsc (compiles to dist/)
- typecheck: tsc --noEmit (CI-friendly)
- dev: ts-node-dev for TS-aware hot reload
- lint:refs: wraps the existing static reference linter
Left alone (no behavior change):
- start: still node server.js
- Dockerfile unchanged (prod still runs vanilla JS)
- All 54 backend .js files untouched
Day 1 scope matches the migration rule: no runtime behavior changes,
nothing deployed. Next: day 2 creates shared/types.ts and flips
server.js to server.ts.
Reference tag: pre-migration-v1 (commit 447eb78).
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.
session persistence, per-tab model selector
Brings detailed coverage to sections that were previously only smoke-
tested:
- sickvisit-workflow.spec.js: generate → mocked note, refine bar
round-trip, load popover, New button clears demographics + transcript.
- hospitalcourse-workflow.spec.js: fill H&P → generate → mocked
narrative renders via /api/generate-hospital-course, refine updates
text, load popover open/close.
- auth-screen.spec.js: unauthenticated landing visible, login form
structure, forgot-password swap + return, register link is
intentionally display:none on this instance (pinned), register form
DOM still wired correctly if the link is manually unhidden, reg-
password has minlength=8 + type=password.
- session-persistence.spec.js: clear cookie simulates logout (UI login
is gated by Turnstile which can't be completed in the e2e container);
re-login via loginAs restores the last tab + sub-pill via localStorage.
- model-selector.spec.js: tab-model-select dropdowns render with >0
options across 7 tabs.
Fixture corrections:
- /api/sick-visit/note and /api/well-visit/note were not being mocked
at all — the wrong /api/generate-sick-visit pattern was intercepting
nothing, so tests fell through to the real AI backend. Both now have
correct patterns and response shapes (sick-visit returns `note`,
well-visit note returns `note`).
- /api/generate-hospital-course response key updated from `narrative`
to `hospitalCourse` to match what the frontend actually reads.
wellvisit-workflow: the Visit Note test now asserts a concrete
waitForResponse on /api/well-visit/note + text render, instead of the
previous "hit either endpoint" fallback.
Suite: 294 passed / 0 failed in 5m30s.
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.
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.