Commit graph

209 commits

Author SHA1 Message Date
Daniel
9811e39cfe feat(client): port Admin panel — all 10 sub-tabs now run in React
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.
2026-04-24 02:27:50 +02:00
Daniel
21facd4e1b feat(client): port BP Percentile — all 10 Calculator pills now run in React
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)
2026-04-24 02:15:17 +02:00
Daniel
7371cbf13a feat(client): port BMI + Vitals + Resus Meds + Equipment calculators
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.
2026-04-24 02:08:05 +02:00
Daniel
941b73f9e5 feat(client): port final 5 Bedside panels — all 15 now run in React
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.
2026-04-24 02:00:50 +02:00
Daniel
9aa3961459 feat(client): port Bilirubin + Fenton calculators with captured vectors
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).
2026-04-24 01:49:45 +02:00
Daniel
591257a0c7 feat(client): port Sedation + Toxicology Bedside panels
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.
2026-04-24 01:35:07 +02:00
Daniel
ec418da0d6 feat(client): port Airway, Agitation, Antiemetics, Antimicrobials, Trauma panels
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).
2026-04-24 01:31:38 +02:00
Daniel
95a5a5e40e feat(client): port Bedside Anaphylaxis + Cardiac + Seizure panels
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).
2026-04-24 01:27:29 +02:00
Daniel
aafe7981dc feat(client): port full PE_DATA checklist + Generate Exam Report flow
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).
2026-04-24 01:21:44 +02:00
Daniel
18550e263f feat(client): port age→weight + BSA + dose + GCS; shared/clinical module
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>
2026-04-24 01:11:58 +02:00
Daniel
22355e8cb2 feat(client): port Admin shell with role-gated sidebar entry
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.
2026-04-24 00:03:02 +02:00
Daniel
c5d2e7310f feat(client): port Calculators sub-nav shell (formulas gated on vectors)
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.
2026-04-24 00:01:14 +02:00
Daniel
43e8611b24 feat(client): port Bedside sub-nav shell (15 pills linked to legacy)
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.
2026-04-23 23:59:34 +02:00
Daniel
9ad34a7f35 feat(client): port PE Guide reference libraries + rescue untracked FAQ data
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.
2026-04-23 23:57:16 +02:00
Daniel
8d7bffb07a feat(client): port Learning Hub to React
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).
2026-04-23 23:49:56 +02:00
Daniel
719d0cb7f7 feat(client): port Settings — Voice + Content (8 sub-sections)
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.
2026-04-23 23:40:33 +02:00
Daniel
520a1f8fb1 feat(client): port Settings — Integrations (Nextcloud + Documents)
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.
2026-04-23 23:35:12 +02:00
Daniel
9dce93cf67 feat(client): port Settings — Security (password, 2FA, sessions)
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.
2026-04-23 23:31:14 +02:00
Daniel
efd3e32574 feat: port Vaccine Schedule + Catch-Up Schedule (real tables, not stubs)
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.
2026-04-23 22:25:41 +02:00
Daniel
0f28f9212b feat(client): port Hospital Course, Chart Review, Well Visit
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).
2026-04-23 22:21:34 +02:00
Daniel
d9fd4fcd69 feat(client): port Encounter HPI, SOAP, Sick Visit
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.
2026-04-23 22:19:12 +02:00
Daniel
552ead0901 feat(client): port FAQ + Dictation to React, add sidebar Layout
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.
2026-04-23 22:16:52 +02:00
Daniel
8d244a167a feat: day 7 — first tab ported to React (Extensions) + Express serves /app/*
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.
2026-04-23 22:09:04 +02:00
Daniel
bc2580b148 test(lint): static reference linter — catches dead-code + orphan refs
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.

scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.

Running it on the current tree surfaced two real problems:

1. shadess.js:917 reached for #wv-note-transcript when collecting
   refine source context. The actual id is #wv-transcript (no -note-
   infix — the transcript element is shared across well-visit sub-
   panels). The bug meant a generated well-visit note's Refine call
   silently missed the original transcript as source context; the AI
   still "worked" but with less signal and no error. Fixed.

2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
   were removed a year+ ago in commit 3173ce6 ("Remove milestone
   admin UI, add CMS content refresh button"). That commit dropped
   the HTML but forgot the JS, which has been dead-loaded on every
   page view since. All its addEventListener calls are guarded with
   optional chaining, so nothing errored — just silent cruft.
   Deleted the file and dropped the <script defer> tag from
   index.html.

scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.

Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.

Suite: 294 passed / 0 failed.
2026-04-23 18:58:38 +02:00
Daniel
69dedbb635 fix(pe-guide): remove PVC entry from cardiac sounds library
PVCs are an ECG / rhythm finding, not a routine auscultation sample —
what you actually hear on the stethoscope is an irregular rhythm with a
compensatory pause, which depends on the underlying rate and is not
teachable from a canned audio clip. The card was also backed by a
synthesized sound, not a real recording. Removing both the card and
the pvc.ogg asset.
2026-04-23 18:58:38 +02:00
Daniel
5c1d1619c7 fix(nav): move image lightbox to index.html so NRP + seizure pathways
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.
2026-04-23 18:58:38 +02:00
Daniel
f4140d45c4 feat(ui-state): persist sub-pill selections across reload + sign-out
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).
2026-04-23 18:58:38 +02:00
Daniel
1431498fd6 feat(nav): promote Bedside to its own tab; reorganise sidebar sections
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.
2026-04-23 18:58:38 +02:00
Daniel
c0cb66ae3e feat(pe-guide): real lung recordings for normal/rhonchi/pleural-rub;
drop grunting + dead synth module

Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):

- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg

Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.

All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
2026-04-23 18:58:38 +02:00
Daniel
33826eb818 feat(pe-guide): unified single-play + remove badges; fix(e2e): shared fixture + raised login limit
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.
2026-04-22 22:57:11 +02:00
Daniel
d20027f24f feat(pe-guide): 7 cardiac sounds, APTM image full-width + tap-to-zoom, synth stop/progress
Three user-flagged fixes:

1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
   heart-sounds + heart-murmurs subcategories). All real recordings:
   - Normal (61 bpm)                 [existing]
   - Infant heartbeat                [new — pediatric reference]
   - VSD                              [existing]
   - Mitral valve prolapse            [existing]
   - Still's murmur in a toddler      [new — classic innocent murmur]
   - Functional murmur (adult female) [new — benign flow murmur]
   - PVCs                             [new — arrhythmia]
   All with native <audio controls> (play/pause/seek/elapsed/total
   work on desktop AND mobile for free).

2. APTM diagram is now full-width on every device, no longer sharing
   row space with the legend on mobile:
   - Image always on its own row, max-width:420px, centered
   - Wrapped in an <a href target="_blank"> — tap/click opens the PNG
     full-size in a new tab where browser pinch-zoom works natively
   - "Tap to open full-size" hint line under the image
   - Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
     on narrow viewports without cramping

3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
   Web-Audio-API generated, no native controls) now have:
   - Play button → kicks off playback and hides itself
   - Stop button appears, lets user terminate early
   - Progress bar animates over 3.2 s, resets at end
   - Only one synth sound plays at a time (clicking another stops the
     previous)
2026-04-22 21:50:54 +02:00
Daniel
fe52ca0cf6 feat(pe-guide): native audio controls + cardiac sounds library + mobile layout
Three user-flagged issues fixed together:

1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
   since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
   Now each sound card has a REAL or SYNTH badge and the library header
   reads: "real recordings where available; synthesised approximations
   labelled SYNTH".

2. No murmur sounds in the CV section. Added a Cardiac sounds library
   between the APTM diagram and the innocent-murmur panel:
     - Normal heart sounds (S1, S2) — 61 bpm reference
     - Ventricular septal defect (VSD) — harsh holosystolic at LLSB
     - Mitral valve prolapse (MVP) — mid-systolic click + late systolic
   All 3 are real recordings from Wikimedia Commons.

3. No pause / stop / duration controls. Replaced the synth-only play
   button with a native <audio controls> element for every real
   recording — gives play, pause, seek, elapsed/total time, volume
   for free on desktop AND mobile (browser-native, accessibility-
   compliant, consistent with platform conventions). Synth sounds
   (rhonchi, pleural rub, grunting, normal vesicular) keep the one-
   shot play button since they\'re Web-Audio-API generated and don\'t
   support seeking.

Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
  which could overflow narrow screens. Switched to repeat(auto-fit,
  minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
  renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
2026-04-22 21:10:36 +02:00
Daniel
3ddd598d74 feat(pe-guide): real audio, innocent-murmur panel, APTM image, all-ages resp+CV
Four changes rolled together:

1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
   in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
   stays as fallback for sounds not available from Wikimedia.
   respiratorySounds.js now tries the real OGG first; falls back to Web
   Audio synthesis if file missing.

2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
   /public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
   Kept the side legend with A/P/E/T/M colour-coded points.

3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
   classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
   bruit, PPS) — age, location, character, confirming maneuver — plus
   the 7 "S" criteria summary.

4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
   has age-appropriate resp + cv content (newborn through adolescent).
   Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
   Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
   Toddler: croup/FB/epiglottitis, innocent murmur peak age.
   Preschool + school-age: adult-pattern transition, sports screening.
2026-04-22 21:00:42 +02:00
Daniel
12460d24ef feat(pe-guide): Cardiovascular system with APTM auscultation SVG
Fourth PE system added. Adolescent cardiovascular exam at the same
teaching-focused depth as respiratory/neuro: five components with
significance + pearls + detailed step methods + watch-for blocks.

APTM auscultation diagram (new, inline SVG, no image file):
- Stylised anterior chest with sternum, clavicles, ICS level lines,
  left mid-clavicular line
- Five colour-coded landmarks:
   A  Aortic    — 2nd ICS right sternal border
   P  Pulmonic  — 2nd ICS left sternal border
   E  Erb's pt  — 3rd ICS left sternal border
   T  Tricuspid — 4th ICS left sternal border
   M  Mitral    — 5th ICS mid-clavicular (apex)
- Side legend: location + what to listen for at each point
- Patient's-left / patient's-right labels to prevent mirror-image
  confusion

CV-specific grading scales (3 new entries in SCALES):
- Murmur grade Levine 1–6
- Pulse amplitude 0–4+
- Capillary refill time thresholds

CV components:
1. Inspection (general appearance, central/peripheral cyanosis,
   clubbing with Schamroth sign, precordial bulge, visible apex, JVP)
2. Palpation (apex position + character, parasternal heave, thrills
   at all 5 points, peripheral pulses upper + lower, radio-femoral
   delay for coarctation)
3. Auscultation — approach (positioning, diaphragm vs bell, systematic
   walk through all 5 points, left lateral decub for MS, leaning
   forward for AR)
4. Auscultation — heart sounds + murmurs (S1, S2 split, S3/S4 gallops,
   murmur characterisation by timing/location/radiation/character,
   Levine grading, dynamic maneuvers, innocent-murmur "7 S" pearl)
5. Peripheral vascular (four-limb BP for coarctation, radio-femoral
   delay, bounding pulse differential)

UI wiring:
- renderSystem() emits APTM diagram card at the top of cv system,
  before scales. Two-column layout: SVG on left, legend on right.
- accentMap/iconMap/labelMap extended with cv = rose accent,
  heart-pulse icon, "Cardiovascular" label
- New sub-tab pill in pe-guide.html
2026-04-22 20:15:08 +02:00
Daniel
4aa4ef0961 feat(pe-guide): Respiratory system with synthesized sounds library
Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.

New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
  - Normal vesicular
  - Wheeze (two-partial + vibrato, filtered sawtooth)
  - Stridor (inspiratory, bandpass-filtered sawtooth sweep)
  - Fine crackles (dense brief high-freq noise bursts, late inspiration)
  - Coarse crackles (sparser, longer, lower-freq bursts)
  - Rhonchi (low-pitched warbled sawtooth, expiratory)
  - Pleural friction rub (bandpass noise, biphasic)
  - Expiratory grunting (square-wave short grunts)

Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.

Respiratory-specific grading scales:
  - RR by age (WHO tachypnea cutoffs)
  - Pulse ox (SpO2) with hypoxemia thresholds
  - Silverman–Andersen (neonatal retractions, 0–10)
  - Westley croup severity score

Components in adolescent respiratory:
  1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
     chest shape, colour, clubbing with Schamroth sign)
  2. Palpation (trachea, expansion symmetry, tactile fremitus,
     tenderness, subcutaneous emphysema)
  3. Percussion (technique + systematic zones + cardiac/hepatic
     dullness + diaphragmatic excursion)
  4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
     bronchial) with systematic side-to-side comparison
  5. Auscultation — adventitious sounds with per-sound listen buttons
     linking directly to the sounds library
  6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy

Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.

UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
2026-04-22 20:09:54 +02:00
Daniel
466f02eb2e feat(pe-guide): teaching-focused redesign — grading scales, pearls, significance
User feedback: the exam steps were too generic (e.g. "Shoulder abduction
— 5/5 bilaterally" never explained what 5/5 means or HOW to test it).
The guide needs to serve as a teaching tool, not just a checkbox list.

Three changes:

1. GRADING SCALES reference card (new). Collapsible panel at the top of
   each system showing the relevant scales:
   - Neuro: MRC strength (0-5), DTR (0-4+), Plantar response
   - MSK: Scoliometer ATR, Beighton hypermobility score
   Each scale shows the grade AND its clinical meaning in a compact
   table. No more orphan "5/5 bilaterally" without definition.

2. Per-component SIGNIFICANCE + PEARL fields (optional). Adolescent
   neuro components enriched with:
   - Significance: one-line clinical relevance (what this component is
     actually for — what pathologies it detects)
   - Teaching pearl: a Hutchison/Bates/Nelson-style tip that helps the
     learner see past the mechanics to the reasoning
   Visually distinct — pearl gets a warm amber accent, significance is
   a crosshair icon under the name.

3. Method strings REWRITTEN for every adolescent strength step. Before:
   "Shoulder abduction — 5/5 bilaterally". After: "Patient abducts both
   arms to 90°. Examiner pushes down on each arm just above the elbow
   while patient resists. Compare sides. — Holds against full resistance
   — MRC 5/5 bilaterally". Same treatment for all 14 strength steps,
   all 8 DTR steps, and tone/pronator-drift.

UI redesign:
- Accent bars on cards (cyan for MSK, purple for neuro) for visual
  anchor
- Numbered step circles instead of "1." prefix
- HOW / NORMAL label badges on each step
- Watch-for block with red left-border for red-flag grouping
- System-level header with icon (bone for MSK, brain for neuro)

Other age groups (newborn through school-age) keep the old data shape
(steps without pearls) — they still render correctly, just without the
pearl/significance blocks. Enriching them is an incremental follow-up.
2026-04-22 19:35:03 +02:00
Daniel
132d321888 fix(ui): replace native alert/confirm with showConfirm/showToast helpers
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').
2026-04-22 19:29:53 +02:00
Daniel
7f437b52a9 feat(extensions): personal Pagers & Extensions directory with soft-delete
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.

Data:
- New table user_phone_extensions (id, user_id, location, name, number,
  type CHECK (extension|pager), notes, trashed_at, timestamps).
  Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.

API (all user-scoped, all params validated):
- GET    /api/extensions?trash=1&q=text  — list active or trash, optional search
- POST   /api/extensions                  — create
- PUT    /api/extensions/:id              — update (requires all three core fields)
- DELETE /api/extensions/:id              — soft-delete (sets trashed_at)
- POST   /api/extensions/:id/restore      — un-trash
- DELETE /api/extensions/:id/purge        — hard-delete (only if trashed)

All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.

UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
  locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
  edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
2026-04-22 18:54:26 +02:00
Daniel
3a028e8f03 chore(pe-guide): restore source-citation comment block
Daniel clarified the earlier feedback: not "never cite in code", but
"ask before citing". He confirmed this block should stay.
2026-04-22 18:08:19 +02:00
Daniel
3fb1aa1e67 chore(pe-guide): remove source-citation comment block
Daniel prefers no citations embedded in the code. Keeps the data-model
comment which is load-bearing for future edits.
2026-04-22 17:49:36 +02:00
Daniel
66ecc2246b feat(pe-guide): step-by-step OSCE checklist, Bates/Nelson/Hutchison sourced
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.

Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.

Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.

Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.

Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
2026-04-22 17:15:27 +02:00
Daniel
46b3a2f678 feat(pe-guide): Physical Exam Guide tab — OSCE reference + narrative report
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
   components with technique, expected normal finding, and abnormal-
   feature watch-list.
2. Documentation generator — physician marks each component
   Normal / Abnormal (with free-text detail) / Skip; AI produces a
   two-section report (Technique + Findings), narrative or structured
   list format.

Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.

Files:
- src/routes/peGuide.js      — POST /api/generate-pe-narrative (mirrors
                                milestone-narrative pattern: AppRole-level
                                injection guard, clinical audit category,
                                PHI redaction upstream already in place)
- src/utils/prompts.js       — peGuideNarrative + peGuideList prompts,
                                structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js       — embedded PE_DATA (all clinical content),
                                render + state + AI call
- public/index.html          — tab button, section, script include
- server.js                  — mount route at /api

No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
2026-04-22 16:52:13 +02:00
Daniel
de5c75511b fix(bilirubin-ui): clarify neurotoxicity-risk-factor label + expandable AAP 2022 list
The dropdown was labeled just "Risk Factors" with option "None (lower risk)"
— ambiguous because AAP 2022 uses "risk factors" in two distinct senses:
(a) risk factors for developing hyperbilirubinemia (screening-only, do not
change thresholds) and (b) neurotoxicity risk factors (do change thresholds).
Only (b) belongs on the threshold nomogram, and a clinician glancing at the
form could easily pick wrong.

Changes:
- Label: "Neurotoxicity risk factors"
- Options: "Absent" / "Present (any one qualifies)" — removes the misleading
  "None (lower risk)" phrasing (no-risk curve actually has HIGHER thresholds)
- Expandable details listing the 6 specific AAP 2022 neurotoxicity risk
  factors (isoimmune hemolysis, G6PD, other hemolysis, sepsis, albumin <3.0,
  clinical instability <24h) with explicit note that GA <38w is handled by
  the per-week curve, not by this checkbox — prevents double-counting.

No data changes. Cite: Kemper et al., Pediatrics 2022;150(3):e2022058859, Box 2.
2026-04-22 13:11:34 +02:00
Daniel
71b717b533 fix(bilirubin): AAP 2022 per-week thresholds, exact match to peditools
The previous tables grouped all ≥38-week infants into one table (using
38w values) and 36-37 into another. AAP 2022 actually has separate
phototherapy curves per completed week for 35, 36, 37, 38, 39, 40+.

Worst real-world impact: a 40-week infant at 72h of life got the 38w
threshold (18.8 mg/dL) instead of the correct 40w threshold
(19.8 mg/dL) — a 1.0 mg/dL error at exactly the clinical decision point.
Borderline infants could be started on phototherapy unnecessarily, or
the reverse (miss a true threshold) depending on the direction.

Replaced the block with 18 distinct per-week tables extracted directly
from peditools.org/bili2022 API:
  - Phototherapy no-risk: 35, 36, 37, 38, 39, 40+ (all differ)
  - Phototherapy with-risk: 35, 36, 37, 38+ (38-41 identical)
  - Exchange (both risk states): 35, 36, 37, 38+ (38-41 identical)

Selection logic updated to map gaNum → correct table. HTML dropdown
label clarified to "completed weeks" with a one-line note that days
don't change the curve per AAP 2022.

Validation: exhaustive roundtrip — 7 GA weeks × 85 hours × 2 risk
profiles = 1190 cases, all match peditools within 0.01 mg/dL (zero
mismatches). See commit message footer.
2026-04-22 12:41:15 +02:00
Daniel
e1e5fbeabc fix(neonatal): replace rounded Fenton 2013 LMS with peditools-validated values
The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.

New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.

Example — 40 5/7 wk male, 3070 g:
  BEFORE: z = -1.38  (matched only one of three external sources)
  AFTER:  z = -1.42  (matches Epic -1.43 and third-source -1.44)
  Peditools at integer 40w: z = -1.10 (exact match with new table at
    integer-week input)

LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
2026-04-22 05:52:00 +02:00
Daniel
86f16f914a feat(wellvisit): add Expected Reflexes section to By Visit Age
Adds an age-appropriate reflex reference after the Expected Growth /
Feeding block for each well-visit age. Each entry shows the reflex
name, an expected-status chip (Present / Fading / Integrated /
Up-going / Down-going), and a short clinical note covering how to
elicit it, when it should fade, and what abnormal persistence means.

Covers primitive reflexes for newborn-6mo (rooting, sucking, Moro,
palmar/plantar grasp, tonic neck, stepping, Galant, tongue thrust,
Babinski), the transition at 6-18mo (protective extension, parachute,
plantar-response switch), and adult-pattern DTRs/frontal-release
screening from age 2 through 21.
2026-04-21 22:20:07 +02:00
Daniel
9106644eb6 fix(admin): restore SSO settings loading on admin panel
loadOidcConfig() was called from the first IIFE in admin.js but declared
in the second IIFE. Function declarations don't cross IIFE boundaries,
so the call threw ReferenceError and left the SSO form empty with
"Disabled" selected — even when SSO was configured and working. Moved
the call into the tabChanged handler of the IIFE where the function
lives.
2026-04-21 22:19:50 +02:00
Daniel
031bfb995a feat: C — extract Bedside reference into ES modules
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.

Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.

Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
2026-04-20 23:19:50 +02:00
Daniel
54285865d5 feat: B — extract drug data to public/data/drugs.json (schema v1.0)
Moved 43 weight-based drug entries out of calculators.js string literals
into a structured JSON reference. Renderers now iterate JSON → S.drugRow.

Sections extracted (43 drugs):
- anaphylaxis (7), sedation (11), agitation (11), antiemetics (7), seizure (7)

Out of scope: seizure refractory drips (compound strings), NRP, antimicrobial
empiric regimens (already structured), airway/cardiac/tox/trauma/respiratory
/sepsis/burns sections (fine as-is or no drugs).

New:
- public/data/drugs.json — { version, last_reviewed, sections.<key>.drugs[] }
- public/js/drugs-loader.js — fetches JSON on boot, exposes window._DRUGS +
  window._DRUGS_READY Promise. Non-fatal: each calc function has a matching
  *_FALLBACK constant so a 404 on drugs.json doesn't break anything.

Schema:
- dose_mg_per_kg | dose_mg_per_kg_low/high (for ranges) | max_mg | unit |
  route | notes | source | optional special-cases (weight bands, age-dep text)

Added one unit test asserting drugs.json loads + has all 5 sections with
non-empty drugs arrays. 37/37 unit tests + 26/26 Playwright e2e tests pass.
2026-04-20 04:23:24 +02:00
Daniel
e54928f7f5 feat: A1+A2 — Playwright smoke suite + index.html mtime-based caching
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.
2026-04-20 03:51:22 +02:00