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:
parent
c1c2c3e717
commit
c5d2e7310f
6 changed files with 154 additions and 4 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
108
client/src/pages/Calculators.tsx
Normal file
108
client/src/pages/Calculators.tsx
Normal 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 0–2 y / CDC 2–20 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>
|
||||
);
|
||||
}
|
||||
40
e2e/tests/calculators-react.spec.js
Normal file
40
e2e/tests/calculators-react.spec.js
Normal 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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue