Closes the Calculators migration. AAP 2017 BP percentile (Rosner
quantile splines) was the biggest table-driven calculator in the
codebase: 6 height-LMS arrays × 218 entries + 4 spline coefficient
matrices × 99 rows × 13 terms = ~3,500 numeric constants. Every one
ported verbatim. 14 parity tests prove the TS port returns identical
percentile and classification outputs to the vanilla calculators.js.
shared/clinical/bp.ts — 571 lines
Generated from public/js/calculators.js via awk-extracted lines
89-94 (LMS) and 97-503 (coefficients), wrapped in TS export
declarations. No rewriting, no reformatting, no reordering — the
data bytes are identical to the vanilla source.
Math (calcHeightPercentile, computeBpPercentile,
classifyBpFromPercentiles) ported verbatim from calculators.js:
505-608. Exports a top-level computeBp() helper returning
{ sysPercentile, diaPercentile, heightPercentile, sysClass,
diaClass, classification }.
scripts/capture-calc-vectors.js — BP cases added
Uses new Function() to evaluate the raw LMS + coefficient blocks
from calculators.js directly, then runs the vanilla math against
14 carefully chosen test cases:
• Typical pediatric ages (3, 5, 8, 10, 12 years, both sexes)
• Adult-threshold cross-over (age 13 — uses absolute mmHg cutoffs)
• Stage 1 / Stage 2 hypertension boundaries
• Edge-of-domain (age 1, age 17)
• Tall / short height-percentile outliers
Fixture regenerated to 14 Bhutani + 58 AAP + 13 Fenton + 8 neonatal
+ 12 BMI + 14 BP = 117 total vectors.
shared/clinical/bp.test.ts — exact-match parity
sysPercentile / diaPercentile are integer selections from 99
candidate predicted values, so tests use .toBe() for exact match.
heightPercentile uses toBeCloseTo(6) (double-precision float).
Classification strings must match exactly. 14/14 pass.
client/src/pages/CalculatorPanels.tsx — BpPanel added
Age/sex/height/SBP/DBP inputs, validation (age 1-17, height 50-200
cm), color-coded overall classification + per-measurement
percentile and tier (Normal / Elevated / Stage 1 / Stage 2) in a
4-column result grid with the height percentile for context.
client/src/pages/Calculators.tsx
Dispatch wires bp → BpPanel. PILLS['bp'].ported = true.
LegacyPanel is now dead code — every pill has a real implementation.
Final test suite: 5 files, 136 tests green
(19 calculators + 70 bilirubin + 21 fenton/neonatal + 12 BMI + 14 BP)
Nine of the ten Calculator pills now run in React. Only BP Percentile
(Rosner quantile splines, ~3,000 hand-transcribed coefficients across
6 LMS arrays and 4 spline matrices) remains legacy-linked; that port
deserves its own dedicated session with extra care.
shared/clinical/bmi.ts — CDC 2000 BMI-for-age
• bmiLMS table ported byte-for-byte from calculators.js:739
(74 LMS triples = 37 age points × 2 sexes, 24-240 months).
• normalCDF (Abramowitz & Stegun), calcBmiPercentile, classifyBMI
(including the %-of-95th severe-obesity split), and a top-level
computeBmi helper — all verbatim translations of the vanilla math.
• classification labels preserved exactly so existing e2e screenshots
or reporting continue to read the same text ('Class 2 Severe
Obesity', 'Healthy Weight', etc.).
shared/clinical/bmi.test.ts — 12 captured vectors covering:
both sexes, edges (2y + 20y), interpolated-between-keys (13m),
each classification cliff (underweight / healthy / overweight /
obese / severe class 2 / severe class 3), and the age-clamping
branches (<24 mo and >240 mo). All 12 pass at 10-decimal precision
(percentile to 6 places since the vanilla rounds to 2).
scripts/capture-calc-vectors.js — BMI section added
Same pattern as bilirubin / Fenton: the vanilla data + math are
inlined verbatim, the script runs 12 chosen cases, and writes to
e2e/fixtures/calc-vectors.json. Re-run after any upstream change.
client/src/pages/CalculatorPanels.tsx (new)
• BmiPanel — age/sex/weight/height inputs, calls computeBmi,
renders color-coded classification badge with BMI, percentile,
Z, and % of 95th when percentile ≥85.
• VitalsPanel — 8-band age selector (premie → >12 yr).
VITALS_DATA ported verbatim from calculators.js:1703-1831 with
every HR/RR/SBP/DBP/temp/weight/SpO₂ range and clinical notes
preserved entry-for-entry.
• ResusPanel — weight input drives all 14 drugs (Adenosine,
Amiodarone, Atropine, CaCl, Ca-gluconate, Dextrose, Epi,
Hydrocortisone, Insulin, Lidocaine, Mg, Naloxone, NaHCO₃) with
calc() closures ported verbatim from RESUS_MEDS lines 1873-2050.
Category colors + labels preserved.
• EquipmentPanel — 9-band age selector (premie → 16+ yr).
EQUIP_DATA ported verbatim from calculators.js:2173-2228 with
12 equipment sizes per band (BVM, NPA, OPA, blade, ETT, LMA,
Glidescope, IV, CVL, NGT, chest tube, Foley).
client/src/pages/Calculators.tsx
Dispatch wires bmi → BmiPanel, vitals → VitalsPanel, resus →
ResusPanel, equipment → EquipmentPanel. PILLS flags all four as
ported: true. Only bp remains on LegacyPanel.
Backend tsc + client tsc + vite build + 122/122 vitest (19 calc +
70 bili + 21 fenton/neonatal + 12 bmi) all green.
Finishes Bedside parity. Neonatal, Respiratory, Ventilation, Sepsis,
and Burns replace their LegacyPanel fallbacks. All 15 vanilla
Bedside sub-modules are now real React components.
shared/clinical/fenton.ts — second, higher-accuracy Fenton table
fentonLmsPeditools: 21 GA weeks × 2 sexes (22-42 in 1-week steps)
ported verbatim from public/js/bedside/neonatal.js:20-37. This is
the peditools-derived table that superseded the older hand-rounded
version (still used by the Growth Charts calculator). Adds
calcZNeonatal (|L|<0.001 threshold) + zToPercentileNeonatal
(Abramowitz & Stegun erf form) so neonatal output matches the
vanilla Bedside numbers to 3 decimal places.
neonatalAssess() returns { gaDecimal, gaClass, bwClass,
weightClass, expectedWeight, z, percentile, L, M, S }.
scripts/capture-calc-vectors.js + e2e/fixtures/calc-vectors.json
Adds 8 neonatal vectors (including the 40w5d male 3070g validated
case from the vanilla file's own comment: z = -1.42).
shared/clinical/fenton.test.ts — now covers neonatal too (110 total
vitest cases pass: 19 calculators, 21 fenton, 70 bilirubin).
client/src/pages/BedsidePanels2.tsx — five new panels
• NeonatalPanel — GA+weight+sex inputs drive the Fenton assessment
(gestational-age class, weight-for-GA class, birth-weight
category, Z-score, percentile, expected M). Full NRP pathway
cards (birth→HR<100→HR<60 escalation), NRP drug table scaled
by weight (epi IV/IO/ETT, NS bolus, D10), and 5-element Apgar
scorer with reassuring/moderately-depressed/severely-depressed
guidance.
• RespiratoryPanel — four sub-modes: Asthma (mild/moderate/
severe with full drug tables + "when to intubate" / ABG /
heliox clinical decision boxes), PRAM scorer (0-12),
Westley croup scorer (0-17 with severity-tiered treatment),
Bronchiolitis admission decision tree (age / SpO₂ / hydration
/ distress) with AAP "NOT recommended" list.
• VentilationPanel — target SpO₂ table by population, 6-step
escalation ladder (NC → FM → NRB → HFNC → NIV → intubation)
with live-scaled HFNC flow (1-2 L/kg/min), BVM how-to,
mechanical vent starting settings (TV, rate by age, PEEP,
FiO₂, I:E), gas-exchange adjustment table, and the
oxygenation-vs-ventilation mental model.
• SepsisPanel — Phoenix Sepsis Criteria (JAMA 2024), red-flag
list, age-banded workup + empirical therapy (neonate /
infant / child abx drugs keyed to weight), SSC 2020 first-hour
bundle (0-5 min recognize → >60 min refractory-shock
vasoactive), and resuscitation targets.
• BurnsPanel — full 19-region Lund-Browder age-adjusted table
(ported verbatim), with per-region % input + live TBSA
computation + override. Parkland formula (4 × kg × TBSA,
8h/16h split + per-hr rates), 4-2-1 maintenance, UOP targets,
pearl list (palm rule, no first-degree, analgesia, tetanus),
and ABA burn-center referral criteria.
client/src/pages/BedsidePanels.tsx — dispatch extended to all 15
pills. REAL_BEDSIDE_PANELS now contains every Bedside pill id, so
the legacy-link fallback is dead code that can be pruned later.
Client tsc + vite build + 110/110 vitest tests all green.
Ships the AAP 2022 phototherapy/exchange nomograms, the Bhutani 1999
risk zones, and the Fenton 2013 preterm weight-for-GA chart as real
React calculators. These were the high-risk "table-driven" ports the
migration checkpoint flagged as needing captured vectors before
landing — the whole vector-capture workflow now exists and can be
reused for BP / BMI / growth-beyond-Fenton next.
Workflow, so future calculator ports have a template:
scripts/capture-calc-vectors.js
Standalone Node script. Data tables + math inlined VERBATIM from
public/js/calculators.js (no rewriting, no reformatting). Picks 83
carefully chosen test cases — edge-of-domain, table-key-exact,
interpolated-between-keys, and clinical-decision-boundary values —
and emits e2e/fixtures/calc-vectors.json with
{ inputs, output } pairs produced by the authoritative math.
e2e/fixtures/calc-vectors.json
12 Bhutani cases, 58 AAP 2022 cases, 13 Fenton cases. Re-run
capture-calc-vectors.js whenever the vanilla file changes.
shared/clinical/bilirubin.ts
• 17 HourTable constants ported byte-for-byte from calculators.js
lines 1489-1512: photo 35w/36w/37w/38w/39w/40+ (low risk) +
35w/36w/37w/38+ (medium risk), same 8 for exchange.
• Bhutani zones (p95/p75/p40) from lines 1644-1651.
• interpolateThreshold helper (lines 1514-1525).
• classifyBhutani + classifyAapBili functions mirror the vanilla
click-handler logic.
shared/clinical/fenton.ts
• 15-week × 2-sex LMS table from lines 1168-1183 preserved entry-
for-entry.
• interpolateLMS + calcZ + zToPercentile (Abramowitz & Stegun
normal CDF, lines 2299-2326) ported byte-for-byte.
• fentonWeightForAge returns { L, M, S, z, percentile };
classifySizeForAge labels SGA/<10 / AGA / LGA/>90.
shared/clinical/bilirubin.test.ts + fenton.test.ts
Vitest suites that import e2e/fixtures/calc-vectors.json and assert
classifyBhutani / classifyAapBili / fentonWeightForAge match
every captured vector to 10 decimal places for threshold values
and 6 decimals for the Fenton M (grams, so 6 is >= 1e-3 g).
All 102 tests pass locally (19 prior + 70 bili + 13 Fenton).
client/src/pages/Calculators.tsx
• BiliPanel with AAP 2022 / Bhutani mode switch, GA + risk-factor
dropdowns, color-coded status / zone badges, both threshold
pairs surfaced in the result grid.
• GrowthPanel runs Fenton with sex + GA + weight inputs, emits
Z-score, percentile, L/M/S reference, and SGA/AGA/LGA label.
• PILLS flags bili + growth as ported: true; ActivePanel routes to
the new components. BP / BMI / vitals / resus / equipment
remain legacy-linked until their own vectors land.
e2e/tests/calculators-react.spec.js
Three new parity tests covering above-phototherapy/above-exchange
transitions (38w 72h TSB 20 → phototherapy; TSB 26 → exchange),
Bhutani high-risk classification at 36h TSB 13, and Fenton 32w
male 1795g landing exactly at 50th percentile AGA.
Backend tsc + client tsc + vite build + vitest (102/102) all green.
Bundle 619 kB / 178 kB gz (+10 kB for the bili tables).
Replaces the legacy-viewer fallback for three of the fifteen Bedside
sub-modules with real React implementations. Weight-based dosing now
runs locally for the most time-critical emergencies — the other 12
modules (neonatal, airway, respiratory, ventilation, sepsis, sedation,
agitation, antiemetics, antimicrobials, burns, toxicology, trauma)
still fall through to LegacyPanel.
shared/clinical/calculators.ts
New formatDose(weightKg, perKg, max?, unit = 'mg') helper that
mirrors the vanilla S.dStr exactly — same rounding, same cap rule,
same label format. Returns { value, unit, capped, perKg, max,
label } so consumers can render rich UI without re-parsing a HTML
string. Pure function; unit tests in the existing vitest suite
still pass.
client/src/pages/BedsidePanels.tsx (new)
Three panels keyed to the Bedside pill id:
• AnaphylaxisPanel — STEP 1 epi IM callout + full 9-row dosing
table (fluids, diphenhydramine, ranitidine, dex, methylpred,
refractory epi gtt, glucagon). Drug per-kg + max values ported
verbatim from ANAPH_FALLBACK in the vanilla module.
• CardiacPanel — PALS dosing with 6 sub-views (general,
asystole/PEA, bradycardia, SVT, VF/pulseless VT, stable VT).
Every drug row carries the same mg/kg + max values as the
vanilla cardiac.js (epi, amio, lido, atropine, adenosine ×2,
bicarb, CaCl/CaGluc, Mg, D10, defib energies). Concentration
disclaimer + AHA citation preserved.
• SeizurePanel — full status-epilepticus timeline (0 min →
40 min refractory) with the vanilla SEIZURE_FALLBACK drug
table (loraz / midaz / diaz for benzo; levetiracetam /
fosphenytoin / valproate / phenobarb for 2nd-line; midaz
pentobarb propofol ketamine infusions for refractory). Key
points + citation preserved.
REAL_BEDSIDE_PANELS set + renderBedsideRealPanel(pillId) export so
Bedside.tsx can dispatch by id without import sprawl.
client/src/pages/Bedside.tsx
When the active pill is in REAL_BEDSIDE_PANELS, render the real
panel; otherwise fall back to LegacyPanel. No other changes — the
sub-nav, age→weight estimator, and 12 legacy-linked modules stay
exactly as they were.
e2e/tests/bedside-react.spec.js
Three new parity tests (in addition to the existing shell checks):
• Anaphylaxis: 20 kg → 0.2 mg epi IM; 70 kg → 0.5 mg (capped).
• Cardiac: 25 kg → 0.25 mg epi IV in general view and asystole.
• Seizure: 10 kg → D10W 20-50 mL, Lorazepam 1 mg.
Client tsc -b + vite build clean. Bundle 609 kB / 173 kB gz (+28 kB
for three full panels; vite's 500 kB chunk warning noted for later
code-splitting, not blocking).
Biggest data port of the migration so far. PE_DATA is the 1000+ line
age-group × system × component × step hierarchy driving the pediatric
physical-exam checklist; every entry, pearl, significance note, and
abnormal-hint array is now available in the React tree.
client/src/data/pe-data.ts — verbatim port
Extracted lines 316-1334 of public/js/peGuide.js with awk/sed, then
wrapped in TS types. Every byte of the data body is byte-identical
to the vanilla source. Added interfaces:
PeStep { label, method, normal }
PeComponent { name, steps[], abnormalHints[], pearl?, significance? }
PeSystem { overview, components[] }
PeAgeGroup { label, msk, neuro, resp, cv }
…plus AGE_GROUP_ORDER / SYSTEM_ORDER / SYSTEM_LABELS canonical
orderings for the UI.
client/src/data/pe-data.test.ts — parity lock
Vitest suite that asserts every count captured from the vanilla
source so any accidental drop surfaces as a red test:
• 6 age groups × 4 systems
• 103 components total
• 27 pearl entries
• 23 significance entries
• per-cell component counts (e.g. toddler.neuro = 7, adolescent.cv = 5)
Counts captured 2026-04-24 against peGuide.js commit 313ba7f.
client/src/pages/PeGuide.tsx — full viewer (replaces legacy-link stub)
• Age-group pills (6) + system pills (4) drive the visible section
• Overview banner per combination
• CV system shows APTM legend + cardiac sounds library + innocent
murmurs reference (unchanged clinical content from the earlier
commit that added the scales/sounds file)
• Resp system shows the respiratory sounds library
• Collapsible grading-scales reference pulls from SYSTEM_SCALES
• Component checklist: per-step Normal / Abnormal toggle, abnormal-
hint list, pearl + significance callouts
• Mark-all-normal + Reset shortcuts
• Generate Exam Report posts the full step payload to
/api/generate-pe-narrative, renders the returned narrative inline
• No more "Open checklist in legacy viewer" amber banner — the
React port now does the whole thing
e2e/tests/peguide-react.spec.js
Age-group pills, system pills, overview rewrite on age change,
CV/resp system-specific reference panels, mark-all-normal + summary,
and a mocked /api/generate-pe-narrative round-trip.
Client tsc -b + vite build clean. Bundle 580.30 kB / 166.08 kB gz
(up ~100 kB from the shell-only port — the 1000-line PE_DATA is the
bulk; acceptable for the clinical reference data it surfaces).
First batch of real calculator ports, routed through a new
shared/clinical/calculators.ts module that both the server tree and
the React client can import. Kept strictly to the simplest, closed-form
formulas — tables (Rosner BP, Fenton LMS, AAP 2022 bili, Bhutani, CDC
BMI) stay in the vanilla viewer until per-table vector files land.
shared/clinical/calculators.ts
Verbatim ports from public/js/calc-math.js:
• parseAgeMonths / formatAgeMonths — legacy age-string parser
• estimateWeightFromAgeMonths — APLS (Luscombe 2007) + Best Guess
(Tinning 2007) weight-for-age. Cross-checked line-by-line against
calc-math.js:14-39; identical branches, formulas, and roundTo
behavior.
• calculateMostellerBsa — sqrt(h·w/3600), Mosteller 1987.
• calculateWeightBasedDose — generic mg/kg with optional max cap
and mg/mL → mL conversion.
• calculateGcs — 1-15 sum with 8/12 severity thresholds.
shared/clinical/calculators.test.ts
Vitest unit coverage for each helper. Numeric assertions match the
legacy function outputs (3y APLS → 14 kg; 20 kg, 110 cm → 0.782 m²;
15 kg × 100 mg/kg capped at 500 mg, etc.). For these closed-form
formulas the hand-verified expected values are equivalent to a
vanilla-captured vector file — table-driven calculators still need
a JSON fixture before they port.
client/src/pages/Bedside.tsx
Top-level age-to-weight estimator now runs in React
(BedsideWeightEstimator). Formula dropdown switches between APLS
and Best Guess live; weight field accepts a manual override. The
15 clinical dosing sub-modules still fall through to the legacy
viewer via LegacyPanel.
client/src/pages/Calculators.tsx
BSA, Weight-Based Dosing, and GCS panels render real React forms
backed by the shared helpers. PILLS gain a `ported` flag so the
four covered panels (bsa, dose, gcs, + the already-shipped pills)
swap out of the legacy fallback while the others remain linked
out. Result blocks carry data-testid hooks for parity tests.
Config + dep hygiene picked up along the way
• client/tsconfig.app.json: @shared/* path alias, exclude test files
from the React tsc pass.
• tsconfig.json: exclude **/*.test.ts from the backend tsc pass.
• package.json: declare google-auth-library and jszip explicitly —
both were already required() in src/utils/ttsGoogle.ts and
src/routes/learningAI.ts but missing from dependencies, which
would break a clean `npm install`. Also adds engines: node >=20
and convenience verify / verify:full scripts.
• knip.json: quiet now-expected ignoreDependencies / ignoreBinaries
entries for marp-cli, tiptap, cap, etc.
• .gitignore: ignore the .codex CLI marker.
e2e/tests/bedside-react.spec.js
Adds the age→weight parity test: 3y APLS → 14 kg, 3y Best Guess →
16 kg, weight field mirrors the estimator output.
e2e/tests/calculators-react.spec.js
Adds BSA (20 kg, 110 cm → 0.782 m²), dose cap (15 kg × 100 mg/kg,
max 500 mg → 500 mg capped), and GCS (15 → 10 when motor drops
to 1) parity tests.
Client tsc -b + backend tsc --noEmit + vite build all clean.
Bundle 476.48 kB / 135.44 kB gzipped.
Co-Authored-By: Codex + vendor model Opus 4.7 (1M context) <noreply@anthropic.com>
Last tab on the revamp roadmap. Ships /app/admin as a shell that
renders either an access-denied card or a legacy-viewer link
depending on me.user.role, plus a new Admin nav group in the
sidebar that is hidden entirely for non-admin users.
client/src/components/Layout.tsx
NavItem gains an optional adminOnly flag. Layout now runs its own
useQuery<MeOk>(['auth-me']) — the query key is already shared with
Settings so the cache is reused across both. Nav groups whose
items all filter out (in practice: the Admin group for non-admins)
don't render their group header either, so the sidebar stays clean
for regular users. 5-minute staleTime so the header doesn't hammer
/me on every route change.
client/src/pages/Admin.tsx
Same /me query + role check. Non-admins land on the
admin-access-denied card; admins see the admin-shell with a link
to the legacy admin viewer. Sub-sections (users, feature flags,
OIDC, SMTP, AI prompts, model management, TTS/STT, email
templates, announcement banner, site-wide saved encounters) stay
in the vanilla admin page for now — each touches production state
immediately on save, so each port needs its own deliberate commit
with dedicated tests before shipping.
e2e/tests/admin-react.spec.js — one smoke test
The seeded e2e user is non-admin, so the expected outcome is the
access-denied card. Guard is written to accept either state so the
test still passes if the seed ever flips to an admin.
With this commit the React sidebar covers the full legacy nav:
• Encounters: Encounter HPI, Dictation HPI
• Notes: Hospital Course, Chart Review, SOAP, Well Visit, Sick Visit
• Clinical Tools: Vax Schedule, Catch-Up, PE Guide, Bedside,
Calculators, Pagers & Extensions, Learning Hub
• Account: Settings, FAQ
• Admin: Admin Panel (role-gated)
Client tsc -b + vite build clean. Bundle 462.48 kB / 131.98 kB gz.
Ships the /app/calculators page with the 10-pill sub-nav and flips
the sidebar Calculators link to available. All formula math stays
in the vanilla viewer for this commit.
client/src/pages/Calculators.tsx
PILLS array mirrors public/components/calculators.html exactly:
BP Percentile, BMI, Growth, Bilirubin, Vital Signs, BSA,
Weight-Based Dosing, Resus Meds, GCS, Equipment. Each pill carries
its canonical source (AAP 2017 Flynn / Fenton 2013 / AAP 2022
Kemper / Bhutani 1999 / Mosteller / PALS / …) so when a reader
opens the page they know which authoritative reference the numbers
trace back to.
Why no math in this commit — explicitly gated
The migration checkpoint has a specific rule for this tab:
"generate test vectors (JSON file with {inputs, expectedOutput}
tuples) by running the vanilla version with 20+ known cases. The
React port must match every vector byte-for-byte. An LLM will
sometimes 'simplify' a long array of numbers and silently break it
— don't let that happen." This applies in particular to:
• Rosner quantile splines in the BP percentile calc
• Fenton 2013 LMS preterm (210 validated cases)
• AAP 2022 bilirubin phototherapy + exchange (1190 validated cases)
• Bhutani nomogram risk zones
• APLS + Best Guess weight-for-age
Each formula gets its own commit once the vector file lands in
e2e/fixtures/ — this shell just makes the nav complete so users
can navigate to the tab in the React tree.
e2e/tests/calculators-react.spec.js — three smoke tests:
all 10 pills render by data-testid in the expected order, pill
click switches the active panel, and the legacy-viewer link is
present.
Client tsc -b + vite build clean. Bundle 460.19 kB / 131.43 kB gz.
First Bedside commit. Delivers the /app/bedside page with the full
15-pill sub-nav in the same order + labels as the vanilla app, and
switches the sidebar Bedside link to available.
client/src/pages/Bedside.tsx
Single-file shell. PILLS array is the single source of truth for
pill ID / label / icon / summary, ordered to match
public/components/bedside.html (neonatal, airway, cardiac,
respiratory, ventilation, seizure, sepsis, anaphylaxis, sedation,
agitation, antiemetics, antimicrobials, burns, toxicology, trauma).
Clicking a pill flips useState<active>, and LegacyPanel renders a
summary of that module + a button to open the legacy Bedside tab.
What is intentionally NOT in this commit
Each pill's actual clinical dosing panel stays in vanilla for now.
Those panels encode weight-based dosing, syndrome-keyed
antimicrobials, and PALS / ALS formulas — exactly the class of
content the migration checkpoint memory flags as must-not-be-
"simplified" by an LLM. They belong in per-module commits that
land alongside the calculators port (APLS + Best Guess weight,
Fenton 2013 LMS, AAP 2022 bilirubin, Rosner BP splines) where
test vectors can verify byte-for-byte parity with the vanilla
output.
The top-level age → weight estimator also waits on calculators —
it calls window._PED_MATH.estimateWeightFromAgeMonths in the
vanilla module, which is defined in public/js/calculators.js.
e2e/tests/bedside-react.spec.js — three smoke tests
All 15 pills render by data-testid, clicking a pill swaps the
panel, and the legacy-viewer link is present. The pill-order list
is hard-coded in the spec so re-ordering or dropping a pill trips
a loud failure.
Client tsc -b + vite build clean. Bundle 456.71 kB / 130.53 kB gz.
Two things in one commit because they're coupled by a gitignore fix:
(1) PE Guide minimum-viable port, (2) a pre-existing bug where
client/src/data/faq.ts was silently gitignored and never committed.
.gitignore — narrow scope
Changed `data/` to `/data/`. The old rule matched every nested
`data/` directory in the tree, including client/src/data/, which
meant faq.ts from the FAQ port (commit c0038da) never landed in
git — the Faq page built locally only because the file existed
on the dev machine. A fresh clone or CI build would fail the
Vite build at '@/data/faq'. The narrowed rule still ignores the
runtime DB directory at repo root while allowing project data
modules to be tracked. This commit also commits the missing
faq.ts so the FAQ page is actually buildable from git again.
client/src/data/pe-guide.ts
Verbatim port of lines 23-311 of public/js/peGuide.js — the stable
reference content:
• SCALES (12 scales: MRC, DTR, plantar, Beighton, ATR, RR, SpO2,
Silverman, Westley, Levine murmur, pulse amp, cap refill)
• SYSTEM_SCALES (per-body-system scale mapping)
• APTM_LEGEND (5 cardiac auscultation points)
• INNOCENT_MURMURS (5 benign childhood murmurs)
• RESP_SOUNDS (7 entries with /audio/respiratory/*.ogg paths)
• CARDIAC_SOUNDS (6 entries with /audio/cardiac/* paths)
Counts preserved exactly. Audio files stay under public/audio/ and
are served unchanged.
What is NOT in this commit — on purpose
PE_DATA (the ~1000-line age-group × system × component × step
hierarchy) stays in the vanilla app. The migration checkpoint memory
explicitly warns about the class of bug where an LLM silently drops
entries from long clinical arrays. PE_DATA porting needs its own
session with per-entry counts + visual diff against the vanilla
source. An amber banner at the top of the React page links to
/#peGuide (the legacy checklist viewer) so users still reach the
full exam-step checklist + Generate-Exam-Report flow.
Also skipped: the big inline APTM_SVG chest diagram. The letter
legend (A/P/E/T/M) carries the clinical content; the pictorial
SVG can land later without content risk.
client/src/pages/PeGuide.tsx — viewer
Grid-of-cards layout: one card per scale, per APTM point, per
innocent-murmur, and per sound entry. Sound cards use native
<audio controls> so the browser does the usual play/pause/seek —
no custom player. data-testid hooks throughout for the spec.
e2e/tests/peguide-react.spec.js — four smoke tests:
all 12 scales render (this is the count that would fail loudly if
someone trimmed SCALES later), APTM has all 5 letters, sound
libraries have the exact respiratory + cardiac keys, and the
legacy-viewer link is present.
Client tsc -b + vite build clean. Bundle 452.91 kB / 129.35 kB gz.
Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.
client/src/pages/Learning.tsx
Three-screen flow: search + category pills drive a feed grid; clicking
a card opens the viewer; viewer shows body + progress + quiz (if any).
Feed: one query key per filter — ['learning-feed'], ['learning-category',
slug], or ['learning-search', q] — so React Query caches each view
independently and flicking between categories is instant after the first
load. Search hits /api/learning/search; category filter hits
/api/learning/category/:slug; default hits /api/learning/feed?limit=30.
Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
the client bundle is a follow-up. Authored content is still clinical
info, so plain-text preservation is acceptable for this commit — no
content is lost, just unstyled. Presentations (content_type === 'presentation')
link to the legacy viewer at /#learning/:slug — Marp slide rendering is
its own port.
Quiz: supports single-choice, multi-select, and true/false. Answers
tracked via { optionId?, optionIds: Set<number> } per question so the
same state shape drives both radio and checkbox rendering. Submit POSTs
to /api/learning/submit-quiz; results screen shows per-question verdict
with correct answer + why-incorrect + general explanation — same fields
the vanilla showQuizResults renders. Retake wipes the answer map;
Back-to-Feed returns to the list.
Progress list reads content.progress[] directly from the content response
— last 5 attempts, color-coded green/amber at 70%.
shared/types.ts + client/src/shared/types.ts — additive:
LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
the wire shape server routes return (snake_case for DB columns).
e2e/tests/learning-react.spec.js — three smoke tests:
shell renders, feed shows items OR empty-state (no crash on empty DB),
typing into search fires /api/learning/search.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
Third and final commit of the Settings port. Adds the remaining eight
sub-sections so the React page matches vanilla settings.html 1:1.
After this commit Settings is fully ported; Layout already flipped to
available in commit 1, and the page fills out cleanly for local-auth
and SSO users alike.
Voice Preferences (VoicePreferencesCard)
GET /api/user/preferences + /api/user/preferences/options populate the
STT model / TTS voice selectors. Save POSTs /api/user/preferences.
Preview persists the current TTS selection, then fetches /api/text-to-
speech (binary blob, bypasses the JSON api wrapper), wraps the blob in
an Audio element and plays it. A one-shot hydrated flag drives the
first selection sync; after that the fields are local state.
Browser Whisper (BrowserWhisperCard) — UI-only port
Persists the enabled flag + model choice under the same localStorage
keys the vanilla BrowserWhisper module reads, so behavior will light
up automatically when the recording components port. The preload +
WASM transcription flow stays in vanilla for this commit — noted in
the page copy so users aren't surprised.
Web Speech Recognition (WebSpeechCard) — UI-only port
Same localStorage approach. Enabling surfaces a styled ConfirmModal
with the HIPAA privacy warning before persisting. Enabling Web Speech
flips Browser Whisper off automatically (mirrors vanilla priority:
Web Speech > Browser Whisper > server).
My Templates (TemplatesCard)
Full Memories CRUD for non-correction entries: category select,
name, content textarea, Add/Update toggle (in-place edit), per-row
Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}.
AI Corrections (CorrectionsCard)
Read-only list filtered to category starting with 'correction_'.
Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split
(same text delimiter the vanilla parseCorrection() uses). Delete is
wired through /api/memories/:id.
Audio Backups (AudioBackupsCard)
Lists /api/audio-backups (server-stored, 24h TTL). Play opens the
decompressed audio stream in a new tab; Delete hits DELETE
/api/audio-backups/:id. Retry flow stays in vanilla for this commit —
it re-submits to /api/transcribe and that integration belongs with
the recording components.
Saved Encounters (SavedEncountersCard)
Lists /api/encounters/saved with label / type / expires / preview.
Delete only — Resume requires the encounter pages to receive
pre-filled state, which ports alongside those pages.
Compliance (ComplianceCard)
Static info card — plain JSX, no API.
shared/types.ts + client/src/shared/types.ts — additive only:
UserPreferencesOk, PreferencesOptionsOk, VoiceOption,
SavedEncounterRow, SavedEncountersListOk, AudioBackupRow,
AudioBackupsListOk, MemoryRow, MemoriesOk.
e2e/tests/settings-react-voice-content.spec.js — seven smoke tests
covering control presence, templates empty-save validation, and the
Web Speech privacy-confirm modal (with the no-native-dialog guard).
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2).
The e2e container still predates /app/*; running these specs against
it needs a rebuild.
Second of three commits porting the vanilla settings.html. This one
delivers the two Integrations sub-sections, rendered below the Security
block and shown to every authenticated user (not gated by canLocalAuth —
SSO users also integrate Nextcloud and manage documents).
client/src/pages/Settings.tsx — NextcloudCard
Form + status line driven by /api/auth/me. Connect POSTs
/api/nextcloud/connect (nextcloudUrl / username / appPassword), which
does a PROPFIND probe against the remote, creates the target folder
via MKCOL, and encrypts the app password at rest. On success we
invalidate the ['auth-me'] query so the status line flips to
"Connected to …" without a reload. Disconnect goes through a
ConfirmModal (not a native confirm) and POSTs /api/nextcloud/disconnect.
When connected, a second row exposes the "Learning Hub — Default
Browse Path" input backed by POST /api/user/webdav-path. (That handler
lives inline in server.ts, not in userPreferences.ts — a quirk of the
existing codebase that the port preserves.)
client/src/pages/Settings.tsx — DocumentsCard
React Query feed off /api/documents. When S3 is not configured the
server returns { s3_configured: false } and we render a static notice
instead of the upload area (same branch as vanilla documents.js). The
upload form bypasses the JSON api wrapper to send multipart FormData
directly via fetch with credentials: 'include' (cookie auth continues
to work). Downloads hit /api/documents/:id/download to receive a 5-min
presigned URL which we open in a new tab. Delete goes through the
shared ConfirmModal — replaces the vanilla showConfirm({ danger, … }).
Downloading-state spinner is per-row (useMutation.variables === doc.id)
so other rows stay clickable while one is in flight.
shared/types.ts + client/src/shared/types.ts
Additive only:
- AuthUser gains webdav_learning_path — already returned by
/api/auth/me but missing from the type.
- New response shapes: NextcloudConnectOk, UserDocument,
DocumentsListOk, DocumentUploadOk, DocumentDownloadOk.
e2e/tests/settings-react-integrations.spec.js
Four smoke tests: field presence, empty-form validation error, the
S3-configured-or-notice branch renders, and a repeat of the
no-native-dialog guard covering the Integrations interactions.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 404.56 kB / 117.12 kB gzipped (+9 kB over commit 1). The e2e
container still predates /app/*; running these specs needs a rebuild.
First of three commits porting the vanilla settings.html (13 sub-sections
total) to the React tree. This commit delivers the Settings page shell
plus the three Security sub-sections. Integrations (Nextcloud, Documents)
and Voice + Content land in the two follow-ups.
client/src/pages/Settings.tsx
Page shell that fetches /api/auth/me and conditionally renders the
local-auth sections only when user.canLocalAuth !== false. SSO-only
users see a brief "managed by your identity provider" notice instead —
matches vanilla behavior, which hides those cards for SSO accounts.
Change Password: three-field form (current / new / confirm) with
client-side validation (8+ chars, match). POSTs /api/auth/change-password.
On success the server destroys all OTHER sessions, so the component
invalidates the ['sessions'] query so the Active Sessions card below
refreshes without a full reload. passwordWarning (pwned-password hint)
surfaces as a follow-up info toast.
Two-Factor Auth: status line ("Enabled" / "Not enabled") reads
user.totp_enabled. Enable button POSTs /api/auth/setup-2fa, renders the
returned QR + secret, accepts the 6-digit code and POSTs /verify-2fa.
First-enable shows a one-shot BackupCodesDisplay modal with Copy + close.
Disable flow is inline (password field + Confirm Disable + Cancel, no
modal) — matches the vanilla UX. Backup-codes remaining count pulls
from /api/auth/2fa/backup-codes/count; a Regenerate button opens a
ConfirmModal with requirePassword=true and POSTs /2fa/backup-codes.
Active Sessions: useQuery on /api/sessions renders one row per session
with the current one highlighted. Per-row Revoke opens a ConfirmModal;
Revoke All Other Sessions opens another ConfirmModal. Both DELETE calls
invalidate ['sessions'] on success.
client/src/components/ConfirmModal.tsx
Reusable styled confirmation dialog — replaces vanilla showConfirm().
Supports a danger variant (destructive button styling) and an optional
password-input variant for confirm-by-password flows. Escape closes,
backdrop click closes, Enter in the password field submits. Carries
data-testid hooks (confirm-modal-ok, confirm-modal-cancel) so Playwright
can drive it without ever hitting window.confirm().
shared/types.ts + client/src/shared/types.ts
Additive changes only:
- AuthUser gains optional canLocalAuth, totp_enabled, email_verified,
nextcloud_url/user/folder, created_at — all fields the server
already returns from /api/auth/me but the type had never described.
- SessionRow reshape to match the wire format the server actually
returns (snake_case ip_address / device_label / created_at /
last_activity), replacing the speculative camelCase draft. No
existing consumer of SessionRow existed outside Settings, so the
rename is a no-op for current code.
- New types for 2FA + change-password response shapes (Setup2faOk,
Verify2faOk, BackupCodesCountOk, RegenBackupCodesOk,
ChangePasswordOk, RevokeAllSessionsOk).
client/src/App.tsx
Adds <Route path="/settings" element={<Settings />} />.
client/src/components/Layout.tsx
Flips the Settings nav entry to available: true.
e2e/tests/settings-react-security.spec.js
Five smoke tests against /app/settings mirroring the coverage of
settings-faq-dictation.spec.js for the vanilla tree: field/button
presence for all three sections, password-mismatch inline error,
revoke-all click surfaces the styled modal (with an explicit
page.on('dialog') guard to catch any future regression to native
confirm()).
Note: the e2e container image currently predates the /app/* route
(its /app/public/app/ directory is absent), so running this spec requires
a rebuild — deferred to Daniel's call. Client tsc -b, server tsc --noEmit,
and vite build all pass locally.
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.
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.
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.
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
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.
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).
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.