pediatric-ai-scribe-v3/client/src/pages/Calculators.tsx
Daniel 9aa3961459 feat(client): port Bilirubin + Fenton calculators with captured vectors
Ships the AAP 2022 phototherapy/exchange nomograms, the Bhutani 1999
risk zones, and the Fenton 2013 preterm weight-for-GA chart as real
React calculators. These were the high-risk "table-driven" ports the
migration checkpoint flagged as needing captured vectors before
landing — the whole vector-capture workflow now exists and can be
reused for BP / BMI / growth-beyond-Fenton next.

Workflow, so future calculator ports have a template:

scripts/capture-calc-vectors.js
  Standalone Node script. Data tables + math inlined VERBATIM from
  public/js/calculators.js (no rewriting, no reformatting). Picks 83
  carefully chosen test cases — edge-of-domain, table-key-exact,
  interpolated-between-keys, and clinical-decision-boundary values —
  and emits e2e/fixtures/calc-vectors.json with
  { inputs, output } pairs produced by the authoritative math.

e2e/fixtures/calc-vectors.json
  12 Bhutani cases, 58 AAP 2022 cases, 13 Fenton cases. Re-run
  capture-calc-vectors.js whenever the vanilla file changes.

shared/clinical/bilirubin.ts
  • 17 HourTable constants ported byte-for-byte from calculators.js
    lines 1489-1512: photo 35w/36w/37w/38w/39w/40+ (low risk) +
    35w/36w/37w/38+ (medium risk), same 8 for exchange.
  • Bhutani zones (p95/p75/p40) from lines 1644-1651.
  • interpolateThreshold helper (lines 1514-1525).
  • classifyBhutani + classifyAapBili functions mirror the vanilla
    click-handler logic.

shared/clinical/fenton.ts
  • 15-week × 2-sex LMS table from lines 1168-1183 preserved entry-
    for-entry.
  • interpolateLMS + calcZ + zToPercentile (Abramowitz & Stegun
    normal CDF, lines 2299-2326) ported byte-for-byte.
  • fentonWeightForAge returns { L, M, S, z, percentile };
    classifySizeForAge labels SGA/<10 / AGA / LGA/>90.

shared/clinical/bilirubin.test.ts + fenton.test.ts
  Vitest suites that import e2e/fixtures/calc-vectors.json and assert
  classifyBhutani / classifyAapBili / fentonWeightForAge match
  every captured vector to 10 decimal places for threshold values
  and 6 decimals for the Fenton M (grams, so 6 is >= 1e-3 g).
  All 102 tests pass locally (19 prior + 70 bili + 13 Fenton).

client/src/pages/Calculators.tsx
  • BiliPanel with AAP 2022 / Bhutani mode switch, GA + risk-factor
    dropdowns, color-coded status / zone badges, both threshold
    pairs surfaced in the result grid.
  • GrowthPanel runs Fenton with sex + GA + weight inputs, emits
    Z-score, percentile, L/M/S reference, and SGA/AGA/LGA label.
  • PILLS flags bili + growth as ported: true; ActivePanel routes to
    the new components. BP / BMI / vitals / resus / equipment
    remain legacy-linked until their own vectors land.

e2e/tests/calculators-react.spec.js
  Three new parity tests covering above-phototherapy/above-exchange
  transitions (38w 72h TSB 20 → phototherapy; TSB 26 → exchange),
  Bhutani high-risk classification at 36h TSB 13, and Fenton 32w
  male 1795g landing exactly at 50th percentile AGA.

Backend tsc + client tsc + vite build + vitest (102/102) all green.
Bundle 619 kB / 178 kB gz (+10 kB for the bili tables).
2026-04-24 01:49:45 +02:00

610 lines
27 KiB
TypeScript

// ============================================================
// CALCULATORS — incremental React port.
// Low-risk pure formulas run here; high-risk table-driven calculators
// stay in the vanilla viewer until legacy vectors land.
//
// WHY this is gated on test vectors (from the migration checkpoint):
// • AAP 2017 BP percentile uses Rosner quantile splines with long
// hard-coded coefficient arrays.
// • Fenton 2013 LMS preterm growth carries 210 validated cases.
// • AAP 2022 bilirubin phototherapy + exchange: per-week risk
// curves, 1190 validated cases.
// • Bhutani nomogram risk zones.
// • APLS + Best Guess weight-for-age.
//
// Per the checkpoint: "An LLM will sometimes 'simplify' a long array
// of numbers and silently break it — don't let that happen." Every
// calculator needs a JSON vector file (~20 known inputs + expected
// outputs captured from public/js/calculators.js) before its React
// port lands, and the port must match every vector byte-for-byte.
//
// Pill order + labels match public/components/calculators.html.
// ============================================================
import { useState } from 'react';
import {
calculateGcs,
calculateMostellerBsa,
calculateWeightBasedDose,
} from '@shared/clinical/calculators';
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
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';
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
const field = 'space-y-1';
const label = 'block text-xs font-medium text-muted-foreground';
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 resultBox = 'rounded-lg border border-border bg-muted/40 p-4';
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';
interface Pill {
id: string;
label: string;
summary: string;
source: string; // where the formulas live
ported?: boolean;
}
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: '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: '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: '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' },
];
function parseOptionalNumber(value: string): number | null {
if (!value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function FormField({
id,
labelText,
value,
onChange,
min,
max,
step = '0.1',
placeholder,
}: {
id: string;
labelText: string;
value: string;
onChange: (value: string) => void;
min?: string;
max?: string;
step?: string;
placeholder?: string;
}) {
return (
<div className={field}>
<label htmlFor={id} className={label}>{labelText}</label>
<input
id={id}
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={input}
/>
</div>
);
}
function BsaPanel() {
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState('');
function calculate() {
const next = calculateMostellerBsa(Number(weight), Number(height));
if (next == null) {
setError('Enter a valid weight and height.');
setResult(null);
return;
}
setError('');
setResult(next);
}
function clear() {
setWeight('');
setHeight('');
setResult(null);
setError('');
}
return (
<section className={card} data-testid="calc-panel-bsa">
<h2 className="text-lg font-semibold">Body Surface Area</h2>
<p className="text-sm text-muted-foreground">
Mosteller formula: BSA (m2) = sqrt(height(cm) x weight(kg) / 3600).
</p>
<div className="grid gap-3 sm:grid-cols-2">
<FormField id="react-bsa-weight" labelText="Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="20" />
<FormField id="react-bsa-height" labelText="Height (cm)" value={height} onChange={setHeight} min="30" max="220" placeholder="110" />
</div>
<div className="flex gap-2">
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-bsa-calculate">Calculate</button>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{error ? <div className={errorBox}>{error}</div> : null}
{result == null ? null : (
<div className={resultBox} data-testid="calc-bsa-result">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Mosteller BSA</div>
<div className="text-2xl font-semibold">{result.toFixed(3)} m²</div>
<div className="text-sm text-muted-foreground">{weight} kg, {height} cm</div>
</div>
)}
</section>
);
}
function DosePanel() {
const [weight, setWeight] = useState('');
const [dosePerKg, setDosePerKg] = useState('');
const [frequency, setFrequency] = useState('1');
const [maxDose, setMaxDose] = useState('');
const [concentration, setConcentration] = useState('');
const [result, setResult] = useState<ReturnType<typeof calculateWeightBasedDose>>(null);
const [error, setError] = useState('');
function calculate() {
const next = calculateWeightBasedDose({
weightKg: Number(weight),
dosePerKg: Number(dosePerKg),
frequencyPerDay: Number(frequency),
maxSingleDoseMg: parseOptionalNumber(maxDose),
concentrationMgPerMl: parseOptionalNumber(concentration),
});
if (next == null) {
setError('Enter a valid weight, mg/kg dose, and frequency.');
setResult(null);
return;
}
setError('');
setResult(next);
}
function clear() {
setWeight('');
setDosePerKg('');
setFrequency('1');
setMaxDose('');
setConcentration('');
setResult(null);
setError('');
}
return (
<section className={card} data-testid="calc-panel-dose">
<h2 className="text-lg font-semibold">Weight-Based Dosing</h2>
<p className="text-sm text-muted-foreground">
Generic mg/kg calculator. Always verify medication-specific dosing against formulary and local policy.
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FormField id="react-dose-weight" labelText="Patient Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="15" />
<FormField id="react-dose-per-kg" labelText="Dose (mg/kg)" value={dosePerKg} onChange={setDosePerKg} min="0.01" step="0.01" placeholder="10" />
<div className={field}>
<label htmlFor="react-dose-frequency" className={label}>Frequency</label>
<select id="react-dose-frequency" value={frequency} onChange={(event) => setFrequency(event.target.value)} className={input}>
<option value="1">Once daily</option>
<option value="2">Twice daily (BID)</option>
<option value="3">Three times daily (TID)</option>
<option value="4">Four times daily (QID)</option>
<option value="6">Every 4 hours (Q4H)</option>
</select>
</div>
<FormField id="react-dose-max" labelText="Max single dose (mg, optional)" value={maxDose} onChange={setMaxDose} min="0" step="1" placeholder="500" />
<FormField id="react-dose-concentration" labelText="Concentration (mg/mL, optional)" value={concentration} onChange={setConcentration} min="0" placeholder="40" />
</div>
<div className="flex gap-2">
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-dose-calculate">Calculate</button>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{error ? <div className={errorBox}>{error}</div> : null}
{result == null ? null : (
<div className={resultBox} data-testid="calc-dose-result">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Single Dose</div>
<div className="text-xl font-semibold">{result.singleDoseMg.toFixed(1)} mg</div>
{result.capped ? <div className="text-xs text-red-600">Capped at max dose</div> : null}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Daily Total</div>
<div className="text-xl font-semibold">{result.dailyDoseMg.toFixed(1)} mg/day</div>
<div className="text-xs text-muted-foreground">x {result.frequencyPerDay}/day</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Volume</div>
<div className="text-xl font-semibold">{result.volumeMl == null ? 'n/a' : `${result.volumeMl.toFixed(1)} mL`}</div>
<div className="text-xs text-muted-foreground">per dose</div>
</div>
</div>
</div>
)}
</section>
);
}
const GCS_OPTIONS = {
child: {
eye: [
['4', '4 - Spontaneous'],
['3', '3 - To speech'],
['2', '2 - To pain'],
['1', '1 - None'],
],
verbal: [
['5', '5 - Oriented'],
['4', '4 - Confused'],
['3', '3 - Inappropriate words'],
['2', '2 - Incomprehensible sounds'],
['1', '1 - None'],
],
motor: [
['6', '6 - Obeys commands'],
['5', '5 - Localizes pain'],
['4', '4 - Withdraws to pain'],
['3', '3 - Abnormal flexion'],
['2', '2 - Abnormal extension'],
['1', '1 - None'],
],
},
infant: {
eye: [
['4', '4 - Spontaneous'],
['3', '3 - To speech/sound'],
['2', '2 - To painful stimuli'],
['1', '1 - None'],
],
verbal: [
['5', '5 - Coos/babbles'],
['4', '4 - Irritable cry'],
['3', '3 - Cries to pain'],
['2', '2 - Moans to pain'],
['1', '1 - None'],
],
motor: [
['6', '6 - Normal spontaneous movement'],
['5', '5 - Withdraws to touch'],
['4', '4 - Withdraws to pain'],
['3', '3 - Abnormal flexion'],
['2', '2 - Abnormal extension'],
['1', '1 - None'],
],
},
} as const;
function GcsSelect({
id,
labelText,
value,
options,
onChange,
}: {
id: string;
labelText: string;
value: string;
options: readonly (readonly [string, string])[];
onChange: (value: string) => void;
}) {
return (
<div className={field}>
<label htmlFor={id} className={label}>{labelText}</label>
<select id={id} value={value} onChange={(event) => onChange(event.target.value)} className={input}>
{options.map(([optionValue, text]) => (
<option key={optionValue} value={optionValue}>{text}</option>
))}
</select>
</div>
);
}
function GcsPanel() {
const [scale, setScale] = useState<'child' | 'infant'>('child');
const [eye, setEye] = useState('4');
const [verbal, setVerbal] = useState('5');
const [motor, setMotor] = useState('6');
const result = calculateGcs(Number(eye), Number(verbal), Number(motor));
const options = GCS_OPTIONS[scale];
function switchScale(next: 'child' | 'infant') {
setScale(next);
setEye('4');
setVerbal('5');
setMotor('6');
}
return (
<section className={card} data-testid="calc-panel-gcs">
<h2 className="text-lg font-semibold">Glasgow Coma Scale</h2>
<p className="text-sm text-muted-foreground">
Select responses to calculate child/adult or infant-modified GCS. Total score 3-15.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => switchScale('child')}
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'child' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
>
Child / Adult
</button>
<button
type="button"
onClick={() => switchScale('infant')}
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'infant' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
>
Infant
</button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<GcsSelect id="react-gcs-eye" labelText="Eye Opening" value={eye} options={options.eye} onChange={setEye} />
<GcsSelect id="react-gcs-verbal" labelText="Verbal Response" value={verbal} options={options.verbal} onChange={setVerbal} />
<GcsSelect id="react-gcs-motor" labelText="Motor Response" value={motor} options={options.motor} onChange={setMotor} />
</div>
{result == null ? null : (
<div className={resultBox} data-testid="calc-gcs-result">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{scale === 'infant' ? 'Infant-modified GCS' : 'Child / adult GCS'}</div>
<div className="text-3xl font-semibold">GCS: {result.total}/15</div>
<div className="text-sm text-muted-foreground">{result.severity}</div>
<div className="mt-2 text-xs text-muted-foreground">Interpretation: 13-15 Mild, 9-12 Moderate, 3-8 Severe/Coma.</div>
</div>
)}
</section>
);
}
function LegacyPanel({ pill }: { pill: Pill }) {
return (
<section className={card} data-testid={'calc-panel-' + pill.id}>
<h2 className="text-lg font-semibold">{pill.label}</h2>
<p className="text-sm text-muted-foreground">{pill.summary}</p>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
<p className="text-amber-900 dark:text-amber-100">
<strong>Source of truth:</strong> {pill.source}.
</p>
<p className="text-amber-900 dark:text-amber-100">
This calculator runs in the legacy viewer. A React port is gated on capturing test vectors
from the vanilla implementation so the numerical output can be verified byte-for-byte
the migration checkpoint specifically flags this class of data as the one an LLM is most
likely to silently simplify.
</p>
</div>
<a href="/#calculators" className={btnPrimary + ' inline-block'}>
Open in legacy viewer
</a>
</section>
);
}
function BiliPanel() {
const [mode, setMode] = useState<'aap' | 'bhutani'>('aap');
const [ga, setGa] = useState('38');
const [hours, setHours] = useState('');
const [tsb, setTsb] = useState('');
const [risk, setRisk] = useState<BiliRisk>('low');
const [aapResult, setAapResult] = useState<ReturnType<typeof classifyAapBili> | null>(null);
const [bhutResult, setBhutResult] = useState<ReturnType<typeof classifyBhutani> | null>(null);
const [error, setError] = useState('');
function calc() {
const hoursNum = Number(hours);
const tsbNum = Number(tsb);
if (!Number.isFinite(hoursNum) || !Number.isFinite(tsbNum) || hoursNum <= 0 || tsbNum <= 0) {
setError('Enter hours of life and TSB (mg/dL).');
setAapResult(null);
setBhutResult(null);
return;
}
setError('');
if (mode === 'aap') {
const gaNum = Number(ga);
if (!Number.isFinite(gaNum) || gaNum < 35) {
setError('AAP 2022 thresholds apply to GA ≥35 weeks.');
setAapResult(null);
return;
}
setAapResult(classifyAapBili(gaNum, hoursNum, tsbNum, risk));
setBhutResult(null);
} else {
setBhutResult(classifyBhutani(hoursNum, tsbNum));
setAapResult(null);
}
}
const statusColor = aapResult
? aapResult.status === 'Above Exchange' ? 'text-red-800 bg-red-100'
: aapResult.status === 'Above Phototherapy' ? 'text-red-700 bg-red-50'
: 'text-green-700 bg-green-50'
: '';
const zoneColor = bhutResult
? bhutResult.zone === 'High-Risk' ? 'text-red-800 bg-red-100'
: bhutResult.zone === 'High-Intermediate' ? 'text-orange-700 bg-orange-50'
: bhutResult.zone === 'Low-Intermediate' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50'
: '';
return (
<section className={card} data-testid="calc-panel-bili">
<h2 className="text-lg font-semibold">Bilirubin</h2>
<div className="flex gap-2">
<button type="button" onClick={() => setMode('aap')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'aap' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-aap">AAP 2022 Phototherapy</button>
<button type="button" onClick={() => setMode('bhutani')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'bhutani' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-bhutani">Bhutani Nomogram</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{mode === 'aap' && (
<>
<div className={field}>
<label htmlFor="bili-ga" className={label}>GA (weeks)</label>
<select id="bili-ga" className={input} value={ga} onChange={(e) => setGa(e.target.value)}>
{[35, 36, 37, 38, 39, 40].map((g) => <option key={g} value={g}>{g}{g === 40 ? '+' : ''}</option>)}
</select>
</div>
<div className={field}>
<label htmlFor="bili-risk" className={label}>Neurotoxicity risk</label>
<select id="bili-risk" className={input} value={risk} onChange={(e) => setRisk(e.target.value as BiliRisk)}>
<option value="low">No risk factors</option>
<option value="medium">With risk factors</option>
</select>
</div>
</>
)}
<FormField id="bili-hours" labelText="Age (hours)" value={hours} onChange={setHours} min="0" max="336" placeholder="48" />
<FormField id="bili-tsb" labelText="TSB (mg/dL)" value={tsb} onChange={setTsb} min="0" max="50" placeholder="15" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-bili-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setHours(''); setTsb(''); setAapResult(null); setBhutResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{aapResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-aap-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + statusColor}>{aapResult.status}</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life (GA {ga}w {risk === 'medium' ? 'with' : 'without'} risk factors)</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Phototherapy</span><div className="font-semibold">{aapResult.photoThreshold.toFixed(1)} mg/dL</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Exchange</span><div className="font-semibold text-red-800">{aapResult.exchangeThreshold.toFixed(1)} mg/dL</div></div>
</div>
<div className="text-xs text-muted-foreground italic">AAP 2022 CPG (Kemper et al.). Always use clinical judgment.</div>
</div>
)}
{bhutResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-bhutani-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + zoneColor}>{bhutResult.zone} Zone</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">40th %ile</span><div className="font-semibold">{bhutResult.p40.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">75th %ile</span><div className="font-semibold">{bhutResult.p75.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">95th %ile</span><div className="font-semibold">{bhutResult.p95.toFixed(1)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Bhutani 1999 hour-specific risk nomogram for infants 35 weeks GA.</div>
</div>
)}
</section>
);
}
function GrowthPanel() {
const [sex, setSex] = useState<Sex>('male');
const [ga, setGa] = useState('');
const [weight, setWeight] = useState('');
const [result, setResult] = useState<ReturnType<typeof fentonWeightForAge> | null>(null);
const [error, setError] = useState('');
function calc() {
const gaNum = Number(ga);
const wtNum = Number(weight);
if (!Number.isFinite(gaNum) || !Number.isFinite(wtNum) || gaNum < 22 || gaNum > 50 || wtNum <= 0) {
setError('Enter GA (22-50 weeks) and weight (grams).');
setResult(null);
return;
}
setError('');
setResult(fentonWeightForAge(gaNum, wtNum, sex));
}
const classification = result ? classifySizeForAge(result.percentile) : null;
const classColor = classification === 'SGA' ? 'text-orange-700 bg-orange-50'
: classification === 'LGA' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50';
return (
<section className={card} data-testid="calc-panel-growth">
<h2 className="text-lg font-semibold">Fenton 2013 Preterm Growth</h2>
<p className="text-sm text-muted-foreground">Weight-for-gestational-age Z-score + percentile + SGA/AGA/LGA classification.</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className={field}>
<label htmlFor="fenton-sex" className={label}>Sex</label>
<select id="fenton-sex" className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)}>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<FormField id="fenton-ga" labelText="GA (weeks)" value={ga} onChange={setGa} min="22" max="50" step="0.1" placeholder="32" />
<FormField id="fenton-weight" labelText="Weight (g)" value={weight} onChange={setWeight} min="200" max="7000" step="10" placeholder="1500" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-fenton-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setGa(''); setWeight(''); setResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{result && classification && (
<div className={resultBox + ' space-y-2'} data-testid="calc-fenton-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + classColor}>{classification}</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile.toFixed(1)}%</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Z-score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Median (M)</span><div className="font-semibold">{Math.round(result.M)} g</div></div>
<div><span className="text-xs uppercase text-muted-foreground">L / S</span><div className="font-mono text-xs">{result.L.toFixed(3)} / {result.S.toFixed(3)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Fenton TR, Kim JH. Systematic review revised Fenton growth chart for preterm infants. BMC Pediatr 2013;13:59.</div>
</div>
)}
</section>
);
}
function ActivePanel({ pill }: { pill: Pill }) {
if (pill.id === 'bsa') return <BsaPanel />;
if (pill.id === 'dose') return <DosePanel />;
if (pill.id === 'gcs') return <GcsPanel />;
if (pill.id === 'bili') return <BiliPanel />;
if (pill.id === 'growth') return <GrowthPanel />;
return <LegacyPanel pill={pill} />;
}
export default function Calculators() {
const [active, setActive] = useState<string>(PILLS[0].id);
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
return (
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Calculators</h1>
<p className="text-sm text-muted-foreground">
Pediatric calculators BP percentiles, bilirubin thresholds, growth, dosing, equipment sizing.
Simple pure-formula calculators run in React now; high-risk table-driven calculators remain
legacy-gated until vectors are captured.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="calc-subnav">
{PILLS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setActive(p.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === p.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'calc-pill-' + p.id}
>
{p.label}{p.ported ? <span className="ml-1 text-[10px] opacity-80">React</span> : null}
</button>
))}
</div>
<ActivePanel pill={pill} />
</div>
);
}