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:
parent
fb51ba9cfe
commit
95a5a5e40e
9 changed files with 739 additions and 57 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
616
client/src/pages/BedsidePanels.tsx
Normal file
616
client/src/pages/BedsidePanels.tsx
Normal 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 & 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 < 60)</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Start CPR if HR < 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 >220 infant / >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 <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 <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 <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 <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 <4 mg/kg/hr and duration <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']);
|
||||
|
|
@ -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
52
public/app/assets/index-CJeLsmYO.js
Normal file
52
public/app/assets/index-CJeLsmYO.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/app/assets/index-DwfNQaVq.css
Normal file
2
public/app/assets/index-DwfNQaVq.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue