Replaces the legacy-link shell at /app/admin with a proper
multi-sub-tab admin panel. Covers every module the vanilla admin.js
exposed: Users, Site settings, Announcement banner, AI models,
TTS / STT providers, SMTP, Email templates, AI prompts, Audit logs.
Priority 1 per Daniel's note: **Users**. Full CRUD flow ported —
list (with live filter), verify, disable/enable, set role
(user/moderator/admin), delete (with confirm modal), admin-side
password reset (inline modal). Self-protection rules preserved:
can't disable/delete yourself, can't demote your own admin role.
client/src/pages/Admin.tsx (rewritten)
Sub-tab shell. Role check via useQuery(['auth-me']) reusing the
Layout cache. 10 pills drive which panel renders. Access-denied
card for non-admins (data-testid='admin-access-denied' unchanged).
client/src/pages/AdminPanels.tsx (new) — batch 1
• AdminUsersTab — useQuery ['admin-users'] + 6 mutations
(verify / disable / enable / set role / delete / reset password).
Color-coded rows (disabled users opacity-60), inline role select,
inline search filter over email+name.
• AdminSettingsTab — GET /api/admin/settings → stats (totalUsers /
totalApiCalls / todayApiCalls) + registration toggle.
• AdminAnnouncementTab — reads announcement.{enabled,type,text} via
/api/admin/config/announcement, saves via 3 parallel PUT
/api/admin/config/<key> calls. Info/Warning/Critical severity
select; text rendered in the top-of-page banner.
client/src/pages/AdminPanels2.tsx (new) — batch 2
• AdminSmtpTab — host/port/user/pass/from/secure form + source
badge (env / database / none). PUT /api/admin/config/smtp,
DELETE /api/admin/config/smtp (with ConfirmModal). Inline test
email sender (recipient + template) calling
POST /api/admin/config/test-email.
• AdminEmailTab — template selector (verify / reset /
password-changed), subject + HTML body textarea. Pulls values
from /api/admin/config, saves via 2 parallel PUT calls.
• AdminPromptsTab — GET /api/admin/config/prompts populates the
selector; textarea edits the active prompt; save via
PUT /api/admin/config/prompt.<key>; reset-to-default via
POST /api/admin/config/prompts/<key>/reset with ConfirmModal.
• AdminModelsTab — GET /api/admin/config/models renders the
provider-scoped model table with per-row Enabled checkbox +
Default radio. Mutations hit /api/admin/config/models/toggle
and /default. LiteLLM + model-discovery flows flagged as
legacy-viewer follow-up.
• AdminTtsTab / AdminSttTab — show active provider + voice/model
selector, save default via PUT /api/admin/config/tts.default_voice
and /stt.default_model respectively.
• AdminLogsTab — GET /api/admin/logs/all?category=&limit= with
sticky-header scrollable table. Category filter
(auth / admin / clinical / export / integration / documents) and
limit selector (50-500). Renders time / user / category /
action / detail / IP per row.
shared/types.ts + client/src/shared/types.ts — additive only:
AdminUser, AdminUsersOk, AdminUserOk, AdminSettingsOk,
AdminLogEntry, AdminLogsOk, AdminConfigRow, AdminConfigOk,
AdminAnnouncementOk, AdminPromptRow, AdminPromptsOk,
AdminSmtpStatusOk, AdminModelRow, AdminModelsOk,
AdminVoiceProviderOk.
With this commit the React sidebar covers the full legacy nav.
Access-control is preserved (Admin is still role-gated and the nav
link hides for non-admins). Every existing backend endpoint is
reused as-is — no server changes.
Backend tsc + client tsc + vite build + 136/136 vitest all green.
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).
Ten of fifteen Bedside pills now have real React implementations.
Remaining five (neonatal, respiratory, ventilation, sepsis, burns)
stay on LegacyPanel — they each have more complex state (Fenton
LMS, Phoenix criteria, Lund-Browder age-adjusted region tables,
bronchiolitis / asthma severity scoring) best tackled in dedicated
sessions where the clinical data can be verified carefully.
client/src/pages/BedsidePanels.tsx
• SedationPanel — full procedural-sedation drug list (ketamine IV/IM,
midazolam IV/IN/PO, propofol, fentanyl IV/IN, nitrous oxide,
dexmedetomidine IN) plus reversal agents (naloxone, flumazenil).
Dose cells mirror the vanilla sedDoseCell helper: range-low/high
with per-kg footer, or single per-kg + max. Pre-sedation checklist
and citation preserved.
• ToxicologyPanel — topic selector with 11 panels (general approach,
acetaminophen, opioids, iron, TCA, β-blocker/CCB, benzo,
organophosphate, salicylate, toxic alcohols, dialyzable drugs).
Every drug dose and clinical pearl carried over byte-for-byte.
Weight-dependent drugs (NAC weight-based loading, naloxone
range, magnesium for TCA torsades) compute against the entered
weight; topic panels work with or without a weight value.
Client tsc -b + vite build clean.
Second batch of Bedside sub-module ports. Eight of the fifteen pills
now run real React (anaphylaxis + cardiac + seizure from the previous
commit, plus these five). Remaining seven — neonatal, respiratory,
ventilation, sepsis, sedation, burns, toxicology — still fall through
to LegacyPanel.
client/src/pages/BedsidePanels.tsx
• AirwayPanel — weight + age inputs drive the full RSI sheet.
Pre-medication, induction, paralytics, and maintenance-sedation
tables with per-kg dosing + max caps preserved from airway.js.
Equipment sizing (ETT uncuffed/cuffed, depth, blade, LMA, Fr
suction, NG, chest tube) follows the same age-/weight-keyed
formulas. Ventilator starting settings carry Vt = 6-8 mL/kg
computed from the current weight.
• AgitationPanel — step 1 / 2 / 3 pathway with weight-conditional
doses for olanzapine (<30 kg vs ≥30 kg) and haloperidol
(<40 kg vs ≥40 kg). Droperidol keeps the range-prefix + computed
dose. Drug list matches AGIT_FALLBACK.
• AntiemeticsPanel — full drug list with the ondansetron weight-
band display alongside the per-kg fallback. Promethazine <2 yr
contraindication text preserved.
• AntimicrobialsPanel — neonate / infant / child × sepsis /
meningitis / pna / uti / skin / ent / neutropenic / intra-abd /
bone regimen map ported verbatim from REG. Age + infection
selectors pick the right cell.
• TraumaPanel — ABCDE primary survey cards with the 20 mL/kg
bolus auto-computed from the entered weight. MTP (1:1:1, TXA,
calcium), NEXUS c-spine, pediatric shock signs, and AMPLE
secondary-survey content all preserved.
renderBedsideRealPanel + REAL_BEDSIDE_PANELS extended to cover the
five new panels.
Client tsc -b + vite build clean (warnings on chunk size are expected
until we code-split; not blocking).
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.
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).