pediatric-ai-scribe-v3/public/js/bedside/neonatal.js
Daniel ddf9ca805b feat: C — extract Bedside reference into ES modules
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.

Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.

Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
2026-04-20 23:19:50 +02:00

247 lines
15 KiB
JavaScript

// ============================================================
// bedside/neonatal.js
// NEONATAL ASSESSMENT (Fenton growth) + NRP pathway + Apgar.
// ============================================================
import { S } from './shared.js';
// Fenton 2013 LMS data (weight in grams, GA in weeks)
var fentonLMS = {
male: {
22:{L:0.21,M:496,S:0.17},23:{L:0.21,M:575,S:0.17},24:{L:0.21,M:660,S:0.17},25:{L:0.21,M:762,S:0.16},
26:{L:0.21,M:870,S:0.16},27:{L:0.20,M:993,S:0.15},28:{L:0.20,M:1124,S:0.15},29:{L:0.19,M:1272,S:0.14},
30:{L:0.18,M:1430,S:0.14},31:{L:0.17,M:1607,S:0.14},32:{L:0.15,M:1795,S:0.14},33:{L:0.13,M:2008,S:0.13},
34:{L:0.12,M:2230,S:0.13},35:{L:0.10,M:2467,S:0.13},36:{L:0.08,M:2710,S:0.13},37:{L:0.06,M:2948,S:0.12},
38:{L:0.04,M:3195,S:0.12},39:{L:0.02,M:3380,S:0.12},40:{L:0.01,M:3530,S:0.12},41:{L:0.00,M:3660,S:0.12},
42:{L:-0.02,M:3820,S:0.12}
},
female: {
22:{L:0.23,M:474,S:0.17},23:{L:0.22,M:538,S:0.17},24:{L:0.22,M:610,S:0.17},25:{L:0.22,M:705,S:0.16},
26:{L:0.22,M:810,S:0.16},27:{L:0.21,M:920,S:0.15},28:{L:0.21,M:1040,S:0.15},29:{L:0.20,M:1178,S:0.14},
30:{L:0.19,M:1330,S:0.14},31:{L:0.18,M:1500,S:0.14},32:{L:0.16,M:1680,S:0.14},33:{L:0.14,M:1880,S:0.13},
34:{L:0.12,M:2090,S:0.13},35:{L:0.10,M:2310,S:0.13},36:{L:0.08,M:2540,S:0.13},37:{L:0.06,M:2766,S:0.12},
38:{L:0.04,M:3000,S:0.12},39:{L:0.02,M:3180,S:0.12},40:{L:0.01,M:3340,S:0.12},41:{L:0.00,M:3480,S:0.12},
42:{L:-0.02,M:3630,S:0.12}
}
};
function interpolateLMS(table, val) {
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
if (val <= keys[0]) return table[keys[0]];
if (val >= keys[keys.length-1]) return table[keys[keys.length-1]];
for (var i = 0; i < keys.length - 1; i++) {
if (val >= keys[i] && val <= keys[i+1]) {
var t = (val - keys[i]) / (keys[i+1] - keys[i]);
var a = table[keys[i]], b = table[keys[i+1]];
return { L: a.L + t*(b.L-a.L), M: a.M + t*(b.M-a.M), S: a.S + t*(b.S-a.S) };
}
}
return table[keys[0]];
}
function calcZ(value, L, M, S) {
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
function zToPercentile(z) {
// Approximation of the standard normal CDF
var a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
var sign = z < 0 ? -1 : 1;
var x = Math.abs(z) / Math.sqrt(2);
var t = 1 / (1 + p * x);
var y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-x*x);
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
}
function classifyGA(weeks, days) {
var total = weeks + (days || 0) / 7;
if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'fa-triangle-exclamation' };
if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'fa-triangle-exclamation' };
if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'fa-circle-exclamation' };
if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'fa-circle-info' };
if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'fa-circle-info' };
if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'fa-circle-check' };
if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'fa-circle-info' };
return { label: 'Post Term', color: '#dc2626', icon: 'fa-triangle-exclamation' };
}
function classifyWeight(percentile) {
if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' };
if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' };
if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' };
if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' };
return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' };
}
function classifyBirthWeight(grams) {
if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' };
if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' };
if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' };
if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' };
return { label: 'Macrosomia (>4000g)', color: '#ea580c' };
}
function neoAssess() {
var weeks = parseInt(document.getElementById('neo-ga-weeks').value);
var days = parseInt(document.getElementById('neo-ga-days').value) || 0;
var weight = parseFloat(document.getElementById('neo-weight').value);
var sex = document.getElementById('neo-sex').value;
var resultDiv = document.getElementById('neo-result');
if (!weeks || !weight) {
resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter GA and birth weight.</p>';
return;
}
var gaDecimal = weeks + days / 7;
var gaClass = classifyGA(weeks, days);
var bwClass = classifyBirthWeight(weight);
// Calculate percentile using Fenton LMS
var lms = interpolateLMS(fentonLMS[sex], gaDecimal);
var z = calcZ(weight, lms.L, lms.M, lms.S);
var percentile = zToPercentile(z);
var wtClass = classifyWeight(percentile);
var expectedWeight = Math.round(lms.M);
var html = '';
// GA Classification
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + gaClass.color + '10;border:1.5px solid ' + gaClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Gestational Age Classification</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + gaClass.color + ';"><i class="fas ' + gaClass.icon + '"></i> ' + gaClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weeks + ' weeks ' + days + ' days (' + gaDecimal.toFixed(1) + ' weeks)</div>';
html += '</div>';
// Weight Classification (AGA/SGA/LGA)
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + wtClass.color + '10;border:1.5px solid ' + wtClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Weight for Gestational Age</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + wtClass.color + ';">' + wtClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + percentile.toFixed(1) + 'th percentile (' + wtClass.detail + ')</div>';
html += '</div>';
html += '</div>';
// Birth Weight Category
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + bwClass.color + '10;border:1.5px solid ' + bwClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Birth Weight Category</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + bwClass.color + ';">' + bwClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + 'g (' + (weight/1000).toFixed(2) + ' kg)</div>';
html += '</div>';
// Stats
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:var(--g50);border:1.5px solid var(--g200);">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Fenton Growth Data (' + (sex === 'male' ? 'Male' : 'Female') + ')</div>';
html += '<div style="font-size:13px;color:var(--g700);line-height:1.8;">';
html += '<strong>Expected weight (50th %ile):</strong> ' + expectedWeight + 'g<br>';
html += '<strong>Z-score:</strong> ' + z.toFixed(2) + '<br>';
html += '<strong>Percentile:</strong> ' + percentile.toFixed(1) + '%';
html += '</div></div>';
html += '</div>';
// Reference
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.</p>';
resultDiv.innerHTML = html;
}
// ---- NRP pathway + drug calc -----------------------------------------
function renderPathway() {
var el = document.getElementById('nrp-pathway-static');
if (!el || el.dataset.rendered) return;
var html = '';
html += S.stepBox('#3b82f6', 'BIRTH — ASSESS (first 30 sec)', 'Term? Tone? Breathing/crying? If <strong>all yes</strong> → routine care with mother. If <strong>any no</strong> → warm, dry, stimulate, position airway, clear airway PRN, evaluate HR & respirations.');
html += S.arrow;
html += S.stepBox('#8b5cf6', 'HR &lt; 100 OR apneic/gasping (60 sec)', '<strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO2 (right hand) &plusmn; ECG. MR SOPA if ineffective: <em>M</em>ask adjust, <em>R</em>eposition, <em>S</em>uction, <em>O</em>pen mouth, <em>P</em>ressure increase, <em>A</em>lternative airway.');
html += S.arrow;
html += S.stepBox('#f59e0b', 'HR &lt; 100 after 30 sec effective PPV', 'Reassess ventilation — ensure chest rise. Consider increasing FiO2, intubation, or LMA. Continue PPV.');
html += S.arrow;
html += S.stepBox('#ef4444', 'HR &lt; 60 after 30 sec of effective PPV', '<strong>Intubate + Chest compressions</strong> — 3:1 ratio (90 compressions + 30 breaths per minute), FiO2 100%, lower 1/3 sternum, depth 1/3 AP chest.');
html += S.arrow;
html += S.stepBox('#dc2626', 'HR &lt; 60 despite compressions + PPV x 60 sec', '<strong>Epinephrine</strong> 1:10,000 (0.1 mg/mL): <br>&bull; <strong>IV/IO:</strong> 0.01-0.03 mg/kg (0.1-0.3 mL/kg) — preferred<br>&bull; <strong>ETT:</strong> 0.05-0.1 mg/kg (0.5-1 mL/kg) — while IV being placed<br>Repeat q3-5 min. If hypovolemia: <strong>NS 10 mL/kg IV/IO</strong> over 5-10 min.');
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:14px;">';
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Target SpO2 (preductal):</strong><br>1 min: 60-65%<br>2 min: 65-70%<br>3 min: 70-75%<br>4 min: 75-80%<br>5 min: 80-85%<br>10 min: 85-95%</div>';
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Initial ETT size:</strong><br>&lt;1 kg / &lt;28 wk: <strong>2.5</strong><br>1-2 kg / 28-34 wk: <strong>3.0</strong><br>2-3 kg / 34-38 wk: <strong>3.5</strong><br>&gt;3 kg / &gt;38 wk: <strong>3.5-4.0</strong></div>';
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>ETT depth (lip):</strong><br>~ 6 + weight(kg) cm</div>';
html += '</div>';
el.innerHTML = html;
el.dataset.rendered = '1';
}
function calcNRP() {
var wt = parseFloat(document.getElementById('nrp-weight').value);
var el = document.getElementById('nrp-result');
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
var epiIvLow = Math.round(wt * 0.01 * 100) / 100;
var epiIvHigh = Math.round(wt * 0.03 * 100) / 100;
var epiEtLow = Math.round(wt * 0.05 * 100) / 100;
var epiEtHigh = Math.round(wt * 0.1 * 100) / 100;
var ns = Math.round(wt * 10);
var d10 = Math.round(wt * 2 * 10) / 10;
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--red)10;border:1.5px solid var(--red);margin-bottom:10px;"><strong style="color:var(--red);">NRP doses — ' + wt + ' kg</strong></div>';
var pk = function(txt) { return ' <span style="color:var(--g500);font-size:11px;">(' + txt + ')</span>'; };
html += S.drugTable(
S.drugRow('Epinephrine 1:10,000', epiIvLow + '-' + epiIvHigh + ' mg, ' + (Math.round(epiIvLow*10)/10) + '-' + (Math.round(epiIvHigh*10)/10) + ' mL' + pk('0.01-0.03 mg/kg = 0.1-0.3 mL/kg'), 'IV / IO', 'Preferred route. Repeat q3-5 min.') +
S.drugRow('Epinephrine 1:10,000', epiEtLow + '-' + epiEtHigh + ' mg, ' + (Math.round(epiEtLow*10)/10) + '-' + (Math.round(epiEtHigh*10)/10) + ' mL' + pk('0.05-0.1 mg/kg = 0.5-1 mL/kg'), 'ETT', 'While IV being placed.') +
S.drugRow('Normal saline', ns + ' mL' + pk('10 mL/kg'), 'IV / IO', 'Over 5-10 min for volume. Repeat PRN.') +
S.drugRow('Dextrose 10%', d10 + ' mL' + pk('2 mL/kg = 0.2 g/kg'), 'IV slow push', 'For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min.')
);
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;"><strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) — that is IM for anaphylaxis / older patients.</div>';
html += S.ref('AHA/AAP Neonatal Resuscitation Program (NRP) 8th edition, 2020.');
el.innerHTML = html;
}
// ---- Apgar -----------------------------------------------------------
function calcApgar() {
var keys = ['appearance','pulse','grimace','activity','respiration'];
var total = keys.reduce(function(s, k) {
return s + (parseInt(document.getElementById('apgar-' + k).value) || 0);
}, 0);
var el = document.getElementById('apgar-result');
var color, severity, guidance;
if (total >= 7) {
color = '#10b981'; severity = 'Reassuring';
guidance = 'Routine newborn care. Continue reassessment. Repeat at 5 min.';
} else if (total >= 4) {
color = '#f59e0b'; severity = 'Moderately depressed';
guidance = 'Stimulate, clear airway, warm. Give O2 if cyanotic. Ventilate with PPV if HR &lt;100 or apneic/gasping. Reassess q30 sec.';
} else {
color = '#ef4444'; severity = 'Severely depressed';
guidance = 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR &lt;60. Epinephrine and volume per NRP.';
}
var html = '<div style="padding:10px 14px;border-radius:8px;background:' + color + '10;border:1.5px solid ' + color + ';">';
html += '<strong style="color:' + color + ';font-size:16px;">Apgar: ' + total + '/10 — ' + severity + '</strong>';
html += '<div style="font-size:12px;color:var(--g700);margin-top:6px;line-height:1.5;">' + guidance + '</div></div>';
html += '<p style="font-size:11px;color:var(--g400);margin-top:8px;font-style:italic;">Apgar is a description of status — <strong>never</strong> delay resuscitation while scoring. Follow NRP algorithm based on HR and breathing. Apgar &lt;7 at 5 min: repeat q5 min up to 20 min.</p>';
el.innerHTML = html;
}
export function init() {
// Neonatal assessment
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-neo-assess' || e.target.closest('#btn-neo-assess')) neoAssess();
});
// NRP pathway render + calc
document.addEventListener('DOMContentLoaded', renderPathway);
document.addEventListener('tabChanged', function() { setTimeout(renderPathway, 100); });
if (document.readyState !== 'loading') setTimeout(renderPathway, 0);
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-nrp-calc' || e.target.closest('#btn-nrp-calc')) calcNRP();
// Render NRP pathway when Neonatal sub-pill is clicked (in case component loaded late)
var np = e.target.closest && e.target.closest('[data-em="neonatal"]');
if (np) setTimeout(renderPathway, 0);
var nav = e.target.closest && e.target.closest('[data-calc="bedside"]');
if (nav) setTimeout(renderPathway, 50);
});
// Apgar
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-apgar-calc' || e.target.closest('#btn-apgar-calc')) calcApgar();
});
}