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 1/2] 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 2/2] =?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; } }