pediatric-ai-scribe-v3/shared/clinical/calculators.ts
Daniel 95a5a5e40e feat(client): port Bedside Anaphylaxis + Cardiac + Seizure panels
Replaces the legacy-viewer fallback for three of the fifteen Bedside
sub-modules with real React implementations. Weight-based dosing now
runs locally for the most time-critical emergencies — the other 12
modules (neonatal, airway, respiratory, ventilation, sepsis, sedation,
agitation, antiemetics, antimicrobials, burns, toxicology, trauma)
still fall through to LegacyPanel.

shared/clinical/calculators.ts
  New formatDose(weightKg, perKg, max?, unit = 'mg') helper that
  mirrors the vanilla S.dStr exactly — same rounding, same cap rule,
  same label format. Returns { value, unit, capped, perKg, max,
  label } so consumers can render rich UI without re-parsing a HTML
  string. Pure function; unit tests in the existing vitest suite
  still pass.

client/src/pages/BedsidePanels.tsx (new)
  Three panels keyed to the Bedside pill id:
    • AnaphylaxisPanel — STEP 1 epi IM callout + full 9-row dosing
      table (fluids, diphenhydramine, ranitidine, dex, methylpred,
      refractory epi gtt, glucagon). Drug per-kg + max values ported
      verbatim from ANAPH_FALLBACK in the vanilla module.
    • CardiacPanel — PALS dosing with 6 sub-views (general,
      asystole/PEA, bradycardia, SVT, VF/pulseless VT, stable VT).
      Every drug row carries the same mg/kg + max values as the
      vanilla cardiac.js (epi, amio, lido, atropine, adenosine ×2,
      bicarb, CaCl/CaGluc, Mg, D10, defib energies). Concentration
      disclaimer + AHA citation preserved.
    • SeizurePanel — full status-epilepticus timeline (0 min →
      40 min refractory) with the vanilla SEIZURE_FALLBACK drug
      table (loraz / midaz / diaz for benzo; levetiracetam /
      fosphenytoin / valproate / phenobarb for 2nd-line; midaz
      pentobarb propofol ketamine infusions for refractory). Key
      points + citation preserved.

  REAL_BEDSIDE_PANELS set + renderBedsideRealPanel(pillId) export so
  Bedside.tsx can dispatch by id without import sprawl.

client/src/pages/Bedside.tsx
  When the active pill is in REAL_BEDSIDE_PANELS, render the real
  panel; otherwise fall back to LegacyPanel. No other changes — the
  sub-nav, age→weight estimator, and 12 legacy-linked modules stay
  exactly as they were.

e2e/tests/bedside-react.spec.js
  Three new parity tests (in addition to the existing shell checks):
    • Anaphylaxis: 20 kg → 0.2 mg epi IM; 70 kg → 0.5 mg (capped).
    • Cardiac: 25 kg → 0.25 mg epi IV in general view and asystole.
    • Seizure: 10 kg → D10W 20-50 mL, Lorazepam 1 mg.

Client tsc -b + vite build clean. Bundle 609 kB / 173 kB gz (+28 kB
for three full panels; vite's 500 kB chunk warning noted for later
code-splitting, not blocking).
2026-04-24 01:27:29 +02:00

239 lines
7 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,
};
}
// Dose formatter used by Bedside drug tables. Mirrors the vanilla
// S.dStr helper in public/js/bedside/shared.js — same rounding, same
// cap logic, same label format. Pure string builder, no HTML.
// Example: formatDose(30, 0.1, 4) → "3 mg (0.1 mg/kg, max 4 mg)"
// formatDose(60, 0.1, 4) → "4 mg (0.1 mg/kg, max 4 mg · capped)"
export interface FormattedDose {
value: number;
unit: string;
capped: boolean;
perKg: number;
max: number | null;
label: string;
}
export function formatDose(weightKg: number, perKg: number, max?: number | null, unit = 'mg'): FormattedDose {
const raw = weightKg * perKg;
const rounded = Math.round(raw * 100) / 100;
const hasMax = max != null && max > 0;
const capped = hasMax && rounded > max!;
const value = capped ? max! : rounded;
let perKgStr = `(${perKg} ${unit}/kg`;
if (hasMax) perKgStr += `, max ${max} ${unit}`;
if (capped) perKgStr += ' · capped';
perKgStr += ')';
return {
value,
unit,
capped,
perKg,
max: hasMax ? max! : null,
label: `${value} ${unit} ${perKgStr}`,
};
}
// 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 };
}