feat(client): port age→weight + BSA + dose + GCS; shared/clinical module

First batch of real calculator ports, routed through a new
shared/clinical/calculators.ts module that both the server tree and
the React client can import. Kept strictly to the simplest, closed-form
formulas — tables (Rosner BP, Fenton LMS, AAP 2022 bili, Bhutani, CDC
BMI) stay in the vanilla viewer until per-table vector files land.

shared/clinical/calculators.ts
  Verbatim ports from public/js/calc-math.js:
    • parseAgeMonths / formatAgeMonths — legacy age-string parser
    • estimateWeightFromAgeMonths — APLS (Luscombe 2007) + Best Guess
      (Tinning 2007) weight-for-age. Cross-checked line-by-line against
      calc-math.js:14-39; identical branches, formulas, and roundTo
      behavior.
    • calculateMostellerBsa — sqrt(h·w/3600), Mosteller 1987.
    • calculateWeightBasedDose — generic mg/kg with optional max cap
      and mg/mL → mL conversion.
    • calculateGcs — 1-15 sum with 8/12 severity thresholds.

shared/clinical/calculators.test.ts
  Vitest unit coverage for each helper. Numeric assertions match the
  legacy function outputs (3y APLS → 14 kg; 20 kg, 110 cm → 0.782 m²;
  15 kg × 100 mg/kg capped at 500 mg, etc.). For these closed-form
  formulas the hand-verified expected values are equivalent to a
  vanilla-captured vector file — table-driven calculators still need
  a JSON fixture before they port.

client/src/pages/Bedside.tsx
  Top-level age-to-weight estimator now runs in React
  (BedsideWeightEstimator). Formula dropdown switches between APLS
  and Best Guess live; weight field accepts a manual override. The
  15 clinical dosing sub-modules still fall through to the legacy
  viewer via LegacyPanel.

client/src/pages/Calculators.tsx
  BSA, Weight-Based Dosing, and GCS panels render real React forms
  backed by the shared helpers. PILLS gain a `ported` flag so the
  four covered panels (bsa, dose, gcs, + the already-shipped pills)
  swap out of the legacy fallback while the others remain linked
  out. Result blocks carry data-testid hooks for parity tests.

Config + dep hygiene picked up along the way
  • client/tsconfig.app.json: @shared/* path alias, exclude test files
    from the React tsc pass.
  • tsconfig.json: exclude **/*.test.ts from the backend tsc pass.
  • package.json: declare google-auth-library and jszip explicitly —
    both were already required() in src/utils/ttsGoogle.ts and
    src/routes/learningAI.ts but missing from dependencies, which
    would break a clean `npm install`. Also adds engines: node >=20
    and convenience verify / verify:full scripts.
  • knip.json: quiet now-expected ignoreDependencies / ignoreBinaries
    entries for marp-cli, tiptap, cap, etc.
  • .gitignore: ignore the .codex CLI marker.

e2e/tests/bedside-react.spec.js
  Adds the age→weight parity test: 3y APLS → 14 kg, 3y Best Guess →
  16 kg, weight field mirrors the estimator output.

e2e/tests/calculators-react.spec.js
  Adds BSA (20 kg, 110 cm → 0.782 m²), dose cap (15 kg × 100 mg/kg,
  max 500 mg → 500 mg capped), and GCS (15 → 10 when motor drops
  to 1) parity tests.

Client tsc -b + backend tsc --noEmit + vite build all clean.
Bundle 476.48 kB / 135.44 kB gzipped.

Co-Authored-By: Codex + vendor model Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-24 01:11:57 +02:00
parent 4250ea47fe
commit 18550e263f
17 changed files with 866 additions and 23 deletions

3
.gitignore vendored
View file

@ -36,3 +36,6 @@ public/models/
e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
# Codex CLI marker
.codex

View file

@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",

View file

@ -1,7 +1,7 @@
// ============================================================
// BEDSIDE — emergency + rapid-reference pediatric tools. This first
// port ships the sub-nav shell at /app/bedside so the sidebar links
// light up and users land on a page that mirrors the legacy layout.
// BEDSIDE — emergency + rapid-reference pediatric tools.
// Top-level age-to-weight estimation is React + pure shared TS.
// Individual dosing modules port one at a time after parity tests.
//
// The 15 clinical sub-modules (neonatal, airway, cardiac, respiratory,
// ventilation, seizures, sepsis, anaphylaxis, sedation, agitation,
@ -13,15 +13,20 @@
// BP splines, Fenton LMS, AAP 2022 bilirubin, APLS weights) where
// test vectors can verify byte-for-byte parity.
//
// The top-level age → weight estimator (APLS / Best Guess formulas)
// also depends on public/js/calculators.js which has not been ported
// yet. It will appear at the top of this page when calculators port.
// ============================================================
import { useState } from 'react';
import {
estimateWeightFromAgeMonths,
formatAgeMonths,
parseAgeMonths,
} from '@shared/clinical/calculators';
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 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';
interface Pill {
id: string;
@ -49,6 +54,94 @@ const PILLS: Pill[] = [
{ id: 'trauma', label: 'Trauma', icon: '🩹', summary: 'PECARN, c-spine, blood-product dosing, TXA.' },
];
function BedsideWeightEstimator() {
const [age, setAge] = useState('');
const [formula, setFormula] = useState<'apls' | 'bestguess'>('apls');
const [manualWeight, setManualWeight] = useState('');
const months = parseAgeMonths(age);
const estimate = months == null ? null : estimateWeightFromAgeMonths(months);
const pickedWeight = estimate
? formula === 'bestguess'
? estimate.all.bestGuess
: estimate.all.apls
: null;
const displayedWeight = manualWeight.trim() || (pickedWeight == null ? '' : String(pickedWeight));
function clear() {
setAge('');
setFormula('apls');
setManualWeight('');
}
return (
<section className={card} data-testid="bedside-weight-estimator">
<div>
<h2 className="text-lg font-semibold">Age Weight Estimator</h2>
<p className="text-sm text-muted-foreground">
Shared starting point for Bedside dosing. Uses the same APLS and Best Guess formulas as the legacy app.
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1.2fr_1fr_1fr_auto] md:items-end">
<div className="space-y-1">
<label htmlFor="bedside-react-age" className={label}>Age</label>
<input
id="bedside-react-age"
value={age}
onChange={(event) => setAge(event.target.value)}
placeholder='e.g. "18m", "3y", "2y5m"'
className={input}
data-testid="bedside-age-input"
/>
</div>
<div className="space-y-1">
<label htmlFor="bedside-react-formula" className={label}>Formula</label>
<select
id="bedside-react-formula"
value={formula}
onChange={(event) => {
setFormula(event.target.value as 'apls' | 'bestguess');
setManualWeight('');
}}
className={input}
data-testid="bedside-formula-select"
>
<option value="apls">APLS</option>
<option value="bestguess">Best Guess</option>
</select>
</div>
<div className="space-y-1">
<label htmlFor="bedside-react-weight" className={label}>Weight (kg)</label>
<input
id="bedside-react-weight"
type="number"
min="0.3"
step="0.1"
value={displayedWeight}
onChange={(event) => setManualWeight(event.target.value)}
className={input}
data-testid="bedside-weight-input"
/>
</div>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{age.trim() && months == null ? (
<div className="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">
Could not parse age. Try "3y", "18 months", or "15 days".
</div>
) : null}
{estimate && pickedWeight != null ? (
<div className="rounded-lg border border-border bg-muted/40 p-4 text-sm" data-testid="bedside-estimate-result">
<div className="font-semibold">{pickedWeight} kg estimated from {formatAgeMonths(months ?? 0)}</div>
<div className="text-muted-foreground">
APLS: {estimate.all.apls} kg · Best Guess: {estimate.all.bestGuess} kg. You can override the weight field.
</div>
</div>
) : null}
</section>
);
}
function LegacyPanel({ pill }: { pill: Pill }) {
return (
<section className={card} data-testid={'bedside-panel-' + pill.id}>
@ -103,6 +196,7 @@ export default function Bedside() {
))}
</div>
<BedsideWeightEstimator />
<LegacyPanel pill={pill} />
</div>
);

View file

@ -1,7 +1,7 @@
// ============================================================
// CALCULATORS — shell + sub-nav. This port intentionally delivers
// ONLY the page shell and sub-navigation. The actual math runs in
// the vanilla viewer until test vectors land.
// 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
@ -22,15 +22,27 @@
// ============================================================
import { useState } from 'react';
import {
calculateGcs,
calculateMostellerBsa,
calculateWeightBasedDose,
} from '@shared/clinical/calculators';
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[] = [
@ -39,13 +51,322 @@ const PILLS: Pill[] = [
{ id: 'growth', label: 'Growth Charts', summary: 'WHO 02 y / CDC 220 y; Fenton 2013 preterm (weight, length, head).', source: 'WHO 2006 + CDC 2000 + Fenton 2013 LMS' },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999' },
{ 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 + DuBois formulas.', source: 'Mosteller 1987 / DuBois 1916' },
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Common pediatric med doses per kg + max dose.', source: 'Lexicomp / Harriet Lane' },
{ 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' },
{ 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}>
@ -69,6 +390,13 @@ function LegacyPanel({ pill }: { pill: Pill }) {
);
}
function ActivePanel({ pill }: { pill: Pill }) {
if (pill.id === 'bsa') return <BsaPanel />;
if (pill.id === 'dose') return <DosePanel />;
if (pill.id === 'gcs') return <GcsPanel />;
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];
@ -79,7 +407,8 @@ export default function Calculators() {
<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.
Formula ports arrive one at a time, each verified against captured test vectors.
Simple pure-formula calculators run in React now; high-risk table-driven calculators remain
legacy-gated until vectors are captured.
</p>
</header>
@ -97,12 +426,12 @@ export default function Calculators() {
}
data-testid={'calc-pill-' + p.id}
>
{p.label}
{p.label}{p.ported ? <span className="ml-1 text-[10px] opacity-80">React</span> : null}
</button>
))}
</div>
<LegacyPanel pill={pill} />
<ActivePanel pill={pill} />
</div>
);
}

View file

@ -22,12 +22,16 @@
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"../shared/**/*.ts"
],
"exclude": [
"../shared/**/*.test.ts"
]
}

View file

@ -40,4 +40,14 @@ test.describe('React Bedside — sub-nav shell', () => {
await openReactBedside(page);
await expect(page.getByText('Open in legacy viewer').first()).toBeVisible();
});
test('age-to-weight estimator runs in React', async ({ authedPage: _, page }) => {
await openReactBedside(page);
await page.fill('[data-testid="bedside-age-input"]', '3y');
await expect(page.locator('[data-testid="bedside-estimate-result"]')).toContainText('14 kg');
await expect(page.locator('[data-testid="bedside-weight-input"]')).toHaveValue('14');
await page.selectOption('[data-testid="bedside-formula-select"]', 'bestguess');
await expect(page.locator('[data-testid="bedside-estimate-result"]')).toContainText('16 kg');
await expect(page.locator('[data-testid="bedside-weight-input"]')).toHaveValue('16');
});
});

View file

@ -37,4 +37,32 @@ test.describe('React Calculators — sub-nav shell', () => {
await openReactCalc(page);
await expect(page.getByText('Open in legacy viewer').first()).toBeVisible();
});
test('BSA calculator runs in React', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-bsa"]');
await page.fill('#react-bsa-weight', '20');
await page.fill('#react-bsa-height', '110');
await page.click('[data-testid="calc-bsa-calculate"]');
await expect(page.locator('[data-testid="calc-bsa-result"]')).toContainText('0.782');
});
test('weight-based dose calculator caps max dose', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-dose"]');
await page.fill('#react-dose-weight', '15');
await page.fill('#react-dose-per-kg', '100');
await page.fill('#react-dose-max', '500');
await page.click('[data-testid="calc-dose-calculate"]');
await expect(page.locator('[data-testid="calc-dose-result"]')).toContainText('500.0 mg');
await expect(page.locator('[data-testid="calc-dose-result"]')).toContainText('Capped');
});
test('GCS calculator updates score from selected components', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-gcs"]');
await expect(page.locator('[data-testid="calc-gcs-result"]')).toContainText('GCS: 15/15');
await page.selectOption('#react-gcs-motor', '1');
await expect(page.locator('[data-testid="calc-gcs-result"]')).toContainText('GCS: 10/15');
});
});

View file

@ -17,7 +17,20 @@
"client/**",
"e2e/**"
],
"exclude": [
"exports",
"types",
"duplicates"
],
"ignoreFiles": [
"src/utils/config.ts"
],
"ignoreBinaries": [
"cap"
],
"ignoreDependencies": [
"@marp-team/marp-cli",
"@tiptap/.*",
"@tsconfig/node20",
"@types/.*",
"ts-node-dev",

2
package-lock.json generated
View file

@ -25,7 +25,9 @@
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"google-auth-library": "^9.15.1",
"helmet": "^8.0.0",
"jszip": "^3.10.1",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"multer": "^1.4.5-lts.1",

View file

@ -3,18 +3,23 @@
"version": "6.35.0",
"description": "AI-powered pediatric clinical documentation platform",
"main": "dist/server.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node dist/server.js",
"prebuild": "rm -rf dist",
"build": "tsc",
"typecheck": "tsc --noEmit",
"verify": "npm run typecheck && npm test && npm run test:node && npm run lint:refs && npm run lint:dead && npm --prefix client run build",
"verify:full": "npm run verify && npm run e2e",
"dev": "ts-node-dev --respawn --transpile-only server.ts",
"test": "vitest run",
"test:node": "node --test test/",
"test:coverage": "vitest run --coverage",
"e2e": "./scripts/e2e.sh",
"lint:refs": "node scripts/lint-references.js",
"lint:dead": "knip",
"lint:dead": "knip --no-config-hints",
"maint:check": "node scripts/maintenance.js check",
"maint:reindex": "node scripts/maintenance.js reindex",
"migrate": "node-pg-migrate",
@ -41,7 +46,9 @@
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"google-auth-library": "^9.15.1",
"helmet": "^8.0.0",
"jszip": "^3.10.1",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"multer": "^1.4.5-lts.1",

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-x592EjRR.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-0qzAvrmR.css">
<script type="module" crossorigin src="/app/assets/index-DmvH7O2M.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-9V57IPOu.css">
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import {
calculateGcs,
calculateMostellerBsa,
calculateWeightBasedDose,
estimateWeightFromAgeMonths,
formatAgeMonths,
parseAgeMonths,
} from './calculators';
describe('parseAgeMonths', () => {
it('parses flexible age strings from the legacy calculator parser', () => {
expect(parseAgeMonths('3y')).toBe(36);
expect(parseAgeMonths('2y5m')).toBe(29);
expect(parseAgeMonths('2 years 5 months')).toBe(29);
expect(parseAgeMonths('6 months')).toBe(6);
expect(parseAgeMonths('2 wk')).toBeCloseTo(14 / 30.4375);
expect(parseAgeMonths('15 days')).toBeCloseTo(15 / 30.4375);
});
it('keeps plain numbers as months for legacy parity', () => {
expect(parseAgeMonths('36')).toBe(36);
expect(parseAgeMonths(6)).toBe(6);
});
it('rejects blank and unparseable ages', () => {
expect(parseAgeMonths('')).toBeNull();
expect(parseAgeMonths('older child')).toBeNull();
expect(parseAgeMonths(null)).toBeNull();
});
});
describe('formatAgeMonths', () => {
it('formats days, months, and years', () => {
expect(formatAgeMonths(15 / 30.4375)).toBe('15 days (0.49 mo)');
expect(formatAgeMonths(18)).toBe('18 months');
expect(formatAgeMonths(29)).toBe('2 yr 5 mo (29 mo total)');
});
});
describe('estimateWeightFromAgeMonths', () => {
it('matches APLS infant band', () => {
const result = estimateWeightFromAgeMonths(6);
expect(result).toEqual({
weight: 7,
formulaLabel: 'APLS 0-12 mo',
all: { apls: 7, bestGuess: 7.5 },
});
});
it('matches APLS 1-5 year band', () => {
const result = estimateWeightFromAgeMonths(36);
expect(result?.weight).toBe(14);
expect(result?.formulaLabel).toBe('APLS 1-5 yr');
expect(result?.all).toEqual({ apls: 14, bestGuess: 16 });
});
it('matches APLS 6-12 year band', () => {
const result = estimateWeightFromAgeMonths(96);
expect(result?.weight).toBe(31);
expect(result?.formulaLabel).toBe('APLS 6-12 yr');
expect(result?.all).toEqual({ apls: 31, bestGuess: 32 });
});
it('uses Best Guess from 13 years onward', () => {
const result = estimateWeightFromAgeMonths(168);
expect(result?.weight).toBe(56);
expect(result?.formulaLabel).toBe('Best Guess 5-14 yr');
});
it('rejects invalid ages', () => {
expect(estimateWeightFromAgeMonths(null)).toBeNull();
expect(estimateWeightFromAgeMonths(Number.NaN)).toBeNull();
expect(estimateWeightFromAgeMonths(-1)).toBeNull();
});
});
describe('calculateMostellerBsa', () => {
it('matches the legacy 20 kg, 110 cm smoke-test value', () => {
expect(calculateMostellerBsa(20, 110)?.toFixed(3)).toBe('0.782');
});
it('rejects invalid measurements', () => {
expect(calculateMostellerBsa(0, 110)).toBeNull();
expect(calculateMostellerBsa(20, 0)).toBeNull();
});
});
describe('calculateWeightBasedDose', () => {
it('calculates a single dose and daily total', () => {
const result = calculateWeightBasedDose({ weightKg: 15, dosePerKg: 10, frequencyPerDay: 3 });
expect(result).toEqual({
singleDoseMg: 150,
dailyDoseMg: 450,
frequencyPerDay: 3,
capped: false,
volumeMl: null,
});
});
it('respects a max single-dose cap', () => {
const result = calculateWeightBasedDose({ weightKg: 15, dosePerKg: 100, maxSingleDoseMg: 500 });
expect(result?.singleDoseMg).toBe(500);
expect(result?.capped).toBe(true);
});
it('calculates dose volume when concentration is supplied', () => {
const result = calculateWeightBasedDose({
weightKg: 20,
dosePerKg: 12.5,
concentrationMgPerMl: 50,
});
expect(result?.singleDoseMg).toBe(250);
expect(result?.volumeMl).toBe(5);
});
it('rejects invalid dose inputs', () => {
expect(calculateWeightBasedDose({ weightKg: 0, dosePerKg: 10 })).toBeNull();
expect(calculateWeightBasedDose({ weightKg: 15, dosePerKg: 0 })).toBeNull();
expect(calculateWeightBasedDose({ weightKg: 15, dosePerKg: 10, frequencyPerDay: 0 })).toBeNull();
});
});
describe('calculateGcs', () => {
it('scores maximum child/adult defaults as mild GCS 15', () => {
expect(calculateGcs(4, 5, 6)).toEqual({ total: 15, severity: 'Mild' });
});
it('scores 4/5/1 as moderate GCS 10', () => {
expect(calculateGcs(4, 5, 1)).toEqual({ total: 10, severity: 'Moderate' });
});
it('scores 1/1/1 as severe coma', () => {
expect(calculateGcs(1, 1, 1)).toEqual({ total: 3, severity: 'Severe (Coma)' });
});
it('rejects invalid component scores', () => {
expect(calculateGcs(0, 5, 6)).toBeNull();
expect(calculateGcs(5, 5, 6)).toBeNull();
expect(calculateGcs(4, 6, 6)).toBeNull();
expect(calculateGcs(4, 5, 7)).toBeNull();
});
});

View file

@ -0,0 +1,206 @@
// ============================================================
// Pure pediatric calculator helpers.
//
// Keep clinical math out of React components and DOM event handlers.
// High-risk tables such as Rosner BP, Fenton LMS, and bilirubin
// thresholds should be added here only after legacy vectors exist.
// ============================================================
export type WeightFormulaBand =
| 'APLS 0-12 mo'
| 'APLS 1-5 yr'
| 'APLS 6-12 yr'
| 'Best Guess 5-14 yr';
export interface EstimatedWeight {
weight: number;
formulaLabel: WeightFormulaBand;
all: {
apls: number;
bestGuess: number;
};
}
export function parseAgeMonths(input: string | number | null | undefined): number | null {
if (input == null) return null;
const value = String(input).toLowerCase().trim();
if (!value) return null;
if (/^[\d.]+$/.test(value)) {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
let total = 0;
let matched = false;
const years = value.match(/([\d.]+)\s*(?:years|year|yrs|yr|y)(?![a-z])/);
if (years) {
total += Number.parseFloat(years[1]) * 12;
matched = true;
}
const months = value.match(/([\d.]+)\s*(?:months|month|mos|mo|m)(?![a-z])/);
if (months) {
total += Number.parseFloat(months[1]);
matched = true;
}
const weeks = value.match(/([\d.]+)\s*(?:weeks|week|wks|wk|w)(?![a-z])/);
if (weeks) {
total += Number.parseFloat(weeks[1]) * 7 / 30.4375;
matched = true;
}
const days = value.match(/([\d.]+)\s*(?:days|day|d)(?![a-z])/);
if (days) {
total += Number.parseFloat(days[1]) / 30.4375;
matched = true;
}
return matched ? total : null;
}
export function formatAgeMonths(months: number): string {
if (months < 1) {
const days = Math.round(months * 30.4375);
return `${days} day${days === 1 ? '' : 's'} (${months.toFixed(2)} mo)`;
}
if (months < 24) return `${roundTo(months, 1)} months`;
let years = Math.floor(months / 12);
let remainingMonths = Math.round(months - years * 12);
if (remainingMonths === 12) {
years += 1;
remainingMonths = 0;
}
return `${years} yr${remainingMonths ? ` ${remainingMonths} mo` : ''} (${Math.round(months)} mo total)`;
}
export interface WeightBasedDoseInput {
weightKg: number;
dosePerKg: number;
frequencyPerDay?: number;
maxSingleDoseMg?: number | null;
concentrationMgPerMl?: number | null;
}
export interface WeightBasedDoseResult {
singleDoseMg: number;
dailyDoseMg: number;
frequencyPerDay: number;
capped: boolean;
volumeMl: number | null;
}
export type GcsSeverity = 'Mild' | 'Moderate' | 'Severe (Coma)';
export interface GcsResult {
total: number;
severity: GcsSeverity;
}
export function roundTo(value: number, places: number): number {
const multiplier = Math.pow(10, places);
return Math.round(value * multiplier) / multiplier;
}
// APLS (Luscombe & Owens 2007 / APLS-UK 2016) and Best Guess
// (Tinning & Acworth 2007). Ported from public/js/calc-math.js.
export function estimateWeightFromAgeMonths(months: number | null | undefined): EstimatedWeight | null {
if (months == null || Number.isNaN(months) || months < 0) return null;
const years = months / 12;
let apls: number;
if (months < 12) apls = 0.5 * months + 4;
else if (years <= 5) apls = 2 * years + 8;
else apls = 3 * years + 7;
let bestGuess: number;
if (months < 12) bestGuess = (months + 9) / 2;
else if (years <= 5) bestGuess = 2 * (years + 5);
else bestGuess = 4 * years;
let formulaLabel: WeightFormulaBand;
if (years < 13) {
if (months < 12) formulaLabel = 'APLS 0-12 mo';
else if (years <= 5) formulaLabel = 'APLS 1-5 yr';
else formulaLabel = 'APLS 6-12 yr';
} else {
formulaLabel = 'Best Guess 5-14 yr';
}
const primary = years < 13 ? apls : bestGuess;
return {
weight: Math.max(0.3, roundTo(primary, 1)),
formulaLabel,
all: {
apls: roundTo(apls, 1),
bestGuess: roundTo(bestGuess, 1),
},
};
}
// Mosteller formula used by the legacy BSA calculator:
// BSA (m2) = sqrt(height(cm) * weight(kg) / 3600).
export function calculateMostellerBsa(weightKg: number, heightCm: number): number | null {
if (!Number.isFinite(weightKg) || !Number.isFinite(heightCm) || weightKg <= 0 || heightCm <= 0) {
return null;
}
return Math.sqrt((heightCm * weightKg) / 3600);
}
// Ported from public/js/calculators.js weight-based dosing panel.
export function calculateWeightBasedDose(input: WeightBasedDoseInput): WeightBasedDoseResult | null {
const frequencyPerDay = input.frequencyPerDay ?? 1;
const maxSingleDoseMg = input.maxSingleDoseMg ?? 0;
const concentrationMgPerMl = input.concentrationMgPerMl ?? 0;
if (
!Number.isFinite(input.weightKg) ||
!Number.isFinite(input.dosePerKg) ||
!Number.isFinite(frequencyPerDay) ||
input.weightKg <= 0 ||
input.dosePerKg <= 0 ||
frequencyPerDay <= 0
) {
return null;
}
let singleDoseMg = input.weightKg * input.dosePerKg;
let capped = false;
if (maxSingleDoseMg > 0 && singleDoseMg > maxSingleDoseMg) {
singleDoseMg = maxSingleDoseMg;
capped = true;
}
return {
singleDoseMg,
dailyDoseMg: singleDoseMg * frequencyPerDay,
frequencyPerDay,
capped,
volumeMl: concentrationMgPerMl > 0 ? singleDoseMg / concentrationMgPerMl : null,
};
}
// Child/adult and infant GCS scoring share the same numeric interpretation.
// Ported from public/js/calculators.js.
export function calculateGcs(eye: number, verbal: number, motor: number): GcsResult | null {
if (
!Number.isFinite(eye) ||
!Number.isFinite(verbal) ||
!Number.isFinite(motor) ||
eye < 1 ||
eye > 4 ||
verbal < 1 ||
verbal > 5 ||
motor < 1 ||
motor > 6
) {
return null;
}
const total = eye + verbal + motor;
let severity: GcsSeverity;
if (total <= 8) severity = 'Severe (Coma)';
else if (total <= 12) severity = 'Moderate';
else severity = 'Mild';
return { total, severity };
}

View file

@ -36,6 +36,7 @@
"e2e",
"scripts",
"test",
"client"
"client",
"**/*.test.ts"
]
}