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.
This commit is contained in:
Daniel 2026-04-24 02:00:49 +02:00
parent f5c9202fe3
commit 941b73f9e5
11 changed files with 1068 additions and 62 deletions

View file

@ -1462,6 +1462,8 @@ export function ToxicologyPanel() {
);
}
import { NeonatalPanel, RespiratoryPanel, VentilationPanel, SepsisPanel, BurnsPanel } from './BedsidePanels2';
// Helper to keep Bedside.tsx concise — dispatch by pill id.
export function renderBedsideRealPanel(pillId: string): React.ReactNode | null {
switch (pillId) {
@ -1475,14 +1477,21 @@ export function renderBedsideRealPanel(pillId: string): React.ReactNode | null {
case 'trauma': return <TraumaPanel />;
case 'sedation': return <SedationPanel />;
case 'toxicology': return <ToxicologyPanel />;
case 'neonatal': return <NeonatalPanel />;
case 'respiratory': return <RespiratoryPanel />;
case 'ventilation': return <VentilationPanel />;
case 'sepsis': return <SepsisPanel />;
case 'burns': return <BurnsPanel />;
default: return null;
}
}
// Expose the pill IDs that now have a real React panel so Bedside.tsx
// can pick between the real panel and the legacy-link fallback.
// All 15 Bedside sub-modules now run in React.
export const REAL_BEDSIDE_PANELS: ReadonlySet<string> = new Set([
'anaphylaxis', 'cardiac', 'seizure',
'airway', 'agitation', 'antiemetics', 'antimicrobials', 'trauma',
'sedation', 'toxicology',
'neonatal', 'respiratory', 'ventilation', 'sepsis', 'burns',
]);

View file

@ -0,0 +1,641 @@
// ============================================================
// BEDSIDE PANELS (second batch) — neonatal, respiratory,
// ventilation, sepsis, burns. Completes parity with the 15
// vanilla Bedside sub-modules. Drug per-kg + max values ported
// byte-for-byte from public/js/bedside/<module>.js.
// ============================================================
import { useState } from 'react';
import { formatDose } from '@shared/clinical/calculators';
import { neonatalAssess, type Sex } from '@shared/clinical/fenton';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
function Dose({ label: l }: { label: string }) {
const i = l.indexOf('(');
if (i < 0) return <span className="font-semibold">{l}</span>;
return <span><span className="font-semibold">{l.slice(0, i).trim()}</span>{' '}<span className="text-xs text-muted-foreground">{l.slice(i)}</span></span>;
}
function DrugTable({ children, notes = true }: { children: React.ReactNode; notes?: boolean }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th>{notes && <th className={th}>Notes</th>}</tr></thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function Row({ name, dose, route, notes }: { name: string; dose: React.ReactNode; route: string; notes?: string }) {
return (
<tr>
<td className={td + ' font-semibold'} dangerouslySetInnerHTML={{ __html: name }} />
<td className={td}>{dose}</td>
<td className={td + ' text-xs'}>{route}</td>
{notes !== undefined && <td className={td + ' text-xs text-muted-foreground'} dangerouslySetInnerHTML={{ __html: notes }} />}
</tr>
);
}
// ── Neonatal ────────────────────────────────────────────────
export function NeonatalPanel() {
const [weeks, setWeeks] = useState('');
const [days, setDays] = useState('0');
const [wtG, setWtG] = useState('');
const [sex, setSex] = useState<Sex>('male');
const [kgForNrp, setKgForNrp] = useState('');
const [apgarScores, setApgarScores] = useState<Record<string, number>>({ appearance: 2, pulse: 2, grimace: 2, activity: 2, respiration: 2 });
const weeksNum = Number.parseInt(weeks, 10);
const daysNum = Number.parseInt(days, 10) || 0;
const wtNum = Number.parseFloat(wtG);
const validAssess = Number.isFinite(weeksNum) && weeksNum >= 22 && weeksNum <= 44 && Number.isFinite(wtNum) && wtNum > 0;
const assess = validAssess ? neonatalAssess(weeksNum, daysNum, wtNum, sex) : null;
const kg = Number.parseFloat(kgForNrp);
const validKg = Number.isFinite(kg) && kg > 0;
const epiIvLow = validKg ? Math.round(kg * 0.01 * 100) / 100 : 0;
const epiIvHigh = validKg ? Math.round(kg * 0.03 * 100) / 100 : 0;
const epiEtLow = validKg ? Math.round(kg * 0.05 * 100) / 100 : 0;
const epiEtHigh = validKg ? Math.round(kg * 0.1 * 100) / 100 : 0;
const ns = validKg ? Math.round(kg * 10) : 0;
const d10 = validKg ? Math.round(kg * 2 * 10) / 10 : 0;
const apgarTotal = Object.values(apgarScores).reduce((s, v) => s + v, 0);
const apgarSeverity = apgarTotal >= 7 ? 'Reassuring' : apgarTotal >= 4 ? 'Moderately depressed' : 'Severely depressed';
const apgarColor = apgarTotal >= 7 ? 'text-green-600 bg-green-50' : apgarTotal >= 4 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
const apgarGuidance = apgarTotal >= 7
? 'Routine newborn care. Continue reassessment. Repeat at 5 min.'
: apgarTotal >= 4
? 'Stimulate, clear airway, warm. Give O₂ if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.'
: 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
return (
<section className={card} data-testid="bedside-panel-neonatal">
<h2 className="text-lg font-semibold">Neonatal Assessment + NRP + Apgar</h2>
{/* Assessment */}
<h3 className="text-sm font-semibold">Gestational age + size assessment (Fenton 2013)</h3>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4 max-w-xl">
<div><label className={label}>GA weeks</label><input type="number" min="22" max="44" className={input} value={weeks} onChange={(e) => setWeeks(e.target.value)} data-testid="neo-weeks" /></div>
<div><label className={label}>GA days (0-6)</label><input type="number" min="0" max="6" className={input} value={days} onChange={(e) => setDays(e.target.value)} data-testid="neo-days" /></div>
<div><label className={label}>Birth wt (g)</label><input type="number" min="200" max="7000" className={input} value={wtG} onChange={(e) => setWtG(e.target.value)} data-testid="neo-weight" /></div>
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="neo-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
</div>
{assess && (
<div className="grid gap-3 sm:grid-cols-2" data-testid="neo-result">
<div className="rounded-md border p-3" style={{ borderColor: assess.gaClass.color + '55', background: assess.gaClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Gestational Age</div>
<div className="text-base font-bold" style={{ color: assess.gaClass.color }}>{assess.gaClass.label}</div>
<div className="text-xs text-muted-foreground">{weeksNum} wk {daysNum} d ({assess.gaDecimal.toFixed(1)} wk)</div>
</div>
<div className="rounded-md border p-3" style={{ borderColor: assess.weightClass.color + '55', background: assess.weightClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Weight for Gestational Age</div>
<div className="text-base font-bold" style={{ color: assess.weightClass.color }}>{assess.weightClass.label}</div>
<div className="text-xs text-muted-foreground">{assess.percentile.toFixed(1)}th percentile · {assess.weightClass.detail}</div>
</div>
<div className="rounded-md border p-3" style={{ borderColor: assess.bwClass.color + '55', background: assess.bwClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Birth Weight Category</div>
<div className="text-base font-bold" style={{ color: assess.bwClass.color }}>{assess.bwClass.label}</div>
<div className="text-xs text-muted-foreground">{wtNum} g ({(wtNum / 1000).toFixed(2)} kg)</div>
</div>
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<div className="text-xs text-muted-foreground uppercase tracking-wide">Fenton ({sex})</div>
<div className="space-y-0.5 mt-1">
<div><strong>Expected weight (M):</strong> {assess.expectedWeight} g</div>
<div><strong>Z-score:</strong> {assess.z.toFixed(2)}</div>
<div><strong>Percentile:</strong> {assess.percentile.toFixed(1)}%</div>
</div>
</div>
</div>
)}
{/* NRP pathway */}
<h3 className="text-sm font-semibold mt-3">NRP pathway (AHA/AAP 8th ed 2020)</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">BIRTH ASSESS (first 30 sec)</div><div className="text-xs text-muted-foreground">Term? Tone? Breathing/crying? All yes routine care. Any no warm, dry, stimulate, clear airway PRN, evaluate HR + resp.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">HR &lt;100 OR apneic/gasping (60 s)</div><div className="text-xs text-muted-foreground"><strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO (right hand) ± ECG. MR SOPA if ineffective.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">HR &lt;100 after 30 s effective PPV</div><div className="text-xs text-muted-foreground">Reassess ventilation ensure chest rise. Consider increasing FiO, intubation, or LMA. Continue PPV.</div></div>
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">HR &lt;60 after 30 s effective PPV</div><div className="text-xs text-muted-foreground"><strong>Intubate + chest compressions</strong> 3:1 ratio (90 compressions + 30 breaths/min), FiO 100%, lower 1/3 sternum, depth 1/3 AP chest.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">HR &lt;60 despite compressions + PPV × 60 s</div><div className="text-xs text-muted-foreground"><strong>Epinephrine 1:10,000 (0.1 mg/mL):</strong> IV/IO 0.01-0.03 mg/kg (0.1-0.3 mL/kg) preferred. ETT 0.05-0.1 mg/kg. Repeat q3-5 min. Hypovolemia: <strong>NS 10 mL/kg IV/IO over 5-10 min</strong>.</div></div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
<div className="rounded-md bg-muted/40 p-2"><strong>Target SpO (preductal):</strong><br />1 min 60-65% · 2 min 65-70% · 3 min 70-75% · 4 min 75-80% · 5 min 80-85% · 10 min 85-95%</div>
<div className="rounded-md bg-muted/40 p-2"><strong>Initial ETT size:</strong><br />&lt;1 kg / &lt;28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · &gt;3 kg / &gt;38 wk: 3.5-4.0</div>
<div className="rounded-md bg-muted/40 p-2"><strong>ETT depth (lip):</strong> ~6 + weight(kg) cm</div>
</div>
<h3 className="text-sm font-semibold mt-3">NRP drug doses</h3>
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={kgForNrp} onChange={(e) => setKgForNrp(e.target.value)} data-testid="nrp-weight" /></div>
{validKg ? (
<DrugTable>
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiIvLow}-${epiIvHigh} mg, ${Math.round(epiIvLow * 10) / 10}-${Math.round(epiIvHigh * 10) / 10} mL (0.01-0.03 mg/kg = 0.1-0.3 mL/kg)`} />} route="IV / IO" notes="Preferred route. Repeat q3-5 min." />
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiEtLow}-${epiEtHigh} mg, ${Math.round(epiEtLow * 10) / 10}-${Math.round(epiEtHigh * 10) / 10} mL (0.05-0.1 mg/kg = 0.5-1 mL/kg)`} />} route="ETT" notes="While IV being placed." />
<Row name="Normal saline" dose={<Dose label={`${ns} mL (10 mL/kg)`} />} route="IV / IO" notes="Over 5-10 min for volume. Repeat PRN." />
<Row name="Dextrose 10%" dose={<Dose label={`${d10} mL (2 mL/kg = 0.2 g/kg)`} />} route="IV slow push" notes="For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min." />
</DrugTable>
) : <p className="text-xs text-destructive">Enter weight (kg) to see NRP doses.</p>}
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) that is IM for anaphylaxis / older patients.
</div>
{/* Apgar */}
<h3 className="text-sm font-semibold mt-3">Apgar score</h3>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-5 text-xs">
{[
['appearance', 'Appearance', ['Blue/pale', 'Body pink, extremities blue', 'All pink']],
['pulse', 'Pulse', ['Absent', '<100 bpm', '≥100 bpm']],
['grimace', 'Grimace', ['No response', 'Grimace', 'Cough/sneeze']],
['activity', 'Activity', ['Limp', 'Some flexion', 'Active motion']],
['respiration', 'Respiration', ['Absent', 'Slow/irregular', 'Good/crying']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={apgarScores[key as string]} onChange={(e) => setApgarScores({ ...apgarScores, [key as string]: Number(e.target.value) })} data-testid={'apgar-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{i} {o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + apgarColor} data-testid="apgar-result">
<div className="text-base font-bold">Apgar: {apgarTotal}/10 {apgarSeverity}</div>
<div className="text-xs text-muted-foreground mt-1">{apgarGuidance}</div>
</div>
<div className="text-xs text-muted-foreground italic">
Fenton TR, Kim JH. BMC Pediatr 2013;13:59 · NRP 8th ed (AHA/AAP 2020) · Apgar is a description of status <strong>never</strong> delay resuscitation while scoring.
</div>
</section>
);
}
// ── Respiratory ─────────────────────────────────────────────
export function RespiratoryPanel() {
const [mode, setMode] = useState<'asthma' | 'pram' | 'croup' | 'bronch'>('asthma');
const [weight, setWeight] = useState('');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const [asthmaSev, setAsthmaSev] = useState<'mild' | 'moderate' | 'severe' | null>(null);
// PRAM inputs (0-12 total)
const [pram, setPram] = useState({ spo2: 0, retractions: 0, scalene: 0, air: 0, wheeze: 0 });
const pramTotal = Object.values(pram).reduce((s, v) => s + v, 0);
const pramSev = pramTotal <= 3 ? 'Mild' : pramTotal <= 7 ? 'Moderate' : 'Severe';
const pramColor = pramTotal <= 3 ? 'text-green-600 bg-green-50' : pramTotal <= 7 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
// Croup / Westley (0-17)
const [croup, setCroup] = useState({ conscious: 0, cyanosis: 0, stridor: 0, air: 0, retractions: 0 });
const croupTotal = Object.values(croup).reduce((s, v) => s + v, 0);
const croupSev = croupTotal <= 2 ? 'Mild' : croupTotal <= 5 ? 'Moderate' : croupTotal <= 11 ? 'Severe' : 'Impending Respiratory Failure';
const croupColor = croupTotal <= 2 ? 'text-green-600 bg-green-50' : croupTotal <= 5 ? 'text-amber-600 bg-amber-50' : croupTotal <= 11 ? 'text-destructive bg-red-50' : 'text-red-900 bg-red-100';
// Bronchiolitis inputs
const [bronch, setBronch] = useState({ age: 'gte12w', spo2: 'ok', hydration: 'ok', distress: 'mild' });
const bronchAdmit = bronch.distress === 'severe' || bronch.spo2 === 'low' || bronch.hydration === 'poor' || bronch.age === 'lt12w';
return (
<section className={card} data-testid="bedside-panel-respiratory">
<h2 className="text-lg font-semibold">Respiratory</h2>
<div className="flex gap-2 flex-wrap">
{(['asthma', 'pram', 'croup', 'bronch'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} className={'px-3 py-1 rounded-full text-xs font-medium border ' + (mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')} data-testid={'resp-mode-' + m}>
{m === 'asthma' ? 'Asthma' : m === 'pram' ? 'PRAM' : m === 'croup' ? 'Croup (Westley)' : 'Bronchiolitis'}
</button>
))}
</div>
{mode !== 'pram' && mode !== 'bronch' && (
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resp-weight" /></div>
)}
{/* ASTHMA */}
{mode === 'asthma' && (
<>
<div className="flex gap-2">
{(['mild', 'moderate', 'severe'] as const).map((s) => (
<button key={s} type="button" onClick={() => setAsthmaSev(s)} className={'px-3 py-1 rounded text-xs font-medium border ' + (asthmaSev === s ? (s === 'mild' ? 'bg-green-600 text-white' : s === 'moderate' ? 'bg-amber-500 text-white' : 'bg-destructive text-white') : 'bg-muted')}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
{asthmaSev && !valid && <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>}
{asthmaSev === 'mild' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in sentences, no accessory muscle use, SpO 94%</p>
<DrugTable>
<Row name="Albuterol (MDI)" dose="4-8 puffs via spacer" route="Inhaled" notes="q20min × 3 doses, then q1-4h" />
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV" notes="Single dose, or 2 days" />
<Row name="Prednisolone" dose={<Dose label={`${f(1, 60)!.label}/day`} />} route="PO" notes="Alternative: 3-5 day course" />
</DrugTable>
</>
)}
{asthmaSev === 'moderate' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in phrases, some accessory muscle use, SpO 90-93%</p>
<DrugTable>
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses, then continuous if needed" />
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV/IM" notes="Single dose" />
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="NC/mask" notes="Titrate to effect" />
</DrugTable>
</>
)}
{asthmaSev === 'severe' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in words only, significant accessory muscle use, SpO &lt;90%. Consider ICU.</p>
<DrugTable>
<Row name="Albuterol continuous" dose={<Dose label={`${f(0.5, 20, 'mg')!.label}/hr`} />} route="Continuous neb" notes="Or 0.15-0.3 mg/kg q20min" />
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="IV" notes="Or methylprednisolone 2 mg/kg IV (max 60 mg)" />
<Row name="Magnesium sulfate" dose={<Dose label={`${f(50, 2000)!.label} IV over 20 min`} />} route="IV" notes="Single dose, monitor BP" />
<Row name="Epinephrine (IM)" dose={<Dose label={`${f(0.01, 0.5)!.label} (1:1000)`} />} route="IM" notes="If impending arrest / no IV access" />
<Row name="Terbutaline" dose={<Dose label={`${f(0.01, 0.4)!.label} SC/IV`} />} route="SC/IV" notes="Then 0.1-10 mcg/kg/min infusion" />
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="High flow / NIPPV" notes="Consider BiPAP/CPAP" />
</DrugTable>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
<strong>Continuous monitoring.</strong> Consider ICU admission. If no response to magnesium terbutaline infusion. If impending respiratory failure intubation (ketamine preferred induction agent).
</div>
</>
)}
<div className="text-xs text-muted-foreground italic">NAEPP/GINA guidelines. Always use clinical judgment.</div>
</>
)}
{/* PRAM */}
{mode === 'pram' && (
<>
<p className="text-sm text-muted-foreground">Pediatric Respiratory Assessment Measure (PRAM) for asthma exacerbation severity (0-12).</p>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
{[
['spo2', 'SpO₂', ['≥95% (0)', '92-94% (1)', '<92% (2)']],
['retractions', 'Suprasternal retractions', ['Absent (0)', 'Present (2)']],
['scalene', 'Scalene muscle use', ['Absent (0)', 'Present (2)']],
['air', 'Air entry', ['Normal (0)', 'Mild ↓ at bases (1)', 'Widespread ↓ (2)', 'Absent/minimal (3)']],
['wheeze', 'Wheezing', ['Absent (0)', 'Expiratory only (1)', 'Ins+exp (2)', 'Audible without stethoscope/silent chest (3)']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={pram[key as keyof typeof pram]} onChange={(e) => setPram({ ...pram, [key as string]: Number(e.target.value) })} data-testid={'pram-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + pramColor} data-testid="pram-result">
<div className="text-base font-bold">PRAM Score: {pramTotal}/12 {pramSev}</div>
<div className="text-xs text-muted-foreground mt-1">Mild (0-3): outpatient management. Moderate (4-7): consider oral steroids + frequent bronchodilators. Severe (8-12): aggressive treatment, consider ICU.</div>
</div>
</>
)}
{/* CROUP */}
{mode === 'croup' && (
<>
<p className="text-sm text-muted-foreground">Westley croup score (0-17).</p>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
{[
['conscious', 'Level of consciousness', ['Normal (0)', 'Disoriented (5)']],
['cyanosis', 'Cyanosis', ['None (0)', 'With agitation (4)', 'At rest (5)']],
['stridor', 'Stridor', ['None (0)', 'With agitation (1)', 'At rest (2)']],
['air', 'Air entry', ['Normal (0)', 'Decreased (1)', 'Severely decreased (2)']],
['retractions', 'Retractions', ['None (0)', 'Mild (1)', 'Moderate (2)', 'Severe (3)']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={croup[key as keyof typeof croup]} onChange={(e) => setCroup({ ...croup, [key as string]: Number(e.target.value) })} data-testid={'croup-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + croupColor} data-testid="croup-result">
<div className="text-base font-bold">Westley: {croupTotal}/17 {croupSev}</div>
<div className="text-xs text-muted-foreground mt-1">Mild 2 · Moderate 3-5 · Severe 6-11 · Impending failure 12.</div>
</div>
{valid && (
<DrugTable>
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route={croupTotal <= 2 ? 'PO' : croupTotal <= 5 ? 'PO/IM' : 'IV/IM'} notes="Preferred corticosteroid; single dose" />
{croupTotal > 2 && <Row name="Racemic epinephrine" dose="0.5 mL of 2.25% solution" route="Nebulized" notes="May repeat q15-20min, observe 2-4 h" />}
{croupTotal > 2 && <Row name="Nebulized epinephrine" dose="0.5 mL/kg of 1:1000 (max 5 mL)" route="Nebulized" notes="Alternative to racemic" />}
{croupTotal > 5 && <Row name="Heliox" dose="70:30 or 80:20" route="Face mask" notes="Consider if not responding" />}
</DrugTable>
)}
</>
)}
{/* BRONCHIOLITIS */}
{mode === 'bronch' && (
<>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-xl">
<div><label className={label}>Age</label><select className={input} value={bronch.age} onChange={(e) => setBronch({ ...bronch, age: e.target.value })}><option value="lt12w">&lt;12 weeks (high risk)</option><option value="gte12w">12 weeks</option></select></div>
<div><label className={label}>SpO</label><select className={input} value={bronch.spo2} onChange={(e) => setBronch({ ...bronch, spo2: e.target.value })}><option value="ok">90%</option><option value="low">&lt;90%</option></select></div>
<div><label className={label}>Hydration</label><select className={input} value={bronch.hydration} onChange={(e) => setBronch({ ...bronch, hydration: e.target.value })}><option value="ok">Adequate</option><option value="poor">Poor oral intake</option></select></div>
<div><label className={label}>Distress</label><select className={input} value={bronch.distress} onChange={(e) => setBronch({ ...bronch, distress: e.target.value })}><option value="mild">Mild</option><option value="moderate">Moderate</option><option value="severe">Severe</option></select></div>
</div>
<div className={'rounded-md p-3 ' + (bronchAdmit ? 'text-destructive bg-red-50' : 'text-green-600 bg-green-50')} data-testid="bronch-result">
<div className="text-base font-bold">{bronchAdmit ? 'Admit / Observe' : 'Likely Safe for Discharge'}</div>
{bronch.age === 'lt12w' && <div className="text-xs text-destructive mt-1"> Age &lt;12 weeks high risk for apnea. Monitor closely.</div>}
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>NOT recommended (AAP 2014/2023):</strong> Albuterol/salbutamol (no benefit), epinephrine (no evidence), systemic corticosteroids (no benefit), antibiotics (unless bacterial co-infection), chest physiotherapy.
</div>
<div className="text-xs text-muted-foreground italic">AAP Clinical Practice Guideline: Management of Bronchiolitis in Infants and Children (2014, reaffirmed 2023). RSV most common (50-80%).</div>
</>
)}
</section>
);
}
// ── Ventilation (O₂ escalation + vent settings reference) ───
export function VentilationPanel() {
const [weight, setWeight] = useState('');
const [age, setAge] = useState('');
const wt = Number.parseFloat(weight);
const ageY = Number.parseFloat(age);
const validWt = Number.isFinite(wt) && wt > 0;
const hfLow = validWt ? Math.round(wt * 1 * 10) / 10 : 0;
const hfHigh = validWt ? Math.round(wt * 2 * 10) / 10 : 0;
const tvLow = validWt ? Math.round(wt * 6 * 10) / 10 : 0;
const tvHigh = validWt ? Math.round(wt * 8 * 10) / 10 : 0;
const hasAge = Number.isFinite(ageY) && ageY >= 0;
const rate = hasAge
? ageY < 0.1 ? '30-40'
: ageY < 1 ? '25-35'
: ageY < 5 ? '20-25'
: ageY < 12 ? '16-20'
: '12-16'
: '';
return (
<section className={card} data-testid="bedside-panel-ventilation">
<h2 className="text-lg font-semibold">O &amp; Ventilation</h2>
<div className="grid gap-2 grid-cols-2 max-w-md">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="vent-weight" /></div>
<div><label className={label}>Age (years)</label><input type="number" min="0" step="0.5" className={input} value={age} onChange={(e) => setAge(e.target.value)} data-testid="vent-age" /></div>
</div>
<h3 className="text-sm font-semibold">Target SpO</h3>
<DrugTable notes>
<Row name="Most children" dose="94-98%" route="—" notes="Normal" />
<Row name="Bronchiolitis (AAP 2014/2023)" dose="≥90%" route="—" notes="Don't chase higher saturations" />
<Row name="Chronic lung disease / CF" dose="90-94%" route="—" notes="Avoid hyperoxia in CO₂ retainers" />
<Row name="Preterm neonate" dose="90-95%" route="—" notes="Minimize ROP risk" />
<Row name="Term neonate (min of life)" dose="Per NRP ladder" route="—" notes="1 min 60-65% · 10 min 85-95%" />
</DrugTable>
<h3 className="text-sm font-semibold mt-2">Escalation ladder</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">1. Nasal cannula (low-flow)</div><div className="text-xs text-muted-foreground"><strong>0.5-6 L/min</strong> · FiO ~24-40% · comfortable, no humidification. Good for mild hypoxia.</div></div>
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">2. Simple face mask</div><div className="text-xs text-muted-foreground"><strong>6-10 L/min</strong> · FiO 35-60%. Must keep flow &gt;6 L/min to flush CO.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">3. Non-rebreather mask</div><div className="text-xs text-muted-foreground"><strong>10-15 L/min</strong> · FiO 60-90%. Reservoir bag must stay inflated.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">4. High-flow nasal cannula (HFNC)</div><div className="text-xs text-muted-foreground"><strong>{validWt ? `1-2 L/kg/min = ${hfLow}-${hfHigh} L/min` : '1-2 L/kg/min'}</strong> · heated + humidified · FiO 30-100% titratable · generates ~2-5 cmHO PEEP. Reassess at 1-2 h.</div></div>
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">5. Non-invasive (CPAP / BiPAP)</div><div className="text-xs text-muted-foreground">CPAP 5-10 cmHO · BiPAP IPAP 10-14 / EPAP 5. Needs cooperative patient, intact airway reflexes, no copious secretions.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">6. Intubate + mechanical ventilation</div><div className="text-xs text-muted-foreground">When NIV fails, airway compromised, apnea, or GCS 8. See Airway tab for RSI drugs.</div></div>
</div>
<h3 className="text-sm font-semibold mt-2">Bag-Valve-Mask (BVM)</h3>
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs space-y-1">
<div><strong>When:</strong> apnea, bradycardia (HR &lt;60 neonate; inadequate breathing at any age), during resuscitation.</div>
<div><strong>Rate:</strong> Newborn 40-60/min · Infant-child 20-30/min · Adolescent 10-12/min (1 breath q5-6 sec).</div>
<div><strong>Tidal volume:</strong> 6-8 mL/kg gentle chest rise only. Avoid over-ventilation.</div>
<div><strong>Technique:</strong> head tilt / jaw thrust, E-C or 2-thumb mask seal, squeeze 1 sec, release fully.</div>
<div><strong>Not ventilating?</strong> MR SOPA Mask reseal, Reposition airway, Suction, Open mouth, Pressure , Alternative airway.</div>
</div>
<h3 className="text-sm font-semibold mt-2">Mechanical vent starting settings</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Mode:</strong> Volume-control OR Pressure-control. PRVC / SIMV-PS hybrids.</div>
<div><strong>Tidal volume:</strong> <strong>{validWt ? `${tvLow}-${tvHigh} mL` : '6-8 mL/kg'}</strong> (6-8 mL/kg). Use 4-6 mL/kg for ARDS.</div>
<div><strong>Rate:</strong> {rate ? `${rate}/min (age ${ageY} yr)` : 'Newborn 30-40 · Infant 25-35 · Child 16-20 · Adolescent 12-16'}.</div>
<div><strong>PEEP:</strong> start 5 cmHO. Increase to 8-12+ for refractory hypoxia.</div>
<div><strong>FiO:</strong> start 100%, wean rapidly to lowest that maintains target SpO.</div>
<div><strong>I:E ratio:</strong> 1:2 normally; 1:3-4 for obstructive disease.</div>
<div><strong>Plateau pressure:</strong> keep &lt;30 cmHO (ideally &lt;28).</div>
</div>
<h3 className="text-sm font-semibold mt-2">Adjusting for gas exchange</h3>
<DrugTable notes>
<Row name="Low SpO₂ (oxygenation)" dose="↑ FiO₂" route="—" notes="Then ↑ PEEP (recruits collapsed alveoli)" />
<Row name="↑ PCO₂ (ventilation)" dose="↑ Rate" route="—" notes="Then ↑ Tidal volume" />
<Row name="↓ PCO₂ (over-ventilating)" dose="↓ Rate" route="—" notes="Then ↓ Tidal volume" />
<Row name="High peak pressure" dose="Check tube / compliance" route="—" notes="Suction, bronchodilator, lower TV" />
<Row name="Auto-PEEP (asthma, bronch)" dose="↓ Rate, ↑ Te" route="—" notes="Disconnect + bag briefly if critical" />
</DrugTable>
<div className="rounded-md bg-green-50 dark:bg-green-950/30 p-3 text-xs text-green-900 dark:text-green-100">
<strong>Mental model:</strong> Oxygenation is mostly <strong>FiO + PEEP</strong>. Ventilation (CO) is mostly <strong>rate + tidal volume</strong>. Obstructive (asthma, bronchiolitis) long expiratory time, permissive hypercapnia. Restrictive (ARDS) low TV, high PEEP, permissive hypercapnia + hypoxia.
</div>
<div className="text-xs text-muted-foreground italic">AAP / PALS / AARC guidance.</div>
</section>
);
}
// ── Sepsis ──────────────────────────────────────────────────
export function SepsisPanel() {
const [weight, setWeight] = useState('');
const [age, setAge] = useState<'neonate' | 'infant' | 'child'>('child');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const ageLbl = age === 'neonate' ? 'Neonate (0-28 d)' : age === 'infant' ? 'Young infant (29 d - 3 mo)' : 'Older child / adolescent';
const bolus = valid ? Math.round(wt * 20) : null;
return (
<section className={card} data-testid="bedside-panel-sepsis">
<h2 className="text-lg font-semibold">Sepsis &amp; Fever</h2>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-md">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="sepsis-weight" /></div>
<div><label className={label}>Age band</label><select className={input} value={age} onChange={(e) => setAge(e.target.value as typeof age)} data-testid="sepsis-age"><option value="neonate">Neonate (0-28 d)</option><option value="infant">Infant (29 d - 3 mo)</option><option value="child">Older child / adolescent</option></select></div>
</div>
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive">
Sepsis approach {ageLbl}{valid ? `, ${wt} kg` : ''}
</div>
<h3 className="text-sm font-semibold mt-2">Definition Phoenix Sepsis Criteria (JAMA 2024)</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Sepsis</strong> = suspected or confirmed infection + Phoenix Score 2 (organ dysfunction across respiratory, cardiovascular, coagulation, neurological).</div>
<div><strong>Septic shock</strong> = sepsis + cardiovascular dysfunction (vasoactive support, or lactate 5, or MAP for age).</div>
<div className="text-muted-foreground italic">Previous SIRS-based criteria (Goldstein 2005) are now superseded.</div>
</div>
<h3 className="text-sm font-semibold mt-2">Red flags</h3>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
Abnormal behavior / mentation · Fever + ill-appearance · Tachycardia out of proportion to fever · Prolonged cap refill (&gt;3 s) · Cold/mottled extremities · Weak pulses or wide pulse pressure ("warm shock") · Hypotension is a <strong>LATE</strong> sign · Any immune compromise / indwelling line.
</div>
<h3 className="text-sm font-semibold mt-2">Empirical therapy {ageLbl}</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs">
{age === 'neonate' && <><strong>Workup (full sepsis eval):</strong> CBC+diff, CRP, blood culture, UA+urine culture (cath), <strong>LP</strong> (CSF+HSV PCR), CXR if respiratory sx, procalcitonin. <strong>Early-onset</strong> (&lt;72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (&gt;72 h): CoNS, S. aureus, gram-negs, Candida.</>}
{age === 'infant' && <><strong>Workup:</strong> Use validated rules PECARN, Aronson, Rochester, Step-by-Step. CBC+ANC, procalcitonin/CRP, blood culture, UA+urine culture. Many warrant LP + admission + empiric abx. <strong>Coverage:</strong> GBS, E. coli, Listeria (up to ~6 wk), S. pneumo, N. meningitidis, H. flu, Salmonella.</>}
{age === 'child' && <><strong>Recognition:</strong> Phoenix score or clinical concern + suspected infection. <strong>Workup:</strong> CBC, CRP, procalcitonin, blood cx (+site-specific), lactate, blood gas, glucose, electrolytes, coags, LP if CNS concern. Source-directed imaging.</>}
</div>
{valid && (
<DrugTable>
{age === 'neonate' && <>
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="q8-12h. Covers GBS, Listeria, Enterococcus." />
<Row name="Gentamicin" dose={<Dose label={f(4, 120)!.label} />} route="IV" notes="q24-48h. Monitor levels." />
<Row name="Cefotaxime (add)" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="If meningitis or gram-neg concern." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="HSV risk: maternal lesions, vesicles, seizures, CSF pleocytosis." />
</>}
{age === 'infant' && <>
<Row name="Ceftriaxone" dose={<Dose label={f(75, 2000)!.label} />} route="IV / IM" notes="q24h (100 mg/kg/day divided q12h for meningitis). <strong>Avoid &lt;28 d</strong> if hyperbilirubinemia." />
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If &lt;6 wk: add for Listeria coverage." />
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="If severe / MRSA risk / meningitis." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="&lt;6 wk with suspicion of HSV." />
</>}
{age === 'child' && <>
<Row name="Ceftriaxone" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="q24h (100 mg/kg/day divided for meningitis)." />
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="q6h. If severe, indwelling line, or MRSA prevalence &gt;10%." />
<Row name="Piperacillin-tazobactam" dose={<Dose label={f(100, 4500)!.label} />} route="IV" notes="If intra-abdominal / neutropenic." />
<Row name="Clindamycin" dose={<Dose label={f(10, 900)!.label} />} route="IV" notes="Adjunct for toxic shock syndrome (toxin suppression)." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="If HSV CNS concern." />
</>}
</DrugTable>
)}
<h3 className="text-sm font-semibold mt-2">First-hour bundle (SSC Peds 2020)</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">0-5 min Recognize</div><div className="text-xs text-muted-foreground">Screen, sepsis huddle/activation, ABCs, O to SpO &gt;94%, warm.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">5-15 min Access &amp; labs</div><div className="text-xs text-muted-foreground">Two IVs or IO. Draw blood cx (ideally before abx), lactate, CBC, CMP, coags, blood gas, glucose. UA + culture. Source-specific cultures.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">15-30 min Fluids</div><div className="text-xs text-muted-foreground">{valid ? <>NS or LR <strong>{bolus} mL</strong> bolus (20 mL/kg) over 5-10 min.</> : <>NS/LR 10-20 mL/kg bolus over 5-10 min.</>} Reassess HR, perfusion, lungs, liver. Repeat up to 40-60 mL/kg; stop if crackles/hepatomegaly.</div></div>
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">30-60 min Antibiotics + reassess</div><div className="text-xs text-muted-foreground">Broad-spectrum empiric abx within 1 hour (1 h in septic shock). Recheck lactate, perfusion.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">&gt;60 min Fluid-refractory shock</div><div className="text-xs text-muted-foreground">Start vasoactive (<strong>epinephrine 0.05-0.3 mcg/kg/min</strong> cold / <strong>norepinephrine 0.05-0.3 mcg/kg/min</strong> warm). Central/IO access. Stress-dose hydrocortisone {valid ? <><strong>{f(2, 100)!.value} mg</strong> IV (2 mg/kg, max 100 mg)</> : '2 mg/kg IV (max 100 mg)'} if catecholamine-resistant. ICU.</div></div>
</div>
<h3 className="text-sm font-semibold mt-2">Resuscitation targets</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs">
Normal mentation · Cap refill 2 s · Warm extremities · Strong peripheral pulses · UOP 1 mL/kg/hr · MAP 5th %ile for age (&gt;65 mmHg adolescent) · SpO 94% · Lactate trending down.
</div>
<div className="text-xs text-muted-foreground italic">Phoenix Sepsis Criteria (Schlapbach et al., JAMA 2024) · Surviving Sepsis Campaign Pediatric 2020 · AAP pediatric sepsis guidance.</div>
</section>
);
}
// ── Burns ───────────────────────────────────────────────────
// Lund-Browder age-adjusted region percentages ported VERBATIM from
// public/js/bedside/burns.js:10-30.
const LUND_BROWDER: Array<{ key: string; label: string; vals: [number, number, number, number, number]; ageSensitive?: boolean }> = [
{ key: 'head', label: 'Head', vals: [18, 13, 11, 9, 7], ageSensitive: true },
{ key: 'neck', label: 'Neck', vals: [2, 2, 2, 2, 2] },
{ key: 'ant_trunk', label: 'Anterior trunk', vals: [13, 13, 13, 13, 13] },
{ key: 'post_trunk', label: 'Posterior trunk', vals: [13, 13, 13, 13, 13] },
{ key: 'r_buttock', label: 'Right buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'l_buttock', label: 'Left buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'genital', label: 'Genitalia', vals: [1, 1, 1, 1, 1] },
{ key: 'r_uparm', label: 'R upper arm', vals: [4, 4, 4, 4, 4] },
{ key: 'l_uparm', label: 'L upper arm', vals: [4, 4, 4, 4, 4] },
{ key: 'r_forearm', label: 'R forearm', vals: [3, 3, 3, 3, 3] },
{ key: 'l_forearm', label: 'L forearm', vals: [3, 3, 3, 3, 3] },
{ key: 'r_hand', label: 'R hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'l_hand', label: 'L hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'r_thigh', label: 'R thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
{ key: 'l_thigh', label: 'L thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
{ key: 'r_leg', label: 'R lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
{ key: 'l_leg', label: 'L lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
{ key: 'r_foot', label: 'R foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
{ key: 'l_foot', label: 'L foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
];
const AGE_BANDS: Array<{ id: 'infant' | 'young' | 'child' | 'adol' | 'adult'; label: string }> = [
{ id: 'infant', label: 'Infant (<1 y)' },
{ id: 'young', label: 'Young child (1-5 y)' },
{ id: 'child', label: 'Child (5-10 y)' },
{ id: 'adol', label: 'Adolescent (10-15 y)' },
{ id: 'adult', label: 'Adult (>15 y)' },
];
export function BurnsPanel() {
const [weight, setWeight] = useState('');
const [ageBand, setAgeBand] = useState<'infant' | 'young' | 'child' | 'adol' | 'adult'>('young');
const [override, setOverride] = useState('');
const [pct, setPct] = useState<Record<string, number>>({});
const ageIdx = AGE_BANDS.findIndex((a) => a.id === ageBand);
const computedTbsa = LUND_BROWDER.reduce(
(sum, r) => sum + r.vals[ageIdx] * Math.min(100, Math.max(0, pct[r.key] ?? 0)) / 100,
0,
);
const tbsa = override.trim() ? Number(override) : Math.round(computedTbsa * 10) / 10;
const wt = Number.parseFloat(weight);
const validWt = Number.isFinite(wt) && wt > 0;
const validTbsa = Number.isFinite(tbsa) && tbsa > 0;
const total = validWt && validTbsa ? Math.round(4 * wt * tbsa) : 0;
const first8 = Math.round(total / 2);
const rateFirst = Math.round(first8 / 8);
const next16 = total - first8;
const rateNext = Math.round(next16 / 16);
const maint = validWt
? Math.round(wt <= 10 ? wt * 4 : wt <= 20 ? 40 + (wt - 10) * 2 : 60 + (wt - 20))
: 0;
return (
<section className={card} data-testid="bedside-panel-burns">
<h2 className="text-lg font-semibold">Burns Lund-Browder + Parkland</h2>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-3 max-w-xl">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="burn-weight" /></div>
<div><label className={label}>Age band</label><select className={input} value={ageBand} onChange={(e) => setAgeBand(e.target.value as typeof ageBand)} data-testid="burn-age">{AGE_BANDS.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}</select></div>
<div><label className={label}>TBSA override (%)</label><input type="number" min="0" max="100" step="1" className={input} value={override} onChange={(e) => setOverride(e.target.value)} placeholder={validTbsa ? String(tbsa) : 'auto'} data-testid="burn-tbsa" /></div>
</div>
<h3 className="text-sm font-semibold">Body parts % of each region burned (2° or deeper)</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{LUND_BROWDER.map((r) => {
const max = r.vals[ageIdx];
return (
<div key={r.key} className="flex items-center gap-2 bg-muted/30 rounded p-2">
<label className="flex-1 text-xs">{r.label} <span className="text-muted-foreground">({max}%{r.ageSensitive ? '*' : ''})</span></label>
<input type="number" min="0" max="100" step="5" className="w-16 rounded border border-input bg-background px-2 py-1 text-xs text-right" value={pct[r.key] ?? 0} onChange={(e) => setPct({ ...pct, [r.key]: Number(e.target.value) })} data-testid={'burn-region-' + r.key} />
<span className="text-xs text-muted-foreground">%</span>
</div>
);
})}
</div>
{validTbsa && <div className="text-sm text-muted-foreground">Computed TBSA: <strong>{tbsa}%</strong></div>}
{!validWt && <p className="text-xs text-destructive">Enter weight (kg) to see Parkland + maintenance fluids.</p>}
{!validTbsa && validWt && <p className="text-xs text-destructive">Enter % per region or override TBSA to compute fluids.</p>}
{validWt && validTbsa && (
<>
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive" data-testid="burn-result">
Burn fluid resuscitation {wt} kg, {tbsa}% TBSA (2° or deeper)
</div>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-sm space-y-1">
<div><strong>Parkland formula:</strong> 4 mL × kg × %TBSA = <strong>{total} mL LR over 24 hours</strong></div>
<div><strong>First 8 h</strong> (from time of burn): {first8} mL (~<strong>{rateFirst} mL/hr</strong>)</div>
<div><strong>Next 16 h:</strong> {next16} mL (~<strong>{rateNext} mL/hr</strong>)</div>
</div>
<div className="rounded-md bg-muted/40 p-3 text-sm">
<strong>Plus maintenance (4-2-1):</strong> {maint} mL/hr (D5 ½NS ± 20 mEq KCl/L once UOP established). Consider dextrose in children &lt;30 kg.
</div>
<div className="rounded-md bg-muted/40 p-3 text-sm">
<strong>Titrate to UOP:</strong> target 1-2 mL/kg/hr (infants / children), 0.5-1 mL/kg/hr (adolescents). <strong>Clinical response trumps formula.</strong>
</div>
</>
)}
<h3 className="text-sm font-semibold">Other pearls</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Rule of palm:</strong> Patient's palm + fingers 1% TBSA good for scattered burns.</div>
<div><strong>First-degree burns DO NOT count</strong> toward TBSA or Parkland.</div>
<div><strong>Analgesia:</strong> Morphine 0.05-0.1 mg/kg IV q2h, or fentanyl 1-2 mcg/kg IV q30-60 min.</div>
<div><strong>Tetanus</strong> prophylaxis if indicated. Tdap/Td ± TIG.</div>
</div>
<h3 className="text-sm font-semibold">Burn center referral (ABA)</h3>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
Partial-thickness &gt;10% TBSA · any full-thickness · face/hands/feet/genital/perineum/major joints · electrical/chemical/inhalation · associated trauma · significant comorbidities · pediatric burns in non-pediatric center.
</div>
<div className="text-xs text-muted-foreground italic">
ABA Advanced Burn Life Support 2018 · Parkland formula: Baxter 1968 · Lund-Browder 1944.
</div>
</section>
);
}

View file

@ -1,6 +1,6 @@
{
"generatedAt": "2026-04-23T23:44:54.613Z",
"source": "public/js/calculators.js",
"generatedAt": "2026-04-23T23:54:26.721Z",
"source": "public/js/calculators.js + public/js/bedside/neonatal.js",
"bhutani": [
{
"name": "Bhutani low-end (6h)",
@ -1169,5 +1169,151 @@
"percentile": 98.83821062755833
}
}
],
"neonatal": [
{
"name": "Neonatal 40w5d male 3070g (validated against peditools/Epic)",
"inputs": {
"weeks": 40,
"days": 5,
"weightGrams": 3070,
"sex": "male"
},
"output": {
"gaDecimal": 40.714285714285715,
"L": 0.48469999999999996,
"M": 3723,
"S": 0.1295157142857143,
"z": -1.421550744831189,
"percentile": 7.8,
"expectedWeight": 3723
}
},
{
"name": "Neonatal 28w male 1079g (M)",
"inputs": {
"weeks": 28,
"days": 0,
"weightGrams": 1079,
"sex": "male"
},
"output": {
"gaDecimal": 28,
"L": 1.3699,
"M": 1079,
"S": 0.20777,
"z": 0,
"percentile": 50,
"expectedWeight": 1079
}
},
{
"name": "Neonatal 32w female 1681g (M)",
"inputs": {
"weeks": 32,
"days": 0,
"weightGrams": 1681,
"sex": "female"
},
"output": {
"gaDecimal": 32,
"L": 1.0122,
"M": 1681,
"S": 0.21846,
"z": 0,
"percentile": 50,
"expectedWeight": 1681
}
},
{
"name": "Neonatal 24w SGA",
"inputs": {
"weeks": 24,
"days": 0,
"weightGrams": 450,
"sex": "male"
},
"output": {
"gaDecimal": 24,
"L": 0.9128,
"M": 651,
"S": 0.16235,
"z": -1.93083045598671,
"percentile": 2.7,
"expectedWeight": 651
}
},
{
"name": "Neonatal 40w LGA 4500g",
"inputs": {
"weeks": 40,
"days": 0,
"weightGrams": 4500,
"sex": "female"
},
"output": {
"gaDecimal": 40,
"L": 0.167,
"M": 3415,
"S": 0.14649,
"z": 1.9274676858691357,
"percentile": 97.3,
"expectedWeight": 3415
}
},
{
"name": "Neonatal 34w3d female 2000g",
"inputs": {
"weeks": 34,
"days": 3,
"weightGrams": 2000,
"sex": "female"
},
"output": {
"gaDecimal": 34.42857142857143,
"L": 0.671185714285714,
"M": 2227.142857142858,
"S": 0.18829285714285712,
"z": -0.5511666089022185,
"percentile": 29.1,
"expectedWeight": 2227
}
},
{
"name": "Neonatal 39w male 3400g",
"inputs": {
"weeks": 39,
"days": 0,
"weightGrams": 3400,
"sex": "male"
},
"output": {
"gaDecimal": 39,
"L": 0.5881,
"M": 3360,
"S": 0.13641,
"z": 0.08705913311792315,
"percentile": 53.5,
"expectedWeight": 3360
}
},
{
"name": "Neonatal 22w SGA",
"inputs": {
"weeks": 22,
"days": 0,
"weightGrams": 400,
"sex": "male"
},
"output": {
"gaDecimal": 22,
"L": 0.5885,
"M": 496,
"S": 0.12802,
"z": -1.5782877644718676,
"percentile": 5.7,
"expectedWeight": 496
}
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-CFyXXJlL.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DrRLZ8fC.css">
<script type="module" crossorigin src="/app/assets/index-DV5ylulF.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-CLSco3q2.css">
</head>
<body>
<div id="root"></div>

View file

@ -53,6 +53,26 @@ const exchangeThresholds36risk = {12:15.2,13:15.3,14:15.4,15:15.6,16:15.7,17:15.
const exchangeThresholds37risk = {12:15.7,13:15.9,14:16.0,15:16.1,16:16.2,17:16.4,18:16.5,19:16.6,20:16.7,21:16.8,22:17.0,23:17.1,24:17.2,25:17.3,26:17.4,27:17.5,28:17.7,29:17.8,30:17.9,31:18.0,32:18.1,33:18.2,34:18.3,35:18.4,36:18.5,37:18.6,38:18.7,39:18.8,40:18.9,41:19.0,42:19.1,43:19.2,44:19.3,45:19.4,46:19.5,47:19.6,48:19.7,49:19.8,50:19.9,51:20.0,52:20.1,53:20.1,54:20.2,55:20.3,56:20.4,57:20.5,58:20.6,59:20.7,60:20.7,61:20.8,62:20.9,63:21.0,64:21.1,65:21.1,66:21.2,67:21.3,68:21.4,69:21.4,70:21.5,71:21.6,72:21.7,73:21.7,74:21.8,75:21.9,76:21.9,77:22.0,78:22.1,79:22.1,80:22.2,81:22.3,82:22.3,83:22.4,84:22.5,85:22.5,86:22.6,87:22.6,88:22.7,89:22.8,90:22.8,91:22.9,92:22.9,93:23.0,94:23.0,95:23.1,96:23.1};
const exchangeThresholds38risk = {12:16.3,13:16.4,14:16.5,15:16.6,16:16.7,17:16.9,18:17.0,19:17.1,20:17.2,21:17.3,22:17.4,23:17.6,24:17.7,25:17.8,26:17.9,27:18.0,28:18.1,29:18.2,30:18.3,31:18.4,32:18.5,33:18.7,34:18.8,35:18.9,36:19.0,37:19.1,38:19.2,39:19.3,40:19.4,41:19.5,42:19.6,43:19.7,44:19.8,45:19.9,46:19.9,47:20.0,48:20.1,49:20.2,50:20.3,51:20.4,52:20.5,53:20.6,54:20.7,55:20.8,56:20.8,57:20.9,58:21.0,59:21.1,60:21.2,61:21.3,62:21.3,63:21.4,64:21.5,65:21.6,66:21.7,67:21.7,68:21.8,69:21.9,70:22.0,71:22.0,72:22.1,73:22.2,74:22.2,75:22.3,76:22.4,77:22.5,78:22.5,79:22.6,80:22.7,81:22.7,82:22.8,83:22.8,84:22.9,85:23.0,86:23.0,87:23.1,88:23.1,89:23.2,90:23.3,91:23.3,92:23.4,93:23.4,94:23.5,95:23.5,96:23.5};
// ── Peditools-derived Fenton LMS (verbatim from bedside/neonatal.js:20-37) ──
const fentonLmsPeditools = {
male: {
22:{L:0.5885,M:496,S:0.12802},23:{L:0.7565,M:571,S:0.14547},24:{L:0.9128,M:651,S:0.16235},25:{L:1.0544,M:741,S:0.17765},
26:{L:1.1862,M:841,S:0.19029},27:{L:1.3051,M:953,S:0.19989},28:{L:1.3699,M:1079,S:0.20777},29:{L:1.4165,M:1223,S:0.21163},
30:{L:1.4172,M:1388,S:0.21185},31:{L:1.3755,M:1578,S:0.20785},32:{L:1.2952,M:1790,S:0.20112},33:{L:1.1974,M:2018,S:0.19143},
34:{L:1.0743,M:2255,S:0.18119},35:{L:0.9583,M:2493,S:0.16992},36:{L:0.8460,M:2726,S:0.16001},37:{L:0.7543,M:2947,S:0.15072},
38:{L:0.6650,M:3156,S:0.14304},39:{L:0.5881,M:3360,S:0.13641},40:{L:0.5237,M:3568,S:0.13173},41:{L:0.4691,M:3785,S:0.12863},
42:{L:0.4216,M:4014,S:0.12735}
},
female: {
22:{L:-0.0868,M:481,S:0.13605},23:{L:0.2119,M:537,S:0.14635},24:{L:0.5281,M:606,S:0.16134},25:{L:0.8258,M:694,S:0.18077},
26:{L:1.0501,M:792,S:0.19889},27:{L:1.2084,M:899,S:0.21323},28:{L:1.2599,M:1017,S:0.22437},29:{L:1.2539,M:1152,S:0.22982},
30:{L:1.2262,M:1306,S:0.23082},31:{L:1.1223,M:1482,S:0.22733},32:{L:1.0122,M:1681,S:0.21846},33:{L:0.8746,M:1897,S:0.20681},
34:{L:0.7299,M:2126,S:0.19407},35:{L:0.5929,M:2362,S:0.18059},36:{L:0.4534,M:2602,S:0.17028},37:{L:0.3462,M:2835,S:0.16139},
38:{L:0.2636,M:3050,S:0.15513},39:{L:0.2069,M:3239,S:0.15004},40:{L:0.1670,M:3415,S:0.14649},41:{L:0.1517,M:3596,S:0.14359},
42:{L:0.1308,M:3787,S:0.14127}
}
};
// ── Fenton 2013 LMS (verbatim from lines 1168-1183) ─────────
const fentonLMS = {
male: {
@ -108,6 +128,32 @@ function calcZ(value, L, M, S) {
return (Math.pow(value / M, L) - 1) / (L * S);
}
// Neonatal variant uses |L|<0.001 threshold; verbatim from
// bedside/neonatal.js:53-56.
function calcZNeonatal(value, L, M, S) {
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
// Abramowitz & Stegun erf form, verbatim from bedside/neonatal.js:58-66.
function zToPercentileNeonatal(z) {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
}
function neonatalAssess(weeks, days, weightGrams, sex) {
const gaDecimal = weeks + days / 7;
const lms = interpolateLMS(fentonLmsPeditools[sex], gaDecimal);
const z = calcZNeonatal(weightGrams, lms.L, lms.M, lms.S);
const percentile = zToPercentileNeonatal(z);
return { gaDecimal, L: lms.L, M: lms.M, S: lms.S, z, percentile, expectedWeight: Math.round(lms.M) };
}
function zToPercentile(z) {
// Abramowitz & Stegun normal CDF approximation (from lines 2318-2326).
const t = 1 / (1 + 0.2316419 * Math.abs(z));
@ -251,16 +297,30 @@ const fentonCases = [
{ name: 'Fenton female 36w LGA 3400g', inputs: { sex: 'female', gaWeeks: 36, weightGrams: 3400 } },
];
const neonatalCases = [
// Validated test case from bedside/neonatal.js comment (40 5/7 wk male 3070g → z = -1.42)
{ name: 'Neonatal 40w5d male 3070g (validated against peditools/Epic)',
inputs: { weeks: 40, days: 5, weightGrams: 3070, sex: 'male' } },
{ name: 'Neonatal 28w male 1079g (M)', inputs: { weeks: 28, days: 0, weightGrams: 1079, sex: 'male' } },
{ name: 'Neonatal 32w female 1681g (M)', inputs: { weeks: 32, days: 0, weightGrams: 1681, sex: 'female' } },
{ name: 'Neonatal 24w SGA', inputs: { weeks: 24, days: 0, weightGrams: 450, sex: 'male' } },
{ name: 'Neonatal 40w LGA 4500g', inputs: { weeks: 40, days: 0, weightGrams: 4500, sex: 'female' } },
{ name: 'Neonatal 34w3d female 2000g', inputs: { weeks: 34, days: 3, weightGrams: 2000, sex: 'female' } },
{ name: 'Neonatal 39w male 3400g', inputs: { weeks: 39, days: 0, weightGrams: 3400, sex: 'male' } },
{ name: 'Neonatal 22w SGA', inputs: { weeks: 22, days: 0, weightGrams: 400, sex: 'male' } },
];
// ── Run + emit ─────────────────────────────────────────────
const vectors = {
generatedAt: new Date().toISOString(),
source: 'public/js/calculators.js',
source: 'public/js/calculators.js + public/js/bedside/neonatal.js',
bhutani: bhutaniCases.map((c) => ({ ...c, output: classifyBhutani(c.inputs.hours, c.inputs.tsb) })),
aap: aapCases.map((c) => ({ ...c, output: classifyAapBili(c.inputs.gaWeeks, c.inputs.hours, c.inputs.tsb, c.inputs.risk) })),
fenton: fentonCases.map((c) => ({ ...c, output: fentonWeightForAge(c.inputs.gaWeeks, c.inputs.weightGrams, c.inputs.sex) })),
neonatal: neonatalCases.map((c) => ({ ...c, output: neonatalAssess(c.inputs.weeks, c.inputs.days, c.inputs.weightGrams, c.inputs.sex) })),
};
const outPath = path.join(__dirname, '..', 'e2e', 'fixtures', 'calc-vectors.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(vectors, null, 2));
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton cases → ${outPath}`);
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton, ${vectors.neonatal.length} Neonatal cases → ${outPath}`);

View file

@ -6,14 +6,19 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fentonWeightForAge, type Sex } from './fenton';
import { fentonWeightForAge, neonatalAssess, type Sex } from './fenton';
interface FentonCase {
name: string;
inputs: { sex: Sex; gaWeeks: number; weightGrams: number };
output: { L: number; M: number; S: number; z: number; percentile: number };
}
interface Vectors { fenton: FentonCase[] }
interface NeonatalCase {
name: string;
inputs: { weeks: number; days: number; weightGrams: number; sex: Sex };
output: { gaDecimal: number; L: number; M: number; S: number; z: number; percentile: number; expectedWeight: number };
}
interface Vectors { fenton: FentonCase[]; neonatal: NeonatalCase[] }
const vectors: Vectors = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'e2e', 'fixtures', 'calc-vectors.json'), 'utf8'),
@ -29,3 +34,16 @@ describe('Fenton 2013 weight-for-GA — parity with vanilla calculators.js', ()
expect(actual.percentile).toBeCloseTo(output.percentile, 10);
});
});
describe('Neonatal peditools Fenton — parity with vanilla bedside/neonatal.js', () => {
it.each(vectors.neonatal)('$name', ({ inputs, output }) => {
const actual = neonatalAssess(inputs.weeks, inputs.days, inputs.weightGrams, inputs.sex);
expect(actual.gaDecimal).toBeCloseTo(output.gaDecimal, 10);
expect(actual.L).toBeCloseTo(output.L, 10);
expect(actual.M).toBeCloseTo(output.M, 6);
expect(actual.S).toBeCloseTo(output.S, 10);
expect(actual.z).toBeCloseTo(output.z, 10);
expect(actual.percentile).toBeCloseTo(output.percentile, 10);
expect(actual.expectedWeight).toBe(output.expectedWeight);
});
});

View file

@ -91,3 +91,135 @@ export function classifySizeForAge(percentile: number): SizeForAge {
if (percentile > 90) return 'LGA';
return 'AGA';
}
// ============================================================
// Fenton 2013 — peditools-derived higher-accuracy table used by
// the Bedside Neonatal module. Ported VERBATIM from
// public/js/bedside/neonatal.js:20-37. Comment from that file:
// "LMS parameters derived empirically from peditools.org/
// fenton2013 — peditools is widely used and consistent with
// the published Fenton TR, Kim JH. BMC Pediatrics 2013;13:59
// reference. Each week's triple was fit against 6 probe weights
// per week (RMSE < 0.005 z-score units). Replaces an earlier
// hand-rounded table whose z-scores drifted ~0.05 SD from
// peditools/Epic near term."
//
// This table is 21 GA weeks (22-42) in 1-week steps, more granular
// than the coarser 15-week version in fentonLMS above. Use
// fentonLMS for the Calculators / Growth Charts pill (matches the
// vanilla calc output). Use fentonLmsPeditools for the Bedside
// Neonatal sub-module (matches peditools.org z-scores to 3 decimal
// places and is what Daniel uses clinically).
// ============================================================
export const fentonLmsPeditools: Record<Sex, Record<number, Lms>> = {
male: {
22: { L: 0.5885, M: 496, S: 0.12802 }, 23: { L: 0.7565, M: 571, S: 0.14547 },
24: { L: 0.9128, M: 651, S: 0.16235 }, 25: { L: 1.0544, M: 741, S: 0.17765 },
26: { L: 1.1862, M: 841, S: 0.19029 }, 27: { L: 1.3051, M: 953, S: 0.19989 },
28: { L: 1.3699, M: 1079, S: 0.20777 }, 29: { L: 1.4165, M: 1223, S: 0.21163 },
30: { L: 1.4172, M: 1388, S: 0.21185 }, 31: { L: 1.3755, M: 1578, S: 0.20785 },
32: { L: 1.2952, M: 1790, S: 0.20112 }, 33: { L: 1.1974, M: 2018, S: 0.19143 },
34: { L: 1.0743, M: 2255, S: 0.18119 }, 35: { L: 0.9583, M: 2493, S: 0.16992 },
36: { L: 0.8460, M: 2726, S: 0.16001 }, 37: { L: 0.7543, M: 2947, S: 0.15072 },
38: { L: 0.6650, M: 3156, S: 0.14304 }, 39: { L: 0.5881, M: 3360, S: 0.13641 },
40: { L: 0.5237, M: 3568, S: 0.13173 }, 41: { L: 0.4691, M: 3785, S: 0.12863 },
42: { L: 0.4216, M: 4014, S: 0.12735 },
},
female: {
22: { L: -0.0868, M: 481, S: 0.13605 }, 23: { L: 0.2119, M: 537, S: 0.14635 },
24: { L: 0.5281, M: 606, S: 0.16134 }, 25: { L: 0.8258, M: 694, S: 0.18077 },
26: { L: 1.0501, M: 792, S: 0.19889 }, 27: { L: 1.2084, M: 899, S: 0.21323 },
28: { L: 1.2599, M: 1017, S: 0.22437 }, 29: { L: 1.2539, M: 1152, S: 0.22982 },
30: { L: 1.2262, M: 1306, S: 0.23082 }, 31: { L: 1.1223, M: 1482, S: 0.22733 },
32: { L: 1.0122, M: 1681, S: 0.21846 }, 33: { L: 0.8746, M: 1897, S: 0.20681 },
34: { L: 0.7299, M: 2126, S: 0.19407 }, 35: { L: 0.5929, M: 2362, S: 0.18059 },
36: { L: 0.4534, M: 2602, S: 0.17028 }, 37: { L: 0.3462, M: 2835, S: 0.16139 },
38: { L: 0.2636, M: 3050, S: 0.15513 }, 39: { L: 0.2069, M: 3239, S: 0.15004 },
40: { L: 0.1670, M: 3415, S: 0.14649 }, 41: { L: 0.1517, M: 3596, S: 0.14359 },
42: { L: 0.1308, M: 3787, S: 0.14127 },
},
};
// Neonatal-specific Z — uses |L|<0.001 threshold instead of L===0
// to handle the interpolated near-zero L values cleanly.
// Verbatim from public/js/bedside/neonatal.js:53-56.
export function calcZNeonatal(value: number, L: number, M: number, S: number): number {
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
// Abramowitz & Stegun normal CDF (Erf form), verbatim from
// public/js/bedside/neonatal.js:58-66. This is a different
// approximation from zToPercentile above (which uses the Pythagoras
// polynomial form) — same asymptotic answer, different rounding.
// Kept as a separate function so the Neonatal numbers match the
// vanilla Bedside output exactly.
export function zToPercentileNeonatal(z: number): number {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
}
export interface NeonatalGaClass { label: string; color: string; icon: string }
export interface NeonatalBwClass { label: string; color: string }
export interface NeonatalWeightClass { label: string; color: string; detail: string }
export function classifyGA(weeks: number, days = 0): NeonatalGaClass {
const total = weeks + days / 7;
if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'triangle-exclamation' };
if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'triangle-exclamation' };
if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'circle-exclamation' };
if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'circle-info' };
if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'circle-info' };
if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'circle-check' };
if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'circle-info' };
return { label: 'Post Term', color: '#dc2626', icon: 'triangle-exclamation' };
}
export function classifyBirthWeight(grams: number): NeonatalBwClass {
if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' };
if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' };
if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' };
if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' };
return { label: 'Macrosomia (>4000g)', color: '#ea580c' };
}
export function classifyWeightPercentile(percentile: number): NeonatalWeightClass {
if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' };
if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' };
if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' };
if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' };
return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' };
}
// Main neonatal assessment using the peditools-accurate Fenton table.
export interface NeonatalAssessment {
gaDecimal: number;
gaClass: NeonatalGaClass;
bwClass: NeonatalBwClass;
weightClass: NeonatalWeightClass;
expectedWeight: number;
z: number;
percentile: number;
L: number; M: number; S: number;
}
export function neonatalAssess(weeks: number, days: number, weightGrams: number, sex: Sex): NeonatalAssessment {
const gaDecimal = weeks + days / 7;
const lms = interpolateLMS(fentonLmsPeditools[sex], gaDecimal);
const z = calcZNeonatal(weightGrams, lms.L, lms.M, lms.S);
const percentile = zToPercentileNeonatal(z);
return {
gaDecimal,
gaClass: classifyGA(weeks, days),
bwClass: classifyBirthWeight(weightGrams),
weightClass: classifyWeightPercentile(percentile),
expectedWeight: Math.round(lms.M),
z,
percentile,
L: lms.L, M: lms.M, S: lms.S,
};
}