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).
610 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|