feat(client): port Bedside Anaphylaxis + Cardiac + Seizure panels

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).
This commit is contained in:
Daniel 2026-04-24 01:27:28 +02:00
parent fb51ba9cfe
commit 95a5a5e40e
9 changed files with 739 additions and 57 deletions

View file

@ -21,6 +21,7 @@ import {
formatAgeMonths,
parseAgeMonths,
} from '@shared/clinical/calculators';
import { renderBedsideRealPanel, REAL_BEDSIDE_PANELS } from './BedsidePanels';
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';
@ -197,7 +198,7 @@ export default function Bedside() {
</div>
<BedsideWeightEstimator />
<LegacyPanel pill={pill} />
{REAL_BEDSIDE_PANELS.has(pill.id) ? renderBedsideRealPanel(pill.id) : <LegacyPanel pill={pill} />}
</div>
);
}

View file

@ -0,0 +1,616 @@
// ============================================================
// BEDSIDE PANELS — real React implementations for a subset of the
// 15 clinical Bedside sub-modules. Each panel:
// • owns its own weight field (matching vanilla UX)
// • uses shared/clinical/calculators.formatDose() for dose math
// • keeps drug per-kg + max values byte-identical to the
// vanilla fallback data in public/js/bedside/<module>.js
//
// Remaining 12 modules (neonatal, airway, respiratory, ventilation,
// sepsis, sedation, agitation, antiemetics, antimicrobials, burns,
// toxicology, trauma) continue to render LegacyPanel in Bedside.tsx.
// ============================================================
import { useState } from 'react';
import { formatDose } from '@shared/clinical/calculators';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
function WeightField({
id,
value,
onChange,
testid,
}: {
id: string;
value: string;
onChange: (v: string) => void;
testid: string;
}) {
return (
<div className="max-w-xs">
<label htmlFor={id} className={label}>Weight (kg)</label>
<input
id={id}
type="number"
min="0.3"
step="0.1"
className={input}
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid={testid}
/>
</div>
);
}
function Dose({ label: l }: { label: string }) {
// The label contains both the numeric dose and the per-kg/max context.
// Split at the first "(" so we can weight the prescription number.
const parenIdx = l.indexOf('(');
if (parenIdx < 0) return <span className="font-semibold">{l}</span>;
const main = l.slice(0, parenIdx).trim();
const rest = l.slice(parenIdx);
return (
<span>
<span className="font-semibold">{main}</span>{' '}
<span className="text-xs text-muted-foreground">{rest}</span>
</span>
);
}
// ── Anaphylaxis ─────────────────────────────────────────────
// Drug per-kg + max values ported verbatim from
// public/js/bedside/anaphylaxis.js ANAPH_FALLBACK.
const ANAPH_DRUGS = {
epi: { perKg: 0.01, max: 0.5, unit: 'mg' },
fluids: { perKg: 20, max: null, unit: 'mL' },
dph: { perKg: 1.25, max: 50, unit: 'mg' },
ranitidine:{ perKg: 1, max: 50, unit: 'mg' },
dex: { perKg: 0.6, max: 16, unit: 'mg' },
mpn: { perKg: 2, max: 125, unit: 'mg' },
glucagon: { perKg: 0.02, max: 1, unit: 'mg' },
} as const;
export function AnaphylaxisPanel() {
const [weight, setWeight] = useState('');
const wt = Number.parseFloat(weight);
if (!Number.isFinite(wt) || wt <= 0) {
return (
<section className={card} data-testid="bedside-panel-anaphylaxis">
<h2 className="text-lg font-semibold">Anaphylaxis</h2>
<p className="text-sm text-muted-foreground">
Emergency management. Epinephrine IM is the immediate and only proven lifesaving step.
</p>
<WeightField id="anaph-wt" value={weight} onChange={setWeight} testid="anaph-weight" />
<p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>
</section>
);
}
const epi = formatDose(wt, ANAPH_DRUGS.epi.perKg, ANAPH_DRUGS.epi.max, ANAPH_DRUGS.epi.unit);
const epiVol = Math.round(epi.value * 10) / 10; // 1:1000 = 1 mg/mL
const autoInjector = wt < 10 ? 'Draw up manually' : wt <= 25 ? 'EpiPen Jr / Auvi-Q 0.15 mg' : 'EpiPen / Auvi-Q 0.3 mg';
const fluids = formatDose(wt, ANAPH_DRUGS.fluids.perKg, ANAPH_DRUGS.fluids.max, ANAPH_DRUGS.fluids.unit);
const dph = formatDose(wt, ANAPH_DRUGS.dph.perKg, ANAPH_DRUGS.dph.max);
const rnt = formatDose(wt, ANAPH_DRUGS.ranitidine.perKg, ANAPH_DRUGS.ranitidine.max);
const dex = formatDose(wt, ANAPH_DRUGS.dex.perKg, ANAPH_DRUGS.dex.max);
const mpn = formatDose(wt, ANAPH_DRUGS.mpn.perKg, ANAPH_DRUGS.mpn.max);
const glc = formatDose(wt, ANAPH_DRUGS.glucagon.perKg, ANAPH_DRUGS.glucagon.max);
const rows: Array<[string, string, string, string, string]> = [
['STEP 2', 'Position', 'Supine + legs elevated', '—', 'If dyspneic: sitting position. If vomiting: recovery position'],
['STEP 3', 'O₂', 'High flow 10-15 L/min', 'Face mask', '100% O₂. Prepare for airway management'],
['STEP 4', 'IV fluids', `NS ${fluids.label} bolus`, 'IV/IO', 'Repeat up to 60 mL/kg for hypotension'],
['STEP 5', 'Diphenhydramine', dph.label, 'IV/IM/PO', 'H1 blocker. NOT first-line — adjunct only'],
['', 'Ranitidine', rnt.label, 'IV over 5 min', 'H2 blocker. Optional adjunct'],
['STEP 6', 'Dexamethasone', dex.label, 'IV/IM/PO', 'Prevents biphasic reaction (4-6 hrs later)'],
['', 'Methylprednisolone', mpn.label, 'IV', 'Alternative steroid'],
['REFRACTORY', 'Epinephrine gtt', '0.1-1 mcg/kg/min', 'IV infusion', 'For persistent hypotension despite fluids + IM epi'],
['', 'Glucagon', glc.label, 'IV/IM', 'For patients on beta-blockers not responding to epi'],
];
return (
<section className={card} data-testid="bedside-panel-anaphylaxis">
<h2 className="text-lg font-semibold">Anaphylaxis</h2>
<WeightField id="anaph-wt" value={weight} onChange={setWeight} testid="anaph-weight" />
<div className="rounded-lg border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-4 space-y-1">
<div className="text-base font-bold text-destructive">STEP 1: Epinephrine IM GIVE IMMEDIATELY</div>
<div className="text-sm font-semibold">
Epinephrine 1:1000 (1 mg/mL): <span className="text-destructive">{epi.value} mg ({epiVol} mL)</span> IM to lateral thigh
</div>
<div className="text-xs text-muted-foreground">
{ANAPH_DRUGS.epi.perKg} mg/kg (max {ANAPH_DRUGS.epi.max} mg) · Auto-injector: {autoInjector}
</div>
<div className="text-xs text-destructive">
May repeat every 5-15 minutes if symptoms persist. No contraindications in anaphylaxis.
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="anaph-table">
<thead>
<tr>
<th className={th}>Step</th>
<th className={th}>Drug</th>
<th className={th}>Dose</th>
<th className={th}>Route</th>
<th className={th}>Notes</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={r[0] === 'REFRACTORY' || (i > 0 && rows[i - 1][0] === 'REFRACTORY' && r[0] === '') ? 'bg-amber-50 dark:bg-amber-950/30' : ''}>
<td className={td + ' font-semibold text-primary text-xs'}>{r[0]}</td>
<td className={td + ' font-semibold'}>{r[1]}</td>
<td className={td}><Dose label={r[2]} /></td>
<td className={td + ' text-xs'}>{r[3]}</td>
<td className={td + ' text-xs text-muted-foreground'}>{r[4]}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>Observe minimum 4-6 hours</strong> after last dose of epinephrine (biphasic reactions occur in 5-20% of cases). Discharge with EpiPen prescription and anaphylaxis action plan. Refer to allergist.
</div>
<div className="text-xs text-muted-foreground italic">
ASCIA Anaphylaxis Guidelines 2021 · WAO Anaphylaxis Guidance 2020 · AAP/ACAAI Practice Parameters.
</div>
</section>
);
}
// ── Cardiac Arrest / PALS ───────────────────────────────────
// Drug per-kg + max values ported verbatim from
// public/js/bedside/cardiac.js.
type CardiacView = 'general' | 'asystole' | 'brady' | 'svt' | 'vfib' | 'vt';
export function CardiacPanel() {
const [weight, setWeight] = useState('');
const [view, setView] = useState<CardiacView>('general');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const views: Array<{ id: CardiacView; label: string }> = [
{ id: 'general', label: 'PALS General' },
{ id: 'asystole', label: 'Asystole / PEA' },
{ id: 'brady', label: 'Bradycardia' },
{ id: 'svt', label: 'SVT' },
{ id: 'vfib', label: 'VF / Pulseless VT' },
{ id: 'vt', label: 'Stable VT' },
];
function Row({ name, dose, route, notes }: { name: string; dose: string; route: string; notes: string }) {
return (
<tr>
<td className={td + ' font-semibold'}>{name}</td>
<td className={td}><Dose label={dose} /></td>
<td className={td + ' text-xs'}>{route}</td>
<td className={td + ' text-xs text-muted-foreground'}>{notes}</td>
</tr>
);
}
function Table({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className={th}>Drug</th>
<th className={th}>Dose</th>
<th className={th}>Route</th>
<th className={th}>Notes</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function GeneralTable() {
if (!valid) return null;
const epiIv = f(0.01, 1, 'mg')!;
const epiMl = f(0.1, 10, 'mL')!;
const epiEt = f(0.1, 2.5, 'mg')!;
const amio = f(5, 300, 'mg')!;
const lido = f(1, 100, 'mg')!;
const atropine = f(0.02, 0.5, 'mg')!;
const aden1 = f(0.1, 6, 'mg')!;
const aden2 = f(0.2, 12, 'mg')!;
const bicarb = f(1, 50, 'mEq')!;
const caCl = f(20, 1000, 'mg')!;
const caClMl = f(0.2, 10, 'mL')!;
const caGluc = f(60, 3000, 'mg')!;
const caGlucMl = f(0.6, 30, 'mL')!;
const mg = f(50, 2000, 'mg')!;
const d10 = f(2, null, 'mL')!;
return (
<Table>
<Row name="Epinephrine 1:10,000" dose={`${epiIv.label} (${epiMl.label})`} route="IV / IO" notes="0.01 mg/kg = 0.1 mL/kg of 1:10,000. Max single dose 1 mg. Repeat q3-5 min." />
<Row name="Epinephrine ET" dose={epiEt.label} route="ETT" notes="0.1 mg/kg (1 mL/kg of 1:10,000) if no IV/IO" />
<Row name="Amiodarone" dose={amio.label} route="IV / IO bolus" notes="5 mg/kg for arrest. Max 300 mg. Repeat up to 2 times (max 15 mg/kg/day)." />
<Row name="Lidocaine" dose={lido.label} route="IV / IO bolus" notes="1 mg/kg. Alternative to amiodarone for VF/VT." />
<Row name="Atropine" dose={`${atropine.label} (min 0.1 mg)`} route="IV / IO" notes="0.02 mg/kg. Bradycardia from ↑vagal tone or AV block." />
<Row name="Adenosine (1st)" dose={aden1.label} route="IV push + flush" notes="0.1 mg/kg (max 6 mg). For SVT. Rapid push, double flush." />
<Row name="Adenosine (2nd)" dose={aden2.label} route="IV push + flush" notes="0.2 mg/kg (max 12 mg). Second dose if first unsuccessful." />
<Row name="Sodium bicarb 8.4%" dose={bicarb.label} route="IV / IO" notes="1 mEq/kg. ONLY if severe metabolic acidosis, hyperK, or TCA OD. Not routine." />
<Row name="Calcium chloride 10%" dose={`${caCl.label} (${caClMl.label})`} route="IV / IO (central preferred)" notes="For hyperK, hypoCa, Mg OD, CCB OD. 20 mg/kg = 0.2 mL/kg." />
<Row name="Calcium gluconate 10%" dose={`${caGluc.label} (${caGlucMl.label})`} route="IV / IO (peripheral OK)" notes="60 mg/kg = 0.6 mL/kg. Preferred peripherally." />
<Row name="Magnesium sulfate" dose={mg.label} route="IV over 10-20 min" notes="25-50 mg/kg. Torsades, severe asthma. Max 2 g." />
<Row name="Dextrose 10%" dose={`${d10.label} (0.2 g/kg)`} route="IV push" notes="For documented hypoglycemia" />
<Row name="Defibrillation" dose="2 J/kg → 4 J/kg → 10 J/kg (max 10 J/kg or adult dose)" route="Pad" notes="For VF/pulseless VT. Resume CPR immediately after shock." />
<Row name="Cardioversion (sync)" dose="0.5-1 J/kg → 2 J/kg" route="Pad" notes="For unstable SVT/VT. Sedate if possible." />
</Table>
);
}
function Asystole() {
if (!valid) return null;
const epiIv = f(0.01, 1, 'mg')!;
const epiEt = f(0.1, 2.5, 'mg')!;
return (
<>
<Table>
<Row name="CPR" dose="100-120/min, depth 1/3 AP" route="—" notes="15:2 (2 rescuer) or 30:2 (single). Rotate every 2 min." />
<Row name="Epinephrine" dose={epiIv.label} route="IV / IO" notes="0.01 mg/kg 1:10,000 = 0.1 mL/kg. Q3-5 min. Start early." />
<Row name="Epinephrine ETT" dose={epiEt.label} route="ETT" notes="0.1 mg/kg if no IV access" />
</Table>
<h3 className="text-sm font-semibold mt-3">Reversible causes (H's &amp; T's)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 p-2"><strong>H's</strong> Hypoxia, Hypovolemia, H+ (acidosis), Hypo/hyperK, Hypoglycemia, Hypothermia</div>
<div className="rounded-md bg-muted/40 p-2"><strong>T's</strong> Tension PTX, Tamponade, Toxins, Thrombosis (MI/PE), Trauma</div>
</div>
</>
);
}
function Brady() {
if (!valid) return null;
const epi = f(0.01, 1, 'mg')!;
const atropine = f(0.02, 0.5, 'mg')!;
return (
<>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm">
<div className="font-semibold text-amber-800 dark:text-amber-200">Bradycardia with poor perfusion (HR &lt; 60)</div>
<div className="text-xs text-muted-foreground mt-1">Start CPR if HR &lt; 60 with poor perfusion despite oxygenation and ventilation.</div>
</div>
<Table>
<Row name="Epinephrine" dose={epi.label} route="IV / IO" notes="First-line. Q3-5 min." />
<Row name="Atropine" dose={`${atropine.label} (min 0.1 mg)`} route="IV / IO" notes="If ↑ vagal tone or AV block. Max 1 mg child / 0.5 mg infant." />
<Row name="Transcutaneous pacing" dose="—" route="Pad" notes="For refractory bradycardia not responsive to drugs." />
<Row name="Consider" dose="—" route="—" notes="Hypoxia, tension pneumothorax, ↑ICP, toxic ingestion (organo, CCB, BB), heart block" />
</Table>
</>
);
}
function Svt() {
if (!valid) return null;
const aden1 = f(0.1, 6, 'mg')!;
const aden2 = f(0.2, 12, 'mg')!;
return (
<>
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3 text-sm">
<div className="font-semibold text-blue-800 dark:text-blue-200">SVT narrow complex, rate usually &gt;220 infant / &gt;180 child</div>
<div className="text-xs text-muted-foreground mt-1">Differentiate from sinus tach: abrupt onset/offset, no P waves or abnormal P axis, HR minimally variable.</div>
</div>
<h3 className="text-sm font-semibold">If STABLE</h3>
<Table>
<Row name="Vagal maneuvers" dose="Ice to face, blow into syringe, Valsalva" route="—" notes="Try first in stable patient" />
<Row name="Adenosine (1st)" dose={aden1.label} route="IV push (proximal) + flush" notes="Rapid push, 3-way stopcock with NS flush" />
<Row name="Adenosine (2nd)" dose={aden2.label} route="IV push + flush" notes="If first dose unsuccessful" />
<Row name="Procainamide" dose="15 mg/kg over 30-60 min" route="IV" notes="Expert consult. Avoid with amiodarone." />
<Row name="Amiodarone" dose="5 mg/kg over 20-60 min" route="IV" notes="Expert consult." />
</Table>
<h3 className="text-sm font-semibold">If UNSTABLE</h3>
<Table>
<Row name="Synchronized cardioversion" dose="0.5-1 J/kg → 2 J/kg" route="Pad" notes="Sedate if possible. Pre-treat with adenosine if IV available." />
</Table>
</>
);
}
function Vfib() {
if (!valid) return null;
const epi = f(0.01, 1, 'mg')!;
const amio = f(5, 300, 'mg')!;
const lido = f(1, 100, 'mg')!;
const mg = f(50, 2000, 'mg')!;
return (
<>
<div className="rounded-md border-l-4 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm">
<div className="font-semibold text-destructive">VF / Pulseless VT</div>
<div className="text-xs text-muted-foreground mt-1">High-quality CPR + defibrillation are key. Minimize interruptions.</div>
</div>
<Table>
<Row name="Defibrillation (1st)" dose="2 J/kg" route="Pad" notes="Resume CPR immediately after shock." />
<Row name="Defibrillation (2nd)" dose="4 J/kg" route="Pad" notes="After 2 min CPR." />
<Row name="Defibrillation (3rd+)" dose="≥4 J/kg (max 10 J/kg or adult dose)" route="Pad" notes="" />
<Row name="Epinephrine" dose={epi.label} route="IV / IO" notes="After 2nd shock. Q3-5 min." />
<Row name="Amiodarone" dose={amio.label} route="IV / IO bolus" notes="Refractory VF/VT. Max 15 mg/kg/day." />
<Row name="Lidocaine" dose={lido.label} route="IV / IO" notes="Alternative to amiodarone. Then 20-50 mcg/kg/min gtt." />
<Row name="Mg sulfate" dose={mg.label} route="IV over 1-2 min" notes="For torsades or hypoMg." />
</Table>
</>
);
}
function Vt() {
if (!valid) return null;
const amio = f(5, 300, 'mg')!;
return (
<>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm">
<div className="font-semibold text-amber-800 dark:text-amber-200">Stable monomorphic VT</div>
<div className="text-xs text-muted-foreground mt-1">Expert consult. Avoid combining QT-prolonging agents.</div>
</div>
<Table>
<Row name="Amiodarone" dose={`5 mg/kg over 20-60 min = ${amio.value} mg over 20-60 min`} route="IV" notes="" />
<Row name="Procainamide" dose="15 mg/kg over 30-60 min" route="IV" notes="Do not combine with amiodarone" />
<Row name="Sync cardioversion" dose="0.5-1 → 2 J/kg" route="Pad" notes="If becomes unstable. Sedate." />
</Table>
</>
);
}
return (
<section className={card} data-testid="bedside-panel-cardiac">
<h2 className="text-lg font-semibold">Cardiac Arrest / PALS</h2>
<WeightField id="cardiac-wt" value={weight} onChange={setWeight} testid="cardiac-weight" />
<div className="flex flex-wrap gap-2">
{views.map((v) => (
<button
key={v.id}
type="button"
onClick={() => setView(v.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border ' +
(view === v.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'cardiac-view-' + v.id}
>
{v.label}
</button>
))}
</div>
{!valid ? (
<p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>
) : (
<div className="space-y-3">
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive">
{views.find((v) => v.id === view)?.label} {wt} kg
</div>
{view === 'general' && <GeneralTable />}
{view === 'asystole' && <Asystole />}
{view === 'brady' && <Brady />}
{view === 'svt' && <Svt />}
{view === 'vfib' && <Vfib />}
{view === 'vt' && <Vt />}
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>Concentrations:</strong> Epi 1:10,000 (0.1 mg/mL) for IV arrest. 1:1000 (1 mg/mL) for IM anaphylaxis. Atropine &lt;0.1 mg can cause paradoxical bradycardia.
</div>
<div className="text-xs text-muted-foreground italic">
AHA PALS Guidelines 2020 · Always verify against institutional protocols.
</div>
</div>
)}
</section>
);
}
// ── Seizure / Status Epilepticus ────────────────────────────
// Drug per-kg + max values ported verbatim from
// public/js/bedside/seizure.js SEIZURE_FALLBACK.
const SEIZURE_DRUGS = {
lorazepam: { perKg: 0.1, max: 4, unit: 'mg', route: 'IV / IO', note: 'Slow push over 1-2 min.', suffix: ' (IV preferred)' },
midazolam: { perKg: 0.2, max: 10, unit: 'mg', route: 'IM / IN / buccal', note: 'Use 5 mg/mL concentrate for IN; split between nares.' },
diazepam: { perKg: 0.5, max: 20, unit: 'mg', route: 'PR', note: 'Only if no IV/IM/IN access.' },
levetiracetam:{ perKg: 60, max: 4500, unit: 'mg', route: 'IV over 5-15 min', note: 'Best tolerability. No ECG monitoring needed.', suffix: ' (preferred)' },
fosphenytoin:{ perKg: 20, max: 1500, unit: 'mg PE', route: 'IV over 10 min', note: 'Max rate 3 mg PE/kg/min. Monitor ECG, BP. Avoid if cardiac dysrhythmia.' },
valproate: { perKg: 40, max: 3000, unit: 'mg', route: 'IV over 10 min', note: 'Avoid <2 yr, hepatic / mitochondrial disease, pregnancy.' },
phenobarb: { perKg: 20, max: 1000, unit: 'mg', route: 'IV over 20 min', note: 'Last-choice 2nd line. High risk of resp depression + hypotension.' },
midazGttBolus:{ perKg: 0.2, max: 10, unit: 'mg' },
midazGttLow: { perKg: 0.05, max: 2, unit: 'mg/hr' },
midazGttHigh:{ perKg: 0.5, max: 18, unit: 'mg/hr' },
pentobarbBolus:{ perKg: 5, max: 200, unit: 'mg' },
pentobarbGttLow: { perKg: 1, max: null, unit: 'mg/hr' },
pentobarbGttHigh:{ perKg: 5, max: null, unit: 'mg/hr' },
propofolBolus: { perKg: 2, max: 100, unit: 'mg' },
propofolGttLow:{ perKg: 2, max: null, unit: 'mg/hr' },
propofolGttHigh:{ perKg: 5, max: null, unit: 'mg/hr' },
ketamineBolus: { perKg: 2, max: 100, unit: 'mg' },
ketamineGttLow:{ perKg: 1, max: null, unit: 'mg/hr' },
ketamineGttHigh:{ perKg: 5, max: null, unit: 'mg/hr' },
} as const;
export function SeizurePanel() {
const [weight, setWeight] = useState('');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const d10Low = valid ? Math.round(wt * 2 * 10) / 10 : 0;
const d10High = valid ? Math.round(wt * 5 * 10) / 10 : 0;
function Step({ time, color, title, children, last = false }: { time: string; color: string; title: string; children: React.ReactNode; last?: boolean }) {
return (
<div className="flex gap-3">
<div className="flex flex-col items-center flex-shrink-0">
<div className={`w-16 py-1.5 rounded-full text-white text-xs font-bold text-center ${color}`}>{time}</div>
{!last && <div className="w-0.5 flex-1 bg-border mt-1" />}
</div>
<div className="flex-1 rounded-r-md border-l-4 p-3 bg-muted/30" style={{ borderLeftColor: 'currentColor' }}>
<div className={`text-xs font-bold uppercase tracking-wide mb-2`}>{title}</div>
<div className="text-sm space-y-2">{children}</div>
</div>
</div>
);
}
function BenzoRow({ name, f: dose, route, notes, suffix }: { name: string; f: ReturnType<typeof formatDose>; route: string; notes: string; suffix?: string }) {
return (
<tr>
<td className={td + ' font-semibold'}>{name}{suffix && <span className="text-xs text-green-700 ml-1">{suffix}</span>}</td>
<td className={td}><Dose label={dose.label} /></td>
<td className={td + ' text-xs'}>{route}</td>
<td className={td + ' text-xs text-muted-foreground'} dangerouslySetInnerHTML={{ __html: notes }} />
</tr>
);
}
return (
<section className={card} data-testid="bedside-panel-seizure">
<h2 className="text-lg font-semibold">Seizures Status Epilepticus Pathway</h2>
<WeightField id="seizure-wt" value={weight} onChange={setWeight} testid="seizure-weight" />
{!valid ? (
<p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>
) : (
<>
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3">
<div className="font-bold text-destructive text-sm">Status Epilepticus Pathway {wt} kg</div>
<div className="text-xs text-muted-foreground mt-1">
Time = 0 at seizure onset. Do not pause between steps for benzo to take effect act on the clock.
</div>
</div>
<div className="space-y-0">
<Step time="0 min" color="bg-blue-500 text-white" title="Stabilize">
<div>
<strong>ABCs</strong> position, 100% O via NC / NRB, suction. <strong>IV or IO</strong> × 2. Continuous SpO, ECG, BP.
</div>
<div>
<strong>POC glucose</strong> if &lt;60 mg/dL: <strong>D10W {d10Low}-{d10High} mL IV</strong> (2-5 mL/kg).
</div>
<div>
<strong>Labs</strong>: CBC, CMP, Mg, Ca, Phos, VBG, lactate, AED levels, toxicology if unclear etiology.
</div>
<div>
<strong>Consider pyridoxine</strong> 100 mg IV in infants &lt;18 mo or INH ingestion. Temperature: treat hyperthermia aggressively.
</div>
</Step>
<Step time="5 min" color="bg-purple-500 text-white" title="1st benzodiazepine (give once, pick by access)">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th><th className={th}>Notes</th></tr>
</thead>
<tbody>
<BenzoRow name="Lorazepam" f={f(SEIZURE_DRUGS.lorazepam.perKg, SEIZURE_DRUGS.lorazepam.max)!} route="IV / IO" notes="Slow push over 1-2 min." suffix=" (IV preferred)" />
<BenzoRow name="Midazolam" f={f(SEIZURE_DRUGS.midazolam.perKg, SEIZURE_DRUGS.midazolam.max)!} route="IM / IN / buccal" notes="Use 5 mg/mL concentrate for IN; split between nares." />
<BenzoRow name="Diazepam" f={f(SEIZURE_DRUGS.diazepam.perKg, SEIZURE_DRUGS.diazepam.max)!} route="PR" notes="Only if no IV/IM/IN access." />
</tbody>
</table>
</div>
</Step>
<Step time="10 min" color="bg-violet-500 text-white" title="2nd benzodiazepine — if still seizing">
Repeat same agent + dose <strong>once</strong>. Prepare 2nd-line now (don't wait to see if benzo works). If apnea/airway compromise: BVM, consider advanced airway.
</Step>
<Step time="20 min" color="bg-amber-500 text-white" title="2nd-line anti-epileptic — pick one (ESETT: equivalent efficacy)">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th><th className={th}>Notes</th></tr>
</thead>
<tbody>
<BenzoRow name="Levetiracetam" f={f(SEIZURE_DRUGS.levetiracetam.perKg, SEIZURE_DRUGS.levetiracetam.max)!} route="IV over 5-15 min" notes="Best tolerability. No ECG monitoring needed." suffix=" (preferred)" />
<BenzoRow name="Fosphenytoin" f={f(SEIZURE_DRUGS.fosphenytoin.perKg, SEIZURE_DRUGS.fosphenytoin.max, 'mg PE')!} route="IV over 10 min" notes="Max rate 3 mg PE/kg/min. Monitor ECG, BP. Avoid if cardiac dysrhythmia." />
<BenzoRow name="Valproic acid" f={f(SEIZURE_DRUGS.valproate.perKg, SEIZURE_DRUGS.valproate.max)!} route="IV over 10 min" notes="<strong>Avoid &lt;2 yr, hepatic / mitochondrial disease, pregnancy.</strong>" />
<BenzoRow name="Phenobarbital" f={f(SEIZURE_DRUGS.phenobarb.perKg, SEIZURE_DRUGS.phenobarb.max)!} route="IV over 20 min" notes="Last-choice 2nd line. High risk of resp depression + hypotension." />
</tbody>
</table>
</div>
</Step>
<Step time="30 min" color="bg-red-500 text-white" title="Still seizing? Consider 2nd agent from 2nd-line OR proceed to refractory">
If the first 2nd-line drug failed, give a different 2nd-line agent <strong>OR</strong> move directly to refractory therapy. Activate ICU, prepare for intubation. Confirm etiology not reversible (electrolytes, glucose, fever, toxin).
</Step>
<Step time="40 min" color="bg-destructive text-white" title="Refractory status — intubate + continuous infusion + continuous EEG" last>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th><th className={th}>Notes</th></tr>
</thead>
<tbody>
<tr>
<td className={td + ' font-semibold'}>Midazolam <span className="text-xs text-green-700">(1st-line infusion)</span></td>
<td className={td + ' text-sm'}>Bolus {f(SEIZURE_DRUGS.midazGttBolus.perKg, SEIZURE_DRUGS.midazGttBolus.max)!.value} mg gtt {f(SEIZURE_DRUGS.midazGttLow.perKg, SEIZURE_DRUGS.midazGttLow.max, 'mg/hr')!.value}{f(SEIZURE_DRUGS.midazGttHigh.perKg, SEIZURE_DRUGS.midazGttHigh.max, 'mg/hr')!.value} mg/hr <span className="text-xs text-muted-foreground">(bolus 0.2 mg/kg, gtt 0.05-0.5 mg/kg/hr)</span></td>
<td className={td + ' text-xs'}>IV</td>
<td className={td + ' text-xs text-muted-foreground'}>Titrate to seizure control / burst suppression.</td>
</tr>
<tr>
<td className={td + ' font-semibold'}>Pentobarbital</td>
<td className={td + ' text-sm'}>Bolus {f(SEIZURE_DRUGS.pentobarbBolus.perKg, SEIZURE_DRUGS.pentobarbBolus.max)!.value} mg gtt {f(SEIZURE_DRUGS.pentobarbGttLow.perKg, null, 'mg/hr')!.value}{f(SEIZURE_DRUGS.pentobarbGttHigh.perKg, null, 'mg/hr')!.value} mg/hr <span className="text-xs text-muted-foreground">(bolus 5 mg/kg, gtt 1-5 mg/kg/hr)</span></td>
<td className={td + ' text-xs'}>IV</td>
<td className={td + ' text-xs text-muted-foreground'}>Causes hypotension often need pressors.</td>
</tr>
<tr>
<td className={td + ' font-semibold'}>Propofol</td>
<td className={td + ' text-sm'}>Bolus {f(SEIZURE_DRUGS.propofolBolus.perKg, SEIZURE_DRUGS.propofolBolus.max)!.value} mg gtt {f(SEIZURE_DRUGS.propofolGttLow.perKg, null, 'mg/hr')!.value}{f(SEIZURE_DRUGS.propofolGttHigh.perKg, null, 'mg/hr')!.value} mg/hr <span className="text-xs text-muted-foreground">(bolus 2 mg/kg, gtt 2-5 mg/kg/hr)</span></td>
<td className={td + ' text-xs'}>IV</td>
<td className={td + ' text-xs text-muted-foreground'}><strong>PRIS risk in children</strong> limit to &lt;4 mg/kg/hr and duration &lt;48 h.</td>
</tr>
<tr>
<td className={td + ' font-semibold'}>Ketamine</td>
<td className={td + ' text-sm'}>Bolus {f(SEIZURE_DRUGS.ketamineBolus.perKg, SEIZURE_DRUGS.ketamineBolus.max)!.value} mg gtt {f(SEIZURE_DRUGS.ketamineGttLow.perKg, null, 'mg/hr')!.value}{f(SEIZURE_DRUGS.ketamineGttHigh.perKg, null, 'mg/hr')!.value} mg/hr <span className="text-xs text-muted-foreground">(bolus 2 mg/kg, gtt 1-5 mg/kg/hr)</span></td>
<td className={td + ' text-xs'}>IV</td>
<td className={td + ' text-xs text-muted-foreground'}>NMDA antagonist rescue. Good BP profile; useful if refractory to GABAergics.</td>
</tr>
</tbody>
</table>
</div>
<div className="text-xs text-muted-foreground mt-2">
<strong>Continuous EEG within 1 h</strong> target electrographic seizure suppression × 24-48 h, then wean. Reassess etiology: CNS imaging, LP, expanded workup.
</div>
</Step>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100 space-y-1">
<div className="font-semibold">Key points</div>
<div> Do <strong>NOT</strong> wait for benzo response before starting the 2nd-line parallel preparation.</div>
<div> Maximum <strong>2 benzo doses</strong> total (1 pre-hospital + 1 in ED, or 2 in ED).</div>
<div> Respiratory depression is common have BVM, airway equipment, naloxone, flumazenil accessible (but avoid flumazenil here).</div>
<div> ESETT trial: levetiracetam = fosphenytoin = valproate for efficacy. Pick by tolerability / contraindications.</div>
<div> Always reassess: ongoing seizure? Non-convulsive status? Pseudo-seizure? continuous EEG if any doubt.</div>
</div>
<div className="text-xs text-muted-foreground italic">
Based on AES Guidelines 2016 and ESETT (Kapur et al., NEJM 2019). Verify all doses against institutional protocols.
</div>
</>
)}
</section>
);
}
// Helper to keep Bedside.tsx concise — dispatch by pill id.
export function renderBedsideRealPanel(pillId: string): React.ReactNode | null {
switch (pillId) {
case 'anaphylaxis': return <AnaphylaxisPanel />;
case 'cardiac': return <CardiacPanel />;
case 'seizure': return <SeizurePanel />;
default: return null;
}
}
// Expose the pill IDs that now have a real React panel so Bedside.tsx
// can pick between the real panel and the legacy-link fallback.
export const REAL_BEDSIDE_PANELS: ReadonlySet<string> = new Set(['anaphylaxis', 'cardiac', 'seizure']);

View file

@ -50,4 +50,36 @@ test.describe('React Bedside — sub-nav shell', () => {
await expect(page.locator('[data-testid="bedside-estimate-result"]')).toContainText('16 kg');
await expect(page.locator('[data-testid="bedside-weight-input"]')).toHaveValue('16');
});
test('anaphylaxis panel computes IM epinephrine dose by weight', async ({ authedPage: _, page }) => {
await openReactBedside(page);
await page.click('[data-testid="bedside-pill-anaphylaxis"]');
await page.fill('[data-testid="anaph-weight"]', '20');
// 20 kg × 0.01 mg/kg = 0.2 mg epi IM (below the 0.5 mg cap).
await expect(page.locator('[data-testid="bedside-panel-anaphylaxis"]')).toContainText('0.2 mg');
// Uncapped — ensure 0.5 kicks in past the cap threshold (wt ≥ 50 kg).
await page.fill('[data-testid="anaph-weight"]', '70');
await expect(page.locator('[data-testid="bedside-panel-anaphylaxis"]')).toContainText('0.5 mg');
});
test('cardiac arrest PALS general table renders weight-scaled epinephrine', async ({ authedPage: _, page }) => {
await openReactBedside(page);
await page.click('[data-testid="bedside-pill-cardiac"]');
await page.fill('[data-testid="cardiac-weight"]', '25');
// Default view is general. Epi IV = 0.01 mg/kg × 25 = 0.25 mg.
await expect(page.locator('[data-testid="bedside-panel-cardiac"]')).toContainText('0.25 mg');
// Switching to Asystole keeps the same dose logic.
await page.click('[data-testid="cardiac-view-asystole"]');
await expect(page.locator('[data-testid="bedside-panel-cardiac"]')).toContainText('0.25 mg');
});
test('seizure pathway computes D10W bolus range by weight', async ({ authedPage: _, page }) => {
await openReactBedside(page);
await page.click('[data-testid="bedside-pill-seizure"]');
await page.fill('[data-testid="seizure-weight"]', '10');
// 10 kg × 2-5 mL/kg = 20-50 mL D10W
await expect(page.locator('[data-testid="bedside-panel-seizure"]')).toContainText('20-50 mL');
// Lorazepam 0.1 mg/kg × 10 = 1 mg
await expect(page.locator('[data-testid="bedside-panel-seizure"]')).toContainText('1 mg');
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<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-Bk7sqZ-P.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-Cun0WMrQ.css">
<script type="module" crossorigin src="/app/assets/index-CJeLsmYO.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DwfNQaVq.css">
</head>
<body>
<div id="root"></div>

View file

@ -179,6 +179,39 @@ export function calculateWeightBasedDose(input: WeightBasedDoseInput): WeightBas
};
}
// 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 {