feat(client): port BMI + Vitals + Resus Meds + Equipment calculators

Nine of the ten Calculator pills now run in React. Only BP Percentile
(Rosner quantile splines, ~3,000 hand-transcribed coefficients across
6 LMS arrays and 4 spline matrices) remains legacy-linked; that port
deserves its own dedicated session with extra care.

shared/clinical/bmi.ts — CDC 2000 BMI-for-age
  • bmiLMS table ported byte-for-byte from calculators.js:739
    (74 LMS triples = 37 age points × 2 sexes, 24-240 months).
  • normalCDF (Abramowitz & Stegun), calcBmiPercentile, classifyBMI
    (including the %-of-95th severe-obesity split), and a top-level
    computeBmi helper — all verbatim translations of the vanilla math.
  • classification labels preserved exactly so existing e2e screenshots
    or reporting continue to read the same text ('Class 2 Severe
    Obesity', 'Healthy Weight', etc.).

shared/clinical/bmi.test.ts — 12 captured vectors covering:
  both sexes, edges (2y + 20y), interpolated-between-keys (13m),
  each classification cliff (underweight / healthy / overweight /
  obese / severe class 2 / severe class 3), and the age-clamping
  branches (<24 mo and >240 mo). All 12 pass at 10-decimal precision
  (percentile to 6 places since the vanilla rounds to 2).

scripts/capture-calc-vectors.js — BMI section added
  Same pattern as bilirubin / Fenton: the vanilla data + math are
  inlined verbatim, the script runs 12 chosen cases, and writes to
  e2e/fixtures/calc-vectors.json. Re-run after any upstream change.

client/src/pages/CalculatorPanels.tsx (new)
  • BmiPanel — age/sex/weight/height inputs, calls computeBmi,
    renders color-coded classification badge with BMI, percentile,
    Z, and % of 95th when percentile ≥85.
  • VitalsPanel — 8-band age selector (premie → >12 yr).
    VITALS_DATA ported verbatim from calculators.js:1703-1831 with
    every HR/RR/SBP/DBP/temp/weight/SpO₂ range and clinical notes
    preserved entry-for-entry.
  • ResusPanel — weight input drives all 14 drugs (Adenosine,
    Amiodarone, Atropine, CaCl, Ca-gluconate, Dextrose, Epi,
    Hydrocortisone, Insulin, Lidocaine, Mg, Naloxone, NaHCO₃) with
    calc() closures ported verbatim from RESUS_MEDS lines 1873-2050.
    Category colors + labels preserved.
  • EquipmentPanel — 9-band age selector (premie → 16+ yr).
    EQUIP_DATA ported verbatim from calculators.js:2173-2228 with
    12 equipment sizes per band (BVM, NPA, OPA, blade, ETT, LMA,
    Glidescope, IV, CVL, NGT, chest tube, Foley).

client/src/pages/Calculators.tsx
  Dispatch wires bmi → BmiPanel, vitals → VitalsPanel, resus →
  ResusPanel, equipment → EquipmentPanel. PILLS flags all four as
  ported: true. Only bp remains on LegacyPanel.

Backend tsc + client tsc + vite build + 122/122 vitest (19 calc +
70 bili + 21 fenton/neonatal + 12 bmi) all green.
This commit is contained in:
Daniel 2026-04-24 02:08:04 +02:00
parent 57d6263daf
commit 7371cbf13a
11 changed files with 835 additions and 62 deletions

View file

@ -0,0 +1,295 @@
// ============================================================
// 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';
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';
// ── 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>
);
}

View file

@ -29,6 +29,7 @@ import {
} from '@shared/clinical/calculators';
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
import { BmiPanel, VitalsPanel, ResusPanel, EquipmentPanel } from './CalculatorPanels';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
@ -49,15 +50,15 @@ interface Pill {
const PILLS: Pill[] = [
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines' },
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS' },
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS', ported: true },
{ id: 'growth', label: 'Growth Charts', summary: 'Fenton 2013 preterm weight-for-GA with Z-score + percentile + SGA/AGA/LGA classification.', source: 'Fenton 2013 LMS', ported: true },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999', ported: true },
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'PALS + AHA reference' },
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'Harriet Lane + PALS + AHA', ported: true },
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller body surface area formula.', source: 'Mosteller 1987', ported: true },
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Generic mg/kg dosing with optional max-dose cap and concentration conversion.', source: 'Legacy calculator formula', ported: true },
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS' },
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS', ported: true },
{ id: 'gcs', label: 'GCS', summary: 'Child/adult and infant Glasgow Coma Scale variants.', source: 'Teasdale + pediatric modification', ported: true },
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference' },
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference', ported: true },
];
function parseOptionalNumber(value: string): number | null {
@ -567,6 +568,10 @@ function ActivePanel({ pill }: { pill: Pill }) {
if (pill.id === 'gcs') return <GcsPanel />;
if (pill.id === 'bili') return <BiliPanel />;
if (pill.id === 'growth') return <GrowthPanel />;
if (pill.id === 'bmi') return <BmiPanel />;
if (pill.id === 'vitals') return <VitalsPanel />;
if (pill.id === 'resus') return <ResusPanel />;
if (pill.id === 'equipment') return <EquipmentPanel />;
return <LegacyPanel pill={pill} />;
}

View file

@ -1,5 +1,5 @@
{
"generatedAt": "2026-04-23T23:54:26.721Z",
"generatedAt": "2026-04-24T00:03:50.703Z",
"source": "public/js/calculators.js + public/js/bedside/neonatal.js",
"bhutani": [
{
@ -1315,5 +1315,271 @@
"expectedWeight": 496
}
}
],
"bmi": [
{
"name": "BMI 2y male healthy",
"inputs": {
"weightKg": 12,
"heightCm": 85,
"ageMonths": 24,
"sex": "male"
},
"output": {
"bmi": 16.60899653979239,
"z": 0.04590054841030556,
"percentile": 51.83,
"L": -1.982374,
"M": 16.5478,
"S": 0.080127,
"classification": {
"label": "Healthy Weight",
"pctOf95": 86.14975440063787,
"bmi95": 19.279215193757317
}
}
},
{
"name": "BMI 2y female underweight",
"inputs": {
"weightKg": 10,
"heightCm": 85,
"ageMonths": 24,
"sex": "female"
},
"output": {
"bmi": 13.84083044982699,
"z": -2.169042945502352,
"percentile": 1.5,
"L": -1.024497,
"M": 16.388,
"S": 0.085026,
"classification": {
"label": "Underweight",
"pctOf95": 72.6229727185413,
"bmi95": 19.058474104976014
}
}
},
{
"name": "BMI 5y male overweight",
"inputs": {
"weightKg": 22,
"heightCm": 110,
"ageMonths": 60,
"sex": "male"
},
"output": {
"bmi": 18.18181818181818,
"z": 1.7619400118187871,
"percentile": 96.1,
"L": -2.615166,
"M": 15.4191,
"S": 0.075992,
"classification": {
"label": "Obese (Class 1)",
"pctOf95": 101.3526600484823,
"bmi95": 17.939162300348958
}
}
},
{
"name": "BMI 5y female obese",
"inputs": {
"weightKg": 27,
"heightCm": 110,
"ageMonths": 60,
"sex": "female"
},
"output": {
"bmi": 22.314049586776857,
"z": 2.5728061038110686,
"percentile": 99.5,
"L": -3.350078,
"M": 15.1519,
"S": 0.0843,
"classification": {
"label": "Class 2 Severe Obesity",
"pctOf95": 122.21644077873168,
"bmi95": 18.257813306129258
}
}
},
{
"name": "BMI 8y male healthy interior",
"inputs": {
"weightKg": 26,
"heightCm": 128,
"ageMonths": 96,
"sex": "male"
},
"output": {
"bmi": 15.869140625,
"z": 0.05328264940184456,
"percentile": 52.12,
"L": -3.183058,
"M": 15.7823,
"S": 0.102091,
"classification": {
"label": "Healthy Weight",
"pctOf95": 79.07470043209199,
"bmi95": 20.068543463693736
}
}
},
{
"name": "BMI 10y female severe obesity",
"inputs": {
"weightKg": 55,
"heightCm": 138,
"ageMonths": 120,
"sex": "female"
},
"output": {
"bmi": 28.880487292585595,
"z": 2.3156540689599736,
"percentile": 98.97,
"L": -2.171296,
"M": 16.8623,
"S": 0.137057,
"classification": {
"label": "Class 2 Severe Obesity",
"pctOf95": 125.657690683571,
"bmi95": 22.983461764637976
}
}
},
{
"name": "BMI 12y male interpolated 13m",
"inputs": {
"weightKg": 45,
"heightCm": 150,
"ageMonths": 155,
"sex": "male"
},
"output": {
"bmi": 20,
"z": 0.5601095425342837,
"percentile": 71.23,
"L": -2.3388571666666667,
"M": 18.416283333333336,
"S": 0.13394933333333334,
"classification": {
"label": "Healthy Weight",
"pctOf95": 79.67578522133032,
"bmi95": 25.101729395502364
}
}
},
{
"name": "BMI 15y female adolescent",
"inputs": {
"weightKg": 55,
"heightCm": 162,
"ageMonths": 180,
"sex": "female"
},
"output": {
"bmi": 20.957171162932475,
"z": 0.31720639335017814,
"percentile": 62.45,
"L": -2.034893,
"M": 19.9306,
"S": 0.150512,
"classification": {
"label": "Healthy Weight",
"pctOf95": 74.51433067032951,
"bmi95": 28.125021018644542
}
}
},
{
"name": "BMI 18y male 20",
"inputs": {
"weightKg": 75,
"heightCm": 178,
"ageMonths": 216,
"sex": "male"
},
"output": {
"bmi": 23.671253629592222,
"z": 0.5483006053710078,
"percentile": 70.83,
"L": -1.87467,
"M": 21.8959,
"S": 0.132286,
"classification": {
"label": "Healthy Weight",
"pctOf95": 81.73895442996405,
"bmi95": 28.959574776399098
}
}
},
{
"name": "BMI 20y female (upper boundary)",
"inputs": {
"weightKg": 60,
"heightCm": 165,
"ageMonths": 240,
"sex": "female"
},
"output": {
"bmi": 22.03856749311295,
"z": 0.09286285862204761,
"percentile": 53.7,
"L": -2.342797,
"M": 21.7219,
"S": 0.153241,
"classification": {
"label": "Healthy Weight",
"pctOf95": 69.30198458421499,
"bmi95": 31.80077399707353
}
}
},
{
"name": "BMI clamped lower (1y)",
"inputs": {
"weightKg": 10,
"heightCm": 75,
"ageMonths": 12,
"sex": "male"
},
"output": {
"bmi": 17.77777777777778,
"z": 0.8341020364361114,
"percentile": 79.79,
"L": -1.982374,
"M": 16.5478,
"S": 0.080127,
"classification": {
"label": "Healthy Weight",
"pctOf95": 92.2121445251272,
"bmi95": 19.279215193757317
}
}
},
{
"name": "BMI clamped upper (25y)",
"inputs": {
"weightKg": 70,
"heightCm": 175,
"ageMonths": 300,
"sex": "male"
},
"output": {
"bmi": 22.857142857142858,
"z": -0.06006050146869193,
"percentile": 47.61,
"L": -1.843581,
"M": 23.0414,
"S": 0.134675,
"classification": {
"label": "Healthy Weight",
"pctOf95": 74.61824032278491,
"bmi95": 30.632111878097128
}
}
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -154,6 +154,48 @@ function neonatalAssess(weeks, days, weightGrams, sex) {
return { gaDecimal, L: lms.L, M: lms.M, S: lms.S, z, percentile, expectedWeight: Math.round(lms.M) };
}
// ── CDC 2000 BMI LMS (verbatim from calculators.js:739) ──
const bmiLMS = {male:{24:{L:-1.982374,M:16.5478,S:0.080127},30:{L:-1.642107,M:16.2497,S:0.075499},36:{L:-1.419991,M:16.0003,S:0.072634},42:{L:-1.438165,M:15.7941,S:0.071495},48:{L:-1.714869,M:15.6282,S:0.071889},54:{L:-2.155348,M:15.5026,S:0.073491},60:{L:-2.615166,M:15.4191,S:0.075992},66:{L:-2.981797,M:15.3795,S:0.079211},72:{L:-3.211705,M:15.3835,S:0.083048},78:{L:-3.314769,M:15.429,S:0.0874},84:{L:-3.323189,M:15.5129,S:0.092131},90:{L:-3.270455,M:15.6317,S:0.097082},96:{L:-3.183058,M:15.7823,S:0.102091},102:{L:-3.079383,M:15.9617,S:0.107013},108:{L:-2.971148,M:16.1671,S:0.111721},114:{L:-2.865311,M:16.3961,S:0.116113},120:{L:-2.765648,M:16.6461,S:0.120112},126:{L:-2.673903,M:16.9151,S:0.123664},132:{L:-2.59056,M:17.2009,S:0.126735},138:{L:-2.51532,M:17.5014,S:0.129309},144:{L:-2.447426,M:17.8146,S:0.131389},150:{L:-2.385858,M:18.1387,S:0.132991},156:{L:-2.329457,M:18.4718,S:0.134141},162:{L:-2.277017,M:18.812,S:0.13488},168:{L:-2.227362,M:19.1576,S:0.135251},174:{L:-2.179426,M:19.5067,S:0.135309},180:{L:-2.132345,M:19.8577,S:0.13511},186:{L:-2.085574,M:20.2086,S:0.134718},192:{L:-2.039015,M:20.5576,S:0.134198},198:{L:-1.99315,M:20.9029,S:0.13362},204:{L:-1.949135,M:21.2425,S:0.133057},210:{L:-1.908831,M:21.5742,S:0.132585},216:{L:-1.87467,M:21.8959,S:0.132286},222:{L:-1.849323,M:22.2054,S:0.132249},228:{L:-1.835138,M:22.5007,S:0.132566},234:{L:-1.833401,M:22.7799,S:0.133339},240:{L:-1.843581,M:23.0414,S:0.134675}},female:{24:{L:-1.024497,M:16.388,S:0.085026},30:{L:-1.534542,M:16.0059,S:0.080932},36:{L:-2.096829,M:15.6992,S:0.078605},42:{L:-2.618733,M:15.4647,S:0.077904},48:{L:-3.018522,M:15.2985,S:0.078713},54:{L:-3.2593,M:15.1961,S:0.080904},60:{L:-3.350078,M:15.1519,S:0.0843},66:{L:-3.325522,M:15.1606,S:0.08868},72:{L:-3.225607,M:15.2169,S:0.093803},78:{L:-3.084291,M:15.3161,S:0.099427},84:{L:-2.926187,M:15.4536,S:0.105325},90:{L:-2.76731,M:15.6252,S:0.111295},96:{L:-2.617192,M:15.827,S:0.117159},102:{L:-2.480952,M:16.0552,S:0.122771},108:{L:-2.360921,M:16.3061,S:0.128014},114:{L:-2.257782,M:16.5763,S:0.132797},120:{L:-2.171296,M:16.8623,S:0.137057},126:{L:-2.100749,M:17.161,S:0.140754},132:{L:-2.045235,M:17.4691,S:0.143868},138:{L:-2.003802,M:17.7836,S:0.146399},144:{L:-1.975521,M:18.1015,S:0.148361},150:{L:-1.95952,M:18.42,S:0.149783},156:{L:-1.954978,M:18.7364,S:0.150705},162:{L:-1.9611,M:19.0481,S:0.151176},168:{L:-1.977074,M:19.3526,S:0.151256},174:{L:-2.002014,M:19.6475,S:0.15101},180:{L:-2.034893,M:19.9306,S:0.150512},186:{L:-2.07446,M:20.1998,S:0.149843},192:{L:-2.119157,M:20.4533,S:0.14909},198:{L:-2.167045,M:20.6891,S:0.148349},204:{L:-2.215738,M:20.9058,S:0.147723},210:{L:-2.262382,M:21.1016,S:0.147323},216:{L:-2.303688,M:21.2753,S:0.147269},222:{L:-2.336038,M:21.4255,S:0.147689},228:{L:-2.355678,M:21.5508,S:0.148724},234:{L:-2.35898,M:21.6501,S:0.150521},240:{L:-2.342797,M:21.7219,S:0.153241}}};
function calcBmiPercentile(bmi, L, M, S) {
let z;
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
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);
const prob = 0.5 * (1 + sign * y);
return { z, percentile: Math.round(prob * 10000) / 100 };
}
function classifyBMI(percentile, bmi, lms) {
const p95z = 1.645;
const bmi95 = Math.abs(lms.L) < 0.001
? lms.M * Math.exp(lms.S * p95z)
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
const pctOf95 = (bmi / bmi95) * 100;
if (percentile >= 95) {
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', pctOf95, bmi95 };
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', pctOf95, bmi95 };
return { label: 'Obese (Class 1)', pctOf95, bmi95 };
}
if (percentile >= 85) return { label: 'Overweight', pctOf95, bmi95 };
if (percentile >= 5) return { label: 'Healthy Weight', pctOf95, bmi95 };
return { label: 'Underweight', pctOf95, bmi95 };
}
function computeBmi(weightKg, heightCm, ageMonths, sex) {
const bmi = weightKg / Math.pow(heightCm / 100, 2);
const clamped = Math.max(24, Math.min(240, ageMonths));
const lms = interpolateLMS(bmiLMS[sex], clamped);
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
const classification = classifyBMI(percentile, bmi, lms);
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
}
function zToPercentile(z) {
// Abramowitz & Stegun normal CDF approximation (from lines 2318-2326).
const t = 1 / (1 + 0.2316419 * Math.abs(z));
@ -297,6 +339,23 @@ const fentonCases = [
{ name: 'Fenton female 36w LGA 3400g', inputs: { sex: 'female', gaWeeks: 36, weightGrams: 3400 } },
];
const bmiCases = [
// Edges + interior, both sexes. Cover underweight / healthy / overweight / obese / severe obesity cliffs.
{ name: 'BMI 2y male healthy', inputs: { weightKg: 12, heightCm: 85, ageMonths: 24, sex: 'male' } },
{ name: 'BMI 2y female underweight', inputs: { weightKg: 10, heightCm: 85, ageMonths: 24, sex: 'female' } },
{ name: 'BMI 5y male overweight', inputs: { weightKg: 22, heightCm: 110, ageMonths: 60, sex: 'male' } },
{ name: 'BMI 5y female obese', inputs: { weightKg: 27, heightCm: 110, ageMonths: 60, sex: 'female' } },
{ name: 'BMI 8y male healthy interior', inputs: { weightKg: 26, heightCm: 128, ageMonths: 96, sex: 'male' } },
{ name: 'BMI 10y female severe obesity', inputs: { weightKg: 55, heightCm: 138, ageMonths: 120, sex: 'female' } },
{ name: 'BMI 12y male interpolated 13m', inputs: { weightKg: 45, heightCm: 150, ageMonths: 155, sex: 'male' } },
{ name: 'BMI 15y female adolescent', inputs: { weightKg: 55, heightCm: 162, ageMonths: 180, sex: 'female' } },
{ name: 'BMI 18y male 20', inputs: { weightKg: 75, heightCm: 178, ageMonths: 216, sex: 'male' } },
{ name: 'BMI 20y female (upper boundary)', inputs: { weightKg: 60, heightCm: 165, ageMonths: 240, sex: 'female' } },
// Boundaries — clamping
{ name: 'BMI clamped lower (1y)', inputs: { weightKg: 10, heightCm: 75, ageMonths: 12, sex: 'male' } },
{ name: 'BMI clamped upper (25y)', inputs: { weightKg: 70, heightCm: 175, ageMonths: 300, sex: 'male' } },
];
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)',
@ -318,9 +377,10 @@ const vectors = {
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) })),
bmi: bmiCases.map((c) => ({ ...c, output: computeBmi(c.inputs.weightKg, c.inputs.heightCm, c.inputs.ageMonths, 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, ${vectors.neonatal.length} Neonatal cases → ${outPath}`);
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton, ${vectors.neonatal.length} Neonatal, ${vectors.bmi.length} BMI cases → ${outPath}`);

View file

@ -0,0 +1,39 @@
// ============================================================
// BMI-FOR-AGE parity tests.
// ============================================================
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { computeBmi } from './bmi';
import type { Sex } from './fenton';
interface BmiCase {
name: string;
inputs: { weightKg: number; heightCm: number; ageMonths: number; sex: Sex };
output: {
bmi: number; z: number; percentile: number;
L: number; M: number; S: number;
classification: { label: string; pctOf95: number; bmi95: number };
};
}
interface Vectors { bmi: BmiCase[] }
const vectors: Vectors = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'e2e', 'fixtures', 'calc-vectors.json'), 'utf8'),
);
describe('CDC 2000 BMI-for-age — parity with vanilla calculators.js', () => {
it.each(vectors.bmi)('$name', ({ inputs, output }) => {
const actual = computeBmi(inputs.weightKg, inputs.heightCm, inputs.ageMonths, inputs.sex);
expect(actual.bmi).toBeCloseTo(output.bmi, 10);
expect(actual.z).toBeCloseTo(output.z, 10);
expect(actual.percentile).toBeCloseTo(output.percentile, 6);
expect(actual.L).toBeCloseTo(output.L, 10);
expect(actual.M).toBeCloseTo(output.M, 10);
expect(actual.S).toBeCloseTo(output.S, 10);
expect(actual.classification.label).toBe(output.classification.label);
expect(actual.classification.pctOf95).toBeCloseTo(output.classification.pctOf95, 6);
expect(actual.classification.bmi95).toBeCloseTo(output.classification.bmi95, 10);
});
});

108
shared/clinical/bmi.ts Normal file
View file

@ -0,0 +1,108 @@
// ============================================================
// BMI-FOR-AGE — CDC 2000 LMS. Table + math + classification
// ported VERBATIM from public/js/calculators.js:739-1017.
// Vitest parity driven by calc-vectors.json.
//
// CDC reference:
// Kuczmarski RJ, Ogden CL, Guo SS, et al. 2000 CDC Growth
// Charts for the United States: methods and development.
// Vital Health Stat 11. 2002;(246):1-190.
// ============================================================
import type { Sex, Lms } from './fenton';
import { interpolateLMS } from './fenton';
// 37 age points (24-240 months, 6-mo steps) × 2 sexes. Verbatim from
// calculators.js:739. Values are floating-point to 6+ decimals; the
// test vectors pin every one.
export const bmiLMS: Record<Sex, Record<number, Lms>> = {
male: {
24: { L: -1.982374, M: 16.5478, S: 0.080127 }, 30: { L: -1.642107, M: 16.2497, S: 0.075499 }, 36: { L: -1.419991, M: 16.0003, S: 0.072634 }, 42: { L: -1.438165, M: 15.7941, S: 0.071495 },
48: { L: -1.714869, M: 15.6282, S: 0.071889 }, 54: { L: -2.155348, M: 15.5026, S: 0.073491 }, 60: { L: -2.615166, M: 15.4191, S: 0.075992 }, 66: { L: -2.981797, M: 15.3795, S: 0.079211 },
72: { L: -3.211705, M: 15.3835, S: 0.083048 }, 78: { L: -3.314769, M: 15.429, S: 0.0874 }, 84: { L: -3.323189, M: 15.5129, S: 0.092131 }, 90: { L: -3.270455, M: 15.6317, S: 0.097082 },
96: { L: -3.183058, M: 15.7823, S: 0.102091 }, 102: { L: -3.079383, M: 15.9617, S: 0.107013 }, 108: { L: -2.971148, M: 16.1671, S: 0.111721 }, 114: { L: -2.865311, M: 16.3961, S: 0.116113 },
120: { L: -2.765648, M: 16.6461, S: 0.120112 }, 126: { L: -2.673903, M: 16.9151, S: 0.123664 }, 132: { L: -2.59056, M: 17.2009, S: 0.126735 }, 138: { L: -2.51532, M: 17.5014, S: 0.129309 },
144: { L: -2.447426, M: 17.8146, S: 0.131389 }, 150: { L: -2.385858, M: 18.1387, S: 0.132991 }, 156: { L: -2.329457, M: 18.4718, S: 0.134141 }, 162: { L: -2.277017, M: 18.812, S: 0.13488 },
168: { L: -2.227362, M: 19.1576, S: 0.135251 }, 174: { L: -2.179426, M: 19.5067, S: 0.135309 }, 180: { L: -2.132345, M: 19.8577, S: 0.13511 }, 186: { L: -2.085574, M: 20.2086, S: 0.134718 },
192: { L: -2.039015, M: 20.5576, S: 0.134198 }, 198: { L: -1.99315, M: 20.9029, S: 0.13362 }, 204: { L: -1.949135, M: 21.2425, S: 0.133057 }, 210: { L: -1.908831, M: 21.5742, S: 0.132585 },
216: { L: -1.87467, M: 21.8959, S: 0.132286 }, 222: { L: -1.849323, M: 22.2054, S: 0.132249 }, 228: { L: -1.835138, M: 22.5007, S: 0.132566 }, 234: { L: -1.833401, M: 22.7799, S: 0.133339 },
240: { L: -1.843581, M: 23.0414, S: 0.134675 },
},
female: {
24: { L: -1.024497, M: 16.388, S: 0.085026 }, 30: { L: -1.534542, M: 16.0059, S: 0.080932 }, 36: { L: -2.096829, M: 15.6992, S: 0.078605 }, 42: { L: -2.618733, M: 15.4647, S: 0.077904 },
48: { L: -3.018522, M: 15.2985, S: 0.078713 }, 54: { L: -3.2593, M: 15.1961, S: 0.080904 }, 60: { L: -3.350078, M: 15.1519, S: 0.0843 }, 66: { L: -3.325522, M: 15.1606, S: 0.08868 },
72: { L: -3.225607, M: 15.2169, S: 0.093803 }, 78: { L: -3.084291, M: 15.3161, S: 0.099427 }, 84: { L: -2.926187, M: 15.4536, S: 0.105325 }, 90: { L: -2.76731, M: 15.6252, S: 0.111295 },
96: { L: -2.617192, M: 15.827, S: 0.117159 }, 102: { L: -2.480952, M: 16.0552, S: 0.122771 }, 108: { L: -2.360921, M: 16.3061, S: 0.128014 }, 114: { L: -2.257782, M: 16.5763, S: 0.132797 },
120: { L: -2.171296, M: 16.8623, S: 0.137057 }, 126: { L: -2.100749, M: 17.161, S: 0.140754 }, 132: { L: -2.045235, M: 17.4691, S: 0.143868 }, 138: { L: -2.003802, M: 17.7836, S: 0.146399 },
144: { L: -1.975521, M: 18.1015, S: 0.148361 }, 150: { L: -1.95952, M: 18.42, S: 0.149783 }, 156: { L: -1.954978, M: 18.7364, S: 0.150705 }, 162: { L: -1.9611, M: 19.0481, S: 0.151176 },
168: { L: -1.977074, M: 19.3526, S: 0.151256 }, 174: { L: -2.002014, M: 19.6475, S: 0.15101 }, 180: { L: -2.034893, M: 19.9306, S: 0.150512 }, 186: { L: -2.07446, M: 20.1998, S: 0.149843 },
192: { L: -2.119157, M: 20.4533, S: 0.14909 }, 198: { L: -2.167045, M: 20.6891, S: 0.148349 }, 204: { L: -2.215738, M: 20.9058, S: 0.147723 }, 210: { L: -2.262382, M: 21.1016, S: 0.147323 },
216: { L: -2.303688, M: 21.2753, S: 0.147269 }, 222: { L: -2.336038, M: 21.4255, S: 0.147689 }, 228: { L: -2.355678, M: 21.5508, S: 0.148724 }, 234: { L: -2.35898, M: 21.6501, S: 0.150521 },
240: { L: -2.342797, M: 21.7219, S: 0.153241 },
},
};
// Abramowitz & Stegun erf-form normal CDF (0-1 range). Verbatim from
// calculators.js:754-761.
export function normalCDF(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 0.5 * (1 + sign * y);
}
// Verbatim from calculators.js:741-752.
export interface BmiZ { z: number; percentile: number }
export function calcBmiPercentile(bmi: number, L: number, M: number, S: number): BmiZ {
let z: number;
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
const p = normalCDF(z);
return { z, percentile: Math.round(p * 10000) / 100 };
}
// Verbatim from calculators.js:1000-1017.
export type BmiClassification =
| 'Underweight' | 'Healthy Weight' | 'Overweight'
| 'Obese (Class 1)' | 'Class 2 Severe Obesity' | 'Class 3 Severe Obesity';
export interface BmiClass {
label: BmiClassification;
color: string;
bg: string;
pctOf95: number;
bmi95: number;
}
export function classifyBMI(percentile: number, bmi: number, lms: Lms): BmiClass {
const p95z = 1.645;
const bmi95 = Math.abs(lms.L) < 0.001
? lms.M * Math.exp(lms.S * p95z)
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
const pctOf95 = (bmi / bmi95) * 100;
if (percentile >= 95) {
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', color: '#7f1d1d', bg: '#fecaca', pctOf95, bmi95 };
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', color: '#dc2626', bg: '#fee2e2', pctOf95, bmi95 };
return { label: 'Obese (Class 1)', color: '#ef4444', bg: '#fee2e2', pctOf95, bmi95 };
}
if (percentile >= 85) return { label: 'Overweight', color: '#f97316', bg: '#ffedd5', pctOf95, bmi95 };
if (percentile >= 5) return { label: 'Healthy Weight', color: '#10b981', bg: '#d1fae5', pctOf95, bmi95 };
return { label: 'Underweight', color: '#f59e0b', bg: '#fef3c7', pctOf95, bmi95 };
}
// Top-level helper combining interpolate + math + classification.
// Mirrors the click-handler flow in calculators.js:1019-1038.
export interface BmiResult extends BmiZ {
bmi: number;
L: number; M: number; S: number;
classification: BmiClass;
}
export function computeBmi(weightKg: number, heightCm: number, ageMonths: number, sex: Sex): BmiResult {
const bmi = weightKg / Math.pow(heightCm / 100, 2);
const clamped = Math.max(24, Math.min(240, ageMonths));
const lms = interpolateLMS(bmiLMS[sex], clamped);
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
const classification = classifyBMI(percentile, bmi, lms);
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
}