From d05831122cad0491e911005fde5230ada29cd141 Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:05:21 -0400 Subject: [PATCH 01/15] Update Order Helper: fix context preview and improve app.js rules handling --- order-helper/app.js | 297 ++++++++++++++++++++++++++++------------ order-helper/index.html | 28 +++- 2 files changed, 237 insertions(+), 88 deletions(-) diff --git a/order-helper/app.js b/order-helper/app.js index aa3fee4..8ecfa77 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -1,10 +1,10 @@ /** - * OraDigit Order Helper – app.js - * - Works with #contextChips (chips UI) and mirrors to hidden #context + * - CT contrast auto-suggestions + * - Indication builder + basic study suggestions */ (function () { @@ -44,33 +44,55 @@ dbg: document.getElementById("dbg"), }; - // -------- Fallback (so UI still works if rules.json fails) -------- + // -------- Fallbacks (so UI still works if rules.json fails/empty) -------- const FALLBACK_RULES = { modalities: { + "PET/CT": { + regions: [ + "Skull base to mid-thigh", + "Whole body", + "Head/Neck", + "Chest", + "Abdomen/Pelvis", + "Cardiac viability" + ], + contexts: [ + "Staging","Restaging","Treatment response","Surveillance","Suspected recurrence","Infection / inflammation","Viability" + ], + conditions: [ + "DLBCL","Hodgkin lymphoma","NSCLC","Melanoma","Colorectal cancer", + "Head and neck SCC","Fever of unknown origin","Cardiac viability" + ], + indication_templates: [ + "FDG PET/CT {region} – {context} for {condition}", + "FDG PET/CT {region} – evaluate {condition}", + "FDG PET/CT {region}{contrast_text} – {context} ({condition})" + ] + }, CT: { regions: [ "Head/Brain","Sinuses","Maxillofacial/Facial Bones","Temporal Bones/IAC","Neck","Chest", - "Low‑Dose Lung CT (Screening)","Abdomen","Pelvis","Abdomen/Pelvis","CT Urogram","CT Enterography", + "Low-Dose Lung CT (Screening)","Abdomen","Pelvis","Abdomen/Pelvis","CT Urogram","CT Enterography", "Spine – Cervical","Spine – Thoracic","Spine – Lumbar","Upper Extremity","Lower Extremity", "Cardiac Coronary CTA","Angiography – Head/Neck CTA","Angiography – Chest CTA (PE)", - "Angiography – Aorta CTA","Angiography – Run‑off CTA (LE)" + "Angiography – Aorta CTA","Angiography – Run-off CTA (LE)" ], contexts: [ "Staging","Restaging","Treatment response","Surveillance","Initial evaluation","Acute symptoms", - "Follow‑up","Pre‑operative planning","Post‑operative complication","Trauma","Screening","Infection / inflammation" + "Follow-up","Pre-operative planning","Post-operative complication","Trauma","Screening","Infection / inflammation" ], conditions: [ "Headache (sudden / thunderclap)","Head trauma","Stroke symptoms / TIA","Sinusitis","Neck mass", "Pulmonary embolism suspected","Aortic dissection / aneurysm suspected","Lung nodule","Pneumonia complication", "Abdominal pain RLQ (appendicitis)","Kidney stone / renal colic","Pancreatitis","Liver lesion characterization", - "Diverticulitis","Inflammatory bowel disease flare","Bowel obstruction","Post‑op abdomen","Hematuria", + "Diverticulitis","Inflammatory bowel disease flare","Bowel obstruction","Post-op abdomen","Hematuria", "Cancer staging (specify primary)","Metastatic disease restaging","Spine trauma","Cervical radiculopathy", - "Spinal stenosis","Extremity fracture","Suspected osteomyelitis","Peripheral arterial disease (LE run‑off)" + "Spinal stenosis","Extremity fracture","Suspected osteomyelitis","Peripheral arterial disease (LE run-off)" ], indication_templates: [ "CT {region} – {context} for {condition}", "CT {region} – rule out {condition}", - "CT {region} {contrast_text} – {context} ({condition})" + "CT {region}{contrast_text} – {context} ({condition})" ], contrast_recommendations: [ { match:["kidney stone","renal colic"], suggest:"without_iv" }, @@ -80,11 +102,11 @@ { match:["liver lesion","pancreatitis"], suggest:"with_iv" }, { match:["bowel obstruction"], suggest:"without_iv" }, { match:["trauma"], suggest:"with_iv" }, - { match:["low‑dose lung ct","screening"], suggest:"without_iv" } + { match:["low-dose lung ct","screening"], suggest:"without_iv" } ] } }, - records: [] + records: [] // keep empty; site-specific rules.json will populate }; let RULES = null; @@ -93,11 +115,20 @@ function setStatus(msg, level = "info") { if (!els.status) return; els.status.textContent = msg; - els.status.className = "status " + (level === "ok" || level === "success" ? "success" : - level === "warn" ? "warn" : - level === "error" ? "error" : ""); + els.status.className = + "status " + + (level === "ok" || level === "success" + ? "success" + : level === "warn" + ? "warn" + : level === "error" + ? "error" + : ""); } + const titleCase = (s) => + (s || "").replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1)); + function fillSelect(selectEl, values, placeholder = "Select…") { if (!selectEl) return; selectEl.innerHTML = ""; @@ -105,7 +136,7 @@ ph.value = ""; ph.textContent = placeholder; selectEl.appendChild(ph); - (values || []).forEach(v => { + (values || []).forEach((v) => { const opt = document.createElement("option"); opt.value = v; opt.textContent = v; @@ -116,7 +147,7 @@ function fillDatalist(datalistEl, items) { if (!datalistEl) return; datalistEl.innerHTML = ""; - (items || []).forEach(v => { + (items || []).forEach((v) => { const opt = document.createElement("option"); opt.value = v; datalistEl.appendChild(opt); @@ -127,14 +158,16 @@ if (!els.contrastGroup) return; els.contrastGroup.classList.toggle("hidden", !show); if (!show) { - const checked = els.contrastGroup.querySelector('input[type=radio]:checked'); + const checked = + els.contrastGroup.querySelector('input[type=radio]:checked'); if (checked) checked.checked = false; if (els.oral) els.oral.checked = false; } } function contrastTextFromForm() { - if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) return ""; + if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) + return ""; const radio = els.contrastGroup.querySelector('input[type=radio]:checked'); const oral = els.oral?.checked ? " + oral contrast" : ""; if (!radio) return oral ? "(" + oral.trim() + ")" : ""; @@ -143,24 +176,28 @@ return oral ? "(" + oral.trim() + ")" : ""; } - // -------- Chips helpers -------- + // -------- Chips helpers (with keyboard support) -------- function renderContextChips(contexts) { if (!els.contextChips) return; els.contextChips.innerHTML = ""; - (contexts || []).forEach(label => { + (contexts || []).forEach((label) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "oh-chip"; btn.textContent = label; btn.setAttribute("aria-pressed", "false"); + btn.setAttribute("aria-label", `Toggle context ${label}`); + btn.setAttribute("tabindex", "0"); els.contextChips.appendChild(btn); }); } function getSelectedContextsFromChips() { if (!els.contextChips) return []; - return Array.from(els.contextChips.querySelectorAll('.oh-chip[aria-pressed="true"]')) - .map(el => (el.textContent || "").trim()) + return Array.from( + els.contextChips.querySelectorAll('.oh-chip[aria-pressed="true"]') + ) + .map((el) => (el.textContent || "").trim()) .filter(Boolean); } @@ -168,7 +205,7 @@ if (!els.context) return; const selected = getSelectedContextsFromChips(); els.context.innerHTML = ""; - selected.forEach(label => { + selected.forEach((label) => { const opt = document.createElement("option"); opt.value = label; opt.textContent = label; @@ -178,16 +215,31 @@ } // -------- Rules loading -------- + function looksLikeRules(obj) { + // Minimal sanity check + if (!obj || typeof obj !== "object") return false; + // accept either {modalities:{}} or at least records:[] + const hasModalities = + obj.modalities && typeof obj.modalities === "object"; + const hasRecords = Array.isArray(obj.records); + return hasModalities || hasRecords; + } + async function loadRules() { try { const res = await fetch(RULES_URL, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - RULES = await res.json(); + const json = await res.json(); + if (!looksLikeRules(json)) throw new Error("Invalid rules schema"); + RULES = json; setStatus("Rules loaded.", "success"); } catch (e) { console.warn("Failed to load rules.json, using fallback", e); RULES = FALLBACK_RULES; - setStatus("Using built-in fallback rules (could not fetch rules.json).", "warn"); + setStatus( + "Using built-in fallback rules (could not fetch rules.json).", + "warn" + ); } } @@ -197,11 +249,13 @@ // If no modalities entry, derive contexts/regions from records (best effort) function deriveFromRecords(modality) { - const recs = (RULES?.records || []).filter(r => (r.modality || "").toUpperCase().includes(modality.toUpperCase())); + const recs = (RULES?.records || []).filter((r) => + (r.modality || "").toUpperCase().includes(modality.toUpperCase()) + ); const setC = new Set(); const setR = new Set(); - recs.forEach(r => { - (r.contexts || []).forEach(c => setC.add(titleCase(c))); + recs.forEach((r) => { + (r.contexts || []).forEach((c) => setC.add(titleCase(c))); if (r.header_coverage) setR.add(r.header_coverage); }); return { @@ -211,15 +265,10 @@ }; } - function titleCase(s) { - return (s || "").replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1)); - } - // -------- Populate UI for modality -------- function populateForModality(modality) { const node = getModalityNode(modality) || deriveFromRecords(modality); - // Regions & Contexts fillSelect(els.region, node.regions || [], "Select region…"); if (els.contextChips) { @@ -229,7 +278,6 @@ fillSelect(els.context, node.contexts || [], "Select context…"); } - // Conditions fillDatalist(els.conditionList, node.conditions || []); // Contrast only for CT @@ -239,8 +287,10 @@ if (els.condition) els.condition.value = ""; if (els.indication) els.indication.value = ""; - // Update preview if page script is listening - try { document.dispatchEvent(new Event("input", { bubbles: true })); } catch { /* ignore */ } + // Ask any external preview sync to re-render + try { + document.dispatchEvent(new Event("input", { bubbles: true })); + } catch {} } // -------- Contrast suggestions for CT -------- @@ -248,16 +298,20 @@ if (!modalityNode?.contrast_recommendations) return; const text = `${conditionText || ""} ${regionText || ""}`.toLowerCase(); for (const rule of modalityNode.contrast_recommendations) { - const allMatch = rule.match.every(token => text.includes(token)); + const allMatch = rule.match.every((token) => text.includes(token)); if (allMatch) { - const target = els.contrastGroup?.querySelector(`input[type=radio][value="${rule.suggest}"]`); + const target = els.contrastGroup?.querySelector( + `input[type=radio][value="${rule.suggest}"]` + ); if (target) target.checked = true; break; } } // Guardrail: any CTA should be with IV if ((regionText || "").toLowerCase().includes("cta")) { - const withIV = els.contrastGroup?.querySelector('input[type=radio][value="with_iv"]'); + const withIV = els.contrastGroup?.querySelector( + 'input[type=radio][value="with_iv"]' + ); if (withIV) withIV.checked = true; } } @@ -266,16 +320,28 @@ function buildIndication(modalityNode, modality) { if (!els.indication) return; const region = els.region?.value || ""; - const contexts = - els.contextChips ? getSelectedContextsFromChips().join(", ") : - (els.context?.value || ""); + const contexts = els.contextChips + ? getSelectedContextsFromChips().join(", ") + : (() => { + const sel = els.context; + return sel + ? [...sel.selectedOptions].map((o) => o.textContent.trim()).join(", ") + : ""; + })(); const condition = els.condition?.value || ""; const contrast_text = contrastTextFromForm(); // e.g., "(with IV contrast + oral contrast)" - const templates = modalityNode?.indication_templates || - (modality === "CT" ? ["CT {region} – {context} for {condition}"] : - modality === "PET/CT" ? ["FDG PET/CT {region} – {context} for {condition}"] : - ["{region} – {context} for {condition}"]); - const t = templates[2] || templates[0]; // prefer contrast-capable template if present + const templates = + modalityNode?.indication_templates || + (modality === "CT" + ? ["CT {region} – {context} for {condition}"] + : modality === "PET/CT" + ? ["FDG PET/CT {region} – {context} for {condition}"] + : ["{region} – {context} for {condition}"]); + // Prefer a template that includes {contrast_text} if present + const t = + (contrast_text && templates.find((x) => x.includes("{contrast_text}"))) || + templates[0]; + const out = t .replace("{region}", region) .replace("{context}", contexts) @@ -286,25 +352,38 @@ // -------- Basic record matcher (suggest studies) -------- function scoreRecord(rec, modality, region, contexts, condition) { - if (!(rec.modality || "").toUpperCase().includes(modality.toUpperCase())) return -1; + if (!(rec.modality || "").toUpperCase().includes(modality.toUpperCase())) + return -1; let s = 0; - if (rec.header_coverage && region && rec.header_coverage.toLowerCase().includes(region.toLowerCase())) s += 2; - (rec.contexts || []).forEach(c => { - if (contexts.some(ctx => ctx.toLowerCase() === (c || "").toLowerCase())) s += 2; + if ( + rec.header_coverage && + region && + rec.header_coverage.toLowerCase().includes(region.toLowerCase()) + ) + s += 2; + (rec.contexts || []).forEach((c) => { + if (contexts.some((ctx) => ctx.toLowerCase() === (c || "").toLowerCase())) + s += 2; }); - (rec.keywords || []).forEach(k => { - if (condition && condition.toLowerCase().includes((k || "").toLowerCase())) s += 2; + (rec.keywords || []).forEach((k) => { + if (condition && condition.toLowerCase().includes((k || "").toLowerCase())) + s += 2; }); - if ((rec.tags || []).includes("oncology-general") && condition && /c\d\d|malig|tumor|cancer/i.test(condition)) s += 1; + if ( + (rec.tags || []).includes("oncology-general") && + condition && + /c\d\d|malig|tumor|cancer/i.test(condition) + ) + s += 1; return s; - } + } function suggestStudies(modality, region, contexts, condition) { if (!els.suggestions) return; const recs = RULES?.records || []; const scored = recs - .map(r => ({ r, s: scoreRecord(r, modality, region, contexts, condition) })) - .filter(x => x.s >= 0) + .map((r) => ({ r, s: scoreRecord(r, modality, region, contexts, condition) })) + .filter((x) => x.s >= 0) .sort((a, b) => b.s - a.s) .slice(0, 5); @@ -317,10 +396,10 @@ return; } - scored.forEach(({ r, s }) => { + scored.forEach(({ r }) => { const li = document.createElement("li"); const cpts = (r.cpt || []).join(", "); - li.innerHTML = `${r.study_name} [${cpts}]`; + li.innerHTML = `${r.study_name || r.header_coverage || "Suggested study"} ${cpts ? "[" + cpts + "]" : ""}`; li.title = (r.reasons || [])[0] || ""; els.suggestions.appendChild(li); }); @@ -329,7 +408,12 @@ // -------- Results panel fill -------- function fillResults(topRec, contextStr, conditionStr) { if (!els.results || !topRec) return; - if (els.outHeader) els.outHeader.textContent = `${topRec.study_name} — CPT: ${(topRec.cpt || []).join(", ")}`; + const header = + topRec.study_name || topRec.header_coverage || "Suggested Study"; + if (els.outHeader) + els.outHeader.textContent = `${header} — CPT: ${(topRec.cpt || []).join( + ", " + )}`; if (els.outReason) { const tmpl = (topRec.reasons || [])[0] || "{context} {condition}"; @@ -341,7 +425,7 @@ function fillUL(ul, arr) { if (!ul) return; ul.innerHTML = ""; - (arr || []).forEach(t => { + (arr || []).forEach((t) => { const li = document.createElement("li"); li.textContent = t; ul.appendChild(li); @@ -360,43 +444,54 @@ els.modality?.addEventListener("change", () => { const modality = els.modality.value; populateForModality(modality); - const node = getModalityNode(modality); + const node = getModalityNode(modality) || (FALLBACK_RULES.modalities[modality] || null); buildIndication(node, modality); }); // Region / Condition input -> suggest contrast if CT and rebuild indication - ["change", "input"].forEach(evt => { + ["change", "input"].forEach((evt) => { els.region?.addEventListener(evt, () => { if (els.modality?.value === "CT") { const node = getModalityNode("CT") || FALLBACK_RULES.modalities.CT; suggestContrastIfCT(node, els.condition?.value, els.region?.value); } - buildIndication(getModalityNode(els.modality?.value), els.modality?.value); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); }); els.condition?.addEventListener(evt, () => { if (els.modality?.value === "CT") { const node = getModalityNode("CT") || FALLBACK_RULES.modalities.CT; suggestContrastIfCT(node, els.condition?.value, els.region?.value); } - buildIndication(getModalityNode(els.modality?.value), els.modality?.value); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); }); }); // Contrast change -> rebuild indication els.contrastGroup?.addEventListener("change", () => { if (els.modality?.value === "CT") { - buildIndication(getModalityNode("CT") || FALLBACK_RULES.modalities.CT, "CT"); + buildIndication( + getModalityNode("CT") || FALLBACK_RULES.modalities.CT, + "CT" + ); } }); - // Chips delegation: toggle aria-pressed, mirror to hidden select, rebuild indication + // Chips: click + keyboard toggle document.addEventListener("click", (e) => { const chip = e.target.closest("#contextChips .oh-chip"); if (!chip) return; const cur = chip.getAttribute("aria-pressed") === "true"; chip.setAttribute("aria-pressed", cur ? "false" : "true"); mirrorChipsToHiddenSelect(); - buildIndication(getModalityNode(els.modality?.value), els.modality?.value); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); + }); + document.addEventListener("keydown", (e) => { + const chip = e.target.closest("#contextChips .oh-chip"); + if (!chip) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + chip.click(); + } }); // Form submit -> suggest order + fill results @@ -404,7 +499,11 @@ e.preventDefault(); const modality = els.modality?.value || ""; const region = els.region?.value || ""; - const contexts = els.contextChips ? getSelectedContextsFromChips() : (els.context?.value ? [els.context.value] : []); + const contexts = els.contextChips + ? getSelectedContextsFromChips() + : els.context + ? [...els.context.selectedOptions].map((o) => o.value) + : []; const condition = els.condition?.value || ""; // Suggestions @@ -413,8 +512,8 @@ // Fill results with top hit if available const recs = RULES?.records || []; const ranked = recs - .map(r => ({ r, s: scoreRecord(r, modality, region, contexts, condition) })) - .filter(x => x.s >= 0) + .map((r) => ({ r, s: scoreRecord(r, modality, region, contexts, condition) })) + .filter((x) => x.s >= 0) .sort((a, b) => b.s - a.s); fillResults(ranked[0]?.r, contexts.join(", "), condition); @@ -426,21 +525,47 @@ els.copyReasonBtn?.addEventListener("click", async () => { const v = els.outReason?.value?.trim(); if (!v) return; - try { await navigator.clipboard.writeText(v); setStatus("Reason copied to clipboard.", "success"); } - catch { setStatus("Unable to copy reason. Select and copy manually.", "warn"); } + try { + await navigator.clipboard.writeText(v); + setStatus("Reason copied to clipboard.", "success"); + } catch { + setStatus("Unable to copy reason. Select and copy manually.", "warn"); + } }); els.copyAllBtn?.addEventListener("click", async () => { const parts = []; if (els.outHeader) parts.push(els.outHeader.textContent); if (els.outReason?.value) parts.push("Reason: " + els.outReason.value); - if (els.outPrep?.children?.length) parts.push("Prep: " + Array.from(els.outPrep.children).map(li => li.textContent).join("; ")); - if (els.outDocs?.children?.length) parts.push("Docs: " + Array.from(els.outDocs.children).map(li => li.textContent).join("; ")); - if (els.outFlags?.children?.length) parts.push("Flags: " + Array.from(els.outFlags.children).map(li => li.textContent).join("; ")); + if (els.outPrep?.children?.length) + parts.push( + "Prep: " + + Array.from(els.outPrep.children) + .map((li) => li.textContent) + .join("; ") + ); + if (els.outDocs?.children?.length) + parts.push( + "Docs: " + + Array.from(els.outDocs.children) + .map((li) => li.textContent) + .join("; ") + ); + if (els.outFlags?.children?.length) + parts.push( + "Flags: " + + Array.from(els.outFlags.children) + .map((li) => li.textContent) + .join("; ") + ); const text = parts.join("\n"); if (!text.trim()) return; - try { await navigator.clipboard.writeText(text); setStatus("All details copied to clipboard.", "success"); } - catch { setStatus("Unable to copy. Select and copy manually.", "warn"); } + try { + await navigator.clipboard.writeText(text); + setStatus("All details copied to clipboard.", "success"); + } catch { + setStatus("Unable to copy. Select and copy manually.", "warn"); + } }); els.printBtn?.addEventListener("click", () => window.print()); @@ -461,9 +586,13 @@ suggestContrastIfCT(node, els.condition?.value, els.region?.value); buildIndication(node, "CT"); } else { - buildIndication(getModalityNode(current), current); + const node = + getModalityNode(current) || (FALLBACK_RULES.modalities[current] || null); + buildIndication(node, current); } - if (els.dbg) els.dbg.textContent = `[OH] Ready (${new Date().toLocaleString()})`; + if (els.dbg) + els.dbg.textContent = `[OH] Ready (${new Date().toLocaleString()})`; })(); })(); + diff --git a/order-helper/index.html b/order-helper/index.html index 141851e..134db2f 100644 --- a/order-helper/index.html +++ b/order-helper/index.html @@ -198,10 +198,30 @@

Clinical Flags

// Keep preview in sync with the form const syncPreview = () => { - $('#pv-modality').textContent = $('#modality')?.value || '—'; - $('#pv-region').textContent = $('#region')?.value || '—'; - $('#pv-context').textContent = $('#context')?.value || '—'; - $('#pv-condition').textContent = $('#condition')?.value || '—'; + $('#pv-modality').textContent = $('#modality')?.value || '—'; + $('#pv-region').textContent = $('#region')?.value || '—'; + + // NEW: show all selected contexts + { const sel = document.getElementById('context'); + const ctx = sel ? [...sel.selectedOptions].map(o => o.textContent.trim()).filter(Boolean) : []; + document.getElementById('pv-context').textContent = ctx.length ? ctx.join(', ') : '—'; + } + + $('#pv-condition').textContent = $('#condition')?.value || '—'; + + const grp = document.getElementById('contrastGroup'); + if (grp && !grp.classList.contains('hidden')) { + const r = grp.querySelector('input[type=radio]:checked'); + const oral = document.getElementById('oralContrast')?.checked; + let txt = r ? (r.value === 'with_iv' ? 'With IV contrast' : 'Without IV contrast') : '—'; + if (oral) txt += ' + oral'; + document.getElementById('pv-contrast').textContent = txt; + } else { + document.getElementById('pv-contrast').textContent = '—'; + } + + document.getElementById('pv-indication').textContent = document.getElementById('indication')?.value?.trim() || '—'; +}; const grp = document.getElementById('contrastGroup'); if (grp && !grp.classList.contains('hidden')) { From 5702b36aab24daaf31b90b8abebff8656680bd40 Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:55:37 -0400 Subject: [PATCH 02/15] =?UTF-8?q?Order=20Helper:=20Steps=203=E2=80=935=20?= =?UTF-8?q?=E2=80=94=20PET/CT+CT+MRI=20rules,=20auto-merge=20loader,=20UI?= =?UTF-8?q?=20polish,=20ICD-10=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- order-helper/app.js | 319 +++++++++++++++++++++++++++++++--------- order-helper/index.html | 30 +++- order-helper/style.css | 206 ++++++++++++++++++++------ 3 files changed, 437 insertions(+), 118 deletions(-) diff --git a/order-helper/app.js b/order-helper/app.js index 8ecfa77..cfc71a5 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -16,6 +16,12 @@ "./data/rules.json"; // -------- Elements -------- + const els = { + // ...existing + outICD: document.getElementById("outICD"), // <-- add this line + // ...existing +}; + const els = { status: document.getElementById("status"), form: document.getElementById("orderForm"), @@ -110,6 +116,37 @@ }; let RULES = null; + // -------- Matching helpers (aliases + fuzzy) -------- +const ALIASES = { + "nsclc": ["non small cell lung cancer","lung adenocarcinoma","lung squamous","lung ca"], + "mets": ["metastasis","metastases","metastatic","secondary tumor"], + "tia": ["transient ischemic attack"], + "pe": ["pulmonary embolism","embolus"], + "fuo": ["fever of unknown origin"], + "pji": ["prosthetic joint infection","prosthesis infection"], + "lbp": ["low back pain","lumbago"], + "hnscc": ["head and neck squamous cell carcinoma","head & neck scc"] +}; + +function norm(s){ return (s||"").toLowerCase().replace(/[^a-z0-9\s]/g," ").replace(/\s+/g," ").trim(); } +function tokens(s){ return norm(s).split(" ").filter(Boolean); } +function bigrams(arr){ const out=[]; for(let i=0;it===norm(v))) { out.add(k); vals.forEach(v=>out.add(norm(v))); } + } + } + return Array.from(out); +} + // -------- Utils -------- function setStatus(msg, level = "info") { @@ -224,24 +261,47 @@ const hasRecords = Array.isArray(obj.records); return hasModalities || hasRecords; } + + async function loadRules() { + function buildSiblingUrls(rulesUrl) { + const meta = new URL(rulesUrl, location.origin); + const search = meta.search; // keeps ?v=... + const dir = new URL(meta.pathname.replace(/[^/]+$/, ''), location.origin); + const mk = (name) => new URL(name + search, dir).toString(); + return [ + rulesUrl, // rules.json (PET/CT) + mk("ct_rules.json"), // CT + mk("mri_rules.json") // MRI + ]; + } - async function loadRules() { - try { - const res = await fetch(RULES_URL, { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - if (!looksLikeRules(json)) throw new Error("Invalid rules schema"); - RULES = json; - setStatus("Rules loaded.", "success"); - } catch (e) { - console.warn("Failed to load rules.json, using fallback", e); - RULES = FALLBACK_RULES; - setStatus( - "Using built-in fallback rules (could not fetch rules.json).", - "warn" - ); + async function tryFetch(url) { + try { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) return null; return await r.json(); } + catch { return null; } + } + + try { + const urls = buildSiblingUrls(RULES_URL); + const loaded = (await Promise.all(urls.map(tryFetch))).filter(Boolean); + + if (!loaded.length) throw new Error("No rules files available"); + + RULES = { modalities: {}, records: [] }; + for (const j of loaded) { + if (j.modalities && typeof j.modalities === "object") Object.assign(RULES.modalities, j.modalities); + if (Array.isArray(j.records)) RULES.records.push(...j.records); } + if (!RULES.records.length && !Object.keys(RULES.modalities).length) throw new Error("Invalid rules schema after merge"); + + setStatus("Rules loaded.", "success"); + } catch (e) { + console.warn("Rules load/merge failed; using fallback", e); + RULES = FALLBACK_RULES; + setStatus("Using built-in fallback rules (could not fetch rules.json).", "warn"); } +} + + function getModalityNode(modality) { return RULES?.modalities?.[modality] || null; @@ -351,33 +411,47 @@ } // -------- Basic record matcher (suggest studies) -------- - function scoreRecord(rec, modality, region, contexts, condition) { - if (!(rec.modality || "").toUpperCase().includes(modality.toUpperCase())) - return -1; - let s = 0; - if ( - rec.header_coverage && - region && - rec.header_coverage.toLowerCase().includes(region.toLowerCase()) - ) - s += 2; - (rec.contexts || []).forEach((c) => { - if (contexts.some((ctx) => ctx.toLowerCase() === (c || "").toLowerCase())) - s += 2; - }); - (rec.keywords || []).forEach((k) => { - if (condition && condition.toLowerCase().includes((k || "").toLowerCase())) - s += 2; - }); - if ( - (rec.tags || []).includes("oncology-general") && - condition && - /c\d\d|malig|tumor|cancer/i.test(condition) - ) - s += 1; - return s; + function scoreRecord(rec, modality, region, contexts, condition) { + // modality gate + if (!(rec.modality || "").toUpperCase().includes((modality||"").toUpperCase())) return -1; + + const ctxNorm = contexts.map(norm); + const cond = norm(condition); + const condExpanded = aliasExpand([cond, ...(rec.keywords||[]).map(norm)]); + + let s = 0; + + // Region match (partial ok) + if (rec.header_coverage && region) { + const rSim = diceSim(rec.header_coverage, region); + if (rSim >= 0.8) s += 4; else if (rSim >= 0.5) s += 2; } + // Context overlap (exact token or fuzzy) + (rec.contexts||[]).forEach(c=>{ + const cN = norm(c); + if (ctxNorm.includes(cN)) s += 3; + else if (ctxNorm.some(u=>diceSim(u,cN)>=0.65)) s += 1.5; + }); + + // Keyword/condition overlap (alias + fuzzy) + (rec.keywords||[]).forEach(k=>{ + const kN = norm(k); + if (!kN) return; + if (condExpanded.includes(kN)) s += 3; + else { + const sim = diceSim(cond,kN); + if (sim>=0.75) s += 2; else if (sim>=0.55) s += 1; + } + }); + + // Oncology general bump if user typed cancer-ish words + if ((rec.tags||[]).includes("oncology-general") && /cancer|tumou?r|carcinoma|lymphoma|melanoma|mets?/i.test(condition)) s += 1; + + return s; +} + + function suggestStudies(modality, region, contexts, condition) { if (!els.suggestions) return; const recs = RULES?.records || []; @@ -404,39 +478,141 @@ els.suggestions.appendChild(li); }); } + // -------- ICD-10 suggestions -------- +// NOTE: Always verify final codes per ICD-10-CM 2025 and payer policy. +const ICD_RULES = [ + // PET/CT oncology + { tokens: ["dlbcl","lymphoma","hodgkin","nhl"], codes: [ + { code:"C83.30", label:"Diffuse large B-cell lymphoma, unspecified site" }, + { code:"C81.90", label:"Hodgkin lymphoma, unspecified, unspecified site" } + ]}, + { tokens: ["nsclc","lung cancer","pulmonary nodule","lung"], codes: [ + { code:"C34.90", label:"Malignant neoplasm of unspecified part of unspecified lung" }, + { code:"R91.1", label:"Solitary pulmonary nodule" } + ]}, + { tokens: ["melanoma"], codes: [{ code:"C43.9", label:"Malignant melanoma of skin, unspecified" }]}, + { tokens: ["colorectal","colon cancer"], codes: [{ code:"C18.9", label:"Malignant neoplasm of colon, unspecified" }]}, + { tokens: ["hnscc","head and neck"], codes: [{ code:"C76.0", label:"Malignant neoplasm of head, face and neck" }]}, + { tokens: ["fever of unknown origin","fuo"], codes: [{ code:"R50.9", label:"Fever, unspecified" }]}, + { tokens: ["viability","ischemic cardiomyopathy","myocardial"], codes: [{ code:"I25.5", label:"Ischemic cardiomyopathy" }]}, + + // CT common + { tokens: ["pulmonary embolism","pe"], codes: [{ code:"I26.99", label:"Other pulmonary embolism without acute cor pulmonale" }]}, + { tokens: ["appendicitis","rlq"], codes: [{ code:"K35.80", label:"Unspecified acute appendicitis" }]}, + { tokens: ["renal colic","kidney stone","flank pain","hematuria"], codes: [ + { code:"N20.0", label:"Calculus of kidney" }, + { code:"N23", label:"Unspecified renal colic" } + ]}, + { tokens: ["pneumonia"], codes: [{ code:"J18.9", label:"Pneumonia, unspecified organism" }]}, + + // MRI neuro + { tokens: ["stroke","cerebral infarction"], codes: [{ code:"I63.9", label:"Cerebral infarction, unspecified" }]}, + { tokens: ["tia"], codes: [{ code:"G45.9", label:"Transient cerebral ischemic attack, unspecified" }]}, + { tokens: ["intracranial hemorrhage","ich"], codes: [{ code:"I62.9", label:"Nontraumatic intracranial hemorrhage, unspecified" }]}, + + // MRI spine + { tokens: ["cervical radiculopathy"], codes: [{ code:"M54.12", label:"Radiculopathy, cervical region" }]}, + { tokens: ["lumbar radiculopathy","sciatica"], codes: [{ code:"M54.16", label:"Radiculopathy, lumbar region" }]}, + { tokens: ["spinal stenosis","stenosis"], codes: [{ code:"M48.061", label:"Spinal stenosis, lumbar region w/o neurogenic claudication" }]}, + { tokens: ["disc herniation"], codes: [{ code:"M51.26", label:"Other intervertebral disc displacement, lumbar region" }]}, + + // Ortho + { tokens: ["meniscal tear"], codes: [{ code:"S83.209A", label:"Tear of unsp meniscus, unsp knee, initial encounter" }]}, + { tokens: ["acl tear"], codes: [{ code:"S83.511A", label:"Sprain of ACL of right knee, initial encounter" }]}, + { tokens: ["rotator cuff"], codes: [{ code:"M75.100", label:"Unspecified rotator cuff tear or rupture, not specified as traumatic" }]} +]; + +function suggestICD10(text) { + const t = (text || "").toLowerCase(); + const out = []; + const seen = new Set(); + for (const rule of ICD_RULES) { + if (rule.tokens.some(tok => t.includes(tok))) { + for (const c of rule.codes) { + if (!seen.has(c.code)) { + out.push(c); + seen.add(c.code); + } + } + } + if (out.length >= 6) break; // keep list tidy + } + return out; +} // -------- Results panel fill -------- - function fillResults(topRec, contextStr, conditionStr) { - if (!els.results || !topRec) return; - const header = - topRec.study_name || topRec.header_coverage || "Suggested Study"; - if (els.outHeader) - els.outHeader.textContent = `${header} — CPT: ${(topRec.cpt || []).join( - ", " - )}`; - - if (els.outReason) { - const tmpl = (topRec.reasons || [])[0] || "{context} {condition}"; - els.outReason.value = tmpl - .replace("{context}", contextStr || "") - .replace("{condition}", conditionStr || ""); - } + function chooseReasonTemplate(rec, contextStr, conditionStr){ + const list = rec.reasons || ["{context} {condition}"]; + // score each reason by overlap with user text + let best = list[0], bestS = -1; + for(const r of list){ + const s = (r.includes("{context}")?1:0) + (r.includes("{condition}")?1:0) + diceSim(r, (contextStr||"")+" "+(conditionStr||"")); + if (s > bestS) { bestS = s; best = r; } + } + return best.replace("{context}", contextStr||"").replace("{condition}", conditionStr||""); +} - function fillUL(ul, arr) { - if (!ul) return; - ul.innerHTML = ""; - (arr || []).forEach((t) => { - const li = document.createElement("li"); - li.textContent = t; - ul.appendChild(li); - }); - } - fillUL(els.outPrep, topRec.prep ? [topRec.prep] : []); - fillUL(els.outDocs, topRec.supporting_docs); - fillUL(els.outFlags, topRec.flags); +function fillResults(topRec, contextStr, conditionStr) { + if (!els.results || !topRec) return; + const header = topRec.study_name || topRec.header_coverage || "Suggested Study"; + if (els.outHeader) els.outHeader.textContent = `${header} — CPT: ${(topRec.cpt || []).join(", ")}`; - els.results.hidden = false; + if (els.outReason) els.outReason.value = chooseReasonTemplate(topRec, contextStr, conditionStr); + + function fillUL(ul, arr) { + if (!ul) return; + ul.innerHTML = ""; + (arr || []).forEach((t) => { + const li = document.createElement("li"); + li.textContent = t; + ul.appendChild(li); + }); } + // ICD-10 suggestions from context+condition text +if (els.outICD) { + const icds = suggestICD10(`${contextStr || ""} ${conditionStr || ""}`); + els.outICD.innerHTML = ""; + if (icds.length) { + icds.forEach(({code,label}) => { + const li = document.createElement("li"); + li.textContent = `${code} — ${label}`; + els.outICD.appendChild(li); + }); + } else { + const li = document.createElement("li"); + li.className = "muted"; + li.textContent = "No suggestions. Edit condition text for better matches."; + els.outICD.appendChild(li); + } +} + + const flags = Array.from(topRec.flags||[]); + if (Array.isArray(topRec.icd10) && topRec.icd10.length) { + flags.unshift("ICD-10: " + topRec.icd10.join(", ")); + } + + fillUL(els.outPrep, topRec.prep ? [topRec.prep] : []); + fillUL(els.outDocs, topRec.supporting_docs); + fillUL(els.outFlags, flags); + + els.results.hidden = false; +} +function updateSuggestions() { + const modality = els.modality?.value || ""; + const region = els.region?.value || ""; + const contexts = els.contextChips ? getSelectedContextsFromChips() + : els.context ? [...els.context.selectedOptions].map(o=>o.value) : []; + const condition = els.condition?.value || ""; + + suggestStudies(modality, region, contexts, condition); + + // Also prefill result with the current top (if exists) so right panel isn’t “dead” + const recs = RULES?.records || []; + const ranked = recs.map(r=>({ r, s: scoreRecord(r, modality, region, contexts, condition) })) + .filter(x=>x.s>=0).sort((a,b)=>b.s-a.s); + if (ranked[0]) fillResults(ranked[0].r, contexts.join(", "), condition); +} + // -------- Event wiring -------- function wireEvents() { @@ -558,6 +734,13 @@ .map((li) => li.textContent) .join("; ") ); + if (els.outICD?.children?.length) { + parts.push( + "ICD-10: " + + Array.from(els.outICD.children).map(li => li.textContent).join("; ") + ); +} + const text = parts.join("\n"); if (!text.trim()) return; try { diff --git a/order-helper/index.html b/order-helper/index.html index 134db2f..87fe89b 100644 --- a/order-helper/index.html +++ b/order-helper/index.html @@ -6,10 +6,11 @@ --- - + - + +
@@ -37,6 +38,11 @@

OraDigit Order Helper

Regions, contexts, and conditions adapt to your selection. +
+

ICD-10 Suggestions

+
    +
    +
    @@ -144,6 +150,26 @@

    Clinical Flags

    + +
    What rules are applied? diff --git a/order-helper/style.css b/order-helper/style.css index 052624d..82f52d5 100644 --- a/order-helper/style.css +++ b/order-helper/style.css @@ -1,66 +1,176 @@ -/* Container & layout */ -.container { max-width: 1080px; margin: 0 auto; padding: 1.25rem; } -.oh-hero { padding: 2rem 0 0.5rem; } -.lead { font-size: 1.05rem; color: #3a3a3a; } +/* OraDigit Order Helper — scoped styles (v2) */ +:root { + --oh-bg: #fff; + --oh-fg: #111827; + --oh-muted: #6b7280; + --oh-border: #e5e7eb; + --oh-accent: #2563eb; + --oh-accent-600: #1d4ed8; + --oh-chip-bg: #f3f4f6; + --oh-chip-active: #e0ecff; + --oh-card-bg: #ffffff; + --oh-card-shadow: 0 1px 2px rgba(0,0,0,.06), 0 1px 3px rgba(0,0,0,.1); + --oh-success: #16a34a; + --oh-warn: #b45309; + --oh-error: #b91c1c; +} -/* Card */ -.oh-card { - background: #fff; - border: 1px solid #e6e9ef; - border-radius: 14px; - padding: 1.25rem; - box-shadow: 0 6px 18px rgba(0,0,0,0.05); +/* Page wrapper */ +.order-helper.container { + max-width: 1280px; /* wider page */ + padding-top: 1.25rem; + padding-bottom: 2.5rem; + color: var(--oh-fg); +} + +.oh-header h1 { + font-size: 1.85rem; + line-height: 1.2; + margin-bottom: .25rem; +} +.oh-header .lead { + color: var(--oh-muted); + margin-bottom: .75rem; + max-width: 70ch; +} +.status { + font-size: .95rem; + padding: .55rem .8rem; + border: 1px solid var(--oh-border); + border-radius: .5rem; + background: #f9fafb; + color: var(--oh-muted); +} +.status.success { border-color: #dcfce7; color: var(--oh-success); background:#f0fdf4; } +.status.warn { border-color: #fee2e2; color: var(--oh-warn); background:#fffbeb; } +.status.error { border-color: #fecaca; color: var(--oh-error); background:#fef2f2; } + +/* Grid layout */ +.oh-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, .9fr); /* more space */ + gap: 2rem; /* more breathing room */ + align-items: start; + margin-top: 1.25rem; +} +@media (max-width: 980px) { + .oh-grid { grid-template-columns: 1fr; } +} + +/* Cards */ +.card { + background: var(--oh-card-bg); + border: 1px solid var(--oh-border); + border-radius: .75rem; + box-shadow: var(--oh-card-shadow); + padding: 1rem; +} +.card h2 { + font-size: 1.15rem; + margin-bottom: .6rem; +} + +/* Form area */ +.oh-form .field { margin-bottom: 1.05rem; } +.oh-form label, .oh-form legend { + display: block; font-weight: 600; margin-bottom: .4rem; } +.oh-form .hint { color: var(--oh-muted); font-size: .87rem; margin-top: .3rem; } -/* Inputs */ -.oh-label { display:block; font-weight:600; margin: 0.5rem 0; } -.oh-input { - width: 100%; padding: 0.75rem 0.9rem; border:1px solid #d5d8e0; border-radius: 10px; - font: inherit; background: #fafbfe; +.oh-form input[type="text"], +.oh-form textarea, +.oh-form select { + width: 100%; + border: 1px solid var(--oh-border); + border-radius: .6rem; + padding: .72rem .8rem; /* larger hit target */ + font-size: 1rem; /* bigger text */ + background: #fff; } -.oh-input:focus { outline: 3px solid rgba(36,99,235,0.15); border-color:#2463eb; } +.oh-form textarea { min-height: 132px; resize: vertical; } -/* Grid */ -.oh-grid { display: grid; gap: 1rem; grid-template-columns: 1fr; margin-top: 1rem; } -@media (min-width: 800px) { .oh-grid { grid-template-columns: 1fr 1fr; } } +/* CT contrast */ +.oh-contrast { + border: 1px dashed var(--oh-border); + border-radius: .6rem; + padding: .7rem .8rem .35rem; + background: #fcfcfd; +} +.oh-contrast legend { font-size: .98rem; padding: 0 .25rem; } +.oh-contrast .inline { + display: inline-flex; align-items: center; gap: .45rem; + margin-right: 1rem; margin-bottom: .4rem; font-weight: 500; +} /* Chips */ -.oh-chips { display:flex; flex-wrap:wrap; gap: 0.5rem; } +.oh-chips { display: flex; flex-wrap: wrap; gap: .55rem; } .oh-chip { - border:1px solid #cfd6e6; border-radius: 999px; padding: 0.35rem 0.75rem; background:#fff; - cursor:pointer; user-select:none; font-size: 0.95rem; + appearance: none; border: 1px solid var(--oh-border); + background: var(--oh-chip-bg); padding: .5rem .7rem; + border-radius: 999px; font-size: .95rem; cursor: pointer; + transition: background .15s, border-color .15s, box-shadow .15s; +} +.oh-chip[aria-pressed="true"] { + background: var(--oh-chip-active); + border-color: var(--oh-accent); + box-shadow: 0 0 0 2px rgba(37,99,235,.15) inset; } -.oh-chip[aria-pressed="true"] { background:#2463eb; color:#fff; border-color:#2463eb; } -/* Buttons */ -.oh-btn { - margin-top: 1rem; padding: 0.75rem 1rem; border: none; border-radius: 10px; - background:#1e40af; color:#fff; font-weight:600; cursor:pointer; -} -.oh-btn:hover { filter: brightness(1.05); } -.oh-btn-secondary { background:#334155; } -.oh-btn-outline { background:#fff; color:#1e40af; border:1px solid #b5c0de; } +/* Actions */ +.actions { + display: flex; flex-wrap: wrap; align-items: center; + gap: .6rem .7rem; margin-top: .4rem; +} +.btn-primary, .btn-ghost, .btn, .btn-secondary, .btn-tertiary, .btn-light { + display: inline-flex; align-items: center; justify-content: center; + border-radius: .55rem; border: 1px solid transparent; + padding: .7rem .95rem; font-weight: 600; cursor: pointer; + font-size: .98rem; text-decoration: none; +} +.btn-primary { background: var(--oh-accent); color: #fff; } +.btn-primary:hover { background: var(--oh-accent-600); } +.btn-ghost { background: #fff; color: var(--oh-accent); border-color: var(--oh-accent); } +.btn-ghost:hover { background: #f0f7ff; } +.btn-secondary { background:#111827; color:#fff; } +.btn-tertiary { background:#f3f4f6; color:#111827; } +.btn-light { background:#f9fafb; color:#111827; } -/* Results */ -.oh-results { margin-top: 1.25rem; } -.oh-result { background:#fff; border:1px solid #e6e9ef; border-radius: 12px; padding: 1rem; margin-bottom:1rem; } -.oh-output { font-weight:600; } -.oh-actions { display:flex; gap:0.5rem; flex-wrap: wrap; margin-top:0.5rem; } +/* Right column content */ +.preview { display: grid; gap: .45rem; font-size: 1rem; } +.preview hr { border: none; border-top: 1px solid var(--oh-border); margin: .55rem 0; } +.indication-box { + background: #0b10251a; border: 1px solid var(--oh-border); + border-radius: .6rem; padding: .7rem .8rem; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: .93rem; +} -.oh-columns { display:grid; gap:1rem; grid-template-columns: 1fr; } -@media (min-width: 900px) { .oh-columns { grid-template-columns: repeat(3, 1fr); } } -.oh-col { background:#fff; border:1px solid #e6e9ef; border-radius:12px; padding:1rem; } +/* Suggestions list */ +.suggest-list { + list-style: none; padding-left: 0; margin: 0; display: grid; gap: .6rem; +} +.suggest-list li { + padding: .7rem .8rem; border: 1px solid var(--oh-border); + border-radius: .6rem; background: #fff; +} +.suggest-list li .muted, .muted { color: var(--oh-muted); } -.oh-list { padding-left: 1.1rem; } -.oh-list li { margin: 0.25rem 0; } +/* Results */ +#results { + border: 1px solid var(--oh-border); border-radius: .8rem; + padding: 1.05rem; background: #fff; +} +#results h2 { font-size: 1.15rem; margin-bottom: .55rem; } +.oh-label { font-weight: 600; margin: .4rem 0; display:block; } +.oh-input { width: 100%; } -/* Misc */ -.sr-only { position:absolute; left:-9999px; } -.oh-disclaimer { font-size:0.92rem; color:#475569; } +/* Utilities */ +.hidden { display: none !important; } /* Print */ @media print { - .oh-card, .oh-actions button { display:none !important; } - body { background:#fff; } - .oh-result, .oh-col { border:none; box-shadow:none; } + .main-header, nav, .actions, .oh-chips, .status, .cookie-banner { display:none !important; } + body { color: #000; } + #results { border: none; box-shadow: none; } } From 6490a75a0d10f6327c45bf9be14f68e7d5231a8b Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:54:51 -0400 Subject: [PATCH 03/15] fix(order-helper): normalize rules.json (dash mojibake, array/comma/braces), validate JSON --- order-helper/data/rules.json | 581 ++++++++++++++++++++++++++++++++++- 1 file changed, 580 insertions(+), 1 deletion(-) diff --git a/order-helper/data/rules.json b/order-helper/data/rules.json index e659811..093dc98 100644 --- a/order-helper/data/rules.json +++ b/order-helper/data/rules.json @@ -1,4 +1,583 @@ -[ + +{ + "schema_version": "1.1", + "generated_at": "2025-08-19", + "modalities": { + "X-Ray": { + "regions": [ + "Chest", + "Abdomen (KUB)", + "Pelvis", + "Skull", + "Cervical spine", + "Thoracic spine", + "Lumbar spine", + "Scoliosis series", + "Upper extremity", + "Lower extremity", + "Shoulder", + "Elbow", + "Wrist/Hand", + "Hip", + "Knee", + "Ankle/Foot", + "Skeleton survey" + ], + "contexts": [ + "Acute", + "Trauma", + "Follow-up", + "Infection", + "Metastatic survey", + "Pre-operative", + "Post-operative" + ], + "conditions": [ + "Fracture", + "Dislocation", + "Osteoarthritis", + "Evaluation for pneumonia", + "Effusion", + "Foreign body", + "Osteomyelitis", + "Metastasis", + "Scoliosis" + ], + "indication_templates": [ + "X-Ray {region} — {context} for {condition}", + "X-Ray {region} — rule out {condition}" + ], + "notes": [ + "Use two views (minimum) for trauma if possible.", + "Skeleton survey used for myeloma or metastatic disease patterns (consider bone scan if broader sensitivity is needed).", + "No contrast used for plain radiography." + ] +} + + "CT": { + "regions": [ + "Head/Brain", + "Sinuses", + "Maxillofacial/Facial Bones", + "Temporal Bones/IAC", + "Neck", + "Chest", + "Low‑Dose Lung CT (Screening)", + "Abdomen", + "Pelvis", + "Abdomen/Pelvis", + "CT Urogram", + "CT Enterography", + "Spine – Cervical", + "Spine – Thoracic", + "Spine – Lumbar", + "Upper Extremity", + "Lower Extremity", + "Cardiac Coronary CTA", + "Angiography – Head/Neck CTA", + "Angiography – Chest CTA (PE)", + "Angiography – Aorta CTA", + "Angiography – Run‑off CTA (LE)" + ], + "contrast_options": [ + { "value": "with_iv", "label": "With IV contrast" }, + { "value": "without_iv", "label": "Without IV contrast" }, + { "value": "oral", "label": "Add oral contrast (abd/pelvis)" }, + { "value": "none", "label": "None / Not applicable" } + ], + "contexts": [ + "Initial evaluation", + "Acute symptoms", + "Follow‑up", + "Pre‑operative planning", + "Post‑operative complication", + "Oncology staging", + "Oncology restaging / surveillance", + "Trauma", + "Screening", + "Infection / inflammation" + ], + "conditions": [ + "Headache (sudden / thunderclap)", + "Head trauma", + "Stroke symptoms / TIA", + "Sinusitis", + "Neck mass", + "Pulmonary embolism suspected", + "Aortic dissection / aneurysm suspected", + "Lung nodule", + "Pneumonia complication", + "Abdominal pain RLQ (appendicitis)", + "Kidney stone / renal colic", + "Pancreatitis", + "Liver lesion characterization", + "Diverticulitis", + "Inflammatory bowel disease flare", + "Bowel obstruction", + "Post‑op abdomen", + "Hematuria", + "Cancer staging (specify primary)", + "Metastatic disease restaging", + "Spine trauma", + "Cervical radiculopathy", + "Spinal stenosis", + "Extremity fracture", + "Suspected osteomyelitis", + "Peripheral arterial disease (LE run‑off)" + ], + "indication_templates": [ + "CT {region} – {context} for {condition}", + "CT {region} – rule out {condition}", + "CT {region} {contrast_text} – {context} ({condition})" + ], + "contrast_recommendations": [ + { "match": ["kidney stone", "renal colic"], "suggest": "without_iv" }, + { "match": ["appendicitis", "rlq"], "suggest": "with_iv" }, + { "match": ["pe", "pulmonary embolism"], "suggest": "with_iv" }, + { "match": ["aortic", "dissection", "aneurysm"], "suggest": "with_iv" }, + { "match": ["liver lesion", "pancreatitis"], "suggest": "with_iv" }, + { "match": ["bowel obstruction"], "suggest": "without_iv" }, + { "match": ["trauma"], "suggest": "with_iv" }, + { "match": ["low‑dose lung ct", "screening"], "suggest": "without_iv" } + ], + "notes": [ + "CTA exams require IV contrast.", + "Oral contrast is site‑dependent for abdomen/pelvis—offered as an option.", + "This tool structures ordering; it is not medical advice." + ] + } + }, + "records": [ + { + "id": "PET-SB-MT-ONC", + "modality": "PET/CT", + "study_name": "FDG PET/CT — Skull Base to Mid-Thigh", + "cpt": ["78815"], + "icd10_common": ["C34.90", "C18.9", "C50.919", "C43.9", "C32.9", "C81.90", "C85.90"], + "contrast": "Radiotracer (± IV contrast on CT)", + "header_coverage": "Skull base → mid-thigh", + "contexts": ["staging", "restaging", "treatment response", "surveillance"], + "keywords": ["lymphoma", "nsclc", "lung cancer", "breast cancer", "colorectal", "colon cancer", "melanoma", "head and neck", "hnscc", "gastric", "pancreatic"], + "reasons": [ + "FDG PET/CT for {context} of {condition}; evaluate disease extent, nodal involvement, and FDG-avid distant metastases." + ], + "prep": "Fast 4–6 h; avoid strenuous exercise 24 h; glucose <200 mg/dL.", + "supporting_docs": [ + "Clinic note documenting diagnosis and clinical question.", + "Prior imaging/report.", + "Therapy timeline (chemo/radiation/surgery) and relevant labs." + ], + "flags": [ + "Recent G-CSF can increase marrow uptake.", + "Hyperglycemia lowers tumor-to-background contrast." + ], + "prior_auth": { "requires_prior_auth": true, "step_imaging_required": true }, + "tags": ["oncology-general"] + }, + { + "id": "PET-WHOLEBODY", + "modality": "PET/CT", + "study_name": "FDG PET/CT — Whole Body (Vertex to Toes)", + "cpt": ["78816"], + "icd10_common": ["C43.9", "C49.9", "C90.00", "M31.7", "R50.9"], + "contrast": "Radiotracer (± IV contrast on CT)", + "header_coverage": "Vertex → toes", + "contexts": ["staging", "restaging", "surveillance"], + "keywords": ["melanoma", "myeloma", "sarcoma", "vasculitis", "fever of unknown origin", "fuo"], + "reasons": [ + "FDG PET/CT whole body for {context} of {condition}; evaluate for extremity involvement and FDG-avid metastatic/inflammatory disease." + ], + "prep": "Standard FDG fasting; keep patient warm to limit brown fat uptake.", + "supporting_docs": ["Referring note with suspicion/diagnosis.", "Biopsy/pathology if available.", "Prior imaging for correlation."], + "flags": ["Include extremities for melanoma/myeloma.", "Consider inflammatory patterns in vasculitis/FOU."], + "prior_auth": { "requires_prior_auth": true }, + "tags": ["whole-body"] + }, + { + "id": "PET-BRAIN-FDG", + "modality": "PET", + "study_name": "FDG Brain PET", + "cpt": ["78608"], + "icd10_common": ["F03.90", "G30.9", "G31.09", "G40.909"], + "contrast": "Radiotracer only", + "header_coverage": "Brain", + "contexts": ["dementia", "epilepsy"], + "keywords": ["alzheim", "frontotemporal", "ftd", "epilepsy", "seizure", "temporal lobe"], + "reasons": [ + "FDG brain PET to evaluate cerebral metabolic patterns in {condition}; correlate with clinical and prior imaging." + ], + "prep": "Quiet, dim environment pre-injection; NPO; epilepsy timing per protocol.", + "supporting_docs": ["Neurology note with symptoms & question.", "Prior MRI/EEG."], + "flags": ["FDG patterns vary by dementia subtype.", "Medication/timing affects epilepsy localization."], + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["neuro"] + }, + { + "id": "PET-CARDIAC-FDG-VIABILITY", + "modality": "PET", + "study_name": "PET Cardiac FDG — Viability", + "cpt": ["78459", "78491", "78492"], + "icd10_common": ["I25.10", "I42.9"], + "contrast": "Radiotracer only", + "header_coverage": "Heart", + "contexts": ["viability"], + "keywords": ["viability", "ischemic cardiomyopathy", "hibernating myocardium"], + "reasons": [ + "FDG PET to assess myocardial viability in ischemic cardiomyopathy; correlate with perfusion and echocardiography." + ], + "prep": "Glucose loading/insulin protocol per SOP; coordinate with perfusion.", + "supporting_docs": ["Cardiology note on revascularization decision.", "Echo/perfusion/coronary imaging reports."], + "flags": ["Glycemic control critical for image quality."], + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["cardiac"] + }, + { + "id": "PET-INFECTION-SB-MT", + "modality": "PET/CT", + "study_name": "FDG PET/CT — Infection/Inflammation (Skull Base to Mid-Thigh)", + "cpt": ["78815"], + "icd10_common": ["M86.9", "T84.59XA", "I33.0", "R50.9"], + "contrast": "Radiotracer (± IV contrast on CT)", + "header_coverage": "Skull base → mid-thigh", + "contexts": ["suspected infection"], + "keywords": ["osteomyelitis", "prosthetic joint", "infection", "endocarditis", "fuo"], + "reasons": [ + "FDG PET/CT to evaluate suspected infection/inflammation related to {condition}; assess extent and possible sites of involvement." + ], + "prep": "Standard FDG fasting; review recent antibiotics.", + "supporting_docs": ["Clinical notes with symptoms/duration.", "WBC, CRP/ESR, cultures.", "Prior imaging for comparison."], + "flags": ["Prosthesis may show inflammatory uptake; interpret with context."], + "prior_auth": { "requires_prior_auth": true }, + "tags": ["infection"] + }, + { + "id": "PET-PSMA", + "modality": "PET/CT", + "study_name": "PSMA PET/CT — Prostate Cancer", + "cpt": ["78815", "78816"], + "icd10_common": ["C61", "R97.21"], + "contrast": "Radiotracer (PSMA) ± IV contrast CT", + "header_coverage": "Skull base → mid-thigh or whole body", + "contexts": ["staging", "biochemical recurrence"], + "keywords": ["prostate", "psa"], + "reasons": [ + "PSMA PET/CT for {context} in prostate cancer; localize recurrence/metastases to guide therapy." + ], + "prep": "Hydration; void frequently; follow tracer-specific guidance.", + "supporting_docs": ["PSA level and trend.", "Prior therapy and pathology."], + "flags": [], + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["prostate"] + }, + { + "id": "PET-DOTATATE", + "modality": "PET/CT", + "study_name": "DOTATATE PET/CT — Neuroendocrine Tumor", + "cpt": ["78815", "78816"], + "icd10_common": ["C7A.8", "C7B.8", "D3A.8"], + "contrast": "Radiotracer (Ga-68/Lu-177) ± IV contrast CT", + "header_coverage": "Skull base → mid-thigh or whole body", + "contexts": ["staging", "restaging", "surveillance"], + "keywords": ["NET", "neuroendocrine tumor", "carcinoid"], + "reasons": [ + "DOTATATE PET/CT to stage/restage neuroendocrine tumor and evaluate metastases/recurrence." + ], + "prep": "Hydration; withhold long-acting somatostatin analogs per protocol.", + "supporting_docs": ["Pathology and tumor grade if known.", "Prior imaging for comparison."], + "flags": [], + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["NET"] + }, + { + "id": "CT-HEAD-WO", + "modality": "CT", + "study_name": "CT Head without Contrast", + "cpt": ["70450"], + "icd10_common": ["I63.9", "S06.9X9A", "R51.9", "R42"], + "contrast": "Without", + "header_coverage": "Head", + "reasons": ["Acute stroke/TIA evaluation.", "Head trauma.", "Severe headache/new neuro deficit."], + "prep": "Remove metal; pregnancy screen PRN.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["neuro", "emergency"] + }, + { + "id": "CTA-HEAD-NECK", + "modality": "CT", + "study_name": "CTA Head & Neck (Stroke/Aneurysm)", + "cpt": ["70496", "70498"], + "icd10_common": ["I65.23", "I67.1", "I63.9", "G45.9"], + "contrast": "With (arterial bolus)", + "header_coverage": "Head & Neck arteries", + "reasons": ["Large-vessel occlusion/aneurysm/stenosis evaluation."], + "prep": "Creatinine per policy; 18–20G IV.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["neuro", "vascular"] + }, + { + "id": "CT-PE-CTPA", + "modality": "CT", + "study_name": "CT Pulmonary Angiography (PE Protocol)", + "cpt": ["71275"], + "icd10_common": ["I26.99", "R06.02", "R07.9", "R79.1"], + "contrast": "With (arterial bolus)", + "header_coverage": "Chest (PE protocol)", + "reasons": ["Suspected pulmonary embolism with elevated clinical probability or D-dimer."], + "prep": "Check creatinine; allergy premedication if needed.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["chest", "vascular"] + }, + { + "id": "CT-ABD-PEL-W", + "modality": "CT", + "study_name": "CT Abdomen/Pelvis with Contrast", + "cpt": ["74177"], + "icd10_common": ["R10.9", "K56.60", "C18.9", "K57.30", "N20.0"], + "contrast": "With", + "header_coverage": "Abdomen & Pelvis", + "reasons": ["Acute abdomen pain/obstruction.", "Cancer staging.", "Suspected appendicitis/diverticulitis."], + "prep": "NPO 3–4 h; oral contrast per protocol.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["abdomen", "oncology"] + }, + { + "id": "CT-UROGRAM", + "modality": "CT", + "study_name": "CT Urogram (A/P w & w/o)", + "cpt": ["74178"], + "icd10_common": ["R31.9", "R31.0", "N28.89", "C67.9"], + "contrast": "With & Without", + "header_coverage": "Kidneys, ureters, bladder", + "reasons": ["Hematuria evaluation (risk-stratified).", "Suspected upper tract urothelial carcinoma."], + "prep": "Hydration; renal labs per policy.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["gu"] + }, + { + "id": "MRI-BRAIN-WWO", + "modality": "MRI", + "study_name": "MRI Brain w & w/o Contrast", + "cpt": ["70553"], + "icd10_common": ["G35", "R56.9", "G45.9", "C71.9", "F03.90"], + "contrast": "With & Without", + "header_coverage": "Brain", + "reasons": ["Tumor, MS, seizures, subacute stroke, dementia workup."], + "prep": "Metal screen; GFR if CKD.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["neuro"] + }, + { + "id": "MRI-IAC", + "modality": "MRI", + "study_name": "MRI IAC / Temporal Bone", + "cpt": ["70553"], + "icd10_common": ["H91.90", "H93.19", "D33.3", "H81.399"], + "contrast": "With & Without", + "header_coverage": "IACs/CPA", + "reasons": ["Asymmetric SNHL, vestibular schwannoma, complex vertigo."], + "prep": "Standard MRI screening; GFR as needed.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["neuro", "ent"] + }, + { + "id": "MRI-TMJ", + "modality": "MRI", + "study_name": "MRI Temporomandibular Joints", + "cpt": ["70336"], + "icd10_common": ["M26.609", "R68.84"], + "contrast": "Without (± With if tumor/infection)", + "header_coverage": "Bilateral TMJs with open/closed mouth", + "reasons": ["TMJ dysfunction, locking, pain refractory to conservative care."], + "prep": "Remove dental metal; bite blocks per protocol.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true, "step_imaging_required": true }, + "tags": ["msk", "ent", "dental"] + }, + { + "id": "MRI-BRACHIAL-PLEXUS", + "modality": "MRI", + "study_name": "MRI Brachial Plexus w & w/o", + "cpt": ["73223"], + "icd10_common": ["G54.0", "S14.3XXA", "C47.3"], + "contrast": "With & Without", + "header_coverage": "Roots to cords", + "reasons": ["Traction injury, tumor, radiation plexopathy."], + "prep": "Consider sedation if needed.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["neuro", "msk"] + }, + { + "id": "MR-NEUROGRAPHY-LE", + "modality": "MRI", + "study_name": "MR Neurography — Lower Extremity", + "cpt": ["73720"], + "icd10_common": ["G57.00", "G57.10", "G57.90"], + "contrast": "Without (± With for mass/inflammation)", + "header_coverage": "Target nerve course", + "reasons": ["Entrapment/neuropathy after failed conservative therapy; traumatic nerve injury."], + "prep": "Standard MRI screening.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true, "step_imaging_required": true }, + "tags": ["neuro", "msk"] + }, + { + "id": "MRI-LSPINE-WWO", + "modality": "MRI", + "study_name": "MRI Lumbar Spine w & w/o", + "cpt": ["72158"], + "icd10_common": ["M54.16", "M48.061", "G95.9", "M51.36"], + "contrast": "With & Without", + "header_coverage": "Lumbar spine", + "reasons": ["Radiculopathy/stenosis with neuro deficits or failed 4–6 wks conservative therapy; infection; tumor."], + "prep": "Metal screen; GFR if CKD.", + "prior_auth": { "requires_prior_auth": true, "step_imaging_required": true }, + "tags": ["spine"] + }, + { + "id": "MRI-PROSTATE-PI-RADS", + "modality": "MRI", + "study_name": "MRI Prostate (PI-RADS) w & w/o", + "cpt": ["72197"], + "icd10_common": ["R97.20", "C61"], + "contrast": "With & Without", + "header_coverage": "Prostate & seminal vesicles", + "reasons": ["Elevated PSA/biochemical recurrence; staging prior to therapy."], + "prep": "NPO 3–4 h; rectal prep per site.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["gu", "oncology"] + }, + { + "id": "US-ABD-COMP", + "modality": "Ultrasound", + "study_name": "Abdominal Ultrasound — Complete", + "cpt": ["76700"], + "icd10_common": ["R10.11", "K80.20", "K76.0", "K74.60"], + "contrast": "None", + "header_coverage": "Abdomen", + "reasons": ["RUQ pain/gallstones; liver disease; abnormal LFTs."], + "prep": "NPO 6–8 h.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["abdomen"] + }, + { + "id": "US-PELVIC-TV", + "modality": "Ultrasound", + "study_name": "Pelvic Ultrasound (Transabdominal + Transvaginal)", + "cpt": ["76856", "76830"], + "icd10_common": ["N93.9", "N83.209", "D25.9", "R10.2", "Z32.01"], + "contrast": "None", + "header_coverage": "Pelvis", + "reasons": ["AUB, pelvic pain, ovarian cyst, fibroids, early pregnancy viability."], + "prep": "Full bladder for TA; empty for TV; document LMP.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["gyn"] + }, + { + "id": "US-CAROTID", + "modality": "Ultrasound", + "study_name": "Carotid Duplex (Bilateral)", + "cpt": ["93880"], + "icd10_common": ["I65.23", "G45.9", "R09.89"], + "contrast": "None", + "header_coverage": "Carotid arteries", + "reasons": ["TIA/stroke symptoms, carotid bruit/stenosis follow-up."], + "prep": "None.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["vascular"] + }, + { + "id": "US-LE-VENOUS", + "modality": "Ultrasound", + "study_name": "Lower Extremity Venous Duplex (DVT) — Bilateral", + "cpt": ["93970"], + "icd10_common": ["I82.4Z3", "M79.89", "R22.40"], + "contrast": "None", + "header_coverage": "LE veins", + "reasons": ["Leg swelling/pain; rule out DVT."], + "prep": "None.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["vascular"] + }, + { + "id": "XR-CHEST-2V", + "modality": "X-Ray", + "study_name": "Chest X-ray (2 Views)", + "cpt": ["71046"], + "icd10_common": ["R05.9", "R06.02", "R07.9", "J18.9"], + "contrast": "None", + "header_coverage": "Chest", + "reasons": ["Cough/SOB/chest pain; pneumonia; trauma baseline."], + "prep": "None.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["chest"] + }, + { + "id": "XR-KUB", + "modality": "X-Ray", + "study_name": "Abdomen X-ray (KUB)", + "cpt": ["74018"], + "icd10_common": ["R10.9", "K59.00", "R14.0", "N20.0", "K56.60"], + "contrast": "None", + "header_coverage": "Abdomen", + "reasons": ["Obstruction/constipation/stone follow-up."], + "prep": "None.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["abdomen"] + }, + { + "id": "NM-HIDA", + "modality": "Nuclear Medicine", + "study_name": "HIDA (± Ejection Fraction)", + "cpt": ["78226", "78227"], + "icd10_common": ["K81.0", "K82.8", "R10.11"], + "contrast": "Radiotracer", + "header_coverage": "Hepatobiliary", + "reasons": ["Acute cholecystitis; biliary dyskinesia (with EF)."], + "prep": "NPO 4–6 h; withhold opiates 4–6 h.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["hepatobiliary"] + }, + { + "id": "NM-BONE-WB", + "modality": "Nuclear Medicine", + "study_name": "Bone Scan — Whole Body", + "cpt": ["78306", "78320"], + "icd10_common": ["C79.51", "M84.48XA", "M86.9"], + "contrast": "Radiotracer", + "header_coverage": "Whole skeleton", + "reasons": ["Bone metastases evaluation; occult fractures; osteomyelitis."], + "prep": "Hydration; frequent voiding.", + "prior_auth": { "requires_prior_auth": true }, + "tags": ["msk", "oncology"] + }, + { + "id": "IR-PARACENTESIS", + "modality": "IR", + "study_name": "US-Guided Paracentesis", + "cpt": ["49083"], + "icd10_common": ["R18.8", "K70.31", "K74.60"], + "contrast": "None", + "header_coverage": "Peritoneal cavity", + "reasons": ["Diagnostic or therapeutic removal of ascites."], + "prep": "Consent; INR/platelets; anticoagulant plan.", + "prior_auth": { "requires_prior_auth": false }, + "tags": ["procedure"] + }, + { + "id": "IR-LUNG-BIOPSY", + "modality": "IR", + "study_name": "CT-Guided Lung Biopsy", + "cpt": ["32408"], + "icd10_common": ["R91.1", "C34.90"], + "contrast": "None", + "header_coverage": "Target lung lesion", + "reasons": ["Tissue diagnosis of pulmonary nodule/mass."], + "prep": "Consent; INR/platelets; post-procedure CXR per policy.", + "prior_auth": { "requires_prior_auth": true, "specialist_referral_required": true }, + "tags": ["procedure", "oncology"] + } + ] +} + +[ { "modality": "PET/CT", "region": "Skull base to mid-thigh", From 9bda63ff442e0cb50a8ec0cef655d198042570d5 Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Fri, 19 Sep 2025 19:29:40 -0400 Subject: [PATCH 04/15] feat(order-helper): centralize OH metas in default layout; remove duplicates from index.html --- _layouts/default.html | 9 +++++++-- order-helper/index.html | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/_layouts/default.html b/_layouts/default.html index 2d8abf3..dc864fc 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -65,10 +65,11 @@ gtag('js', new Date()); gtag('config','G-FKVXD8061R',{ send_page_view:true }); - + {% if page.custom_css %} {% endif %} + {% if page.url contains '/order-helper/' %} + + + + + {% endif %} - diff --git a/order-helper/index.html b/order-helper/index.html index b0edc98..d3a7867 100644 --- a/order-helper/index.html +++ b/order-helper/index.html @@ -3,6 +3,7 @@ title: OraDigit Order Helper permalink: /order-helper/ description: Evidence-based PET/CT, CT, MRI, US, X-Ray — structured order helper with validation and PDF export. No PHI. Educational use only. +oh_loader: true --- From 7f1f1882bca7f7ad626193da4afd52e6c2c68760 Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:51:14 -0400 Subject: [PATCH 05/15] feat(order-helper): add public rules loader to default layout --- _layouts/default.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/_layouts/default.html b/_layouts/default.html index dc864fc..cb8ebb3 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -69,6 +69,15 @@ {% if page.custom_css %} {% endif %} +{% if page.url contains '/order-helper/' %} + + +{% endif %} {% if page.url contains '/order-helper/' %} From a9041f6f726e43c49ca71571be70242b0bdf138c Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:49:29 -0400 Subject: [PATCH 06/15] feat(order-helper): add non-destructive rules loader shim + cascades --- order-helper/app.js | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/order-helper/app.js b/order-helper/app.js index 2ca2150..b1d427f 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -714,3 +714,123 @@ } })(); +/* ===== OH loader shim (non-destructive) ===== */ +(() => { + // Wait for DOM if this script isn’t loaded with `defer` + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } + + function init() { + // Namespace (won't clobber your existing code) + window.OH = window.OH || {}; + const $ = (s) => document.querySelector(s); + const setText = (el, msg) => { if (el) el.textContent = msg; }; + + // Elements (adjust IDs only if yours differ) + const els = { + status: $('#status'), + modality: $('#modality'), + region: $('#region'), + bodyPart: $('#bodyPart'), + contrast: $('#contrast'), + laterality: $('#laterality'), + context: $('#context'), + }; + + // Use the exact meta path (you already include ?v=20250913) + const RULES_URL = + document.querySelector('meta[name="oh-rules-path"]')?.content || + '/order-helper/data/rules.json'; + + const FALLBACK = Object.freeze({ + schema_version: '1.1', + modalities: { + 'PET/CT': { + regions: ['Skull base to mid-thigh', 'Whole body'], + body_parts: ['Head/Neck', 'Chest', 'Abdomen/Pelvis'], + contrast: ['None'], + laterality: ['N/A'], + contexts: ['Staging','Restaging','Treatment response','Surveillance','Acute'], + }, + 'CT': { + regions: ['Head/Brain','Chest','Abdomen/Pelvis'], + body_parts: ['Head','Chest','Abdomen','Pelvis'], + contrast: ['None','IV','Oral','IV + Oral'], + laterality: ['N/A','Right','Left','Bilateral'], + contexts: ['Acute','Follow-up','Staging'], + }, + 'MRI': { + regions: ['Brain','Spine','MSK'], + body_parts: ['Brain','Cervical','Lumbar','Hip'], + contrast: ['None','Gadolinium'], + laterality: ['N/A','Right','Left','Bilateral'], + contexts: ['Acute','Follow-up','Staging'], + } + } + }); + + const validate = (cat) => !!(cat && typeof cat === 'object' && cat.modalities && typeof cat.modalities === 'object'); + + function setOptions(selectEl, items, placeholder) { + if (!selectEl) return; + const list = Array.isArray(items) ? items : []; + selectEl.innerHTML = ''; + const ph = document.createElement('option'); + ph.value = ''; + ph.textContent = placeholder || 'Select…'; + ph.disabled = true; ph.selected = true; + selectEl.appendChild(ph); + for (const v of list) { + const opt = document.createElement('option'); + opt.value = v; opt.textContent = v; + selectEl.appendChild(opt); + } + } + + function bindCascades(cat) { + if (!els.modality) return; + const modalities = Object.keys(cat.modalities || {}); + setOptions(els.modality, modalities, 'Select modality…'); + + els.modality.addEventListener('change', () => { + const m = els.modality.value; + const spec = (cat.modalities || {})[m] || {}; + setOptions(els.region, spec.regions, 'Select region…'); + setOptions(els.bodyPart, spec.body_parts, 'Select body part…'); + setOptions(els.contrast, spec.contrast, 'Select contrast…'); + setOptions(els.laterality, spec.laterality, 'Select laterality…'); + setOptions(els.context, spec.contexts, 'Select context…'); + }); + } + + async function loadCatalog() { + setText(els.status, 'Loading rules…'); + try { + const res = await fetch(RULES_URL, { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (!validate(json)) throw new Error('Invalid rules schema'); + setText(els.status, 'Rules loaded.'); + return json; + } catch (err) { + console.warn('[OH] rules load failed, using fallback:', err); + setText(els.status, 'Using built-in defaults (rules.json unavailable).'); + return FALLBACK; + } + } + + // Expose for the rest of your app (non-breaking) + window.OH.loadCatalog = loadCatalog; + + // Boot: load then bind; announce readiness + loadCatalog().then(cat => { + window.OH.catalog = cat; + bindCascades(cat); + document.dispatchEvent(new CustomEvent('oh:catalog-ready', { detail: { catalog: cat } })); + }); + } +})(); + From 25b4bcc97dae879e0f963a8bab7b7a14a5e7409c Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:40:55 -0400 Subject: [PATCH 07/15] OH: robust app init (new rules loader + fallbacks); live preview always on --- order-helper/app.js | 963 ++++++++-------------------------------- order-helper/index.html | 357 +++++++++------ 2 files changed, 398 insertions(+), 922 deletions(-) diff --git a/order-helper/app.js b/order-helper/app.js index b1d427f..a57065f 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -1,836 +1,251 @@ - - -/* ===== OH loader shim (non-destructive) ===== */ -(() => { - // Wait for DOM if this script isn’t loaded with `defer` - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init, { once: true }); + // Run after parse + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); } else { init(); } - - function init() { - // Namespace (won't clobber your existing code) - window.OH = window.OH || {}; - const $ = (s) => document.querySelector(s); - const setText = (el, msg) => { if (el) el.textContent = msg; }; - - // Elements (adjust IDs only if yours differ) - const els = { - status: $('#status'), - modality: $('#modality'), - region: $('#region'), - bodyPart: $('#bodyPart'), - contrast: $('#contrast'), - laterality: $('#laterality'), - context: $('#context'), - }; - - // Use the exact meta path (you already include ?v=20250913) - const RULES_URL = - document.querySelector('meta[name="oh-rules-path"]')?.content || - '/order-helper/data/rules.json'; - - const FALLBACK = Object.freeze({ - schema_version: '1.1', - modalities: { - 'PET/CT': { - regions: ['Skull base to mid-thigh', 'Whole body'], - body_parts: ['Head/Neck', 'Chest', 'Abdomen/Pelvis'], - contrast: ['None'], - laterality: ['N/A'], - contexts: ['Staging','Restaging','Treatment response','Surveillance','Acute'], - }, - 'CT': { - regions: ['Head/Brain','Chest','Abdomen/Pelvis'], - body_parts: ['Head','Chest','Abdomen','Pelvis'], - contrast: ['None','IV','Oral','IV + Oral'], - laterality: ['N/A','Right','Left','Bilateral'], - contexts: ['Acute','Follow-up','Staging'], - }, - 'MRI': { - regions: ['Brain','Spine','MSK'], - body_parts: ['Brain','Cervical','Lumbar','Hip'], - contrast: ['None','Gadolinium'], - laterality: ['N/A','Right','Left','Bilateral'], - contexts: ['Acute','Follow-up','Staging'], - } - } - }); - - const validate = (cat) => !!(cat && typeof cat === 'object' && cat.modalities && typeof cat.modalities === 'object'); - - function setOptions(selectEl, items, placeholder) { - if (!selectEl) return; - const list = Array.isArray(items) ? items : []; - selectEl.innerHTML = ''; - const ph = document.createElement('option'); - ph.value = ''; - ph.textContent = placeholder || 'Select…'; - ph.disabled = true; ph.selected = true; - selectEl.appendChild(ph); - for (const v of list) { - const opt = document.createElement('option'); - opt.value = v; opt.textContent = v; - selectEl.appendChild(opt); - } - } - - function bindCascades(cat) { - if (!els.modality) return; - const modalities = Object.keys(cat.modalities || {}); - setOptions(els.modality, modalities, 'Select modality…'); - - els.modality.addEventListener('change', () => { - const m = els.modality.value; - const spec = (cat.modalities || {})[m] || {}; - setOptions(els.region, spec.regions, 'Select region…'); - setOptions(els.bodyPart, spec.body_parts, 'Select body part…'); - setOptions(els.contrast, spec.contrast, 'Select contrast…'); - setOptions(els.laterality, spec.laterality, 'Select laterality…'); - setOptions(els.context, spec.contexts, 'Select context…'); - }); - } - - async function loadCatalog() { - setText(els.status, 'Loading rules…'); - try { - const res = await fetch(RULES_URL, { cache: 'no-store' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - if (!validate(json)) throw new Error('Invalid rules schema'); - setText(els.status, 'Rules loaded.'); - return json; - } catch (err) { - console.warn('[OH] rules load failed, using fallback:', err); - setText(els.status, 'Using built-in defaults (rules.json unavailable).'); - return FALLBACK; - } - } - - // Expose for the rest of your app (non-breaking) - window.OH.loadCatalog = loadCatalog; - - // Boot: load then bind; announce readiness - loadCatalog().then(cat => { - window.OH.catalog = cat; - bindCascades(cat); - document.dispatchEvent(new CustomEvent('oh:catalog-ready', { detail: { catalog: cat } })); - }); - } })(); - diff --git a/order-helper/index.html b/order-helper/index.html index d3a7867..20d97a4 100644 --- a/order-helper/index.html +++ b/order-helper/index.html @@ -2,24 +2,26 @@ layout: default title: OraDigit Order Helper permalink: /order-helper/ -description: Evidence-based PET/CT, CT, MRI, US, X-Ray — structured order helper with validation and PDF export. No PHI. Educational use only. -oh_loader: true +description: Evidence-based PET/CT, CT, and MRI order helper with validation and export. No PHI. Educational use only. --- - - - + + + @@ -30,200 +32,259 @@ if (!s) return; try { const url = document.querySelector('meta[name="oh-rules-path"]')?.content; - if (!url) { s.textContent = 'Missing oh-rules-path meta.'; s.className = 'oh-status error'; return; } + if (!url) { s.textContent = 'Missing oh-rules-path meta.'; s.className = 'status error'; return; } s.textContent = 'Boot check… fetching rules…'; const r = await fetch(url, { cache: 'no-store' }); - if (r.ok) { s.textContent = 'Boot check OK. Loading app…'; s.className = 'oh-status success'; } - else { s.textContent = `Rules fetch failed (${r.status}). Check path/case: ` + url; s.className = 'oh-status error'; } + if (r.ok) { s.textContent = 'Boot check OK. Loading app…'; s.className = 'status success'; } + else { s.textContent = `Rules fetch failed (${r.status}). Check path/case: ` + url; s.className = 'status error'; } } catch (e) { s.textContent = 'Rules fetch error: ' + e.message; - s.className = 'oh-status error'; + s.className = 'status error'; } }); -
    -
    +
    +

    OraDigit Order Helper

    -

    - Structure payer-ready imaging orders. Select modality and context; we’ll generate a professional - clinical indication, show prep/flags, and export a PDF. All client-side. No PHI. +

    + Structure clear, payer-ready imaging orders. Select modality and context; we’ll generate + a professional clinical indication and surface prep/flags. All client-side. No PHI.

    -
    Initializing…
    - -
    -
    +
    + + -
    -
    + -
    +
    - Regions, body parts, contrast, and contexts adapt to your selection. -
    - - -
    -
    - - -
    -
    - - -
    + Regions, contexts, and conditions adapt to your selection.
    - -
    -
    - - -
    -
    - - -
    + +
    + +
    - -
    -
    - - - e.g., Staging, Restaging, Treatment response, Surveillance, Acute -
    -
    - - -
    -
    + +
    + - -
    -
    - - - -
    -
    - - - -
    -
    + +
    - -
    - - -
    + + - -
    - - + Click one or more (e.g., staging, restaging, surveillance, acute, follow-up).
    - -
    -
    - - -
    -
    - - -
    + +
    + + + + Start typing to see common conditions for the chosen modality.
    -
    - - -
    + + -
    - - + +
    + +
    - -
    - - - - - +
    + + + +
    - -
    - + + + - - +<<<<<<< HEAD + + + + + - - - +======= + + +>>>>>>> 47d41e0 (OH: switch to data/ (case fix); update index + app (live preview & loader)) From c6e0d15d407038b67c8960c4055693b2515b5029 Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:24:13 -0400 Subject: [PATCH 08/15] =?UTF-8?q?OH:=20rebuild=20UI=E2=80=94populate=20reg?= =?UTF-8?q?ions/contexts/conditions;=20robust=20rules=20loader=20+=20live?= =?UTF-8?q?=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- order-helper/app.js | 413 ++++++++++++++++++++++---------------------- 1 file changed, 204 insertions(+), 209 deletions(-) diff --git a/order-helper/app.js b/order-helper/app.js index a57065f..0eebf3b 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -1,251 +1,246 @@ -/* OraDigit Order Helper – robust init + live preview - - Reads rules URL from - - Works if rules.json is missing or has a different shape - - Populates Region, Context chips, Condition suggestions - - Keeps the right-side Order Preview live -*/ - (() => { - const $ = (s) => document.querySelector(s); - - const FALLBACK = { - CONTEXTS: [ - "Staging", - "Restaging", - "Treatment response", - "Surveillance", - "Suspected recurrence", - "Infection / inflammation", - "Viability", - ], - REGIONS: { - "PET/CT": ["Whole body", "Head & neck", "Thorax/Chest", "Abdomen/Pelvis", "Brain"], - CT: ["Head", "Neck", "Chest", "Abdomen", "Pelvis", "Abdomen/Pelvis", "Angio/PE", "Sinus", "Spine", "Extremity"], - MRI: ["Brain", "C-spine", "T-spine", "L-spine", "Shoulder", "Knee", "Hip", "Abdomen", "Pelvis", "Prostate", "Cardiac"], - }, - CONDITIONS: { - CT: ["Renal colic", "Pulmonary embolism", "Appendicitis", "Diverticulitis", "Kidney stone"], - MRI: ["Multiple sclerosis", "Lumbar radiculopathy", "Rotator cuff tear", "Meniscal tear", "Hip labral tear"], - "PET/CT": ["NSCLC", "Lymphoma", "Breast cancer", "Colorectal cancer", "Melanoma"], - }, + const qs = (s) => document.querySelector(s); + const setStatus = (msg, cls = 'status success') => { + const s = qs('#status'); if (s) { s.textContent = msg; s.className = cls; } }; - function setStatus(msg, kind = "success") { - const s = $("#status"); - if (!s) return; - s.textContent = msg; - s.className = `status ${kind}`; - } + // Catch sync + async errors in one place + window.addEventListener('error', e => setStatus('JavaScript error: ' + (e.message || 'Unknown'), 'status error')); + window.addEventListener('unhandledrejection', e => setStatus('App error: ' + (e.reason?.message || e.reason || 'Unknown'), 'status error')); - function rulesURL() { - const meta = document.querySelector('meta[name="oh-rules-path"]'); - const url = meta?.content?.trim(); - return url || "/order-helper/data/rules.json"; - } + document.addEventListener('DOMContentLoaded', init); - async function loadRules() { + async function init() { try { - const r = await fetch(rulesURL(), { cache: "no-store" }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const json = await r.json(); - setStatus("Rules loaded.", "success"); - return json; + // 1) Load rules.json from the meta tag (case-safe) + const rulesPath = document.querySelector('meta[name="oh-rules-path"]')?.content; + let rules = null; + try { + const r = await fetch(rulesPath, { cache: 'no-store' }); + if (r.ok) rules = await r.json(); + } catch (_) { /* fall through to defaults */ } + if (!rules) { rules = defaultRules(); setStatus('Using built-in defaults (rules.json not found).', 'status warn'); } + + // 2) Normalize rules into a simple catalog + const catalog = normalizeRules(rules); + + // 3) Build the UI + wire events + buildUI(catalog); + + setStatus('Rules loaded.'); } catch (e) { - setStatus("Using built-in defaults (rules not available).", "warn"); - return null; + setStatus('Init failed: ' + e.message, 'status error'); } } - // Try multiple likely shapes, return array or null - function listFromRules(rules, modality, key) { - if (!rules) return null; + // ---------- Rules handling ---------- + function defaultRules() { + return { + modalities: { + "PET/CT": { + regions: ["Skull base to mid-thigh","Whole body","Brain","Head/Neck","Chest","Abdomen/Pelvis"], + contexts: ["Staging","Restaging","Treatment response","Surveillance","Suspected recurrence","Infection / inflammation","Viability"], + conditions: ["NSCLC","Lymphoma","Colorectal cancer","Melanoma","Head & neck cancer"] + }, + "CT": { + regions: ["Head","Neck","Chest","Abdomen","Pelvis","Abdomen/Pelvis","Angio chest (PE)"], + contexts: ["Acute","Chronic","Follow-up"], + conditions: ["Renal colic","PE","Appendicitis","Pancreatitis"] + }, + "MRI": { + regions: ["Brain","Cervical spine","Thoracic spine","Lumbar spine","Abdomen","Pelvis"], + contexts: ["Acute","Follow-up","Problem solving"], + conditions: ["MS","Seizure","Stroke","Back pain","Prostate cancer"] + } + } + }; + } + + function normalizeRules(r) { + // Accept either {modalities:{...}} or top-level { "PET/CT": {...}, ... } + const src = r.modalities ? r.modalities : { "PET/CT": r["PET/CT"], "CT": r["CT"], "MRI": r["MRI"] }; + const out = { modalities: {} }; + + for (const [mod, spec] of Object.entries(src || {})) { + if (!spec) continue; + const regions = Array.isArray(spec.regions) ? spec.regions + : Array.isArray(spec?.regions?.list) ? spec.regions.list + : Object.keys(spec.regions || {}); + const contexts = spec.contexts || spec.context || ["Staging","Restaging","Treatment response","Surveillance","Suspected recurrence"]; + const conditions = spec.conditions || spec.condition || []; + out.modalities[mod] = { + regions: [...new Set(regions)].filter(Boolean), + contexts: [...new Set(contexts)].filter(Boolean), + conditions: [...new Set(conditions)].filter(Boolean), + }; + } + return out; + } - // Common shapes we support: - // 1) { modalities: { "CT": { regions:[...], contexts:[...], conditions:[...] } } } - if (rules.modalities?.[modality]?.[key]) return rules.modalities[modality][key]; + // ---------- UI wiring ---------- + function buildUI(cat) { + const modalitySel = qs('#modality'); + const regionSel = qs('#region'); + const ctxChips = qs('#contextChips'); + const ctxSelect = qs('#context'); // hidden + * - CT contrast auto-suggestions + * - Indication builder with {contrast_text} + * - Study suggestions + Results panel + ICD-10 suggestions + improved copy-all + * - Fallback rules so UI stays usable if JSON fetch fails + */ +(function () { + "use strict"; + + // -------- Config / Rules path -------- + const RULES_URL = + document.querySelector('meta[name="oh-rules-path"]')?.content || + "./data/rules.json"; + + // -------- Elements -------- + const els = { + status: document.getElementById("status"), + form: document.getElementById("orderForm"), + modality: document.getElementById("modality"), + region: document.getElementById("region"), + context: document.getElementById("context"), + contextChips: document.getElementById("contextChips"), + condition: document.getElementById("condition"), + conditionList: document.getElementById("conditionList"), + indication: document.getElementById("indication"), + contrastGroup: document.getElementById("contrastGroup"), + oral: document.getElementById("oralContrast"), + + // Results area + outHeader: document.getElementById("outHeader"), + outReason: document.getElementById("outReason"), + outPrep: document.getElementById("outPrep"), + outDocs: document.getElementById("outDocs"), + outFlags: document.getElementById("outFlags"), + outICD: document.getElementById("outICD"), + results: document.getElementById("results"), + copyReasonBtn: document.getElementById("copyReasonBtn"), + copyAllBtn: document.getElementById("copyAllBtn"), + printBtn: document.getElementById("printBtn"), + + suggestions: document.getElementById("suggestions"), + errMsg: document.getElementById("errMsg"), + dbg: document.getElementById("dbg"), }; - // Catch sync + async errors in one place - window.addEventListener('error', e => setStatus('JavaScript error: ' + (e.message || 'Unknown'), 'status error')); - window.addEventListener('unhandledrejection', e => setStatus('App error: ' + (e.reason?.message || e.reason || 'Unknown'), 'status error')); + // -------- Fallbacks (UI remains useful if rules fail) -------- + const FALLBACK_RULES = { + modalities: { + "PET/CT": { + regions: [ + "Skull base to mid-thigh","Whole body","Head/Neck", + "Chest","Abdomen/Pelvis","Brain","Cardiac viability" + ], + contexts: [ + "Staging","Restaging","Treatment response","Surveillance", + "Suspected recurrence","Infection / inflammation","Viability" + ], + conditions: [ + "DLBCL","Hodgkin lymphoma","NSCLC","Melanoma","Colorectal cancer", + "Head and neck SCC","Fever of unknown origin","Osteomyelitis","Cardiac viability" + ], + indication_templates: [ + "FDG PET/CT {region} – {context} for {condition}", + "FDG PET/CT {region} – evaluate {condition}", + "FDG PET/CT {region}{contrast_text} – {context} ({condition})" + ] + }, + CT: { + regions: [ + "Head/Brain","Neck","Chest","Low-Dose Lung CT (Screening)", + "Abdomen","Pelvis","Abdomen/Pelvis","CT Urogram", + "Spine – Cervical","Spine – Thoracic","Spine – Lumbar" + ], + contexts: [ + "Staging","Restaging","Treatment response","Surveillance","Initial evaluation", + "Acute symptoms","Follow-up","Pre-operative planning","Post-operative complication", + "Trauma","Screening","Infection / inflammation" + ], + conditions: [ + "Pulmonary embolism","Lung nodule","Pneumonia complication","NSCLC", + "Appendicitis","Renal colic","Abdominal pain RLQ","Stroke/TIA","Head trauma" + ], + indication_templates: [ + "CT {region}{contrast_text} – {context} for {condition}", + "CT {region} – rule out {condition}", + "CT {region}{contrast_text} – evaluate {condition}" + ], + contrast_recommendations: [ + { match:["kidney stone","renal colic"], suggest:"without_iv" }, + { match:["appendicitis","rlq"], suggest:"with_iv" }, + { match:["pe","pulmonary embolism"], suggest:"with_iv" }, + { match:["aortic","dissection","aneurysm"], suggest:"with_iv" }, + { match:["liver lesion","pancreatitis"], suggest:"with_iv" }, + { match:["bowel obstruction"], suggest:"without_iv"}, + { match:["low-dose lung ct","screening","ldct"], suggest:"without_iv" } + ] + } + }, + records: [] + }; + + let RULES = null; + + // -------- Utils -------- + function setStatus(msg, level = "info") { + if (!els.status) return; + els.status.textContent = msg; + els.status.className = + "status " + + (level === "ok" || level === "success" + ? "success" + : level === "warn" + ? "warn" + : level === "error" + ? "error" + : ""); + } + + const titleCase = (s) => + (s || "").replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1)); + + function fillSelect(selectEl, values, placeholder = "Select…") { + if (!selectEl) return; + selectEl.innerHTML = ""; + const ph = document.createElement("option"); + ph.value = ""; + ph.textContent = placeholder; + selectEl.appendChild(ph); + (values || []).forEach((v) => { + const opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + selectEl.appendChild(opt); + }); + } + + function fillDatalist(datalistEl, items) { + if (!datalistEl) return; + datalistEl.innerHTML = ""; + (items || []).forEach((v) => { + const opt = document.createElement("option"); + opt.value = v; + datalistEl.appendChild(opt); + }); + } - document.addEventListener('DOMContentLoaded', init); + function showContrast(show) { + if (!els.contrastGroup) return; + els.contrastGroup.classList.toggle("hidden", !show); + if (!show) { + const checked = els.contrastGroup.querySelector('input[type=radio]:checked'); + if (checked) checked.checked = false; + if (els.oral) els.oral.checked = false; + } + } + + function contrastTextFromForm() { + if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) return ""; + const radio = els.contrastGroup.querySelector('input[type=radio]:checked'); + const oral = els.oral?.checked ? " + oral contrast" : ""; + if (!radio) return oral ? "(" + oral.trim() + ")" : ""; + if (radio.value === "with_iv") return "(with IV contrast" + oral + ")"; + if (radio.value === "without_iv") return "(without IV contrast" + oral + ")"; + return oral ? "(" + oral.trim() + ")" : ""; + } + + // -------- Chips helpers (with keyboard support) -------- + function renderContextChips(contexts) { + if (!els.contextChips) return; + els.contextChips.innerHTML = ""; + (contexts || []).forEach((label) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "oh-chip"; + btn.textContent = label; + btn.setAttribute("aria-pressed", "false"); + btn.setAttribute("aria-label", `Toggle context ${label}`); + btn.setAttribute("tabindex", "0"); + els.contextChips.appendChild(btn); + }); + } - async function init() { + function getSelectedContextsFromChips() { + if (!els.contextChips) return []; + return Array.from( + els.contextChips.querySelectorAll('.oh-chip[aria-pressed="true"]') + ) + .map((el) => (el.textContent || "").trim()) + .filter(Boolean); + } + + function mirrorChipsToHiddenSelect() { + if (!els.context) return; + const selected = getSelectedContextsFromChips(); + els.context.innerHTML = ""; + selected.forEach((label) => { + const opt = document.createElement("option"); + opt.value = label; + opt.textContent = label; + opt.selected = true; + els.context.appendChild(opt); + }); + } + + // -------- Rules loading: auto-merge PET/CT + CT + MRI -------- + async function loadRules() { + function buildSiblingUrls(rulesUrl) { + const meta = new URL(rulesUrl, location.origin); + const search = meta.search; // keep ?v=... + const dir = new URL(meta.pathname.replace(/[^/]+$/, ''), location.origin); + const mk = (name) => new URL(name + search, dir).toString(); + return [ + rulesUrl, // rules.json (PET/CT + general) + mk("ct_rules.json"), // CT + mk("mri_rules.json") // MRI + ]; + } + async function tryFetch(url) { + try { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) return null; return await r.json(); } + catch { return null; } + } try { - // 1) Load rules.json from the meta tag (case-safe) - const rulesPath = document.querySelector('meta[name="oh-rules-path"]')?.content; - let rules = null; - try { - const r = await fetch(rulesPath, { cache: 'no-store' }); - if (r.ok) rules = await r.json(); - } catch (_) { /* fall through to defaults */ } - if (!rules) { rules = defaultRules(); setStatus('Using built-in defaults (rules.json not found).', 'status warn'); } - - // 2) Normalize rules into a simple catalog - const catalog = normalizeRules(rules); - - // 3) Build the UI + wire events - buildUI(catalog); - - setStatus('Rules loaded.'); + const urls = buildSiblingUrls(RULES_URL); + const loaded = (await Promise.all(urls.map(tryFetch))).filter(Boolean); + if (!loaded.length) throw new Error("No rules files available"); + + RULES = { modalities: {}, records: [] }; + for (const j of loaded) { + if (j.modalities && typeof j.modalities === "object") { + Object.assign(RULES.modalities, j.modalities); + } + if (Array.isArray(j.records)) { + RULES.records.push(...j.records); + } else if (Array.isArray(j)) { + // support legacy "pure records array" JSON files + RULES.records.push(...j); + } + } + if (!RULES.records.length && !Object.keys(RULES.modalities).length) { + throw new Error("Invalid rules schema after merge"); + } + setStatus("Rules loaded.", "success"); } catch (e) { - setStatus('Init failed: ' + e.message, 'status error'); + console.warn("Rules load/merge failed; using fallback", e); + RULES = FALLBACK_RULES; + setStatus("Using built-in fallback rules (could not fetch rules.json).", "warn"); } } - // ---------- Rules handling ---------- - function defaultRules() { + function getModalityNode(modality) { + return RULES?.modalities?.[modality] || null; + } + + // If no modalities entry, derive contexts/regions from records (best effort) + function deriveFromRecords(modality) { + const recs = (RULES?.records || []).filter((r) => + (r.modality || "").toUpperCase().includes(modality.toUpperCase()) + ); + const setC = new Set(); + const setR = new Set(); + recs.forEach((r) => { + (r.contexts || []).forEach((c) => setC.add(titleCase(c))); + if (r.header_coverage) setR.add(r.header_coverage); + }); return { - modalities: { - "PET/CT": { - regions: ["Skull base to mid-thigh","Whole body","Brain","Head/Neck","Chest","Abdomen/Pelvis"], - contexts: ["Staging","Restaging","Treatment response","Surveillance","Suspected recurrence","Infection / inflammation","Viability"], - conditions: ["NSCLC","Lymphoma","Colorectal cancer","Melanoma","Head & neck cancer"] - }, - "CT": { - regions: ["Head","Neck","Chest","Abdomen","Pelvis","Abdomen/Pelvis","Angio chest (PE)"], - contexts: ["Acute","Chronic","Follow-up"], - conditions: ["Renal colic","PE","Appendicitis","Pancreatitis"] - }, - "MRI": { - regions: ["Brain","Cervical spine","Thoracic spine","Lumbar spine","Abdomen","Pelvis"], - contexts: ["Acute","Follow-up","Problem solving"], - conditions: ["MS","Seizure","Stroke","Back pain","Prostate cancer"] - } - } + contexts: Array.from(setC), + regions: Array.from(setR), + conditions: [] }; } - function normalizeRules(r) { - // Accept either {modalities:{...}} or top-level { "PET/CT": {...}, ... } - const src = r.modalities ? r.modalities : { "PET/CT": r["PET/CT"], "CT": r["CT"], "MRI": r["MRI"] }; - const out = { modalities: {} }; - -<<<<<<< HEAD - for (const [mod, spec] of Object.entries(src || {})) { - if (!spec) continue; - const regions = Array.isArray(spec.regions) ? spec.regions - : Array.isArray(spec?.regions?.list) ? spec.regions.list - : Object.keys(spec.regions || {}); - const contexts = spec.contexts || spec.context || ["Staging","Restaging","Treatment response","Surveillance","Suspected recurrence"]; - const conditions = spec.conditions || spec.condition || []; - out.modalities[mod] = { - regions: [...new Set(regions)].filter(Boolean), - contexts: [...new Set(contexts)].filter(Boolean), - conditions: [...new Set(conditions)].filter(Boolean), - }; -======= + // -------- Populate UI for modality -------- + function populateForModality(modality) { + const node = getModalityNode(modality) || deriveFromRecords(modality); + fillSelect(els.region, node.regions || [], "Select region…"); if (els.contextChips) { @@ -93,11 +294,11 @@ if (els.condition) els.condition.value = ""; if (els.indication) els.indication.value = ""; - // Ask any external preview sync to re-render + // Notify preview helpers to re-render try { document.dispatchEvent(new Event("input", { bubbles: true })); } catch {} } - // -------- Contrast suggestions for CT -------- + // -------- CT contrast suggestions -------- function suggestContrastIfCT(modalityNode, conditionText, regionText) { if (!modalityNode?.contrast_recommendations) return; const text = `${conditionText || ""} ${regionText || ""}`.toLowerCase(); @@ -113,9 +314,7 @@ } // Guardrail: any CTA should be with IV if ((regionText || "").toLowerCase().includes("cta")) { - const withIV = els.contrastGroup?.querySelector( - 'input[type=radio][value="with_iv"]' - ); + const withIV = els.contrastGroup?.querySelector('input[type=radio][value="with_iv"]'); if (withIV) withIV.checked = true; } } @@ -151,7 +350,7 @@ els.indication.value = out.trim(); } - // -------- Basic record matcher (suggest studies) -------- + // -------- Study suggestions -------- function scoreRecord(rec, modality, region, contexts, condition) { if (!(rec.modality || "").toUpperCase().includes(modality.toUpperCase())) return -1; @@ -203,10 +402,8 @@ }); } - // -------- ICD-10 suggestions (lightweight helper) -------- - // NOTE: Verify final billing codes per ICD-10-CM and payer policy. + // -------- ICD-10 suggestions (informational only) -------- const ICD_RULES = [ - // PET/CT oncology { tokens: ["dlbcl","lymphoma","hodgkin","nhl"], codes: [ { code:"C83.30", label:"Diffuse large B-cell lymphoma, unspecified site" }, { code:"C81.90", label:"Hodgkin lymphoma, unspecified, unspecified site" } @@ -220,8 +417,6 @@ { tokens: ["hnscc","head and neck"], codes: [{ code:"C76.0", label:"Malignant neoplasm of head, face and neck" }]}, { tokens: ["fever of unknown origin","fuo"], codes: [{ code:"R50.9", label:"Fever, unspecified" }]}, { tokens: ["viability","ischemic cardiomyopathy","myocardial"], codes: [{ code:"I25.5", label:"Ischemic cardiomyopathy" }]}, - - // CT common { tokens: ["pulmonary embolism","pe"], codes: [{ code:"I26.99", label:"Other pulmonary embolism without acute cor pulmonale" }]}, { tokens: ["appendicitis","rlq"], codes: [{ code:"K35.80", label:"Unspecified acute appendicitis" }]}, { tokens: ["renal colic","kidney stone","flank pain","hematuria"], codes: [ @@ -229,24 +424,17 @@ { code:"N23", label:"Unspecified renal colic" } ]}, { tokens: ["pneumonia"], codes: [{ code:"J18.9", label:"Pneumonia, unspecified organism" }]}, - - // MRI neuro { tokens: ["stroke","cerebral infarction"], codes: [{ code:"I63.9", label:"Cerebral infarction, unspecified" }]}, { tokens: ["tia"], codes: [{ code:"G45.9", label:"Transient cerebral ischemic attack, unspecified" }]}, { tokens: ["intracranial hemorrhage","ich"], codes: [{ code:"I62.9", label:"Nontraumatic intracranial hemorrhage, unspecified" }]}, - - // MRI spine { tokens: ["cervical radiculopathy"], codes: [{ code:"M54.12", label:"Radiculopathy, cervical region" }]}, { tokens: ["lumbar radiculopathy","sciatica"], codes: [{ code:"M54.16", label:"Radiculopathy, lumbar region" }]}, { tokens: ["spinal stenosis","stenosis"], codes: [{ code:"M48.061", label:"Spinal stenosis, lumbar region w/o neurogenic claudication" }]}, { tokens: ["disc herniation"], codes: [{ code:"M51.26", label:"Other intervertebral disc displacement, lumbar region" }]}, - - // Ortho { tokens: ["meniscal tear"], codes: [{ code:"S83.209A", label:"Tear of unsp meniscus, unsp knee, initial encounter" }]}, { tokens: ["acl tear"], codes: [{ code:"S83.511A", label:"Sprain of ACL of right knee, initial encounter" }]}, { tokens: ["rotator cuff"], codes: [{ code:"M75.100", label:"Unspecified rotator cuff tear or rupture, not specified as traumatic" }]} ]; - function suggestICD10(text) { const t = (text || "").toLowerCase(); const out = []; @@ -254,182 +442,176 @@ for (const rule of ICD_RULES) { if (rule.tokens.some(tok => t.includes(tok))) { for (const c of rule.codes) { - if (!seen.has(c.code)) { - out.push(c); - seen.add(c.code); - } + if (!seen.has(c.code)) { out.push(c); seen.add(c.code); } } } - if (out.length >= 6) break; // keep tidy ->>>>>>> 1a5762e (Order Helper: unify app.js (rules auto-merge + ICD-10) and align with updated index) + if (out.length >= 6) break; } return out; } - // ---------- UI wiring ---------- - function buildUI(cat) { - const modalitySel = qs('#modality'); - const regionSel = qs('#region'); - const ctxChips = qs('#contextChips'); - const ctxSelect = qs('#context'); // hidden +
    @@ -231,10 +222,6 @@

    ICD-10 Suggestions

    }); -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> 1a5762e (Order Helper: unify app.js (rules auto-merge + ICD-10) and align with updated index) -<<<<<<< HEAD - - -======= - - ->>>>>>> 47d41e0 (OH: switch to data/ (case fix); update index + app (live preview & loader)) -======= ->>>>>>> 1a5762e (Order Helper: unify app.js (rules auto-merge + ICD-10) and align with updated index) + From 7cbc8b2963bb3a617f3c8d4732f11214bfa3028c Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Fri, 5 Sep 2025 20:47:23 -0400 Subject: [PATCH 11/15] OH: move preview sync into app.js; live Order Preview --- order-helper/app.js | 393 +++++++++++++++++++++++--------------------- 1 file changed, 209 insertions(+), 184 deletions(-) diff --git a/order-helper/app.js b/order-helper/app.js index 7fb0e4c..f30c610 100644 --- a/order-helper/app.js +++ b/order-helper/app.js @@ -1,12 +1,13 @@ /** - * OraDigit Order Helper – app.js (golden rev for steps 1–5) - * - Auto-merges rules.json + ct_rules.json + mri_rules.json (respects ?v= cache-buster) - * - Chips UI + keyboard support; mirrors to hidden * - CT contrast auto-suggestions - * - Indication builder with {contrast_text} - * - Study suggestions + Results panel + ICD-10 suggestions + improved copy-all - * - Fallback rules so UI stays usable if JSON fetch fails + * - Indication builder + basic study suggestions + * - NEW: syncPreviewPanel() updates the right-side preview (no inline script needed) */ + (function () { "use strict"; @@ -21,46 +22,55 @@ form: document.getElementById("orderForm"), modality: document.getElementById("modality"), region: document.getElementById("region"), - context: document.getElementById("context"), - contextChips: document.getElementById("contextChips"), + context: document.getElementById("context"), // hidden mirror select (optional) + contextChips: document.getElementById("contextChips"), // primary UI condition: document.getElementById("condition"), conditionList: document.getElementById("conditionList"), indication: document.getElementById("indication"), contrastGroup: document.getElementById("contrastGroup"), oral: document.getElementById("oralContrast"), - // Results area + // Results area (optional on page) outHeader: document.getElementById("outHeader"), outReason: document.getElementById("outReason"), outPrep: document.getElementById("outPrep"), outDocs: document.getElementById("outDocs"), outFlags: document.getElementById("outFlags"), - outICD: document.getElementById("outICD"), results: document.getElementById("results"), copyReasonBtn: document.getElementById("copyReasonBtn"), copyAllBtn: document.getElementById("copyAllBtn"), printBtn: document.getElementById("printBtn"), - suggestions: document.getElementById("suggestions"), errMsg: document.getElementById("errMsg"), dbg: document.getElementById("dbg"), + + // Preview panel (right side) + pvModality: document.getElementById("pv-modality"), + pvRegion: document.getElementById("pv-region"), + pvContext: document.getElementById("pv-context"), + pvCondition: document.getElementById("pv-condition"), + pvContrast: document.getElementById("pv-contrast"), + pvIndication: document.getElementById("pv-indication"), }; - // -------- Fallbacks (UI remains useful if rules fail) -------- + // -------- Fallbacks (so UI still works if rules.json fails/empty) -------- const FALLBACK_RULES = { modalities: { "PET/CT": { regions: [ - "Skull base to mid-thigh","Whole body","Head/Neck", - "Chest","Abdomen/Pelvis","Brain","Cardiac viability" + "Skull base to mid-thigh", + "Whole body", + "Head/Neck", + "Chest", + "Abdomen/Pelvis", + "Cardiac viability" ], contexts: [ - "Staging","Restaging","Treatment response","Surveillance", - "Suspected recurrence","Infection / inflammation","Viability" + "Staging","Restaging","Treatment response","Surveillance","Suspected recurrence","Infection / inflammation","Viability" ], conditions: [ "DLBCL","Hodgkin lymphoma","NSCLC","Melanoma","Colorectal cancer", - "Head and neck SCC","Fever of unknown origin","Osteomyelitis","Cardiac viability" + "Head and neck SCC","Fever of unknown origin","Cardiac viability" ], indication_templates: [ "FDG PET/CT {region} – {context} for {condition}", @@ -70,36 +80,42 @@ }, CT: { regions: [ - "Head/Brain","Neck","Chest","Low-Dose Lung CT (Screening)", - "Abdomen","Pelvis","Abdomen/Pelvis","CT Urogram", - "Spine – Cervical","Spine – Thoracic","Spine – Lumbar" + "Head/Brain","Sinuses","Maxillofacial/Facial Bones","Temporal Bones/IAC","Neck","Chest", + "Low-Dose Lung CT (Screening)","Abdomen","Pelvis","Abdomen/Pelvis","CT Urogram","CT Enterography", + "Spine – Cervical","Spine – Thoracic","Spine – Lumbar","Upper Extremity","Lower Extremity", + "Cardiac Coronary CTA","Angiography – Head/Neck CTA","Angiography – Chest CTA (PE)", + "Angiography – Aorta CTA","Angiography – Run-off CTA (LE)" ], contexts: [ - "Staging","Restaging","Treatment response","Surveillance","Initial evaluation", - "Acute symptoms","Follow-up","Pre-operative planning","Post-operative complication", - "Trauma","Screening","Infection / inflammation" + "Staging","Restaging","Treatment response","Surveillance","Initial evaluation","Acute symptoms", + "Follow-up","Pre-operative planning","Post-operative complication","Trauma","Screening","Infection / inflammation" ], conditions: [ - "Pulmonary embolism","Lung nodule","Pneumonia complication","NSCLC", - "Appendicitis","Renal colic","Abdominal pain RLQ","Stroke/TIA","Head trauma" + "Headache (sudden / thunderclap)","Head trauma","Stroke symptoms / TIA","Sinusitis","Neck mass", + "Pulmonary embolism suspected","Aortic dissection / aneurysm suspected","Lung nodule","Pneumonia complication", + "Abdominal pain RLQ (appendicitis)","Kidney stone / renal colic","Pancreatitis","Liver lesion characterization", + "Diverticulitis","Inflammatory bowel disease flare","Bowel obstruction","Post-op abdomen","Hematuria", + "Cancer staging (specify primary)","Metastatic disease restaging","Spine trauma","Cervical radiculopathy", + "Spinal stenosis","Extremity fracture","Suspected osteomyelitis","Peripheral arterial disease (LE run-off)" ], indication_templates: [ - "CT {region}{contrast_text} – {context} for {condition}", + "CT {region} – {context} for {condition}", "CT {region} – rule out {condition}", - "CT {region}{contrast_text} – evaluate {condition}" + "CT {region}{contrast_text} – {context} ({condition})" ], contrast_recommendations: [ - { match:["kidney stone","renal colic"], suggest:"without_iv" }, - { match:["appendicitis","rlq"], suggest:"with_iv" }, - { match:["pe","pulmonary embolism"], suggest:"with_iv" }, - { match:["aortic","dissection","aneurysm"], suggest:"with_iv" }, - { match:["liver lesion","pancreatitis"], suggest:"with_iv" }, - { match:["bowel obstruction"], suggest:"without_iv"}, - { match:["low-dose lung ct","screening","ldct"], suggest:"without_iv" } + { match:["kidney stone","renal colic"], suggest:"without_iv" }, + { match:["appendicitis","rlq"], suggest:"with_iv" }, + { match:["pe","pulmonary embolism"], suggest:"with_iv" }, + { match:["aortic","dissection","aneurysm"], suggest:"with_iv" }, + { match:["liver lesion","pancreatitis"], suggest:"with_iv" }, + { match:["bowel obstruction"], suggest:"without_iv" }, + { match:["trauma"], suggest:"with_iv" }, + { match:["low-dose lung ct","screening"], suggest:"without_iv" } ] } }, - records: [] + records: [] // keep empty; site-specific rules.json will populate }; let RULES = null; @@ -151,18 +167,20 @@ if (!els.contrastGroup) return; els.contrastGroup.classList.toggle("hidden", !show); if (!show) { - const checked = els.contrastGroup.querySelector('input[type=radio]:checked'); + const checked = + els.contrastGroup.querySelector('input[type=radio]:checked'); if (checked) checked.checked = false; if (els.oral) els.oral.checked = false; } } function contrastTextFromForm() { - if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) return ""; + if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) + return ""; const radio = els.contrastGroup.querySelector('input[type=radio]:checked'); const oral = els.oral?.checked ? " + oral contrast" : ""; if (!radio) return oral ? "(" + oral.trim() + ")" : ""; - if (radio.value === "with_iv") return "(with IV contrast" + oral + ")"; + if (radio.value === "with_iv") return "(with IV contrast" + oral + ")"; if (radio.value === "without_iv") return "(without IV contrast" + oral + ")"; return oral ? "(" + oral.trim() + ")" : ""; } @@ -205,48 +223,32 @@ }); } - // -------- Rules loading: auto-merge PET/CT + CT + MRI -------- + // -------- Rules loading -------- + function looksLikeRules(obj) { + // Minimal sanity check + if (!obj || typeof obj !== "object") return false; + // accept either {modalities:{}} or at least records:[] + const hasModalities = + obj.modalities && typeof obj.modalities === "object"; + const hasRecords = Array.isArray(obj.records); + return hasModalities || hasRecords; + } + async function loadRules() { - function buildSiblingUrls(rulesUrl) { - const meta = new URL(rulesUrl, location.origin); - const search = meta.search; // keep ?v=... - const dir = new URL(meta.pathname.replace(/[^/]+$/, ''), location.origin); - const mk = (name) => new URL(name + search, dir).toString(); - return [ - rulesUrl, // rules.json (PET/CT + general) - mk("ct_rules.json"), // CT - mk("mri_rules.json") // MRI - ]; - } - async function tryFetch(url) { - try { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) return null; return await r.json(); } - catch { return null; } - } try { - const urls = buildSiblingUrls(RULES_URL); - const loaded = (await Promise.all(urls.map(tryFetch))).filter(Boolean); - if (!loaded.length) throw new Error("No rules files available"); - - RULES = { modalities: {}, records: [] }; - for (const j of loaded) { - if (j.modalities && typeof j.modalities === "object") { - Object.assign(RULES.modalities, j.modalities); - } - if (Array.isArray(j.records)) { - RULES.records.push(...j.records); - } else if (Array.isArray(j)) { - // support legacy "pure records array" JSON files - RULES.records.push(...j); - } - } - if (!RULES.records.length && !Object.keys(RULES.modalities).length) { - throw new Error("Invalid rules schema after merge"); - } + const res = await fetch(RULES_URL, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (!looksLikeRules(json)) throw new Error("Invalid rules schema"); + RULES = json; setStatus("Rules loaded.", "success"); } catch (e) { - console.warn("Rules load/merge failed; using fallback", e); + console.warn("Failed to load rules.json, using fallback", e); RULES = FALLBACK_RULES; - setStatus("Using built-in fallback rules (could not fetch rules.json).", "warn"); + setStatus( + "Using built-in fallback rules (could not fetch rules.json).", + "warn" + ); } } @@ -268,10 +270,40 @@ return { contexts: Array.from(setC), regions: Array.from(setR), - conditions: [] + conditions: [] // leave empty; user types }; } + // -------- NEW: Preview sync -------- + function getContrastPreviewText() { + if (!els.contrastGroup || els.contrastGroup.classList.contains("hidden")) return "—"; + const r = els.contrastGroup.querySelector('input[type=radio]:checked'); + const oral = !!els.oral?.checked; + let txt = "—"; + if (r) txt = r.value === "with_iv" ? "With IV contrast" : "Without IV contrast"; + if (oral) txt = (txt === "—" ? "" : txt) + (txt === "—" ? "Oral contrast" : " + oral"); + return txt; + } + + function selectedContexts() { + return els.contextChips + ? getSelectedContextsFromChips() + : (els.context ? [...els.context.selectedOptions].map(o => o.value) : []); + } + + function syncPreviewPanel() { + try { + if (els.pvModality) els.pvModality.textContent = els.modality?.value || "—"; + if (els.pvRegion) els.pvRegion.textContent = els.region?.value || "—"; + if (els.pvContext) els.pvContext.textContent = (selectedContexts().join(", ")) || "—"; + if (els.pvCondition) els.pvCondition.textContent = els.condition?.value || "—"; + if (els.pvContrast) els.pvContrast.textContent = getContrastPreviewText(); + if (els.pvIndication)els.pvIndication.textContent= (els.indication?.value || "").trim() || "—"; + } catch (e) { + console.warn("Preview sync error:", e); + } + } + // -------- Populate UI for modality -------- function populateForModality(modality) { const node = getModalityNode(modality) || deriveFromRecords(modality); @@ -294,11 +326,11 @@ if (els.condition) els.condition.value = ""; if (els.indication) els.indication.value = ""; - // Notify preview helpers to re-render - try { document.dispatchEvent(new Event("input", { bubbles: true })); } catch {} + // Re-sync preview after repopulation + syncPreviewPanel(); } - // -------- CT contrast suggestions -------- + // -------- Contrast suggestions for CT -------- function suggestContrastIfCT(modalityNode, conditionText, regionText) { if (!modalityNode?.contrast_recommendations) return; const text = `${conditionText || ""} ${regionText || ""}`.toLowerCase(); @@ -314,9 +346,12 @@ } // Guardrail: any CTA should be with IV if ((regionText || "").toLowerCase().includes("cta")) { - const withIV = els.contrastGroup?.querySelector('input[type=radio][value="with_iv"]'); + const withIV = els.contrastGroup?.querySelector( + 'input[type=radio][value="with_iv"]' + ); if (withIV) withIV.checked = true; } + syncPreviewPanel(); } // -------- Indication builder -------- @@ -327,10 +362,12 @@ ? getSelectedContextsFromChips().join(", ") : (() => { const sel = els.context; - return sel ? [...sel.selectedOptions].map((o) => o.textContent.trim()).join(", ") : ""; + return sel + ? [...sel.selectedOptions].map((o) => o.textContent.trim()).join(", ") + : ""; })(); const condition = els.condition?.value || ""; - const contrast_text = contrastTextFromForm(); + const contrast_text = contrastTextFromForm(); // e.g., "(with IV contrast + oral contrast)" const templates = modalityNode?.indication_templates || (modality === "CT" @@ -338,6 +375,7 @@ : modality === "PET/CT" ? ["FDG PET/CT {region} – {context} for {condition}"] : ["{region} – {context} for {condition}"]); + // Prefer a template that includes {contrast_text} if present const t = (contrast_text && templates.find((x) => x.includes("{contrast_text}"))) || templates[0]; @@ -348,9 +386,12 @@ .replace("{condition}", condition) .replace("{contrast_text}", contrast_text ? ` ${contrast_text}` : ""); els.indication.value = out.trim(); + + // keep preview current + syncPreviewPanel(); } - // -------- Study suggestions -------- + // -------- Basic record matcher (suggest studies) -------- function scoreRecord(rec, modality, region, contexts, condition) { if (!(rec.modality || "").toUpperCase().includes(modality.toUpperCase())) return -1; @@ -359,7 +400,8 @@ rec.header_coverage && region && rec.header_coverage.toLowerCase().includes(region.toLowerCase()) - ) s += 2; + ) + s += 2; (rec.contexts || []).forEach((c) => { if (contexts.some((ctx) => ctx.toLowerCase() === (c || "").toLowerCase())) s += 2; @@ -370,8 +412,10 @@ }); if ( (rec.tags || []).includes("oncology-general") && - condition && /c\d\d|malig|tumor|cancer/i.test(condition) - ) s += 1; + condition && + /c\d\d|malig|tumor|cancer/i.test(condition) + ) + s += 1; return s; } @@ -402,93 +446,36 @@ }); } - // -------- ICD-10 suggestions (informational only) -------- - const ICD_RULES = [ - { tokens: ["dlbcl","lymphoma","hodgkin","nhl"], codes: [ - { code:"C83.30", label:"Diffuse large B-cell lymphoma, unspecified site" }, - { code:"C81.90", label:"Hodgkin lymphoma, unspecified, unspecified site" } - ]}, - { tokens: ["nsclc","lung cancer","pulmonary nodule","lung"], codes: [ - { code:"C34.90", label:"Malignant neoplasm of unspecified part of unspecified lung" }, - { code:"R91.1", label:"Solitary pulmonary nodule" } - ]}, - { tokens: ["melanoma"], codes: [{ code:"C43.9", label:"Malignant melanoma of skin, unspecified" }]}, - { tokens: ["colorectal","colon cancer"], codes: [{ code:"C18.9", label:"Malignant neoplasm of colon, unspecified" }]}, - { tokens: ["hnscc","head and neck"], codes: [{ code:"C76.0", label:"Malignant neoplasm of head, face and neck" }]}, - { tokens: ["fever of unknown origin","fuo"], codes: [{ code:"R50.9", label:"Fever, unspecified" }]}, - { tokens: ["viability","ischemic cardiomyopathy","myocardial"], codes: [{ code:"I25.5", label:"Ischemic cardiomyopathy" }]}, - { tokens: ["pulmonary embolism","pe"], codes: [{ code:"I26.99", label:"Other pulmonary embolism without acute cor pulmonale" }]}, - { tokens: ["appendicitis","rlq"], codes: [{ code:"K35.80", label:"Unspecified acute appendicitis" }]}, - { tokens: ["renal colic","kidney stone","flank pain","hematuria"], codes: [ - { code:"N20.0", label:"Calculus of kidney" }, - { code:"N23", label:"Unspecified renal colic" } - ]}, - { tokens: ["pneumonia"], codes: [{ code:"J18.9", label:"Pneumonia, unspecified organism" }]}, - { tokens: ["stroke","cerebral infarction"], codes: [{ code:"I63.9", label:"Cerebral infarction, unspecified" }]}, - { tokens: ["tia"], codes: [{ code:"G45.9", label:"Transient cerebral ischemic attack, unspecified" }]}, - { tokens: ["intracranial hemorrhage","ich"], codes: [{ code:"I62.9", label:"Nontraumatic intracranial hemorrhage, unspecified" }]}, - { tokens: ["cervical radiculopathy"], codes: [{ code:"M54.12", label:"Radiculopathy, cervical region" }]}, - { tokens: ["lumbar radiculopathy","sciatica"], codes: [{ code:"M54.16", label:"Radiculopathy, lumbar region" }]}, - { tokens: ["spinal stenosis","stenosis"], codes: [{ code:"M48.061", label:"Spinal stenosis, lumbar region w/o neurogenic claudication" }]}, - { tokens: ["disc herniation"], codes: [{ code:"M51.26", label:"Other intervertebral disc displacement, lumbar region" }]}, - { tokens: ["meniscal tear"], codes: [{ code:"S83.209A", label:"Tear of unsp meniscus, unsp knee, initial encounter" }]}, - { tokens: ["acl tear"], codes: [{ code:"S83.511A", label:"Sprain of ACL of right knee, initial encounter" }]}, - { tokens: ["rotator cuff"], codes: [{ code:"M75.100", label:"Unspecified rotator cuff tear or rupture, not specified as traumatic" }]} - ]; - function suggestICD10(text) { - const t = (text || "").toLowerCase(); - const out = []; - const seen = new Set(); - for (const rule of ICD_RULES) { - if (rule.tokens.some(tok => t.includes(tok))) { - for (const c of rule.codes) { - if (!seen.has(c.code)) { out.push(c); seen.add(c.code); } - } - } - if (out.length >= 6) break; - } - return out; - } - // -------- Results panel fill -------- function fillResults(topRec, contextStr, conditionStr) { if (!els.results || !topRec) return; - const header = topRec.study_name || topRec.header_coverage || "Suggested Study"; - if (els.outHeader) { - const cptStr = (topRec.cpt || []).join(", "); - els.outHeader.textContent = cptStr ? `${header} — CPT: ${cptStr}` : header; - } + const header = + topRec.study_name || topRec.header_coverage || "Suggested Study"; + if (els.outHeader) + els.outHeader.textContent = `${header} — CPT: ${(topRec.cpt || []).join( + ", " + )}`; + if (els.outReason) { const tmpl = (topRec.reasons || [])[0] || "{context} {condition}"; els.outReason.value = tmpl .replace("{context}", contextStr || "") .replace("{condition}", conditionStr || ""); } + function fillUL(ul, arr) { if (!ul) return; ul.innerHTML = ""; - (arr || []).forEach((t) => { const li = document.createElement("li"); li.textContent = t; ul.appendChild(li); }); + (arr || []).forEach((t) => { + const li = document.createElement("li"); + li.textContent = t; + ul.appendChild(li); + }); } - fillUL(els.outPrep, topRec.prep ? [topRec.prep] : []); - fillUL(els.outDocs, topRec.supporting_docs); + fillUL(els.outPrep, topRec.prep ? [topRec.prep] : []); + fillUL(els.outDocs, topRec.supporting_docs); fillUL(els.outFlags, topRec.flags); - if (els.outICD) { - const icds = suggestICD10(`${contextStr || ""} ${conditionStr || ""}`); - els.outICD.innerHTML = ""; - if (icds.length) { - icds.forEach(({code,label}) => { - const li = document.createElement("li"); - li.textContent = `${code} — ${label}`; - els.outICD.appendChild(li); - }); - } else { - const li = document.createElement("li"); - li.className = "muted"; - li.textContent = "No suggestions. Edit condition text for better matches."; - els.outICD.appendChild(li); - } - } els.results.hidden = false; } @@ -500,6 +487,7 @@ populateForModality(modality); const node = getModalityNode(modality) || (FALLBACK_RULES.modalities[modality] || null); buildIndication(node, modality); + syncPreviewPanel(); }); // Region / Condition input -> suggest contrast if CT and rebuild indication @@ -509,24 +497,28 @@ const node = getModalityNode("CT") || FALLBACK_RULES.modalities.CT; suggestContrastIfCT(node, els.condition?.value, els.region?.value); } - const m = els.modality?.value; - buildIndication(getModalityNode(m) || (FALLBACK_RULES.modalities[m] || null), m); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); + syncPreviewPanel(); }); els.condition?.addEventListener(evt, () => { if (els.modality?.value === "CT") { const node = getModalityNode("CT") || FALLBACK_RULES.modalities.CT; suggestContrastIfCT(node, els.condition?.value, els.region?.value); } - const m = els.modality?.value; - buildIndication(getModalityNode(m) || (FALLBACK_RULES.modalities[m] || null), m); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); + syncPreviewPanel(); }); }); - // Contrast change -> rebuild indication + // Contrast change -> rebuild indication + preview els.contrastGroup?.addEventListener("change", () => { if (els.modality?.value === "CT") { - buildIndication(getModalityNode("CT") || FALLBACK_RULES.modalities.CT, "CT"); + buildIndication( + getModalityNode("CT") || FALLBACK_RULES.modalities.CT, + "CT" + ); } + syncPreviewPanel(); }); // Chips: click + keyboard toggle @@ -536,27 +528,30 @@ const cur = chip.getAttribute("aria-pressed") === "true"; chip.setAttribute("aria-pressed", cur ? "false" : "true"); mirrorChipsToHiddenSelect(); - const m = els.modality?.value; - buildIndication(getModalityNode(m) || (FALLBACK_RULES.modalities[m] || null), m); + buildIndication(getModalityNode(els.modality?.value) || (FALLBACK_RULES.modalities[els.modality?.value] || null), els.modality?.value); + syncPreviewPanel(); }); document.addEventListener("keydown", (e) => { const chip = e.target.closest("#contextChips .oh-chip"); if (!chip) return; - if (e.key === " " || e.key === "Enter") { e.preventDefault(); chip.click(); } + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + chip.click(); + } }); - // Form submit -> suggest order + fill results + // Form submit -> suggest order + fill results (preview already synced) els.form?.addEventListener("submit", (e) => { e.preventDefault(); const modality = els.modality?.value || ""; - const region = els.region?.value || ""; - const contexts = els.contextChips - ? getSelectedContextsFromChips() - : (els.context ? [...els.context.selectedOptions].map((o) => o.value) : []); + const region = els.region?.value || ""; + const contexts = selectedContexts(); const condition = els.condition?.value || ""; + // Suggestions suggestStudies(modality, region, contexts, condition); + // Fill results with top hit if available const recs = RULES?.records || []; const ranked = recs .map((r) => ({ r, s: scoreRecord(r, modality, region, contexts, condition) })) @@ -564,6 +559,7 @@ .sort((a, b) => b.s - a.s); fillResults(ranked[0]?.r, contexts.join(", "), condition); + // Status message setStatus("Order suggested below. Review, copy, or print.", "success"); }); @@ -571,22 +567,47 @@ els.copyReasonBtn?.addEventListener("click", async () => { const v = els.outReason?.value?.trim(); if (!v) return; - try { await navigator.clipboard.writeText(v); setStatus("Reason copied to clipboard.", "success"); } - catch { setStatus("Unable to copy reason. Select and copy manually.", "warn"); } + try { + await navigator.clipboard.writeText(v); + setStatus("Reason copied to clipboard.", "success"); + } catch { + setStatus("Unable to copy reason. Select and copy manually.", "warn"); + } }); els.copyAllBtn?.addEventListener("click", async () => { const parts = []; if (els.outHeader) parts.push(els.outHeader.textContent); if (els.outReason?.value) parts.push("Reason: " + els.outReason.value); - if (els.outPrep?.children?.length) parts.push("Prep: " + Array.from(els.outPrep.children).map(li => li.textContent).join("; ")); - if (els.outDocs?.children?.length) parts.push("Docs: " + Array.from(els.outDocs.children).map(li => li.textContent).join("; ")); - if (els.outFlags?.children?.length) parts.push("Flags: " + Array.from(els.outFlags.children).map(li => li.textContent).join("; ")); - if (els.outICD?.children?.length) parts.push("ICD-10: "+ Array.from(els.outICD.children).map(li => li.textContent).join("; ")); + if (els.outPrep?.children?.length) + parts.push( + "Prep: " + + Array.from(els.outPrep.children) + .map((li) => li.textContent) + .join("; ") + ); + if (els.outDocs?.children?.length) + parts.push( + "Docs: " + + Array.from(els.outDocs.children) + .map((li) => li.textContent) + .join("; ") + ); + if (els.outFlags?.children?.length) + parts.push( + "Flags: " + + Array.from(els.outFlags.children) + .map((li) => li.textContent) + .join("; ") + ); const text = parts.join("\n"); if (!text.trim()) return; - try { await navigator.clipboard.writeText(text); setStatus("All details copied to clipboard.", "success"); } - catch { setStatus("Unable to copy. Select and copy manually.", "warn"); } + try { + await navigator.clipboard.writeText(text); + setStatus("All details copied to clipboard.", "success"); + } catch { + setStatus("Unable to copy. Select and copy manually.", "warn"); + } }); els.printBtn?.addEventListener("click", () => window.print()); @@ -601,17 +622,21 @@ const current = els.modality?.value || "PET/CT"; populateForModality(current); - // Pre-run indication (and CT contrast if needed) + // If CT selected at load, pre-run contrast suggestion if (current === "CT") { const node = getModalityNode("CT") || FALLBACK_RULES.modalities.CT; suggestContrastIfCT(node, els.condition?.value, els.region?.value); buildIndication(node, "CT"); } else { - const node = getModalityNode(current) || (FALLBACK_RULES.modalities[current] || null); + const node = + getModalityNode(current) || (FALLBACK_RULES.modalities[current] || null); buildIndication(node, current); } - if (els.dbg) els.dbg.textContent = `[OH] Ready (${new Date().toLocaleString()})`; + // Final: make sure preview shows current form state + syncPreviewPanel(); + + if (els.dbg) + els.dbg.textContent = `[OH] Ready (${new Date().toLocaleString()})`; })(); })(); - From 3e35ab78d41fada6c57e3168c2e72d51452f160b Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Fri, 5 Sep 2025 20:54:12 -0400 Subject: [PATCH 12/15] Normalize rules path case (Data -> data); resolve merge; keep lowercase rules.json --- _layouts/default.html | 140 +++--------------------------------------- 1 file changed, 8 insertions(+), 132 deletions(-) diff --git a/_layouts/default.html b/_layouts/default.html index cb8ebb3..f178143 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -65,35 +65,12 @@ gtag('js', new Date()); gtag('config','G-FKVXD8061R',{ send_page_view:true }); - + {% if page.custom_css %} {% endif %} -{% if page.url contains '/order-helper/' %} - - -{% endif %} - -{% if page.url contains '/order-helper/' %} - - - - - - - -{% endif %} + @@ -311,113 +288,12 @@ } }); }); -{% if page.url contains '/order-helper/' %} - - - - - - - - -{% endif %} + + + + + + From 127d3b88d9b01b223238e0567c9a1347713e590a Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:02:11 -0400 Subject: [PATCH 13/15] chore: ignore local backup files --- .gitignore | Bin 412 -> 560 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index 382ab03de9ff37ef0dddd0322bb94ab37c73a224..ce8be027238ddc437b434addebca52b28d1334b5 100644 GIT binary patch literal 560 zcmZWn$xg#C5WV{=j4C0BRQn%L3xxw96>+T`dzzYhi9Ak9FMK=WHfdOVnC;EHS>zd? zm&c!Ae{_yZ{$TT}tNE*H0M8iPpv@qe_&DXvwbHvEu7|9Q%8Ch8h$G5o1Mzb-K(skV zXG1eE1`2L`G@S#KY}*B$Z44-X$3yGq2I{U6E;bcAsuceDkWx%%y#14Z8R4$8&hTrV z!MDQz#$uMNE`Ve@_K;{_$*RIcGW)hj$|n_*Eio7Mo>Z-hjLxv?iwr!J6g)(BY4^BT z(!ww6p_S-S)4Fs&Ye11&tT*c%6R@{k=W7(Eo@(!Hf?8p@*eYV4E+)1ELIk6kbQwyy zseBoFszlHUGV+@Mv>4aXdz){^aUk?ekPsESI%)V+zI8CMyhGAZ{(`rt$1-A~p!^2s CBc{Rt literal 412 zcmb`Bu?oW=6h-eW_zyxiLGulPW(b9vwNueVnp9iGSm@{1G*h8NhAi*$4u{KCU30G6 z>#4cO{LYl9&OYl_%ud7 Date: Fri, 5 Sep 2025 21:02:57 -0400 Subject: [PATCH 14/15] chore: ignore local backup files --- .gitignore | 59 +++++++++++++++--------------------------------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index ce8be02..5da20ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,19 @@ -# ----- Jekyll / GitHub Pages ----- -_site/ -.jekyll-cache/ -.jekyll-metadata -.sass-cache/ +OPENAI_KEY.txt +OPenAI-sk-proj-*.txt -# ----- Node / tooling ----- -node_modules/ -functions/node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* +OPENAI_KEY.txt +OPenAI-sk-proj-*.txt -# ----- Build & temp ----- -dist/ -build/ -coverage/ -*.log -*.tmp -*.temp -*.orig -*.lock -*.bak -*.old -*~ -*.swp -*.swo +OPENAI_KEY.txt +OPenAI-sk-proj-*.txt +OPENAI_KEY.txt +OPenAI-sk-proj-*.txt +OPENAI_KEY.txt +OPenAI-sk-proj-*.txt +# OraDigit local backups +order-helper/*.bak.* +order-helper/*.backup.html -# ----- IDE / OS ----- -.vscode/ -.idea/ -.DS_Store -Thumbs.db - -# ----- Firebase / Hosting ----- -.firebase/ -.firebaserc.local - -# ----- Env / secrets (never commit) ----- -.env -.env.* -!.env.example - -# ----- Vercel / misc ----- -.vercel/ \ No newline at end of file +# OraDigit local backups +order-helper/*.bak.* +order-helper/*.backup.html From c6747bbda01baaa14211962ce66bcdbb217dcb2e Mon Sep 17 00:00:00 2001 From: Lissan <150966211+DataForSolution@users.noreply.github.com> Date: Fri, 19 Sep 2025 20:35:28 -0400 Subject: [PATCH 15/15] chore: ignore local order-helper backups --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5da20ee..ce73f42 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ order-helper/*.backup.html # OraDigit local backups order-helper/*.bak.* order-helper/*.backup.html + +order-helper.bak.*/