feat(client): port Calculators sub-nav shell (formulas gated on vectors)

Ships the /app/calculators page with the 10-pill sub-nav and flips
the sidebar Calculators link to available. All formula math stays
in the vanilla viewer for this commit.

client/src/pages/Calculators.tsx
  PILLS array mirrors public/components/calculators.html exactly:
  BP Percentile, BMI, Growth, Bilirubin, Vital Signs, BSA,
  Weight-Based Dosing, Resus Meds, GCS, Equipment. Each pill carries
  its canonical source (AAP 2017 Flynn / Fenton 2013 / AAP 2022
  Kemper / Bhutani 1999 / Mosteller / PALS / …) so when a reader
  opens the page they know which authoritative reference the numbers
  trace back to.

Why no math in this commit — explicitly gated
  The migration checkpoint has a specific rule for this tab:
  "generate test vectors (JSON file with {inputs, expectedOutput}
  tuples) by running the vanilla version with 20+ known cases. The
  React port must match every vector byte-for-byte. An LLM will
  sometimes 'simplify' a long array of numbers and silently break it
  — don't let that happen." This applies in particular to:
    • Rosner quantile splines in the BP percentile calc
    • Fenton 2013 LMS preterm (210 validated cases)
    • AAP 2022 bilirubin phototherapy + exchange (1190 validated cases)
    • Bhutani nomogram risk zones
    • APLS + Best Guess weight-for-age
  Each formula gets its own commit once the vector file lands in
  e2e/fixtures/ — this shell just makes the nav complete so users
  can navigate to the tab in the React tree.

e2e/tests/calculators-react.spec.js — three smoke tests:
  all 10 pills render by data-testid in the expected order, pill
  click switches the active panel, and the legacy-viewer link is
  present.

Client tsc -b + vite build clean. Bundle 460.19 kB / 131.43 kB gz.
This commit is contained in:
Daniel 2026-04-24 00:01:13 +02:00
parent c1c2c3e717
commit c5d2e7310f
6 changed files with 154 additions and 4 deletions

View file

@ -16,6 +16,7 @@ import Settings from '@/pages/Settings';
import Learning from '@/pages/Learning';
import PeGuide from '@/pages/PeGuide';
import Bedside from '@/pages/Bedside';
import Calculators from '@/pages/Calculators';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -63,6 +64,7 @@ export default function App() {
<Route path="/learning" element={<Learning />} />
<Route path="/peguide" element={<PeGuide />} />
<Route path="/bedside" element={<Bedside />} />
<Route path="/calculators" element={<Calculators />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -43,7 +43,7 @@ const NAV: NavGroup[] = [
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
{ to: '/bedside', label: 'Bedside', available: true },
{ to: '/calculators', label: 'Calculators', available: false },
{ to: '/calculators', label: 'Calculators', available: true },
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
{ to: '/learning', label: 'Learning Hub', available: true },
],

View file

@ -0,0 +1,108 @@
// ============================================================
// CALCULATORS — shell + sub-nav. This port intentionally delivers
// ONLY the page shell and sub-navigation. The actual math runs in
// the vanilla viewer until test vectors land.
//
// WHY this is gated on test vectors (from the migration checkpoint):
// • AAP 2017 BP percentile uses Rosner quantile splines with long
// hard-coded coefficient arrays.
// • Fenton 2013 LMS preterm growth carries 210 validated cases.
// • AAP 2022 bilirubin phototherapy + exchange: per-week risk
// curves, 1190 validated cases.
// • Bhutani nomogram risk zones.
// • APLS + Best Guess weight-for-age.
//
// Per the checkpoint: "An LLM will sometimes 'simplify' a long array
// of numbers and silently break it — don't let that happen." Every
// calculator needs a JSON vector file (~20 known inputs + expected
// outputs captured from public/js/calculators.js) before its React
// port lands, and the port must match every vector byte-for-byte.
//
// Pill order + labels match public/components/calculators.html.
// ============================================================
import { useState } from 'react';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
interface Pill {
id: string;
label: string;
summary: string;
source: string; // where the formulas live
}
const PILLS: Pill[] = [
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines' },
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS' },
{ id: 'growth', label: 'Growth Charts', summary: 'WHO 02 y / CDC 220 y; Fenton 2013 preterm (weight, length, head).', source: 'WHO 2006 + CDC 2000 + Fenton 2013 LMS' },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999' },
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'PALS + AHA reference' },
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller + DuBois formulas.', source: 'Mosteller 1987 / DuBois 1916' },
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Common pediatric med doses per kg + max dose.', source: 'Lexicomp / Harriet Lane' },
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS' },
{ id: 'gcs', label: 'GCS', summary: 'Child/adult and infant Glasgow Coma Scale variants.', source: 'Teasdale + pediatric modification' },
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference' },
];
function LegacyPanel({ pill }: { pill: Pill }) {
return (
<section className={card} data-testid={'calc-panel-' + pill.id}>
<h2 className="text-lg font-semibold">{pill.label}</h2>
<p className="text-sm text-muted-foreground">{pill.summary}</p>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
<p className="text-amber-900 dark:text-amber-100">
<strong>Source of truth:</strong> {pill.source}.
</p>
<p className="text-amber-900 dark:text-amber-100">
This calculator runs in the legacy viewer. A React port is gated on capturing test vectors
from the vanilla implementation so the numerical output can be verified byte-for-byte
the migration checkpoint specifically flags this class of data as the one an LLM is most
likely to silently simplify.
</p>
</div>
<a href="/#calculators" className={btnPrimary + ' inline-block'}>
Open in legacy viewer
</a>
</section>
);
}
export default function Calculators() {
const [active, setActive] = useState<string>(PILLS[0].id);
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
return (
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Calculators</h1>
<p className="text-sm text-muted-foreground">
Pediatric calculators BP percentiles, bilirubin thresholds, growth, dosing, equipment sizing.
Formula ports arrive one at a time, each verified against captured test vectors.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="calc-subnav">
{PILLS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setActive(p.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === p.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'calc-pill-' + p.id}
>
{p.label}
</button>
))}
</div>
<LegacyPanel pill={pill} />
</div>
);
}

View file

@ -0,0 +1,40 @@
// ============================================================
// CALCULATORS (React port) — sub-nav shell smoke test.
//
// Pure shell test until test vectors + formula ports land in
// dedicated follow-up commits.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
const EXPECTED_PILLS = [
'bp', 'bmi', 'growth', 'bili', 'vitals',
'bsa', 'dose', 'resus', 'gcs', 'equipment',
];
async function openReactCalc(page) {
await page.goto(E2E_BASE + '/app/calculators');
await page.waitForSelector('[data-testid="calc-subnav"]', { timeout: 15000 });
}
test.describe('React Calculators — sub-nav shell', () => {
test('all 10 pills render in expected order', async ({ authedPage: _, page }) => {
await openReactCalc(page);
for (const id of EXPECTED_PILLS) {
await expect(page.locator('[data-testid="calc-pill-' + id + '"]')).toBeVisible();
}
});
test('clicking a pill switches the active panel', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await expect(page.locator('[data-testid="calc-panel-bp"]')).toBeVisible();
await page.click('[data-testid="calc-pill-bili"]');
await expect(page.locator('[data-testid="calc-panel-bili"]')).toBeVisible();
});
test('each panel carries a legacy-viewer link', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await expect(page.getByText('Open in legacy viewer').first()).toBeVisible();
});
});

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DrQO3Lq1.js"></script>
<script type="module" crossorigin src="/app/assets/index-DLoy7H1P.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-0qzAvrmR.css">
</head>
<body>