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>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
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();
|
|
});
|
|
});
|