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:
parent
4250ea47fe
commit
18550e263f
17 changed files with 866 additions and 23 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -36,3 +36,6 @@ public/models/
|
|||
e2e/node_modules/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
|
||||
# Codex CLI marker
|
||||
.codex
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 0–2 y / CDC 2–20 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,16 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"../shared/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../shared/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
13
knip.json
13
knip.json
|
|
@ -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
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
2
public/app/assets/index-9V57IPOu.css
Normal file
2
public/app/assets/index-9V57IPOu.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
143
shared/clinical/calculators.test.ts
Normal file
143
shared/clinical/calculators.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
206
shared/clinical/calculators.ts
Normal file
206
shared/clinical/calculators.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
"e2e",
|
||||
"scripts",
|
||||
"test",
|
||||
"client"
|
||||
"client",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue