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>
206 lines
5.9 KiB
TypeScript
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 };
|
|
}
|