Closes the Calculators migration. AAP 2017 BP percentile (Rosner
quantile splines) was the biggest table-driven calculator in the
codebase: 6 height-LMS arrays × 218 entries + 4 spline coefficient
matrices × 99 rows × 13 terms = ~3,500 numeric constants. Every one
ported verbatim. 14 parity tests prove the TS port returns identical
percentile and classification outputs to the vanilla calculators.js.
shared/clinical/bp.ts — 571 lines
Generated from public/js/calculators.js via awk-extracted lines
89-94 (LMS) and 97-503 (coefficients), wrapped in TS export
declarations. No rewriting, no reformatting, no reordering — the
data bytes are identical to the vanilla source.
Math (calcHeightPercentile, computeBpPercentile,
classifyBpFromPercentiles) ported verbatim from calculators.js:
505-608. Exports a top-level computeBp() helper returning
{ sysPercentile, diaPercentile, heightPercentile, sysClass,
diaClass, classification }.
scripts/capture-calc-vectors.js — BP cases added
Uses new Function() to evaluate the raw LMS + coefficient blocks
from calculators.js directly, then runs the vanilla math against
14 carefully chosen test cases:
• Typical pediatric ages (3, 5, 8, 10, 12 years, both sexes)
• Adult-threshold cross-over (age 13 — uses absolute mmHg cutoffs)
• Stage 1 / Stage 2 hypertension boundaries
• Edge-of-domain (age 1, age 17)
• Tall / short height-percentile outliers
Fixture regenerated to 14 Bhutani + 58 AAP + 13 Fenton + 8 neonatal
+ 12 BMI + 14 BP = 117 total vectors.
shared/clinical/bp.test.ts — exact-match parity
sysPercentile / diaPercentile are integer selections from 99
candidate predicted values, so tests use .toBe() for exact match.
heightPercentile uses toBeCloseTo(6) (double-precision float).
Classification strings must match exactly. 14/14 pass.
client/src/pages/CalculatorPanels.tsx — BpPanel added
Age/sex/height/SBP/DBP inputs, validation (age 1-17, height 50-200
cm), color-coded overall classification + per-measurement
percentile and tier (Normal / Elevated / Stage 1 / Stage 2) in a
4-column result grid with the height percentile for context.
client/src/pages/Calculators.tsx
Dispatch wires bp → BpPanel. PILLS['bp'].ported = true.
LegacyPanel is now dead code — every pill has a real implementation.
Final test suite: 5 files, 136 tests green
(19 calculators + 70 bilirubin + 21 fenton/neonatal + 12 BMI + 14 BP)
366 lines
30 KiB
TypeScript
366 lines
30 KiB
TypeScript
// ============================================================
|
|
// CALCULATOR PANELS — BMI / Vitals / Resus / Equipment.
|
|
// Data ported VERBATIM from public/js/calculators.js:
|
|
// • VITALS_DATA lines 1703-1831
|
|
// • RESUS_MEDS lines 1873-2050
|
|
// • EQUIP_DATA lines 2173-2228
|
|
// BMI math + LMS table live in shared/clinical/bmi.ts, verified
|
|
// byte-for-byte by calc-vectors.json (12 BMI cases).
|
|
// ============================================================
|
|
|
|
import { useState } from 'react';
|
|
import { computeBmi } from '@shared/clinical/bmi';
|
|
import type { Sex } from '@shared/clinical/fenton';
|
|
import { computeBp, type BpClassification } from '@shared/clinical/bp';
|
|
|
|
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 btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
|
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
|
|
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
|
|
|
|
// ── BP Percentile (AAP 2017 Rosner splines) ────────────────
|
|
const BP_CLASS_STYLE: Record<BpClassification, { label: string; color: string; bg: string }> = {
|
|
normal: { label: 'Normal', color: '#10b981', bg: '#d1fae5' },
|
|
elevated: { label: 'Elevated', color: '#f59e0b', bg: '#fef3c7' },
|
|
stage1: { label: 'Stage 1 Hypertension', color: '#f97316', bg: '#ffedd5' },
|
|
stage2: { label: 'Stage 2 Hypertension', color: '#ef4444', bg: '#fee2e2' },
|
|
};
|
|
|
|
export function BpPanel() {
|
|
const [ageYears, setAgeYears] = useState('');
|
|
const [sex, setSex] = useState<'female' | 'male'>('female');
|
|
const [heightCm, setHeightCm] = useState('');
|
|
const [sbp, setSbp] = useState('');
|
|
const [dbp, setDbp] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [result, setResult] = useState<ReturnType<typeof computeBp> | null>(null);
|
|
|
|
function calc() {
|
|
const a = Number.parseFloat(ageYears);
|
|
const h = Number.parseFloat(heightCm);
|
|
const s = Number.parseFloat(sbp);
|
|
const d = Number.parseFloat(dbp);
|
|
if (!Number.isFinite(a) || !Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(d)) {
|
|
setError('Fill in all fields.'); setResult(null); return;
|
|
}
|
|
if (a < 1 || a > 17) { setError('Age must be 1-17 years.'); setResult(null); return; }
|
|
if (h < 50 || h > 200) { setError('Height must be 50-200 cm.'); setResult(null); return; }
|
|
setError('');
|
|
setResult(computeBp(a, sex, h, s, d));
|
|
}
|
|
|
|
const style = result ? BP_CLASS_STYLE[result.classification] : null;
|
|
|
|
return (
|
|
<section className={card} data-testid="calc-panel-bp">
|
|
<h2 className="text-lg font-semibold">BP Percentile (AAP 2017)</h2>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div><label className={label}>Age (years)</label><input type="number" min="1" max="17" step="0.1" className={input} value={ageYears} onChange={(e) => setAgeYears(e.target.value)} data-testid="bp-age" /></div>
|
|
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as typeof sex)} data-testid="bp-sex"><option value="female">Female</option><option value="male">Male</option></select></div>
|
|
<div><label className={label}>Height (cm)</label><input type="number" min="50" max="200" step="0.1" className={input} value={heightCm} onChange={(e) => setHeightCm(e.target.value)} data-testid="bp-height" /></div>
|
|
<div><label className={label}>SBP (mmHg)</label><input type="number" min="50" max="220" step="1" className={input} value={sbp} onChange={(e) => setSbp(e.target.value)} data-testid="bp-sbp" /></div>
|
|
<div><label className={label}>DBP (mmHg)</label><input type="number" min="30" max="150" step="1" className={input} value={dbp} onChange={(e) => setDbp(e.target.value)} data-testid="bp-dbp" /></div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bp-calculate">Calculate</button>
|
|
<button type="button" onClick={() => { setAgeYears(''); setHeightCm(''); setSbp(''); setDbp(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
|
|
</div>
|
|
{error && <div className={errorBox}>{error}</div>}
|
|
{result && style && (
|
|
<div
|
|
className="rounded-lg p-4 space-y-2"
|
|
style={{ background: style.bg, borderLeft: `4px solid ${style.color}` }}
|
|
data-testid="calc-bp-result"
|
|
>
|
|
<div className="text-base font-bold" style={{ color: style.color }}>{style.label}</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
|
<div><span className="text-xs uppercase text-muted-foreground">Systolic</span><div className="font-semibold">{result.sysPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.sysClass].label}</div></div>
|
|
<div><span className="text-xs uppercase text-muted-foreground">Diastolic</span><div className="font-semibold">{result.diaPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.diaClass].label}</div></div>
|
|
<div><span className="text-xs uppercase text-muted-foreground">Height</span><div className="font-semibold">{result.heightPercentile.toFixed(0)}th %ile</div></div>
|
|
<div><span className="text-xs uppercase text-muted-foreground">Overall</span><div className="font-semibold">{style.label}</div></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground italic">
|
|
Flynn JT et al. Clinical Practice Guideline for Screening and Management of High Blood Pressure in Children and Adolescents. Pediatrics 2017;140(3):e20171904.
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── BMI ─────────────────────────────────────────────────────
|
|
export function BmiPanel() {
|
|
const [ageYr, setAgeYr] = useState('');
|
|
const [ageMo, setAgeMo] = useState('');
|
|
const [sex, setSex] = useState<Sex>('male');
|
|
const [weight, setWeight] = useState('');
|
|
const [height, setHeight] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [result, setResult] = useState<ReturnType<typeof computeBmi> | null>(null);
|
|
|
|
function calc() {
|
|
const yr = Number.parseFloat(ageYr) || 0;
|
|
const mo = Number.parseInt(ageMo, 10) || 0;
|
|
const age = yr + mo / 12;
|
|
const w = Number.parseFloat(weight);
|
|
const h = Number.parseFloat(height);
|
|
if (!age || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) {
|
|
setError('Fill in all fields.'); setResult(null); return;
|
|
}
|
|
if (age < 2 || age > 20) { setError('Age must be 2-20 years.'); setResult(null); return; }
|
|
setError('');
|
|
setResult(computeBmi(w, h, Math.round(age * 12), sex));
|
|
}
|
|
|
|
return (
|
|
<section className={card} data-testid="calc-panel-bmi">
|
|
<h2 className="text-lg font-semibold">BMI Percentile (CDC 2000)</h2>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div className="grid grid-cols-2 gap-2 sm:col-span-1">
|
|
<div><label className={label}>Age (yr)</label><input type="number" min="2" max="20" step="0.1" className={input} value={ageYr} onChange={(e) => setAgeYr(e.target.value)} data-testid="bmi-age-yr" /></div>
|
|
<div><label className={label}>Months</label><input type="number" min="0" max="11" className={input} value={ageMo} onChange={(e) => setAgeMo(e.target.value)} data-testid="bmi-age-mo" /></div>
|
|
</div>
|
|
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="bmi-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
|
|
<div><label className={label}>Weight (kg)</label><input type="number" min="1" max="200" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="bmi-weight" /></div>
|
|
<div className="sm:col-span-1"><label className={label}>Height (cm)</label><input type="number" min="50" max="220" step="0.1" className={input} value={height} onChange={(e) => setHeight(e.target.value)} data-testid="bmi-height" /></div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bmi-calculate">Calculate</button>
|
|
<button type="button" onClick={() => { setAgeYr(''); setAgeMo(''); setWeight(''); setHeight(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
|
|
</div>
|
|
{error && <div className={errorBox}>{error}</div>}
|
|
{result && (
|
|
<div
|
|
className="rounded-lg p-4 space-y-2"
|
|
style={{ background: result.classification.bg, borderLeft: `4px solid ${result.classification.color}` }}
|
|
data-testid="calc-bmi-result"
|
|
>
|
|
<div className="text-base font-bold" style={{ color: result.classification.color }}>{result.classification.label}</div>
|
|
<div className="text-sm">BMI {result.bmi.toFixed(1)} kg/m² — {result.percentile}th percentile</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
|
<div><span className="text-xs uppercase text-muted-foreground">BMI</span><div className="font-semibold">{result.bmi.toFixed(1)}</div></div>
|
|
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile}th</div></div>
|
|
<div><span className="text-xs uppercase text-muted-foreground">Z-Score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
|
|
{result.percentile >= 85 && <div><span className="text-xs uppercase text-muted-foreground">% of 95th</span><div className="font-semibold">{result.classification.pctOf95.toFixed(0)}%</div></div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground italic">CDC 2000 LMS tables · Kuczmarski et al. Vital Health Stat 11. 2002;(246).</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Vitals ──────────────────────────────────────────────────
|
|
// Data ported verbatim from calculators.js:1703-1831.
|
|
interface VitalsEntry {
|
|
label: string;
|
|
hr: { awake: string; sleeping: string };
|
|
rr: string;
|
|
sbp: string;
|
|
dbp: string;
|
|
temp: string;
|
|
weight: string;
|
|
spo2: string;
|
|
notes: string[];
|
|
}
|
|
const VITALS_DATA: Record<string, VitalsEntry> = {
|
|
premie: { label: 'Premie', hr: { awake: '120-170', sleeping: '100-150' }, rr: '40-70', sbp: '55-75', dbp: '35-45', temp: '36.5-37.5', weight: '0.5-2.5 kg', spo2: '88-95% (target)',
|
|
notes: ['HR and RR are highly variable and depend on gestational age', 'BP increases with gestational age and postnatal age', 'Target SpO2 88-95% to reduce retinopathy of prematurity risk', 'Temperature instability is common — use servo-controlled warmers', 'Bradycardia (<100 bpm) and apnea are common in premature infants'] },
|
|
'0-3mo': { label: '0-3 Months', hr: { awake: '100-150', sleeping: '85-135' }, rr: '35-55', sbp: '65-85', dbp: '45-55', temp: '36.5-37.5', weight: '2.5-6 kg', spo2: '>95%',
|
|
notes: ['HR normally increases with crying (up to 180-190 bpm) — this is physiologic', 'Periodic breathing (pauses <10 sec) is normal in neonates', 'Acrocyanosis (blue hands/feet) is normal; central cyanosis is not', 'BP is best measured in the right arm (pre-ductal) in neonates', 'Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days'] },
|
|
'3-6mo': { label: '3-6 Months', hr: { awake: '90-120', sleeping: '75-110' }, rr: '30-45', sbp: '70-90', dbp: '50-65', temp: '36.5-37.5', weight: '5-8 kg', spo2: '>95%',
|
|
notes: ['Expected weight gain: 20-30 g/day (150-200 g/week)', 'HR gradually decreases as vagal tone matures', 'RR >60 at rest may indicate lower respiratory tract disease', 'BP should be measured with appropriate cuff size (width 40% of arm circumference)'] },
|
|
'6-12mo': { label: '6-12 Months', hr: { awake: '80-120', sleeping: '70-110' }, rr: '25-40', sbp: '80-100', dbp: '55-65', temp: '36.0-37.5', weight: '8-10 kg', spo2: '>95%',
|
|
notes: ['Expected weight: triple birth weight by 12 months (~10 kg average)', 'Weight gain slows to ~10-15 g/day', 'Sinus arrhythmia (HR varies with breathing) is normal', 'Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37'] },
|
|
'1-3yr': { label: '1-3 Years', hr: { awake: '70-110', sleeping: '60-100' }, rr: '20-30', sbp: '90-105', dbp: '55-70', temp: '36.0-37.5', weight: '10-15 kg', spo2: '>95%',
|
|
notes: ['Expected weight gain: ~200-250 g/month (2-2.5 kg/year)', 'Tachycardia: HR >110 at rest warrants evaluation', 'Tachypnea: RR >30 at rest may indicate respiratory distress', 'BP screening begins at age 3 per AAP 2017 guidelines', 'Estimated weight: 2 x (age in years) + 8'] },
|
|
'3-6yr': { label: '3-6 Years', hr: { awake: '65-110', sleeping: '55-100' }, rr: '20-25', sbp: '95-110', dbp: '60-75', temp: '36.0-37.5', weight: '14-20 kg', spo2: '>95%',
|
|
notes: ['Annual BP screening recommended from age 3', 'Normal BP <90th percentile for age, sex, and height', 'Elevated BP: 90th to <95th percentile (or 120/80 if lower)', 'Estimated weight: 2 x (age in years) + 8', 'ETT size (uncuffed): (age/4) + 4'] },
|
|
'6-12yr': { label: '6-12 Years', hr: { awake: '60-95', sleeping: '50-85' }, rr: '14-22', sbp: '100-120', dbp: '60-75', temp: '36.0-37.5', weight: '20-40 kg', spo2: '>95%',
|
|
notes: ['Resting HR >95 or <60 warrants evaluation', 'BP should be measured at every clinical encounter', 'Stage 1 HTN: >=95th percentile on 3 separate occasions', 'Estimated weight: 3 x (age in years) + 7', 'ETT size (cuffed): (age/4) + 3.5'] },
|
|
'>12yr': { label: '>12 Years', hr: { awake: '55-85', sleeping: '45-75' }, rr: '12-18', sbp: '110-135', dbp: '65-85', temp: '36.0-37.5', weight: '40-80 kg', spo2: '>95%',
|
|
notes: ['Vital signs approach adult values', 'From age 13: use adult BP thresholds (AAP 2017)', 'Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg', 'Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg', 'Orthostatic vitals: measure lying, sitting, standing if dizzy', 'Athletic bradycardia (HR 45-60) may be normal in trained adolescents'] },
|
|
};
|
|
const VITALS_ORDER = ['premie', '0-3mo', '3-6mo', '6-12mo', '1-3yr', '3-6yr', '6-12yr', '>12yr'];
|
|
|
|
export function VitalsPanel() {
|
|
const [key, setKey] = useState<string>('1-3yr');
|
|
const v = VITALS_DATA[key];
|
|
return (
|
|
<section className={card} data-testid="calc-panel-vitals">
|
|
<h2 className="text-lg font-semibold">Vital Signs by Age</h2>
|
|
<div className="max-w-xs">
|
|
<label className={label}>Age group</label>
|
|
<select className={input} value={key} onChange={(e) => setKey(e.target.value)} data-testid="vitals-age-select">
|
|
{VITALS_ORDER.map((k) => <option key={k} value={k}>{VITALS_DATA[k].label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" data-testid="vitals-result">
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (awake)</div><div className="font-semibold">{v.hr.awake} bpm</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (sleep)</div><div className="font-semibold">{v.hr.sleeping} bpm</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Respiratory rate</div><div className="font-semibold">{v.rr} /min</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SpO₂</div><div className="font-semibold">{v.spo2}</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SBP</div><div className="font-semibold">{v.sbp} mmHg</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">DBP</div><div className="font-semibold">{v.dbp} mmHg</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Temperature</div><div className="font-semibold">{v.temp} °C</div></div>
|
|
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Weight</div><div className="font-semibold">{v.weight}</div></div>
|
|
</div>
|
|
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs">
|
|
<div className="font-semibold mb-1">Clinical notes</div>
|
|
<ul className="list-disc pl-5 space-y-0.5">{v.notes.map((n, i) => <li key={i}>{n}</li>)}</ul>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook 23rd ed · PALS · AAP 2017 BP guidelines.</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Resus Meds ──────────────────────────────────────────────
|
|
// Data + math ported verbatim from calculators.js:1873-2050.
|
|
interface ResusResult { dose: string; extra: string; max: string }
|
|
interface ResusMed { name: string; indication: string; category: 'cardiac' | 'metabolic' | 'reversal'; route: string; calc: (w: number) => ResusResult }
|
|
const RESUS_MEDS: ResusMed[] = [
|
|
{ name: 'Adenosine', indication: 'SVT', category: 'cardiac', route: 'IV/IO rapid bolus',
|
|
calc: (w) => { const d1 = +(w * 0.1).toFixed(2); const d2 = +(w * 0.2).toFixed(2); const d3 = +(w * 0.3).toFixed(2); return { dose: `${d1} mg (0.1 mg/kg)`, extra: `May repeat: ${Math.min(d2, 12)} mg (0.2 mg/kg), then ${Math.min(d3, 12)} mg (0.3 mg/kg)`, max: 'Max first dose 6 mg, max subsequent 12 mg' }; } },
|
|
{ name: 'Amiodarone', indication: 'VT / VF', category: 'cardiac', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 5).toFixed(1); return { dose: `${Math.min(d, 300)} mg (5 mg/kg)`, extra: 'No pulse: push undiluted. Pulse: over 20-60 min. Subsequent max 150 mg.', max: 'Max first 300 mg, max total 15 mg/kg/24hr or 2200 mg' }; } },
|
|
{ name: 'Atropine', indication: 'Bradycardia', category: 'cardiac', route: 'IV/IO/IM',
|
|
calc: (w) => { const d = +(w * 0.02).toFixed(3); const ett = `${(w * 0.04).toFixed(3)}-${(w * 0.06).toFixed(3)}`; return { dose: `${Math.min(d, 0.5)} mg (0.02 mg/kg)`, extra: `ETT dose: ${ett} mg (0.04-0.06 mg/kg)`, max: 'Max single 0.5 mg, max total 1 mg' }; } },
|
|
{ name: 'Calcium Chloride 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 20).toFixed(0); return { dose: `${Math.min(d, 1000)} mg (20 mg/kg)`, extra: 'Give slowly. Central line preferred.', max: 'Max 1 g (1000 mg)' }; } },
|
|
{ name: 'Calcium Gluconate 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 60).toFixed(0); return { dose: `${Math.min(d, 3000)} mg (60 mg/kg)`, extra: 'Give slowly over 10-20 min with cardiac monitoring.', max: 'Max 3 g (3000 mg)' }; } },
|
|
{ name: 'Dextrose', indication: 'Hypoglycemia', category: 'metabolic', route: 'IV',
|
|
calc: (w) => {
|
|
const grams = `${+(w * 0.5).toFixed(1)}-${+(w * 1).toFixed(1)}`;
|
|
let detail = '';
|
|
if (w < 5) detail = `D10W: ${(w * 5).toFixed(1)}-${(w * 10).toFixed(1)} mL (5-10 mL/kg)`;
|
|
else if (w < 45) detail = `D25W: ${(w * 2).toFixed(1)}-${(w * 4).toFixed(1)} mL (2-4 mL/kg)`;
|
|
else detail = `D50W: ${(w * 1).toFixed(1)}-${(w * 2).toFixed(1)} mL (1-2 mL/kg)`;
|
|
return { dose: `${grams} g (0.5-1 g/kg)`, extra: detail, max: 'Max 25 g' };
|
|
} },
|
|
{ name: 'Epinephrine', indication: 'Pulseless arrest / Anaphylaxis', category: 'cardiac', route: 'IV/IO/IM/ETT',
|
|
calc: (w) => { const iv = +(w * 0.01).toFixed(3); const ivVol = +(w * 0.1).toFixed(2); const ett = +(w * 0.1).toFixed(2); const im = +(w * 0.01).toFixed(3);
|
|
return { dose: `${Math.min(iv, 1)} mg IV/IO (0.01 mg/kg of 0.1 mg/mL = ${Math.min(ivVol, 10)} mL) q3-5 min`, extra: `ETT: ${Math.min(ett, 2.5)} mg (0.1 mg/kg of 1 mg/mL). Anaphylaxis IM: ${Math.min(im, 0.5)} mg (0.01 mg/kg)`, max: 'Max IV 1 mg, max ETT 2.5 mg, max IM 0.5 mg' }; } },
|
|
{ name: 'Hydrocortisone', indication: 'Adrenal crisis', category: 'metabolic', route: 'IV/IM/IO',
|
|
calc: (w) => { const d = +(w * 2).toFixed(1); return { dose: `${Math.min(d, 100)} mg (2 mg/kg)`, extra: 'Stress dosing for adrenal insufficiency.', max: 'Max 100 mg' }; } },
|
|
{ name: 'Insulin (Regular)', indication: 'Hyperkalemia', category: 'metabolic', route: 'IV',
|
|
calc: (w) => { const d = +(w * 0.1).toFixed(2); const dex = +(w * 0.5).toFixed(1); return { dose: `${Math.min(d, 5)} units (0.1 units/kg)`, extra: `Give with ${dex} g/kg dextrose (0.5 g/kg). Monitor glucose closely.`, max: 'Max 5 units' }; } },
|
|
{ name: 'Lidocaine', indication: 'Antiarrhythmic', category: 'cardiac', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 1).toFixed(1); const ett = `${(w * 2).toFixed(1)}-${(w * 3).toFixed(1)}`; return { dose: `${Math.min(d, 100)} mg (1 mg/kg)`, extra: `ETT: ${ett} mg (2-3 mg/kg). May repeat q5 min.`, max: 'Max 100 mg/dose, max total 3 mg/kg' }; } },
|
|
{ name: 'Magnesium Sulfate', indication: 'Torsades de Pointes', category: 'cardiac', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 50).toFixed(0); return { dose: `${Math.min(d, 2000)} mg (50 mg/kg)`, extra: 'Give over 10-20 min (faster if pulseless).', max: 'Max 2 g (2000 mg)' }; } },
|
|
{ name: 'Naloxone', indication: 'Opioid overdose', category: 'reversal', route: 'IV/IO/IM/IN/ETT',
|
|
calc: (w) => { const partial = `${+(w * 0.001).toFixed(4)}-${+(w * 0.005).toFixed(4)}`; const full = +(w * 0.1).toFixed(3); return { dose: `Partial: ${partial} mg (0.001-0.005 mg/kg)`, extra: `Full reversal: ${Math.min(full, 2)} mg (0.1 mg/kg)`, max: 'Max partial first dose 0.1 mg, max full 2 mg' }; } },
|
|
{ name: 'Sodium Bicarbonate', indication: 'Metabolic acidosis', category: 'metabolic', route: 'IV/IO',
|
|
calc: (w) => { const d = +(w * 1).toFixed(1); return { dose: `${Math.min(d, 50)} mEq (1 mEq/kg)`, extra: w < 10 ? 'Dilute to 0.5 mEq/mL (use 4.2% solution) for neonates/small infants.' : 'Use 8.4% solution (1 mEq/mL).', max: 'Max 50 mEq' }; } },
|
|
];
|
|
const catColor: Record<ResusMed['category'], string> = { cardiac: '#ef4444', metabolic: '#3b82f6', reversal: '#10b981' };
|
|
const catLabel: Record<ResusMed['category'], string> = { cardiac: 'Cardiac', metabolic: 'Metabolic', reversal: 'Reversal' };
|
|
|
|
export function ResusPanel() {
|
|
const [weight, setWeight] = useState('');
|
|
const wt = Number.parseFloat(weight);
|
|
const valid = Number.isFinite(wt) && wt > 0;
|
|
|
|
return (
|
|
<section className={card} data-testid="calc-panel-resus">
|
|
<h2 className="text-lg font-semibold">Resus Medications</h2>
|
|
<div className="max-w-xs">
|
|
<label className={label}>Weight (kg)</label>
|
|
<input type="number" min="0.5" max="100" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resus-weight" />
|
|
</div>
|
|
{!valid ? <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p> : (
|
|
<>
|
|
<div className="text-sm font-semibold">Doses for {wt} kg patient</div>
|
|
<div className="flex gap-3 flex-wrap text-xs">
|
|
{(['cardiac', 'metabolic', 'reversal'] as const).map((c) => (
|
|
<span key={c} className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: catColor[c] }} />{catLabel[c]}</span>
|
|
))}
|
|
</div>
|
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3" data-testid="resus-result">
|
|
{RESUS_MEDS.map((med) => {
|
|
const r = med.calc(wt);
|
|
const color = catColor[med.category];
|
|
return (
|
|
<div key={med.name} className="rounded-lg border bg-card overflow-hidden" style={{ borderColor: color + '55' }}>
|
|
<div className="px-3 py-2 border-b" style={{ background: color + '10', borderColor: color + '22' }}>
|
|
<div className="text-sm font-bold" style={{ color }}>{med.name}</div>
|
|
<div className="text-xs text-muted-foreground">{med.indication}</div>
|
|
</div>
|
|
<div className="p-3 text-sm space-y-1">
|
|
<div><strong>Dose:</strong> {r.dose}</div>
|
|
<div className="text-xs text-muted-foreground">{r.extra}</div>
|
|
<div className="text-xs text-muted-foreground"><strong>Max:</strong> {r.max}</div>
|
|
<div className="text-xs text-muted-foreground"><strong>Route:</strong> {med.route}</div>
|
|
</div>
|
|
</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>Disclaimer:</strong> Always verify doses against institutional protocols and current guidelines.
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Equipment ───────────────────────────────────────────────
|
|
// Data ported verbatim from calculators.js:2173-2228.
|
|
interface EquipEntry {
|
|
label: string;
|
|
bvm: string; nasal: string; oral: string; blade: string;
|
|
ett: string; lma: string; glidescope: string;
|
|
iv: string; cvl: string; ngt: string; chest: string; foley: string;
|
|
}
|
|
const EQUIP_DATA: Record<string, EquipEntry> = {
|
|
premie: { label: 'Premie (1-3 kg)', bvm: 'Infant', nasal: '12 Fr', oral: 'Infant', blade: 'Miller 0', ett: '2.5-3.0', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3 Fr', ngt: '5 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
|
newborn: { label: 'Newborn (2-4 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 50 mm', blade: 'Miller 0', ett: '3.0-3.5', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3-4 Fr', ngt: '5-8 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
|
'6mo': { label: '6 months (6-8 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 60 mm', blade: 'Miller 1', ett: '3.5', lma: '1.5', glidescope: '2', iv: '20-24 ga', cvl: '4 Fr', ngt: '8 Fr', chest: '12-18 Fr', foley: '8 Fr' },
|
|
'1yr': { label: '1 year (10 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 60 mm', blade: 'Miller 1 / MAC 2', ett: '4.0', lma: '2', glidescope: '2', iv: '20-24 ga', cvl: '4-5 Fr', ngt: '10 Fr', chest: '16-20 Fr', foley: '8 Fr' },
|
|
'2-3yr': { label: '2-3 years (12-16 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 70 mm', blade: 'Miller 1 / MAC 2', ett: '4.0-4.5', lma: '2', glidescope: '2', iv: '18-22 ga', cvl: '4-5 Fr', ngt: '10-12 Fr', chest: '16-24 Fr', foley: '8 Fr' },
|
|
'4-6yr': { label: '4-6 years (20-25 kg)', bvm: 'Child', nasal: '16-20 Fr', oral: 'Small 70-80 mm', blade: 'Miller 2 / MAC 2', ett: '4.5-5.0', lma: '2.5', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-28 Fr', foley: '8 Fr' },
|
|
'7-10yr': { label: '7-10 years (25-35 kg)', bvm: 'Child / Small adult', nasal: '18-22 Fr', oral: 'Medium 80-90 mm', blade: 'Miller 2 / MAC 2', ett: '5.5-6.0', lma: '2.5-3', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-32 Fr', foley: '8 Fr' },
|
|
'11-15yr': { label: '11-15 years (40-50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '6.0-6.5', lma: '3', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-38 Fr', foley: '10 Fr' },
|
|
'16yr': { label: '16+ years (>50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '7.0-8.0', lma: '4', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-42 Fr', foley: '12 Fr' },
|
|
};
|
|
const EQUIP_ORDER = ['premie', 'newborn', '6mo', '1yr', '2-3yr', '4-6yr', '7-10yr', '11-15yr', '16yr'];
|
|
|
|
export function EquipmentPanel() {
|
|
const [key, setKey] = useState('1yr');
|
|
const e = EQUIP_DATA[key];
|
|
const rows: Array<[string, string]> = [
|
|
['BVM', e.bvm],
|
|
['Nasopharyngeal', e.nasal],
|
|
['Oropharyngeal', e.oral],
|
|
['Laryngoscope', e.blade],
|
|
['ETT', e.ett],
|
|
['LMA', e.lma],
|
|
['Glidescope', e.glidescope],
|
|
['IV', e.iv],
|
|
['Central line', e.cvl],
|
|
['NG tube', e.ngt],
|
|
['Chest tube', e.chest],
|
|
['Foley', e.foley],
|
|
];
|
|
return (
|
|
<section className={card} data-testid="calc-panel-equipment">
|
|
<h2 className="text-lg font-semibold">Equipment Sizing</h2>
|
|
<div className="max-w-xs">
|
|
<label className={label}>Age / weight band</label>
|
|
<select className={input} value={key} onChange={(e2) => setKey(e2.target.value)} data-testid="equip-age-select">
|
|
{EQUIP_ORDER.map((k) => <option key={k} value={k}>{EQUIP_DATA[k].label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm" data-testid="equip-result">
|
|
{rows.map(([lbl, val]) => (
|
|
<div key={lbl} className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">{lbl}</div><div className="font-semibold">{val}</div></div>
|
|
))}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook · PALS · Broselow cross-reference.</div>
|
|
</section>
|
|
);
|
|
}
|