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:
parent
f5c9202fe3
commit
941b73f9e5
11 changed files with 1068 additions and 62 deletions
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
641
client/src/pages/BedsidePanels2.tsx
Normal file
641
client/src/pages/BedsidePanels2.tsx
Normal 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 <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 <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 <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 <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 /><1 kg / <28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · >3 kg / >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₂ <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"><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"><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 <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₂ & 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 >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 cmH₂O 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 cmH₂O · 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 <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 cmH₂O. 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 <30 cmH₂O (ideally <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 & 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 (>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> (<72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (>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 <28 d</strong> if hyperbilirubinemia." />
|
||||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If <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="<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 >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₂ >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 & 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">>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 (>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 <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 >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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
2
public/app/assets/index-CLSco3q2.css
Normal file
2
public/app/assets/index-CLSco3q2.css
Normal file
File diff suppressed because one or more lines are too long
52
public/app/assets/index-DV5ylulF.js
Normal file
52
public/app/assets/index-DV5ylulF.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue