pediatric-ai-scribe-v3/public/js/peGuide.js
2026-05-08 08:56:21 +02:00

435 lines
25 KiB
JavaScript

// ============================================================
// PHYSICAL EXAM GUIDE — step-by-step OSCE reference + report generator
// ============================================================
// Sources for the exam content:
// - Bates' Guide to Physical Examination, 13th edition (technique)
// - Nelson Textbook of Pediatrics, 22nd edition (age-specific content)
// - Hutchison's Clinical Methods, 25th edition (systematic approach)
// - Developmental reflex timing: Fenichel Clinical Pediatric Neurology, 8th ed
//
// Data model:
// PE_DATA[ageGroup][system] = { overview, components: [{ name, steps: [{label, method, normal}], abnormalHints }] }
// Each step has its own Normal / Abnormal / Skip toggle so the physician
// ticks the exam off step-by-step and flags specific abnormal findings.
// Report generation (POST /api/generate-pe-narrative) receives the flat
// step array and produces a Technique + Findings narrative.
// ============================================================
var SCALES = {};
var SYSTEM_SCALES = {};
var APTM_LEGEND = [];
var INNOCENT_MURMURS = [];
var RESP_SOUNDS = [];
var CARDIAC_SOUNDS = [];
var PE_DATA = {};
var PE_GUIDE_DATA_URL = '/data/pe-guide.json';
var peGuideDataPromise = null;
function applyPeGuideData(data) {
data = data || {};
SCALES = data.scales || {};
SYSTEM_SCALES = data.systemScales || {};
APTM_LEGEND = data.aptmLegend || [];
INNOCENT_MURMURS = data.innocentMurmurs || [];
RESP_SOUNDS = data.respiratorySounds || [];
CARDIAC_SOUNDS = data.cardiacSounds || [];
PE_DATA = data.peData || {};
}
function loadPeGuideData() {
if (!peGuideDataPromise) {
peGuideDataPromise = fetch(PE_GUIDE_DATA_URL)
.then(function (r) {
if (!r.ok) throw new Error('Failed to load PE Guide data');
return r.json();
})
.then(function (data) {
applyPeGuideData(data);
return data;
});
}
return peGuideDataPromise;
}
// ────────────────────────────────────────────────────────────
// STATE + RENDER
// ────────────────────────────────────────────────────────────
var _inited = false;
// state keyed as 'system-componentIdx-stepIdx' → { label, method, normal, status, note }
var state = {};
var currentSystem = 'msk';
var currentAgeGroup = '';
document.addEventListener('tabChanged', function (e) {
if (e.detail.tab !== 'peguide' || _inited) return;
_inited = true;
init().catch(function (err) {
console.error('[PEGuide] init error', err);
var content = document.getElementById('pe-content');
if (content) content.innerHTML = '<p style="text-align:center;color:#ef4444;padding:40px;">Unable to load PE Guide data.</p>';
});
});
async function init() {
await loadPeGuideData();
var ageSelect = document.getElementById('pe-age-group');
var content = document.getElementById('pe-content');
var actions = document.getElementById('pe-actions');
function applyAgeGroup(ag) {
currentAgeGroup = ag;
state = {};
document.getElementById('pe-output').classList.add('hidden');
if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) {
content.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">Select an age group above.</p>';
actions.style.display = 'none';
return;
}
renderSystem();
actions.style.display = 'flex';
}
ageSelect.addEventListener('change', function () {
applyAgeGroup(ageSelect.value);
if (window.UIState) window.UIState.set('pe.ageGroup', ageSelect.value || '');
});
function applySystem(sys) {
document.querySelectorAll('[data-pesystem]').forEach(function (b) {
b.classList.toggle('active', b.dataset.pesystem === sys);
});
currentSystem = sys;
state = {};
document.getElementById('pe-output').classList.add('hidden');
if (currentAgeGroup) renderSystem();
}
document.querySelectorAll('[data-pesystem]').forEach(function (btn) {
btn.addEventListener('click', function () {
applySystem(btn.dataset.pesystem);
if (window.UIState) window.UIState.set('pe.system', btn.dataset.pesystem);
});
});
// Restore persisted selections. Age group must be restored first because
// the system render depends on it.
if (window.UIState) {
var savedAge = window.UIState.get('pe.ageGroup');
if (savedAge && PE_DATA[savedAge]) {
ageSelect.value = savedAge;
applyAgeGroup(savedAge);
}
var savedSys = window.UIState.get('pe.system');
if (savedSys && /^(msk|neuro|resp|cv)$/.test(savedSys)) {
applySystem(savedSys);
}
}
document.getElementById('pe-all-normal').addEventListener('click', setAllNormal);
document.getElementById('pe-all-clear').addEventListener('click', clearAll);
document.getElementById('pe-generate-btn').addEventListener('click', generate);
}
// Responsive two-column grid helper: side-by-side on wide screens, stacked on narrow
var TWO_COL_GRID = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;align-items:start;';
// Render a single sound card with native <audio controls> (play/pause/seek
// free on all platforms incl. mobile). Falls back to a synth play button
// when the sound has no real recording.
function renderSoundCard(s) {
var player = '<audio class="pe-audio" controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>';
var html = '';
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);margin-bottom:4px;">' + esc(s.title) + '</div>';
if (s.where) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
if (s.rate) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Rate:</strong> ' + esc(s.rate) + '</div>';
if (s.features) html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:3px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
if (s.clinical) html += ' <div style="font-size:11px;color:var(--g600);line-height:1.5;margin-bottom:8px;"><strong>Clinical:</strong> ' + esc(s.clinical) + '</div>';
html += ' <div style="margin-top:6px;">' + player + '</div>';
html += '</div>';
return html;
}
function renderSoundsLibrary(title, sounds, accent, accentTint, icon) {
var html = '';
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas ' + icon + '"></i> ' + esc(title) + '</h3></div>';
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
sounds.forEach(function (s) { html += renderSoundCard(s); });
html += ' </div>';
html += '</div>';
return html;
}
function renderSystem() {
var content = document.getElementById('pe-content');
var group = PE_DATA[currentAgeGroup];
if (!group || !group[currentSystem]) {
content.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">No data for this combination.</p>';
return;
}
var section = group[currentSystem];
var accentMap = { msk: '#0891b2', neuro: '#7c3aed', resp: '#0ea5e9', cv: '#e11d48' }; // cyan / purple / sky / rose
var accentTintMap = { msk: '#ecfeff', neuro: '#f5f3ff', resp: '#f0f9ff', cv: '#fff1f2' };
var iconMap = { msk: 'bone', neuro: 'brain', resp: 'lungs', cv: 'heart-pulse' };
var labelMap = { msk: 'Musculoskeletal', neuro: 'Neurologic', resp: 'Respiratory', cv: 'Cardiovascular' };
var accent = accentMap[currentSystem] || '#0891b2';
var accentTint = accentTintMap[currentSystem] || '#ecfeff';
var icon = iconMap[currentSystem] || 'circle-info';
var sysLabel = labelMap[currentSystem] || currentSystem;
var html = '';
// ─ Overview banner ─
html += '<div class="card" style="margin-bottom:14px;border-left:4px solid ' + accent + ';">';
html += ' <div class="card-header" style="border:0;padding:14px 16px 6px;"><h3 style="margin:0;font-size:16px;color:' + accent + ';"><i class="fas fa-' + icon + '"></i> ' + esc(group.label) + ' — ' + sysLabel + '</h3></div>';
html += ' <div style="padding:0 16px 14px;font-size:13px;line-height:1.65;color:var(--g700);">' + esc(section.overview) + '</div>';
html += '</div>';
// ─ APTM cardiac auscultation diagram + innocent-murmur map (cv only) ─
if (currentSystem === 'cv') {
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas fa-stethoscope"></i> Auscultation landmarks — APTM + Erb\'s</h3></div>';
// Image ALWAYS on its own row (full width on every device, responsive
// max-width caps it so it isn't huge on desktop). Tap/click to open
// full-size in a new tab — native browser zoom works there. Legend
// below stacks on narrow screens via auto-fit grid with minmax.
html += ' <div style="padding:14px 16px 6px;">';
html += ' <a href="/images/pe-guide/aptm.png" target="_blank" rel="noopener" aria-label="Open APTM diagram full size in a new tab" style="display:block;text-align:center;cursor:zoom-in;">';
html += ' <img src="/images/pe-guide/aptm.png" alt="APTM cardiac auscultation points diagram" style="max-width:100%;width:100%;max-width:420px;height:auto;display:inline-block;border-radius:8px;border:1px solid var(--g200);"/>';
html += ' <div style="font-size:11px;color:var(--g500);margin-top:6px;"><i class="fas fa-up-right-from-square" style="margin-right:4px;"></i>Tap / click to open full-size in a new tab (pinch to zoom on mobile)</div>';
html += ' </a>';
html += ' </div>';
html += ' <div style="padding:4px 16px 14px;">';
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:10px 16px;font-size:12px;line-height:1.55;">';
APTM_LEGEND.forEach(function (p) {
html += '<div style="display:flex;gap:10px;align-items:flex-start;">';
html += ' <div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:' + p.color + ';color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:12px;">' + p.letter + '</div>';
html += ' <div style="flex:1;min-width:0;">';
html += ' <div style="font-weight:600;color:var(--g800);font-size:13px;">' + esc(p.title) + '</div>';
html += ' <div style="color:var(--g600);font-size:11px;margin-top:1px;">' + esc(p.location) + '</div>';
html += ' <div style="color:var(--g700);margin-top:3px;font-size:12px;"><strong>Listen for:</strong> ' + esc(p.listen) + '</div>';
if (p.innocent) {
html += ' <div style="color:#047857;margin-top:2px;font-size:11px;"><i class="fas fa-leaf" style="margin-right:3px;"></i><em>Innocent:</em> ' + esc(p.innocent) + '</div>';
}
html += ' </div>';
html += '</div>';
});
html += ' </div>';
html += ' </div>';
// ─ Cardiac sounds library ─
html += ' <div style="border-top:1px solid var(--g200);padding:14px;">';
html += ' <div style="font-weight:600;font-size:13px;color:' + accent + ';margin-bottom:10px;"><i class="fas fa-volume-high"></i> Cardiac sounds library</div>';
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
CARDIAC_SOUNDS.forEach(function (s) {
html += renderSoundCard(s);
});
html += ' </div>';
html += ' </div>';
// ─ Innocent murmur reference panel ─
html += ' <div style="border-top:1px solid var(--g200);padding:14px;background:#f0fdf4;">';
html += ' <div style="font-weight:600;font-size:13px;color:#047857;margin-bottom:8px;"><i class="fas fa-leaf"></i> Classic innocent murmurs (benign, no workup needed)</div>';
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;">';
INNOCENT_MURMURS.forEach(function (m) {
html += '<div style="background:#fff;border:1px solid var(--g200);border-radius:8px;padding:10px 12px;">';
html += ' <div style="font-weight:600;color:var(--g800);font-size:13px;margin-bottom:3px;">' + esc(m.name) + '</div>';
html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:4px;">Age: ' + esc(m.age) + ' · Location: ' + esc(m.location) + '</div>';
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.55;"><strong>Sound:</strong> ' + esc(m.character) + '</div>';
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.55;margin-top:3px;"><strong>Confirm innocent:</strong> ' + esc(m.confirm) + '</div>';
html += '</div>';
});
html += ' </div>';
html += ' <div style="margin-top:10px;font-size:11px;color:var(--g600);line-height:1.55;"><strong>The 7 "S" innocent-murmur criteria:</strong> Soft (≤ 2/6) · Systolic · Short · Single (no S3/S4) · Small (non-radiating) · Sweet (musical) · Sensitive to position/respiration. Any murmur breaking this pattern — diastolic, ≥ grade 3, radiating, continuous, accompanied by symptoms — deserves pediatric cardiology referral.</div>';
html += ' </div>';
html += '</div>';
}
// ─ Respiratory sounds library (only for resp system) ─
if (currentSystem === 'resp') {
html += renderSoundsLibrary('Respiratory sounds library', RESP_SOUNDS, accent, accentTint, 'fa-lungs');
}
// ─ Grading scales reference (collapsible) ─
var scaleKeys = SYSTEM_SCALES[currentSystem] || [];
if (scaleKeys.length) {
html += '<details class="card" style="margin-bottom:14px;background:' + accentTint + ';border:1px solid ' + accent + '33;">';
html += ' <summary style="padding:10px 16px;cursor:pointer;font-weight:600;font-size:13px;color:' + accent + ';list-style:none;display:flex;align-items:center;gap:8px;"><i class="fas fa-book-medical"></i> Grading scales &amp; reference <span style="font-weight:400;color:var(--g500);font-size:11px;">(click to expand)</span></summary>';
html += ' <div style="padding:0 16px 14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;">';
scaleKeys.forEach(function (sk) {
var sc = SCALES[sk]; if (!sc) return;
html += '<div style="background:#fff;border:1px solid var(--g200);border-radius:8px;padding:10px 12px;">';
html += ' <div style="font-weight:600;font-size:12px;color:var(--g800);margin-bottom:6px;"><i class="fas ' + sc.icon + '" style="color:' + accent + ';margin-right:6px;"></i>' + esc(sc.title) + '</div>';
html += ' <table style="width:100%;font-size:11px;line-height:1.5;border-collapse:collapse;">';
sc.rows.forEach(function (row) {
html += '<tr><td style="padding:2px 0;white-space:nowrap;color:' + accent + ';font-weight:600;font-family:ui-monospace,monospace;">' + esc(row[0]) + '</td><td style="padding:2px 0 2px 10px;color:var(--g700);">' + esc(row[1]) + '</td></tr>';
});
html += ' </table>';
html += '</div>';
});
html += ' </div>';
html += '</details>';
}
// ─ Components ─
section.components.forEach(function (c, ci) {
html += '<div class="card" style="margin-bottom:12px;border-left:3px solid ' + accent + ';">';
// Header
html += ' <div style="padding:12px 16px 8px;display:flex;align-items:center;justify-content:space-between;gap:10px;">';
html += ' <h3 style="margin:0;font-size:15px;color:var(--g800);">' + esc(c.name) + '</h3>';
html += ' <span style="font-size:10px;font-weight:600;padding:3px 8px;border-radius:10px;background:' + accent + '22;color:' + accent + ';text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;">' + c.steps.length + ' steps</span>';
html += ' </div>';
// Significance line
if (c.significance) {
html += ' <div style="padding:0 16px 6px;font-size:12px;color:var(--g600);line-height:1.55;"><i class="fas fa-bullseye" style="color:' + accent + ';margin-right:6px;font-size:11px;"></i>' + esc(c.significance) + '</div>';
}
// Teaching pearl
if (c.pearl) {
html += ' <div style="margin:6px 16px 10px;padding:10px 12px;background:#fef7e7;border-left:3px solid #f59e0b;border-radius:0 6px 6px 0;font-size:12px;line-height:1.6;color:#78350f;"><i class="fas fa-lightbulb" style="color:#f59e0b;margin-right:6px;"></i><em>' + esc(c.pearl) + '</em></div>';
}
// Steps
html += ' <div style="padding:4px 12px 10px;">';
c.steps.forEach(function (s, si) {
var key = currentSystem + '-' + ci + '-' + si;
if (!state[key]) state[key] = { component: c.name, label: s.label, method: s.method, normal: s.normal, status: null, note: '' };
var st = state[key].status;
var rowBg = st === 'normal' ? '#ecfdf5' : st === 'abnormal' ? '#fef2f2' : 'transparent';
var rowBorder = st === 'normal' ? '#a7f3d0' : st === 'abnormal' ? '#fecaca' : 'var(--g200)';
html += '<div class="pe-step" data-pe-key="' + key + '" style="padding:10px 12px;border:1px solid ' + rowBorder + ';border-radius:8px;margin-bottom:6px;background:' + rowBg + ';transition:background 0.12s,border-color 0.12s;">';
html += ' <div style="display:flex;align-items:flex-start;gap:10px;">';
html += ' <div style="flex-shrink:0;width:22px;height:22px;border-radius:50%;background:' + accent + '22;color:' + accent + ';font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;line-height:1;">' + (si + 1) + '</div>';
html += ' <div style="flex:1;min-width:0;">';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);">' + esc(s.label) + '</div>';
html += ' <div style="font-size:12px;color:var(--g600);margin-top:3px;line-height:1.55;"><span style="color:var(--g500);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;font-size:10px;">How</span> &nbsp;' + esc(s.method) + '</div>';
html += ' <div style="font-size:12px;color:var(--g600);margin-top:2px;line-height:1.55;"><span style="color:#059669;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;font-size:10px;">Normal</span> &nbsp;' + esc(s.normal) + '</div>';
html += ' </div>';
html += ' <div style="display:flex;gap:3px;flex-shrink:0;">';
html += ' <button class="visit-status-btn ' + (st === 'normal' ? 'done' : '') + '" data-pe-status="normal" data-pe-key="' + key + '" title="Normal">✓</button>';
html += ' <button class="visit-status-btn ' + (st === 'abnormal' ? 'refused' : '') + '" data-pe-status="abnormal" data-pe-key="' + key + '" title="Abnormal">✗</button>';
html += ' <button class="visit-status-btn ' + (st === null ? 'not-due' : '') + '" data-pe-status="skip" data-pe-key="' + key + '" title="Skip">—</button>';
html += ' </div>';
html += ' </div>';
html += ' <div class="pe-abnormal-detail" style="margin-top:8px;' + (st === 'abnormal' ? '' : 'display:none;') + '">';
html += ' <input type="text" class="pe-note" data-pe-key="' + key + '" placeholder="Describe the abnormal finding (e.g., 4/5 strength, left pronator drift)" value="' + esc(state[key].note || '') + '" style="width:100%;padding:6px 10px;font-size:12px;border:1px solid #fecaca;border-radius:6px;background:#fff;">';
html += ' </div>';
html += '</div>';
});
html += ' </div>';
// Watch-for
if (c.abnormalHints && c.abnormalHints.length) {
html += '<div style="margin:0 12px 12px;padding:10px 12px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:0 6px 6px 0;font-size:11px;line-height:1.7;color:#7f1d1d;">';
html += '<div style="font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#991b1b;margin-bottom:4px;font-size:10px;"><i class="fas fa-triangle-exclamation"></i> Watch for</div>';
html += c.abnormalHints.map(esc).join(' &nbsp;·&nbsp; ');
html += '</div>';
}
html += '</div>';
});
content.innerHTML = html;
content.querySelectorAll('[data-pe-status]').forEach(function (btn) {
btn.addEventListener('click', function () { handleStatus(btn); });
});
content.querySelectorAll('.pe-note').forEach(function (inp) {
inp.addEventListener('input', function () {
var key = inp.dataset.peKey;
if (state[key]) state[key].note = inp.value;
});
});
// Single-playback policy: when any <audio> starts, pause every other one.
content.querySelectorAll('.pe-audio').forEach(function (a) {
a.addEventListener('play', function () {
content.querySelectorAll('.pe-audio').forEach(function (other) {
if (other !== a && !other.paused) other.pause();
});
});
});
}
function handleStatus(btn) {
var key = btn.dataset.peKey;
var status = btn.dataset.peStatus;
if (!state[key]) return;
state[key].status = (status === 'skip') ? null : status;
var row = btn.closest('.pe-step');
if (row) {
var s = state[key].status;
row.style.background = s === 'normal' ? '#ecfdf5' : s === 'abnormal' ? '#fef2f2' : 'transparent';
row.style.borderColor = s === 'normal' ? '#a7f3d0' : s === 'abnormal' ? '#fecaca' : 'var(--g200)';
row.querySelectorAll('[data-pe-status]').forEach(function (b) {
b.classList.remove('done', 'refused', 'not-due');
if (b.dataset.peStatus === 'normal' && state[key].status === 'normal') b.classList.add('done');
if (b.dataset.peStatus === 'abnormal' && state[key].status === 'abnormal') b.classList.add('refused');
if (b.dataset.peStatus === 'skip' && state[key].status === null) b.classList.add('not-due');
});
var detail = row.querySelector('.pe-abnormal-detail');
if (detail) detail.style.display = (state[key].status === 'abnormal') ? '' : 'none';
}
}
function setAllNormal() {
Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) state[k].status = 'normal'; });
renderSystem();
}
function clearAll() {
Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) { state[k].status = null; state[k].note = ''; } });
renderSystem();
}
function generate() {
if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) return;
var steps = Object.keys(state)
.filter(function (k) { return k.indexOf(currentSystem + '-') === 0; })
.map(function (k) { return state[k]; });
var assessed = steps.filter(function (s) { return s.status !== null; });
if (assessed.length === 0) { showToast('Mark at least one step Normal or Abnormal', 'error'); return; }
var btn = document.getElementById('pe-generate-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
var modelSel = document.querySelector('#peguide-tab .tab-model-select');
var model = modelSel ? modelSel.value : '';
var format = document.getElementById('pe-format').value;
fetch('/api/generate-pe-narrative', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
steps: steps,
ageGroup: PE_DATA[currentAgeGroup].label,
system: currentSystem,
patientAge: document.getElementById('pe-age').value,
patientGender: document.getElementById('pe-gender').value,
model: model,
format: format
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
var out = document.getElementById('pe-output');
out.classList.remove('hidden');
document.getElementById('pe-narrative-text').textContent = data.narrative;
var tag = document.getElementById('pe-model-tag');
if (tag) tag.textContent = data.model || '';
var bar = document.getElementById('pe-summary-bar');
if (bar && data.summary) {
bar.classList.remove('hidden');
bar.innerHTML = '<span style="color:#10b981;">' + data.summary.normal + ' normal</span> · <span style="color:#ef4444;">' + data.summary.abnormal + ' abnormal</span> · <span style="color:#9ca3af;">' + data.summary.notAssessed + ' skipped</span>';
}
})
.catch(function (err) { console.error('[PEGuide] generate error', err); showToast('Request failed', 'error'); })
.finally(function () {
btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Exam Report';
});
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}