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