pediatric-ai-scribe-v3/shared/clinical/calculators.ts
Daniel 18550e263f 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>
2026-04-24 01:11:58 +02:00

206 lines
5.9 KiB
TypeScript

// ============================================================
// 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 };
}