Game:520 Unit 1 Dosage Calc Study Buddy: Difference between revisions
Jump to navigation
Jump to search
Auto-loading iframe using JavaScript |
Embedded HTML game directly (raw HTML enabled) |
||
| Line 1: | Line 1: | ||
<div id=" | <html> | ||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<meta name="viewport" content="width=device-width,initial-scale=1" /> | |||
<title>Unit 1 Dosage Calc</title> | |||
<style> | |||
:root{ | |||
--bg:#0b1220; --panel:#0f1a2e; --card:#111f3a; --muted:#9fb0d0; --text:#e9f0ff; | |||
--accent:#7aa2ff; --good:#3ddc97; --warn:#ffd166; --bad:#ff5c7a; --line:#223458; | |||
} | |||
*{box-sizing:border-box} | |||
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;color:var(--text); | |||
background:radial-gradient(1000px 600px at 15% 0%, #14224a 0%, rgba(20,34,74,0) 60%), | |||
radial-gradient(900px 500px at 100% 20%, #1a2a59 0%, rgba(26,42,89,0) 55%), | |||
linear-gradient(180deg,#070b14, #0b1220 35%, #070b14)} | |||
.app{display:grid;grid-template-columns:350px 1fr;min-height:100vh} | |||
aside{padding:18px;border-right:1px solid var(--line);background:rgba(15,26,46,.65);backdrop-filter: blur(8px)} | |||
main{padding:18px 18px 32px} | |||
h1{margin:4px 0 8px;font-size:18px} | |||
h2{margin:0;font-size:16px} | |||
.sub{color:var(--muted);font-size:12px;line-height:1.35} | |||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap} | |||
.btn{border:1px solid var(--line);background:rgba(17,31,58,.9);color:var(--text);padding:10px 12px;border-radius:12px;cursor:pointer} | |||
.btn:hover{border-color:#365089} | |||
.btn.primary{background:rgba(122,162,255,.18);border-color:rgba(122,162,255,.45)} | |||
.btn.danger{background:rgba(255,92,122,.12);border-color:rgba(255,92,122,.35)} | |||
.btn:disabled{opacity:.5;cursor:not-allowed} | |||
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px} | |||
.stat{border:1px solid var(--line);background:rgba(17,31,58,.7);border-radius:14px;padding:10px} | |||
.stat .k{font-size:11px;color:var(--muted)} | |||
.stat .v{font-size:18px;margin-top:4px} | |||
.progressWrap{margin-top:14px;border:1px solid var(--line);border-radius:14px;padding:12px;background:rgba(17,31,58,.65)} | |||
.bar{height:10px;border-radius:999px;background:#1b2a49;overflow:hidden;border:1px solid #223458} | |||
.bar > div{height:100%;width:0;background:linear-gradient(90deg,var(--accent),#9bb8ff);transition:.2s} | |||
.small{font-size:11px;color:var(--muted);margin-top:6px;display:flex;justify-content:space-between;gap:10px} | |||
.filters{margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:10px} | |||
select,input[type="search"]{width:100%;padding:10px 10px;border-radius:12px;border:1px solid var(--line);background:rgba(17,31,58,.7);color:var(--text);outline:none} | |||
.list{margin-top:12px;max-height:34vh;overflow:auto;padding-right:6px} | |||
.qitem{border:1px solid var(--line);border-radius:12px;padding:10px;margin-bottom:10px;background:rgba(17,31,58,.6);cursor:pointer} | |||
.qitem:hover{border-color:#365089} | |||
.qitem .top{display:flex;justify-content:space-between;gap:10px} | |||
.qitem .t{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.25} | |||
.tag{font-size:11px;color:var(--muted);border:1px solid var(--line);padding:4px 8px;border-radius:999px;white-space:nowrap} | |||
.tag.good{border-color:rgba(61,220,151,.5);color:rgba(61,220,151,.95)} | |||
.tag.bad{border-color:rgba(255,92,122,.5);color:rgba(255,92,122,.95)} | |||
.tag.warn{border-color:rgba(255,209,102,.5);color:rgba(255,209,102,.95)} | |||
.card{border:1px solid var(--line);border-radius:16px;padding:16px;background:rgba(17,31,58,.65)} | |||
.header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap} | |||
.meta{display:flex;gap:8px;flex-wrap:wrap} | |||
.badge{font-size:12px;padding:6px 10px;border-radius:999px;border:1px solid var(--line);background:rgba(15,26,46,.55)} | |||
.prompt{margin:10px 0 0;color:var(--text);line-height:1.45} | |||
.hint{margin:10px 0 0;color:var(--muted);font-size:12px} | |||
.opts{margin-top:12px;display:grid;gap:10px} | |||
.optCard{ | |||
border:1px solid var(--line); | |||
border-radius:12px; | |||
padding:12px; | |||
background:rgba(15,26,46,.5); | |||
cursor:pointer; | |||
display:flex; gap:10px; align-items:flex-start; | |||
user-select:none; | |||
} | |||
.optCard:hover{border-color:#365089} | |||
.optMark{ | |||
width:18px;height:18px;border-radius:6px; | |||
border:1px solid #365089;background:rgba(122,162,255,.06); | |||
flex:0 0 auto;margin-top:2px; | |||
} | |||
.optCard.selected .optMark{ | |||
background:rgba(122,162,255,.35); | |||
border-color:rgba(122,162,255,.8); | |||
box-shadow:0 0 0 2px rgba(122,162,255,.12) inset; | |||
} | |||
.optText{flex:1} | |||
.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px} | |||
.revealBox{margin-top:12px;border:1px dashed #365089;border-radius:14px;padding:12px;background:rgba(122,162,255,.06)} | |||
.revealBox .rtitle{font-size:12px;color:var(--muted);margin-bottom:8px} | |||
.split{display:grid;grid-template-columns:1.2fr .8fr;gap:12px} | |||
.miniTable{width:100%;border-collapse:collapse;margin-top:10px} | |||
.miniTable th,.miniTable td{padding:10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top} | |||
.kpiRow{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-top:10px} | |||
.miniList{margin:10px 0 0;padding:0;list-style:none} | |||
.miniList li{padding:8px 10px;border:1px solid var(--line);border-radius:12px;background:rgba(15,26,46,.45);margin-bottom:8px} | |||
.miniList small{color:var(--muted)} | |||
.chip{display:inline-block;margin-left:8px;font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)} | |||
.chip.bad{border-color:rgba(255,92,122,.45);color:rgba(255,92,122,.95)} | |||
.chip.warn{border-color:rgba(255,209,102,.45);color:rgba(255,209,102,.95)} | |||
.caseBox{border:1px solid var(--line);border-radius:14px;padding:12px;background:rgba(15,26,46,.4);margin-top:12px} | |||
.caseStep{border-top:1px solid var(--line);margin-top:12px;padding-top:12px} | |||
.caseStep:first-child{border-top:none;margin-top:0;padding-top:0} | |||
textarea{width:100%;min-height:72px;resize:vertical;padding:10px;border-radius:12px;border:1px solid var(--line);background:rgba(15,26,46,.55);color:var(--text);outline:none} | |||
.table{width:100%;border-collapse:collapse;margin-top:10px} | |||
.table th,.table td{padding:10px;border-bottom:1px solid var(--line);text-align:left} | |||
.err{margin-top:12px;border:1px solid rgba(255,92,122,.35);background:rgba(255,92,122,.08);padding:10px;border-radius:12px;color:#ffd0d9;font-size:12px;display:none} | |||
.logicGrid{display:grid;gap:10px;margin-top:10px} | |||
.logicBox{border:1px solid var(--line);background:rgba(15,26,46,.45);border-radius:12px;padding:10px} | |||
.logicBox .ttl{font-size:12px;color:var(--muted);margin-bottom:6px} | |||
.logicBox .txt{line-height:1.35} | |||
@media (max-width: 980px){.app{grid-template-columns:1fr} aside{border-right:none;border-bottom:1px solid var(--line)} .split{grid-template-columns:1fr}} | |||
</style> | |||
</head> | |||
<body> | |||
<div class="app"> | |||
<aside> | |||
<h1>Dosage Calculation Study Bot</h1> | |||
<div class="sub"> | |||
Unit 1 Dosage Calculation ONLY | |||
<br/>No Patho/Pharm content. | |||
</div> | |||
<div class="progressWrap"> | |||
<div class="bar"><div id="barFill"></div></div> | |||
<div class="small"> | |||
<span id="progText">Progress: 0/30</span> | |||
<span id="pctText">0%</span> | |||
</div> | |||
<div class="kpiRow"> | |||
<div class="small" style="margin:0"><span>Topics to revisit (partial/missed)</span><span id="reviewCount">0</span></div> | |||
</div> | |||
<ul class="miniList" id="reviewTopics"></ul> | |||
</div> | |||
<div class="statgrid"> | |||
<div class="stat"><div class="k">Weighted Score</div><div class="v" id="scoreV">0</div></div> | |||
<div class="stat"><div class="k">Readiness</div><div class="v" id="bandV">—</div></div> | |||
</div> | |||
<div class="filters"> | |||
<select id="filterMode"> | |||
<option value="all">All</option> | |||
<option value="missed">Missed (below 70%)</option> | |||
<option value="partial">Partial (70–99%)</option> | |||
<option value="flagged">Flagged</option> | |||
<option value="unanswered">Unanswered</option> | |||
</select> | |||
<select id="topicFilter"></select> | |||
</div> | |||
<div style="margin-top:10px"> | |||
<input id="searchBox" type="search" placeholder="Search prompt / topic…" /> | |||
</div> | |||
<div class="row" style="margin-top:12px"> | |||
<button class="btn" id="prevBtn">Prev</button> | |||
<button class="btn primary" id="nextBtn">Next</button> | |||
<button class="btn" id="jumpReportBtn">Report</button> | |||
<button class="btn danger" id="resetBtn">Reset</button> | |||
</div> | |||
<div class="list" id="qList"></div> | |||
</aside> | |||
<main> | |||
<div class="card" id="viewer"></div> | |||
<div class="err" id="errBox"></div> | |||
<div class="card" id="reportCard" style="margin-top:16px"> | |||
<div class="header"> | |||
<div> | |||
<h2>End Report</h2> | |||
<div class="sub">Weighted performance + a clean list of topics to revisit (partial or missed).</div> | |||
</div> | |||
<div class="meta"> | |||
<span class="badge" id="repWeighted">Weighted: 0</span> | |||
<span class="badge" id="repPct">Percent: 0%</span> | |||
<span class="badge" id="repBand">Band: —</span> | |||
</div> | |||
</div> | |||
<div class="split" style="margin-top:12px"> | |||
<div class="caseBox"> | |||
<div style="display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap"> | |||
<div> | |||
<div style="font-size:13px;color:var(--muted)">Concept mastery (by topic)</div> | |||
<div class="sub">Percent is weighted by item format + difficulty.</div> | |||
</div> | |||
<button class="btn" id="downloadBtn">Download session JSON</button> | |||
</div> | |||
<table class="miniTable" id="topicTable"> | |||
<thead><tr><th>Topic</th><th>Mastery</th><th>Status</th></tr></thead> | |||
<tbody></tbody> | |||
</table> | |||
</div> | |||
<div class="caseBox"> | |||
<div style="font-size:13px;color:var(--muted)">Topics to revisit (list)</div> | |||
<div class="sub">Anything partial or missed appears here, sorted by frequency/priority.</div> | |||
<ul class="miniList" id="revisitList"></ul> | |||
</div> | |||
</div> | |||
</div> | |||
</main> | |||
</div> | |||
<script> | <script> | ||
document. | /* ========================= | ||
Global error capture | |||
if ( | ========================= */ | ||
window.addEventListener("error", (e)=>{ | |||
const box = document.getElementById("errBox"); | |||
box.style.display = "block"; | |||
box.textContent = "Script error: " + (e.message || "Unknown") + (e.filename ? (" @ " + e.filename) : ""); | |||
}); | |||
/* ========================= | |||
CONFIG (editable) | |||
========================= */ | |||
const CONFIG = { | |||
unitName: "Unit 1", | |||
passingHesiScore: 875, | |||
readinessBands: [ | |||
{ name: "Needs Work", minPct: 0 }, | |||
{ name: "Borderline", minPct: 70 }, | |||
{ name: "Ready", minPct: 85 } | |||
], | |||
difficultyWeights: { 1: 1, 2: 2, 3: 3 }, | |||
formatMultipliers: { | |||
mcq: 1, | |||
sata: 1, | |||
dropdown: 1, | |||
case: 2, | |||
matrix: 2, | |||
bowtie: 3, | |||
trends: 3 | |||
}, | |||
maxItemWeight: 6 | |||
}; | |||
/* ========================= | |||
QUESTIONS (Unit 1 ONLY) | |||
========================= */ | |||
const QUESTIONS = [ | |||
// ========================= | |||
// MCQ (foundation + common HESI/NCLEX styles) | |||
// ========================= | |||
{ | |||
id: 1, type: "mcq", topic: "Dose by Supply", difficulty: 2, | |||
stem: "Order: Morphine 4 mg IV now. Available: 10 mg/5 mL. How many mL will the nurse administer?", | |||
options: ["1 mL", "2 mL", "4 mL", "5 mL"], | |||
correct: [1], | |||
rationale: "Dimensional analysis: (4 mg ÷ 10 mg) × 5 mL = 2 mL.", | |||
plain: "You need 4 mg. Your vial has 10 mg in every 5 mL. Since 4 is less than half of 10, you need less than half the liquid (2 mL).", | |||
mnemonic: "D / H x Q (Desired / Have x Quantity)" | |||
}, | |||
{ | |||
id: 2, type: "mcq", topic: "Tablet Calculation", difficulty: 1, | |||
stem: "Order: Amoxicillin 500 mg PO now. Available: 250 mg/tablet. How many tablets will the nurse give?", | |||
options: ["1", "2", "3", "4"], | |||
correct: [1], | |||
rationale: "500 ÷ 250 = 2 tablets.", | |||
plain: "You need 500 total. Each pill is 250. 250 + 250 = 500.", | |||
mnemonic: "Want / Got (What you want / What you got)" | |||
}, | |||
{ | |||
id: 3, type: "mcq", topic: "IV Pump Rate (mL/hr)", difficulty: 1, | |||
stem: "An IV order reads: 1000 mL to infuse over 8 hours. What is the pump rate?", | |||
options: ["100 mL/hr", "125 mL/hr", "150 mL/hr", "175 mL/hr"], | |||
correct: [1], | |||
rationale: "1000 mL ÷ 8 hr = 125 mL/hr.", | |||
plain: "The pump only speaks 'mL per hour'. Simply take the total bag size and divide by total hours.", | |||
mnemonic: "Pump Rate = Total Volume / Total Time (Hours)" | |||
}, | |||
{ | |||
id: 4, type: "mcq", topic: "gtt/min (Gravity)", difficulty: 2, | |||
stem: "Infuse 500 mL over 4 hours. Tubing drop factor: 15 gtt/mL. What is the flow rate in gtt/min?", | |||
options: ["20 gtt/min", "25 gtt/min", "31 gtt/min", "40 gtt/min"], | |||
correct: [2], | |||
rationale: "mL/min = 500 ÷ 240 = 2.083; gtt/min = 2.083 × 15 = 31.25 ≈ 31.", | |||
plain: "Gravity drips are calculated in MINUTES. First convert 4 hours to 240 minutes. Then do (Vol x DF) / Minutes.", | |||
mnemonic: "The Video Doctor Films Minutes (Total Vol x Drop Factor / Minutes)" | |||
}, | |||
{ | |||
id: 5, type: "mcq", topic: "Weight-Based Dose (kg)", difficulty: 2, | |||
stem: "Client weighs 154 lb. Order: enoxaparin 1 mg/kg. Available: 100 mg/mL. How many mL will the nurse administer?", | |||
options: ["0.35 mL", "0.50 mL", "0.70 mL", "1.4 mL"], | |||
correct: [2], | |||
rationale: "154 lb ÷ 2.2 = 70 kg. Dose = 70 mg. Volume = 70 mg ÷ (100 mg/mL) = 0.70 mL.", | |||
plain: "Step 1: Pounds to Kg (make it smaller). Step 2: Multiply Kg by dose. Step 3: Divide by concentration.", | |||
mnemonic: "2.2 is the key (Lbs / 2.2 = Kg)" | |||
}, | |||
{ | |||
id: 6, type: "mcq", topic: "Safe Dose Range (Peds)", difficulty: 3, | |||
stem: "A child weighs 22 lb. Safe dose is 2–4 mg/kg/day. Provider orders 50 mg/day. What is the nurse’s priority action?", | |||
options: [ | |||
"Administer as ordered", | |||
"Clarify the order before giving", | |||
"Split the dose into two doses", | |||
"Give with food to reduce GI upset" | |||
], | |||
correct: [1], | |||
rationale: "22 lb ÷ 2.2 = 10 kg. Safe range = 20–40 mg/day. Ordered 50 mg/day exceeds safe range.", | |||
plain: "The baby is 10kg. The max safe dose is 4 mg x 10kg = 40mg. The doctor ordered 50mg. That's an overdose.", | |||
mnemonic: "Safety First: Calculate the range BEFORE checking the order." | |||
}, | |||
// ========================= | |||
// SATA (MCMA partial credit style) | |||
// ========================= | |||
{ | |||
id: 7, type: "sata", topic: "Error Prevention", difficulty: 2, | |||
stem: "Which actions reduce dosage calculation errors? (Select all that apply)", | |||
options: [ | |||
"Convert units to one system before solving", | |||
"Estimate whether the final answer is reasonable", | |||
"Skip unit labels to save time", | |||
"Double-check decimal placement", | |||
"Use an independent double-check for high-alert meds" | |||
], | |||
correct: [0, 1, 3, 4], | |||
rationale: "Unit consistency, reasonableness checks, decimal safety, and double-checks reduce medication errors.", | |||
plain: "Make sure apples match apples (units), and ask 'Does this answer make sense?' before giving it.", | |||
mnemonic: "The 6 Rights + 'Does it make sense?'" | |||
}, | |||
{ | |||
id: 8, type: "sata", topic: "High-Alert Meds Safety", difficulty: 2, | |||
stem: "Which medications are commonly treated as high-alert and require extra safeguards? (Select all that apply)", | |||
options: ["Insulin", "Heparin", "Potassium chloride IV", "Acetaminophen", "Warfarin"], | |||
correct: [0, 1, 2, 4], | |||
rationale: "Insulin, heparin, IV KCl, and warfarin are commonly high-alert due to serious harm risk if misdosed.", | |||
plain: "Think: Which drugs can kill a patient instantly if the math is wrong? Blood thinners, insulin, and IV potassium are top offenders.", | |||
mnemonic: "PINCH (Potassium, Insulin, Narcotics, Chemo, Heparin)" | |||
}, | |||
{ | |||
id: 9, type: "sata", topic: "Unit Conversions", difficulty: 2, | |||
stem: "Which conversions are correct? (Select all that apply)", | |||
options: [ | |||
"1 g = 1000 mg", | |||
"1 mg = 1000 mcg", | |||
"1 L = 100 mL", | |||
"2.2 lb = 1 kg", | |||
"1 tsp = 10 mL" | |||
], | |||
correct: [0, 1, 3], | |||
rationale: "Correct: 1 g=1000 mg; 1 mg=1000 mcg; 2.2 lb=1 kg. 1 L=1000 mL; 1 tsp=5 mL.", | |||
plain: "Remember your 1000s rule. Grams to milligrams is 1000. Milligrams to micrograms is 1000. But teaspoons are only 5.", | |||
mnemonic: "King Henry Died By Drinking Chocolate Milk (Kilo, Hecto, Deka, Base, Deci, Centi, Milli)" | |||
}, | |||
// ========================= | |||
// Dropdowns | |||
// ========================= | |||
{ | |||
id: 10, type: "dropdown", topic: "Dose by Supply", difficulty: 1, | |||
stem: "Complete the calculation.", | |||
blanks: [ | |||
{ text: "Order: furosemide 40 mg IV. Available: 20 mg/2 mL. Give: ", options: ["2 mL", "4 mL", "6 mL", "8 mL"], correct: "4 mL" } | |||
], | |||
rationale: "20 mg in 2 mL → 10 mg/mL. Need 40 mg → 4 mL.", | |||
plain: "If 2mL holds 20mg, then 1mL holds 10mg. You need 40mg, so you need 4mL.", | |||
mnemonic: "Double the dose = Double the volume (if concentration stays same)" | |||
}, | |||
{ | |||
id: 11, type: "dropdown", topic: "IV Pump Rate (mL/hr)", difficulty: 1, | |||
stem: "Complete the calculation.", | |||
blanks: [ | |||
{ text: "Infuse 250 mL over 2 hours. Pump rate: ", options: ["100", "125", "150", "175"], correct: "125" } | |||
], | |||
rationale: "250 ÷ 2 = 125 mL/hr.", | |||
plain: "The pump needs to know 'how much per ONE hour'. Divide total (250) by hours (2).", | |||
mnemonic: "Total Vol / Total Hours" | |||
}, | |||
{ | |||
id: 12, type: "dropdown", topic: "Reconstitution/Concentration", difficulty: 2, | |||
stem: "A vial is reconstituted to a total volume of 10 mL and contains 1 g total drug. Select the final concentration.", | |||
blanks: [ | |||
{ text: "Concentration (mg/mL): ", options: ["10", "50", "100", "200"], correct: "100" } | |||
], | |||
rationale: "1 g = 1000 mg. 1000 mg ÷ 10 mL = 100 mg/mL.", | |||
plain: "Always convert grams to mg first. 1 gram is huge (1000mg). Spread 1000mg across 10mL = 100 per mL.", | |||
mnemonic: "1 g = 1 paperclip = 1000 tiny grains of sand (mg)" | |||
}, | |||
// ========================= | |||
// MATRIX / GRID (2) | |||
// ========================= | |||
{ | |||
id: 13, type: "matrix", topic: "Method Selection", difficulty: 1, | |||
stem: "Match each scenario to the best calculation approach.", | |||
rows: [ | |||
{ row: "IV gravity rate ordered in gtt/min", options: ["mL/hr pump", "gtt/min formula", "tablet calculation"], correct: "gtt/min formula" }, | |||
{ row: "Oral tablets available as mg/tablet", options: ["tablet calculation", "gtt/min formula", "titration protocol"], correct: "tablet calculation" }, | |||
{ row: "Weight-based medication order (mg/kg)", options: ["weight-based dosing", "tablet calculation", "unitless ratio only"], correct: "weight-based dosing" } | |||
], | |||
rationale: "Picking the right method prevents setup errors before the math even starts.", | |||
plain: "Don't overcomplicate it. If it asks for drops, use the drop formula. If it involves weight, start with kg.", | |||
mnemonic: "Match the formula to the 'Ask' (Unit)" | |||
}, | |||
{ | |||
id: 14, type: "matrix", topic: "Dose Forms", difficulty: 2, | |||
stem: "Match medication form to what you calculate directly.", | |||
rows: [ | |||
{ row: "Liquid PO", options: ["mL to administer", "gtt/min only", "tablets only"], correct: "mL to administer" }, | |||
{ row: "IV pump infusion", options: ["mL/hr", "tablets", "mcg/min without conversion"], correct: "mL/hr" }, | |||
{ row: "Tablets/capsules", options: ["number of tablets", "gtt/min only", "mL/hr only"], correct: "number of tablets" } | |||
], | |||
rationale: "Dose form tells you what the final ‘answer unit’ must be.", | |||
plain: "Pumps speak mL/hr. Syringes speak mL. Pill cups speak tablets. Know your output unit.", | |||
mnemonic: "Pump = Hour. Gravity = Minute. Syringe = mL." | |||
}, | |||
// ========================= | |||
// BOWTIE (2) | |||
// ========================= | |||
{ | |||
id: 15, type: "bowtie", topic: "NGN Bowtie – Overdose Risk", difficulty: 3, | |||
stem: "Bowtie: Identify the risk, contributing factors, and nursing actions.", | |||
center: { | |||
label: "Primary risk", | |||
options: ["Medication overdose", "Medication underdose", "Allergic reaction", "Therapeutic effect only"], | |||
correct: "Medication overdose" | |||
}, | |||
left: { | |||
label: "Contributing factors (pick 2)", pick: 2, | |||
options: ["Decimal misplacement", "Incorrect unit conversion", "Patient education provided", "Medication reconciliation completed"], | |||
correct: ["Decimal misplacement", "Incorrect unit conversion"] | |||
}, | |||
right: { | |||
label: "Priority actions (pick 2)", pick: 2, | |||
options: ["Recalculate with units and estimate reasonableness", "Use independent double-check when indicated", "Administer quickly to avoid delay", "Skip labels to reduce clutter"], | |||
correct: ["Recalculate with units and estimate reasonableness", "Use independent double-check when indicated"] | |||
}, | |||
rationale: "Most critical dosing errors come from decimals and unit conversions; recalculation + double-checks prevent harm.", | |||
plain: "A misplaced decimal can turn a dose of 1.0 into 10.0. That's 10x the dose. Always estimate: 'Does this look right?'", | |||
mnemonic: "Leading Zero = Hero (0.5). Trailing Zero = No (5.0 -> 5)." | |||
}, | |||
{ | |||
id: 16, type: "bowtie", topic: "NGN Bowtie – Heparin Safety", difficulty: 3, | |||
stem: "Bowtie: Identify the risk, contributing factors, and nursing actions.", | |||
center: { | |||
label: "Primary risk", | |||
options: ["Bleeding complication", "Dehydration", "Hypoglycemia", "Pain escalation only"], | |||
correct: "Bleeding complication" | |||
}, | |||
left: { | |||
label: "Contributing factors (pick 2)", pick: 2, | |||
options: ["Dose miscalculation", "No baseline labs reviewed", "Client prefers morning meds", "IV site is patent"], | |||
correct: ["Dose miscalculation", "No baseline labs reviewed"] | |||
}, | |||
right: { | |||
label: "Priority actions (pick 2)", pick: 2, | |||
options: ["Verify weight-based order and concentration", "Review relevant labs per protocol", "Increase rate without verification", "Document later to save time"], | |||
correct: ["Verify weight-based order and concentration", "Review relevant labs per protocol"] | |||
}, | |||
rationale: "Heparin errors can cause bleeding. Verify math + concentration and check protocol labs.", | |||
plain: "Heparin is high stakes. Wrong math = bleeding out. Always check the PTT/labs and weight first.", | |||
mnemonic: "Heparin = Hemorrhage Risk (H & H)" | |||
}, | |||
// ========================= | |||
// TRENDS (2) | |||
// ========================= | |||
{ | |||
id: 17, type: "trends", topic: "NGN Trends – Insulin Correction", difficulty: 2, | |||
stem: "Trends: A client is on a sliding-scale insulin protocol. Identify the best action based on trend + protocol.", | |||
table: { | |||
headers: ["Time", "BG (mg/dL)", "Symptoms"], | |||
rows: [ | |||
["0800", "310", "Thirsty"], | |||
["1200", "260", "No symptoms"], | |||
["1600", "220", "No symptoms"], | |||
["2000", "180", "No symptoms"] | |||
] | |||
}, | |||
parts: [ | |||
{ | |||
type: "mcq", | |||
prompt: "The trend indicates the client is moving toward:", | |||
options: ["Worsening hyperglycemia", "Improving glycemic control", "Hypoglycemia risk", "No meaningful change"], | |||
correct: [1] | |||
}, | |||
{ | |||
type: "sata", | |||
prompt: "Which nursing checks remain priority while insulin is being adjusted? (Select all that apply)", | |||
options: ["Verify meal timing relative to insulin", "Monitor for hypoglycemia symptoms", "Hold all BG checks if improving", "Confirm insulin type/dose drawn up", "Encourage skipping meals"], | |||
correct: [0, 1, 3] | |||
} | |||
], | |||
rationale: "BG is trending down (improving). Still verify timing, dose/type, and monitor for hypoglycemia.", | |||
plain: "Numbers are going down, which is good. But don't get lazy—insulin is dangerous. Confirm the dose and feed the patient.", | |||
mnemonic: "Cold & Clammy = Need some Candy. Hot & Dry = Sugar High." | |||
}, | |||
{ | |||
id: 18, type: "trends", topic: "NGN Trends – IV Fluids + Output", difficulty: 2, | |||
stem: "Trends: A client is ordered IV fluids. Use the trend to identify the best interpretation and action.", | |||
table: { | |||
headers: ["Hour", "IV Rate (mL/hr)", "Urine Output (mL/hr)", "BP"], | |||
rows: [ | |||
["0–1", "125", "15", "92/58"], | |||
["1–2", "125", "20", "94/60"], | |||
["2–3", "125", "28", "98/62"], | |||
["3–4", "125", "35", "104/66"] | |||
] | |||
}, | |||
parts: [ | |||
{ | |||
type: "mcq", | |||
prompt: "The trend most strongly suggests:", | |||
options: ["Worsening hypoperfusion", "Improving perfusion response", "Fluid overload", "Medication error"], | |||
correct: [1] | |||
}, | |||
{ | |||
type: "dropdown", | |||
prompt: "Best next nursing action:", | |||
blanks: [ | |||
{ text: "Action: ", options: ["Stop fluids immediately", "Continue per order and reassess", "Increase rate without order", "Remove IV"], correct: "Continue per order and reassess" } | |||
] | |||
} | |||
], | |||
rationale: "BP and UO are improving at the current ordered rate; continue and reassess per protocol.", | |||
plain: "Kidneys are waking up (output > 30 is the goal) and BP is rising. The fluids are working. Don't stop now.", | |||
mnemonic: "Urine Output Goal > 30 mL/hr" | |||
}, | |||
// ========================= | |||
// CASE STUDIES (4) – unfolding (3 steps each) | |||
// ========================= | |||
{ | |||
id: 19, type: "case", topic: "Unfolding Case – Pediatric Acetaminophen", difficulty: 2, | |||
stem: "A child weighs 18 kg. Provider orders acetaminophen 15 mg/kg/dose PO. Available: 160 mg/5 mL.", | |||
steps: [ | |||
{ | |||
title: "Step 1 — Calculate Dose", | |||
type: "mcq", | |||
prompt: "What dose (mg) should the nurse give?", | |||
options: ["180 mg", "240 mg", "270 mg", "300 mg"], | |||
correct: [2], | |||
rationale: "18 kg × 15 mg/kg = 270 mg.", | |||
plain: "18 kg times 15 mg each = 270 total mg needed.", | |||
mnemonic: "Kg x Dose/Kg = Total Dose" | |||
}, | |||
{ | |||
title: "Step 2 — Convert to mL", | |||
type: "mcq", | |||
prompt: "How many mL will the nurse administer?", | |||
options: ["5 mL", "6 mL", "7.5 mL", "8.5 mL"], | |||
correct: [2], | |||
rationale: "160 mg/5 mL = 32 mg/mL. 270 mg ÷ 32 = 8.4375 mL (not listed). Using ratio: (270/160)*5 = 8.4375.", | |||
plain: "You need 270. Concentration is 32mg/mL. 270 / 32 is roughly 8.4. Closest safe option is 8.5 mL.", | |||
mnemonic: "D/H x V" | |||
}, | |||
{ | |||
title: "Step 3 — Safety Priority", | |||
type: "mcq", | |||
prompt: "Which check is most important before giving this dose?", | |||
options: ["Last dose timing and total daily limit", "Ask the parent to administer at home", "Skip weight verification if charted", "Give an extra dose for faster relief"], | |||
correct: [0], | |||
rationale: "Acetaminophen toxicity risk requires timing + total daily dose limit checks.", | |||
plain: "Tylenol hurts the liver if given too much/often. Check when the last dose was!", | |||
mnemonic: "Liver Lover? Watch the Tylenol total." | |||
} | |||
] | |||
}, | |||
{ | |||
id: 20, type: "case", topic: "Unfolding Case – Heparin Infusion", difficulty: 3, | |||
stem: "Heparin infusion order: 18 units/kg/hr. Client weight: 80 kg. Bag: 25,000 units in 500 mL.", | |||
steps: [ | |||
{ | |||
title: "Step 1 — Units per hour", | |||
type: "mcq", | |||
prompt: "How many units/hr should the client receive?", | |||
options: ["960 units/hr", "1200 units/hr", "1440 units/hr", "1800 units/hr"], | |||
correct: [2], | |||
rationale: "80 × 18 = 1440 units/hr.", | |||
plain: "Weight (80) x Rate (18) = 1440 units needed per hour.", | |||
mnemonic: "Kg x Units/Kg = Total Units" | |||
}, | |||
{ | |||
title: "Step 2 — Convert to mL/hr", | |||
type: "mcq", | |||
prompt: "What rate in mL/hr should the pump be set to?", | |||
options: ["14.4 mL/hr", "28.8 mL/hr", "36 mL/hr", "57.6 mL/hr"], | |||
correct: [1], | |||
rationale: "Concentration: 25,000 ÷ 500 = 50 units/mL. Rate: 1440 ÷ 50 = 28.8 mL/hr.", | |||
plain: "Bag concentration is 50 units in every mL. You need 1440 units. 1440 / 50 = 28.8.", | |||
mnemonic: "Total Units / Concentration = Rate" | |||
}, | |||
{ | |||
title: "Step 3 — Safety Check", | |||
type: "sata", | |||
prompt: "Which actions are priority for safe heparin administration? (Select all that apply)", | |||
options: ["Confirm concentration on bag", "Use independent double-check if policy requires", "Increase rate if patient reports pain", "Monitor for bleeding", "Skip protocol labs if stable"], | |||
correct: [0, 1, 3], | |||
rationale: "Verify concentration, double-check, and monitor bleeding per protocol. Pain doesn’t justify rate changes; labs are not optional.", | |||
plain: "Heparin is a high-alert med. Double check everything. Watch for blood.", | |||
mnemonic: "High Alert = Double Check" | |||
} | |||
] | |||
}, | |||
{ | |||
id: 21, type: "case", topic: "Unfolding Case – IV Antibiotic Volume", difficulty: 2, | |||
stem: "Order: ceftriaxone 1 g IV. Vial: 1 g. After reconstitution, concentration is 100 mg/mL. IV push max volume per policy is 10 mL per syringe.", | |||
steps: [ | |||
{ | |||
title: "Step 1 — Convert Dose", | |||
type: "mcq", | |||
prompt: "1 g equals how many mg?", | |||
options: ["10 mg", "100 mg", "1000 mg", "10,000 mg"], | |||
correct: [2], | |||
rationale: "1 g = 1000 mg.", | |||
plain: "Grams are big. Milligrams are small. 1 big gram = 1000 small mg.", | |||
mnemonic: "G to mg = x1000" | |||
}, | |||
{ | |||
title: "Step 2 — Calculate Volume", | |||
type: "mcq", | |||
prompt: "How many mL contains 1 g at 100 mg/mL?", | |||
options: ["5 mL", "10 mL", "15 mL", "20 mL"], | |||
correct: [1], | |||
rationale: "1000 mg ÷ 100 mg/mL = 10 mL.", | |||
plain: "You have 1000mg total. Each mL fits 100mg. You need 10 mL to hold it all.", | |||
mnemonic: "Total / Concentration = Volume" | |||
}, | |||
{ | |||
title: "Step 3 — Safety Decision", | |||
type: "mcq", | |||
prompt: "Given the policy limit of 10 mL per syringe, the nurse should:", | |||
options: ["Proceed with 10 mL in one syringe", "Split into two syringes automatically", "Hold medication without notifying anyone", "Give IM instead"], | |||
correct: [0], | |||
rationale: "10 mL meets the max per syringe; proceed per policy and administration guidelines.", | |||
plain: "Policy says max is 10mL. You have exactly 10mL. You are safe to proceed.", | |||
mnemonic: "Know your max limits." | |||
} | |||
] | |||
}, | |||
{ | |||
id: 22, type: "case", topic: "Unfolding Case – Gravity Drip Check", difficulty: 2, | |||
stem: "Order: 1000 mL over 10 hours. Tubing: 20 gtt/mL. The nurse is using gravity tubing (no pump).", | |||
steps: [ | |||
{ | |||
title: "Step 1 — mL/min", | |||
type: "mcq", | |||
prompt: "What is the rate in mL/min?", | |||
options: ["1.0", "1.5", "1.67", "2.0"], | |||
correct: [2], | |||
rationale: "10 hr = 600 min. 1000 ÷ 600 = 1.67 mL/min.", | |||
plain: "Convert 10 hours to 600 minutes. 1000 mL / 600 min = 1.67 mL/min.", | |||
mnemonic: "Hours x 60 = Minutes" | |||
}, | |||
{ | |||
title: "Step 2 — gtt/min", | |||
type: "mcq", | |||
prompt: "What is the flow rate in gtt/min?", | |||
options: ["20", "28", "33", "40"], | |||
correct: [2], | |||
rationale: "1.67 × 20 = 33.4 ≈ 33 gtt/min.", | |||
plain: "Take mL/min (1.67) and multiply by the drop factor (20). ~33 drops per minute.", | |||
mnemonic: "mL/min x DF = gtt/min" | |||
}, | |||
{ | |||
title: "Step 3 — Safety Habit", | |||
type: "mcq", | |||
prompt: "Best practice after setting the gtt/min is to:", | |||
options: ["Recheck in 15 minutes and with any position change", "Assume it stays accurate all shift", "Turn the roller clamp fully open", "Silence alarms to prevent interruptions"], | |||
correct: [0], | |||
rationale: "Gravity flow rates drift with position and venous pressure; recheck is a safety standard.", | |||
plain: "Gravity drips aren't robots. If the patient moves their arm, the rate changes. Recheck often.", | |||
mnemonic: "Gravity is unreliable. Trust but verify." | |||
} | |||
] | |||
}, | |||
// ========================= | |||
// More MCQ / SATA / Dropdown to reach 30 total | |||
// ========================= | |||
{ | |||
id: 23, type: "mcq", topic: "mcg ↔ mg Conversion", difficulty: 2, | |||
stem: "Order: levothyroxine 75 mcg PO. Available: 0.1 mg tablets. How many tablets will the nurse administer?", | |||
options: ["0.5 tablet", "0.75 tablet", "1 tablet", "1.5 tablets"], | |||
correct: [1], | |||
rationale: "0.1 mg = 100 mcg. Need 75 mcg → 75/100 = 0.75 tablet.", | |||
plain: "First, convert mg to mcg. 0.1 mg is 100 mcg. You need 75. 75 is three-quarters of 100.", | |||
mnemonic: "0.1 mg = 100 mcg" | |||
}, | |||
{ | |||
id: 24, type: "mcq", topic: "mEq Calculation (Basic)", difficulty: 3, | |||
stem: "Order: potassium chloride 20 mEq IV. Available: 10 mEq/5 mL. How many mL will the nurse administer?", | |||
options: ["5 mL", "10 mL", "15 mL", "20 mL"], | |||
correct: [1], | |||
rationale: "10 mEq in 5 mL → 2 mEq/mL. Need 20 mEq → 10 mL.", | |||
plain: "You need 20 mEq. You have 10 mEq in every 5mL scoop. You need two scoops. 5 + 5 = 10 mL.", | |||
mnemonic: "D/H x Q" | |||
}, | |||
{ | |||
id: 25, type: "sata", topic: "Rounding Rules & Policy", difficulty: 2, | |||
stem: "Which statements about rounding are safest for NCLEX/HESI-style dosing? (Select all that apply)", | |||
options: [ | |||
"Follow facility policy for rounding (especially pediatrics/IV)", | |||
"Round only at the end of the problem", | |||
"Round aggressively early to save time", | |||
"If options don’t match, recheck math and units first", | |||
"Document your rounding method in high-risk situations" | |||
], | |||
correct: [0, 1, 3, 4], | |||
rationale: "Don’t round early; confirm units; follow policy; document when needed. If options don’t match, it’s a red flag to recheck.", | |||
plain: "Rounding early introduces error. Keep the long decimals in your calculator until the very final step.", | |||
mnemonic: "Round at the End, not the Trend" | |||
}, | |||
{ | |||
id: 26, type: "dropdown", topic: "Ratio/Proportion Setup", difficulty: 1, | |||
stem: "Complete the statement.", | |||
blanks: [ | |||
{ text: "If 5 mL contains 250 mg, then 10 mL contains: ", options: ["250 mg", "500 mg", "750 mg", "1000 mg"], correct: "500 mg" } | |||
], | |||
rationale: "Doubling the volume doubles the dose: 250 → 500 mg.", | |||
plain: "If you double the liquid (5 to 10), you double the drug (250 to 500).", | |||
mnemonic: "Direct Proportion: Double one, double the other." | |||
}, | |||
{ | |||
id: 27, type: "mcq", topic: "IVPB Rate (mL/hr)", difficulty: 1, | |||
stem: "An IVPB antibiotic is 100 mL to infuse over 30 minutes. What pump rate is needed?", | |||
options: ["100 mL/hr", "150 mL/hr", "200 mL/hr", "250 mL/hr"], | |||
correct: [2], | |||
rationale: "30 minutes = 0.5 hr. 100 ÷ 0.5 = 200 mL/hr.", | |||
plain: "You need 100 mL in half an hour. That means in a full hour, you'd need double that amount (200).", | |||
mnemonic: "Half hour run? Double the rate." | |||
}, | |||
{ | |||
id: 28, type: "mcq", topic: "Dose by Supply (Liquid)", difficulty: 2, | |||
stem: "Order: diphenhydramine 25 mg PO. Available: 12.5 mg/5 mL. How many mL will the nurse administer?", | |||
options: ["5 mL", "10 mL", "12 mL", "20 mL"], | |||
correct: [1], | |||
rationale: "25 mg is double 12.5 mg, so volume doubles: 5 mL → 10 mL.", | |||
plain: "You need 25. You have 12.5. 12.5 is half of 25. So you need two doses. 5mL x 2 = 10mL.", | |||
mnemonic: "D/H x V" | |||
}, | |||
{ | |||
id: 29, type: "sata", topic: "Clinical Reasonableness Check", difficulty: 2, | |||
stem: "A nurse calculates a dose volume of 25 mL for an IV push medication. Which actions are appropriate? (Select all that apply)", | |||
options: [ | |||
"Recheck the concentration and units", | |||
"Confirm route and whether dilution/IVPB is required", | |||
"Administer anyway if patient is in pain", | |||
"Consult policy/charge nurse if volume seems unsafe for IV push", | |||
"Assess if the ordered dose exceeds safe range" | |||
], | |||
correct: [0, 1, 3, 4], | |||
rationale: "Large IV push volumes are a red flag. Verify units/concentration, route/policy, and safe range before giving.", | |||
plain: "Imagine pushing 25 mL into an IV line by hand. That's a huge syringe. Red flag! Stop and recheck.", | |||
mnemonic: "Big Volume IV Push? Stop & Shush (Check it)" | |||
}, | |||
{ | |||
id: 30, type: "mcq", topic: "Time to Infuse", difficulty: 2, | |||
stem: "An IV is running at 75 mL/hr. How long will it take to infuse 450 mL?", | |||
options: ["4 hours", "5 hours", "6 hours", "7 hours"], | |||
correct: [2], | |||
rationale: "Time = volume ÷ rate = 450 ÷ 75 = 6 hours.", | |||
plain: "You have 450 total. You use 75 every hour. How many 75s fit into 450? (6).", | |||
mnemonic: "Total / Rate = Time" | |||
} | |||
]; | |||
/* ========================= | |||
State + Storage (MOVED UP FOR SAFETY) | |||
========================= */ | |||
const LS_KEY = "unit1_ngn_hesi_session_v5"; | |||
let state = loadState() || { | |||
currentId: 1, | |||
startedAt: Date.now(), | |||
flagged: {}, | |||
answers: {}, | |||
scores: {}, | |||
topicAgg: {} | |||
}; | |||
function saveState(){ localStorage.setItem(LS_KEY, JSON.stringify(state)); } | |||
function loadState(){ try{ return JSON.parse(localStorage.getItem(LS_KEY)); }catch(e){ return null; } } | |||
function resetState(){ | |||
localStorage.removeItem(LS_KEY); | |||
state = { | |||
currentId: 1, | |||
startedAt: Date.now(), | |||
flagged: {}, | |||
answers: {}, | |||
scores: {}, | |||
topicAgg: {} | |||
}; | |||
renderAll(); | |||
} | |||
// DEFINING 'el' HERE SO IT EXISTS BEFORE ANY RENDER FUNCTION USES IT | |||
const el = (id)=>document.getElementById(id); | |||
const clamp=(n,min,max)=>Math.max(min,Math.min(max,n)); | |||
const uniq=(arr)=>Array.from(new Set(arr)); | |||
function escapeHtml(s){ | |||
return String(s) | |||
.replaceAll("&","&") | |||
.replaceAll("<","<") | |||
.replaceAll(">",">") | |||
.replaceAll('"',""") | |||
.replaceAll("'","'"); | |||
} | |||
/* ========================= | |||
Scoring | |||
========================= */ | |||
function itemWeight(q){ | |||
const d = CONFIG.difficultyWeights[q.difficulty] ?? 1; | |||
const m = CONFIG.formatMultipliers[q.type] ?? 1; | |||
return Math.min(CONFIG.maxItemWeight, d * m); | |||
} | |||
function computeStatus(pct){ | |||
if (pct >= 0.999) return "full"; | |||
if (pct >= 0.70) return "partial"; | |||
return "missed"; | |||
} | |||
function mcqScore(selectedIdx, correctIdx){ | |||
const earned = (selectedIdx.length===1 && correctIdx.includes(selectedIdx[0])) ? 1 : 0; | |||
return { earned, possible: 1 }; | |||
} | |||
function sataScore(selectedIdx, correctIdx){ | |||
const correctSet = new Set(correctIdx); | |||
let raw = 0; | |||
for (const idx of selectedIdx){ | |||
raw += correctSet.has(idx) ? 1 : -1; | |||
} | |||
raw = Math.max(0, raw); | |||
const possible = correctIdx.length; | |||
const earned = Math.min(possible, raw); | |||
return { earned, possible }; | |||
} | |||
function dropdownScore(values, blanks){ | |||
let earned = 0; | |||
for (let i=0;i<blanks.length;i++){ | |||
if ((values[i] ?? "") === blanks[i].correct) earned += 1; | |||
} | |||
return { earned, possible: blanks.length }; | |||
} | |||
function matrixScore(map, rows){ | |||
let earned = 0; | |||
for (let i=0;i<rows.length;i++){ | |||
if ((map[i] ?? "") === rows[i].correct) earned += 1; | |||
} | |||
return { earned, possible: rows.length }; | |||
} | |||
function bowtieScore(payload, q){ | |||
let earned = 0; | |||
const possible = 1 + q.left.pick + q.right.pick; | |||
if ((payload.center ?? "") === q.center.correct) earned += 1; | |||
const leftCorrect = new Set(q.left.correct); | |||
let leftEarned = 0; | |||
for (const v of (payload.left ?? [])){ | |||
leftEarned += leftCorrect.has(v) ? 1 : -1; | |||
} | |||
earned += clamp(leftEarned, 0, q.left.pick); | |||
const rightCorrect = new Set(q.right.correct); | |||
let rightEarned = 0; | |||
for (const v of (payload.right ?? [])){ | |||
rightEarned += rightCorrect.has(v) ? 1 : -1; | |||
} | |||
earned += clamp(rightEarned, 0, q.right.pick); | |||
return { earned, possible }; | |||
} | |||
function trendsScore(payload, q){ | |||
let earned = 0, possible = 0; | |||
q.parts.forEach((p, idx) => { | |||
if (p.type === "mcq"){ | |||
const s = mcqScore([payload[idx]], p.correct); | |||
earned += s.earned; possible += s.possible; | |||
} else if (p.type === "sata"){ | |||
const s = sataScore(payload[idx] ?? [], p.correct); | |||
earned += s.earned; possible += s.possible; | |||
} else if (p.type === "dropdown"){ | |||
const s = dropdownScore(payload[idx] ?? [], p.blanks); | |||
earned += s.earned; possible += s.possible; | |||
} | |||
}); | |||
return { earned, possible }; | |||
} | |||
function caseScore(payload, q){ | |||
let earned = 0, possible = 0; | |||
q.steps.forEach((s, idx) => { | |||
if (s.type === "mcq"){ | |||
const r = mcqScore([payload[idx]], s.correct); | |||
earned += r.earned; possible += r.possible; | |||
} else if (s.type === "sata"){ | |||
const r = sataScore(payload[idx] ?? [], s.correct); | |||
earned += r.earned; possible += r.possible; | |||
} else if (s.type === "dropdown"){ | |||
const r = dropdownScore(payload[idx] ?? [], s.blanks); | |||
earned += r.earned; possible += r.possible; | |||
} | |||
}); | |||
return { earned, possible }; | |||
} | |||
function scoreQuestion(q, payload){ | |||
let earned=0, possible=0; | |||
if (q.type==="mcq"){ | |||
({earned, possible} = mcqScore(payload || [], q.correct)); | |||
} else if (q.type==="sata"){ | |||
({earned, possible} = sataScore(payload || [], q.correct)); | |||
} else if (q.type==="dropdown"){ | |||
({earned, possible} = dropdownScore(payload || [], q.blanks)); | |||
} else if (q.type==="matrix"){ | |||
({earned, possible} = matrixScore(payload || {}, q.rows)); | |||
} else if (q.type==="bowtie"){ | |||
({earned, possible} = bowtieScore(payload || {}, q)); | |||
} else if (q.type==="trends"){ | |||
({earned, possible} = trendsScore(payload || [], q)); | |||
} else if (q.type==="case"){ | |||
({earned, possible} = caseScore(payload || [], q)); | |||
} | |||
const pct = possible ? (earned/possible) : 0; | |||
const status = computeStatus(pct); | |||
const w = itemWeight(q); | |||
const earnedW = pct * w; | |||
const possibleW = w; | |||
return { earned, possible, pct, status, earnedW, possibleW, at: Date.now() }; | |||
} | |||
/* ========================= | |||
Topic aggregation + revisit tracker | |||
========================= */ | |||
function bumpTopic(topic, earnedW, possibleW, status){ | |||
if (!state.topicAgg[topic]) state.topicAgg[topic] = { earned:0, possible:0, missedCount:0, partialCount:0 }; | |||
state.topicAgg[topic].earned += earnedW; | |||
state.topicAgg[topic].possible += possibleW; | |||
if (status === "missed") state.topicAgg[topic].missedCount += 1; | |||
if (status === "partial") state.topicAgg[topic].partialCount += 1; | |||
} | |||
function rebuildTopicAgg(){ | |||
state.topicAgg = {}; | |||
for (const q of QUESTIONS){ | |||
const sc = state.scores[q.id]; | |||
if (!sc) continue; | |||
bumpTopic(q.topic, sc.earnedW, sc.possibleW, sc.status); | |||
} | |||
} | |||
function buildRevisitList(){ | |||
rebuildTopicAgg(); | |||
const out = []; | |||
for (const [topic, agg] of Object.entries(state.topicAgg)){ | |||
const mastery = agg.possible ? (agg.earned/agg.possible)*100 : 0; | |||
const count = agg.missedCount + agg.partialCount; | |||
if (count <= 0) continue; | |||
const worst = agg.missedCount > 0 ? "missed" : "partial"; | |||
out.push({ topic, mastery, count, worst }); | |||
} | |||
out.sort((a,b)=> (a.worst===b.worst ? 0 : (a.worst==="missed" ? -1 : 1)) || (b.count-a.count) || (a.mastery-b.mastery)); | |||
return out; | |||
} | |||
/* ========================= | |||
Summary metrics | |||
========================= */ | |||
function overallMastery(){ | |||
let e=0,p=0; | |||
for (const q of QUESTIONS){ | |||
const sc = state.scores[q.id]; | |||
if (!sc) continue; | |||
e += sc.earnedW; p += sc.possibleW; | |||
} | |||
return p ? (e/p)*100 : 0; | |||
} | |||
function weightedScoreOutOf1200(){ | |||
const pct = overallMastery(); | |||
const score = Math.round(300 + (pct/100) * 900); | |||
return clamp(score, 300, 1200); | |||
} | |||
function statusTag(qid){ | |||
const sc = state.scores[qid]; | |||
if (!sc) return { text:"Unanswered", cls:"" }; | |||
if (sc.status === "full") return { text:"Full", cls:"good" }; | |||
if (sc.status === "partial") return { text:"Partial", cls:"warn" }; | |||
return { text:"Missed", cls:"bad" }; | |||
} | |||
/* ========================= | |||
Clickable option engine | |||
========================= */ | |||
function getSelection(q){ return state.answers[q.id]; } | |||
function setSelection(q, payload){ state.answers[q.id] = payload; saveState(); } | |||
function toggleSingle(q, idx){ setSelection(q, [idx]); } | |||
function toggleMulti(q, idx){ | |||
const cur = getSelection(q) || []; | |||
const set = new Set(cur); | |||
if (set.has(idx)) set.delete(idx); else set.add(idx); | |||
setSelection(q, [...set].sort((a,b)=>a-b)); | |||
} | |||
/* ========================= | |||
Render helpers | |||
========================= */ | |||
function renderOptionCards(q, isMulti){ | |||
const wrap = document.createElement("div"); | |||
wrap.className = "opts"; | |||
const selected = getSelection(q) || []; | |||
q.options.forEach((opt, idx)=>{ | |||
const card = document.createElement("div"); | |||
const active = isMulti ? selected.includes(idx) : (selected[0]===idx); | |||
card.className = "optCard" + (active ? " selected" : ""); | |||
card.tabIndex = 0; | |||
card.setAttribute("role","button"); | |||
card.setAttribute("aria-pressed", active ? "true" : "false"); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`; | |||
card.onclick = ()=>{ | |||
if (isMulti) toggleMulti(q, idx); | |||
else toggleSingle(q, idx); | |||
renderAll(true); | |||
}; | |||
card.onkeydown = (e)=>{ | |||
if (e.key==="Enter" || e.key===" "){ | |||
e.preventDefault(); | |||
card.click(); | |||
} | |||
}; | |||
wrap.appendChild(card); | |||
}); | |||
return wrap; | |||
} | |||
function renderQuestionBody(q){ | |||
const wrap = document.createElement("div"); | |||
const existing = getSelection(q); | |||
if (q.type === "mcq"){ | |||
wrap.appendChild(renderOptionCards(q, false)); | |||
} | |||
if (q.type === "sata"){ | |||
wrap.appendChild(renderOptionCards(q, true)); | |||
} | |||
if (q.type === "dropdown"){ | |||
const box = document.createElement("div"); | |||
box.className = "caseBox"; | |||
const vals = existing || []; | |||
q.blanks.forEach((b, i)=>{ | |||
const sel = document.createElement("select"); | |||
sel.dataset.blankIndex = i; | |||
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(""); | |||
sel.value = vals[i] || ""; | |||
sel.onchange = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
cur[i] = sel.value || ""; | |||
setSelection(q, cur); | |||
}; | |||
const line = document.createElement("div"); | |||
line.style.marginTop = "8px"; | |||
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`; | |||
line.appendChild(sel); | |||
box.appendChild(line); | |||
}); | |||
wrap.appendChild(box); | |||
} | |||
if (q.type === "matrix"){ | |||
const box = document.createElement("div"); | |||
box.className = "caseBox"; | |||
const table = document.createElement("table"); | |||
table.className = "table"; | |||
table.innerHTML = `<thead><tr><th>Row</th><th>Pick one</th></tr></thead>`; | |||
const tb = document.createElement("tbody"); | |||
const map = existing || {}; | |||
q.rows.forEach((r, i)=>{ | |||
const tr = document.createElement("tr"); | |||
const td1 = document.createElement("td"); | |||
td1.textContent = r.row; | |||
const td2 = document.createElement("td"); | |||
const sel = document.createElement("select"); | |||
sel.innerHTML = `<option value="">Select…</option>` + r.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(""); | |||
sel.value = map[i] || ""; | |||
sel.onchange = ()=>{ | |||
const cur = Object.assign({}, (getSelection(q) || {})); | |||
cur[i] = sel.value || ""; | |||
setSelection(q, cur); | |||
}; | |||
td2.appendChild(sel); | |||
tr.appendChild(td1); tr.appendChild(td2); | |||
tb.appendChild(tr); | |||
}); | |||
table.appendChild(tb); | |||
box.appendChild(table); | |||
wrap.appendChild(box); | |||
} | |||
if (q.type === "bowtie"){ | |||
const box = document.createElement("div"); | |||
box.className = "caseBox"; | |||
const payload = existing || { center:"", left:[], right:[] }; | |||
const center = document.createElement("div"); | |||
center.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:6px">${escapeHtml(q.center.label)}</div>`; | |||
const cSel = document.createElement("select"); | |||
cSel.innerHTML = `<option value="">Select…</option>` + q.center.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(""); | |||
cSel.value = payload.center || ""; | |||
cSel.onchange = ()=>{ | |||
setSelection(q, { ...payload, center: cSel.value || "" }); | |||
}; | |||
center.appendChild(cSel); | |||
const mkPickList = (side, cfg)=>{ | |||
const sec = document.createElement("div"); | |||
sec.className = "caseStep"; | |||
sec.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:6px">${escapeHtml(cfg.label)} (pick ${cfg.pick})</div>`; | |||
cfg.options.forEach((o)=>{ | |||
const card = document.createElement("div"); | |||
const selected = (payload[side] || []).includes(o); | |||
card.className = "optCard" + (selected ? " selected" : ""); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(o)}</div>`; | |||
card.onclick = ()=>{ | |||
const cur = new Set(payload[side] || []); | |||
if (cur.has(o)) cur.delete(o); | |||
else cur.add(o); | |||
const arr = [...cur]; | |||
if (arr.length > cfg.pick) return; | |||
const next = { ...payload, [side]: arr }; | |||
setSelection(q, next); | |||
renderAll(true); | |||
}; | |||
sec.appendChild(card); | |||
}); | |||
return sec; | |||
}; | |||
box.appendChild(center); | |||
box.appendChild(mkPickList("left", q.left)); | |||
box.appendChild(mkPickList("right", q.right)); | |||
wrap.appendChild(box); | |||
} | |||
if (q.type === "trends"){ | |||
const box = document.createElement("div"); | |||
box.className = "caseBox"; | |||
const t = document.createElement("table"); | |||
t.className = "table"; | |||
const head = `<tr>${q.table.headers.map(h=>`<th>${escapeHtml(h)}</th>`).join("")}</tr>`; | |||
const rows = q.table.rows.map(r=>`<tr>${r.map(c=>`<td>${escapeHtml(String(c))}</td>`).join("")}</tr>`).join(""); | |||
t.innerHTML = `<thead>${head}</thead><tbody>${rows}</tbody>`; | |||
box.appendChild(t); | |||
const payload = existing || []; | |||
q.parts.forEach((p, idx)=>{ | |||
const sec = document.createElement("div"); | |||
sec.className = "caseStep"; | |||
sec.innerHTML = `<div style="font-weight:600;margin-bottom:8px">${escapeHtml(p.prompt)}</div>`; | |||
if (p.type==="mcq"){ | |||
const chosen = payload[idx] ?? null; | |||
p.options.forEach((opt, oi)=>{ | |||
const card = document.createElement("div"); | |||
const selected = chosen === oi; | |||
card.className = "optCard" + (selected ? " selected" : ""); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`; | |||
card.onclick = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
cur[idx] = oi; | |||
setSelection(q, cur); | |||
renderAll(true); | |||
}; | |||
sec.appendChild(card); | |||
}); | |||
} else if (p.type==="sata"){ | |||
const chosen = payload[idx] || []; | |||
p.options.forEach((opt, oi)=>{ | |||
const selected = chosen.includes(oi); | |||
const card = document.createElement("div"); | |||
card.className = "optCard" + (selected ? " selected" : ""); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`; | |||
card.onclick = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
const set = new Set(cur[idx] || []); | |||
if (set.has(oi)) set.delete(oi); else set.add(oi); | |||
cur[idx] = [...set].sort((a,b)=>a-b); | |||
setSelection(q, cur); | |||
renderAll(true); | |||
}; | |||
sec.appendChild(card); | |||
}); | |||
} else if (p.type==="dropdown"){ | |||
const vals = payload[idx] || []; | |||
p.blanks.forEach((b, bi)=>{ | |||
const sel = document.createElement("select"); | |||
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(""); | |||
sel.value = vals[bi] || ""; | |||
sel.onchange = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
const part = (cur[idx] || []).slice(); | |||
part[bi] = sel.value || ""; | |||
cur[idx] = part; | |||
setSelection(q, cur); | |||
}; | |||
const line = document.createElement("div"); | |||
line.style.marginTop = "8px"; | |||
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`; | |||
line.appendChild(sel); | |||
sec.appendChild(line); | |||
}); | |||
} | |||
box.appendChild(sec); | |||
}); | |||
wrap.appendChild(box); | |||
} | |||
if (q.type === "case"){ | |||
const box = document.createElement("div"); | |||
box.className = "caseBox"; | |||
box.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:8px">Unfolding case (3 steps)</div>`; | |||
const payload = existing || []; | |||
q.steps.forEach((s, idx)=>{ | |||
const sec = document.createElement("div"); | |||
sec.className = "caseStep"; | |||
sec.innerHTML = `<div style="font-weight:700;margin-bottom:6px">${escapeHtml(s.title)}</div> | |||
<div style="margin-bottom:10px">${escapeHtml(s.prompt)}</div>`; | |||
if (s.type==="mcq"){ | |||
const chosen = payload[idx] ?? null; | |||
s.options.forEach((opt, oi)=>{ | |||
const card = document.createElement("div"); | |||
const selected = chosen === oi; | |||
card.className = "optCard" + (selected ? " selected" : ""); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`; | |||
card.onclick = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
cur[idx] = oi; | |||
setSelection(q, cur); | |||
renderAll(true); | |||
}; | |||
sec.appendChild(card); | |||
}); | |||
} else if (s.type==="sata"){ | |||
const chosen = payload[idx] || []; | |||
s.options.forEach((opt, oi)=>{ | |||
const selected = chosen.includes(oi); | |||
const card = document.createElement("div"); | |||
card.className = "optCard" + (selected ? " selected" : ""); | |||
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`; | |||
card.onclick = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
const set = new Set(cur[idx] || []); | |||
if (set.has(oi)) set.delete(oi); else set.add(oi); | |||
cur[idx] = [...set].sort((a,b)=>a-b); | |||
setSelection(q, cur); | |||
renderAll(true); | |||
}; | |||
sec.appendChild(card); | |||
}); | |||
} else if (s.type==="dropdown"){ | |||
const vals = payload[idx] || []; | |||
s.blanks.forEach((b, bi)=>{ | |||
const sel = document.createElement("select"); | |||
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join(""); | |||
sel.value = vals[bi] || ""; | |||
sel.onchange = ()=>{ | |||
const cur = (getSelection(q) || []).slice(); | |||
const step = (cur[idx] || []).slice(); | |||
step[bi] = sel.value || ""; | |||
cur[idx] = step; | |||
setSelection(q, cur); | |||
}; | |||
const line = document.createElement("div"); | |||
line.style.marginTop = "8px"; | |||
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`; | |||
line.appendChild(sel); | |||
sec.appendChild(line); | |||
}); | |||
} | |||
box.appendChild(sec); | |||
}); | |||
wrap.appendChild(box); | |||
} | |||
return wrap; | |||
} | |||
/* ========================= | |||
Read payloads for scoring | |||
========================= */ | |||
function readAnswerPayload(q){ | |||
const payload = getSelection(q); | |||
if (q.type==="mcq") return payload || []; | |||
if (q.type==="sata") return payload || []; | |||
if (q.type==="dropdown") return payload || []; | |||
if (q.type==="matrix") return payload || {}; | |||
if (q.type==="bowtie") return payload || { center:"", left:[], right:[] }; | |||
if (q.type==="trends") return payload || []; | |||
if (q.type==="case") return payload || []; | |||
return payload; | |||
} | |||
/* ========================= | |||
Reveal content (UPDATED TO USE DISTINCT FIELDS) | |||
========================= */ | |||
function renderReveal(q){ | |||
const sc = state.scores[q.id]; | |||
const scoreLine = sc | |||
? `<div class="sub">Score: ${Math.round(sc.pct*100)}% • Status: <strong>${escapeHtml(sc.status)}</strong></div>` | |||
: `<div class="sub">Submit first to compute score.</div>`; | |||
// UPDATED: Now uses direct fields for distinct logic | |||
const nursingLogic = q.rationale || "Rationale not provided."; | |||
const plainLogic = q.plain || "Plain speak explanation not provided."; | |||
const mnemonic = q.mnemonic || "No specific mnemonic for this item."; | |||
const keyBlock = (()=>{ | |||
if (!sc) return ""; | |||
if (q.type==="mcq" || q.type==="sata"){ | |||
const correctText = q.correct.map(i=>q.options[i]).map(escapeHtml); | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong> ${correctText.join(q.type==="sata" ? " • " : "")}</div>`; | |||
} | |||
if (q.type==="dropdown"){ | |||
const key = q.blanks.map(b=>`${escapeHtml(b.text)} <strong>${escapeHtml(b.correct)}</strong>`).join("<br/>"); | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${key}</div>`; | |||
} | |||
if (q.type==="matrix"){ | |||
const key = q.rows.map(r=>`${escapeHtml(r.row)} → <strong>${escapeHtml(r.correct)}</strong>`).join("<br/>"); | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${key}</div>`; | |||
} | } | ||
if (q.type==="bowtie"){ | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong></div> | |||
<div><small class="sub">${escapeHtml(q.center.label)}:</small> <strong>${escapeHtml(q.center.correct)}</strong></div> | |||
<div style="margin-top:6px"><small class="sub">${escapeHtml(q.left.label)}:</small> <strong>${escapeHtml(q.left.correct.join(" • "))}</strong></div> | |||
<div style="margin-top:6px"><small class="sub">${escapeHtml(q.right.label)}:</small> <strong>${escapeHtml(q.right.correct.join(" • "))}</strong></div>`; | |||
} | |||
if (q.type==="trends"){ | |||
const keyParts = q.parts.map((p, idx)=>{ | |||
if (p.type==="mcq") return `Part ${idx+1}: <strong>${escapeHtml(p.options[p.correct[0]])}</strong>`; | |||
if (p.type==="sata") return `Part ${idx+1}: <strong>${escapeHtml(p.correct.map(i=>p.options[i]).join(" • "))}</strong>`; | |||
if (p.type==="dropdown") return `Part ${idx+1}: <strong>${escapeHtml(p.blanks.map(b=>b.correct).join(" • "))}</strong>`; | |||
return `Part ${idx+1}: <strong>—</strong>`; | |||
}).join("<br/>"); | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${keyParts}</div>`; | |||
} | |||
if (q.type==="case"){ | |||
const keySteps = q.steps.map((s, idx)=>{ | |||
if (s.type==="mcq") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.options[s.correct[0]])}</strong>`; | |||
if (s.type==="sata") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.correct.map(i=>s.options[i]).join(" • "))}</strong>`; | |||
if (s.type==="dropdown") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.blanks.map(b=>b.correct).join(" • "))}</strong>`; | |||
return `${escapeHtml(s.title)} → <strong>—</strong>`; | |||
}).join("<br/>"); | |||
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${keySteps}</div>`; | |||
} | |||
return ""; | |||
})(); | |||
return ` | |||
${scoreLine} | |||
${keyBlock} | |||
<div class="logicGrid"> | |||
<div class="logicBox"> | |||
<div class="ttl">Logic (nursing terminology)</div> | |||
<div class="txt">${escapeHtml(nursingLogic)}</div> | |||
</div> | |||
<div class="logicBox"> | |||
<div class="ttl">Logic (plain talk)</div> | |||
<div class="txt">${escapeHtml(plainLogic)}</div> | |||
</div> | |||
<div class="logicBox"> | |||
<div class="ttl">Mnemonic / memory hook</div> | |||
<div class="txt">${escapeHtml(mnemonic)}</div> | |||
</div> | |||
</div> | |||
`; | |||
} | |||
/* ========================= | |||
Render sidebar + progress + report + viewer | |||
========================= */ | |||
function renderSidebar(){ | |||
const topics = uniq(QUESTIONS.map(q=>q.topic)).sort((a,b)=>a.localeCompare(b)); | |||
const tf = el("topicFilter"); | |||
tf.innerHTML = `<option value="all">All topics</option>` + topics.map(t=>`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join(""); | |||
const list = el("qList"); | |||
const mode = el("filterMode").value; | |||
const topicSel = el("topicFilter").value; | |||
const search = (el("searchBox").value || "").trim().toLowerCase(); | |||
list.innerHTML = ""; | |||
for (const q of QUESTIONS){ | |||
const sc = state.scores[q.id]; | |||
const flagged = !!state.flagged[q.id]; | |||
const tag = statusTag(q.id); | |||
if (topicSel !== "all" && q.topic !== topicSel) continue; | |||
if (search && !(q.stem.toLowerCase().includes(search) || q.topic.toLowerCase().includes(search))) continue; | |||
if (mode === "flagged" && !flagged) continue; | |||
if (mode === "unanswered" && sc) continue; | |||
if (mode === "missed" && (!sc || sc.status !== "missed")) continue; | |||
if (mode === "partial" && (!sc || sc.status !== "partial")) continue; | |||
const div = document.createElement("div"); | |||
div.className = "qitem"; | |||
div.onclick = ()=>{ state.currentId = q.id; saveState(); renderAll(true); }; | |||
const flagChip = flagged ? `<span class="tag warn">Flag</span>` : ""; | |||
div.innerHTML = ` | |||
<div class="top"> | |||
<div><strong>Q${q.id}</strong> <span class="tag">${escapeHtml(q.type.toUpperCase())}</span></div> | |||
<div style="display:flex;gap:8px;align-items:center;justify-content:flex-end"> | |||
${flagChip} | |||
<span class="tag ${tag.cls}">${tag.text}</span> | |||
</div> | |||
</div> | |||
<div class="t">${escapeHtml(q.topic)} • Diff ${q.difficulty}</div> | |||
`; | |||
list.appendChild(div); | |||
} | |||
} | |||
function renderProgress(){ | |||
const answered = Object.keys(state.scores).length; | |||
const total = QUESTIONS.length; | |||
const pct = Math.round((answered/total)*100); | |||
el("barFill").style.width = `${pct}%`; | |||
el("progText").textContent = `Progress: ${answered}/${total}`; | |||
el("pctText").textContent = `${pct}%`; | |||
const score1200 = weightedScoreOutOf1200(); | |||
el("scoreV").textContent = score1200; | |||
// Benchmark: passing HESI score is 875 | |||
el("bandV").textContent = (score1200 >= CONFIG.passingHesiScore) ? `PASS (≥${CONFIG.passingHesiScore})` : `NOT YET (<${CONFIG.passingHesiScore})`; | |||
const revisit = buildRevisitList(); | |||
el("reviewCount").textContent = revisit.length; | |||
const ul = el("reviewTopics"); | |||
ul.innerHTML = ""; | |||
revisit.slice(0,8).forEach(r=>{ | |||
const li = document.createElement("li"); | |||
li.innerHTML = `<strong>${escapeHtml(r.topic)}</strong> | |||
<span class="chip ${r.worst==='missed'?'bad':'warn'}">${r.worst}</span> | |||
<div><small>${r.count} item(s) impacted • mastery ${Math.round(r.mastery)}%</small></div>`; | |||
ul.appendChild(li); | |||
}); | |||
if (revisit.length > 8){ | |||
const li = document.createElement("li"); | |||
li.innerHTML = `<small>+ ${revisit.length-8} more… (see End Report)</small>`; | |||
ul.appendChild(li); | |||
} | |||
const overallPct = overallMastery(); | |||
el("repWeighted").textContent = `Weighted: ${score1200}`; | |||
el("repPct").textContent = `Percent: ${Math.round(overallPct)}%`; | |||
el("repBand").textContent = `Benchmark: ${score1200 >= CONFIG.passingHesiScore ? "PASS" : "NOT YET"} (875)`; | |||
} | |||
function renderReport(){ | |||
rebuildTopicAgg(); | |||
const topics = uniq(QUESTIONS.map(q=>q.topic)).sort((a,b)=>a.localeCompare(b)); | |||
const tbody = el("topicTable").querySelector("tbody"); | |||
tbody.innerHTML = ""; | |||
topics.forEach(t=>{ | |||
const agg = state.topicAgg[t]; | |||
const mastery = agg && agg.possible ? (agg.earned/agg.possible)*100 : 0; | |||
const status = mastery >= 85 ? "Strong" : (mastery >= 70 ? "Developing" : (agg ? "Needs Work" : "Not started")); | |||
const tr = document.createElement("tr"); | |||
tr.innerHTML = ` | |||
<td>${escapeHtml(t)}</td> | |||
<td>${Math.round(mastery)}%</td> | |||
<td>${escapeHtml(status)}</td> | |||
`; | |||
tbody.appendChild(tr); | |||
}); | |||
const rev = buildRevisitList(); | |||
const ul = el("revisitList"); | |||
ul.innerHTML = ""; | |||
if (rev.length === 0){ | |||
const li = document.createElement("li"); | |||
li.innerHTML = `<strong>No revisit topics yet.</strong><div><small>Answer items and this list will populate automatically.</small></div>`; | |||
ul.appendChild(li); | |||
} else { | |||
rev.forEach(r=>{ | |||
const li = document.createElement("li"); | |||
li.innerHTML = `<strong>${escapeHtml(r.topic)}</strong> | |||
<span class="chip ${r.worst==='missed'?'bad':'warn'}">${r.worst}</span> | |||
<div><small>${r.count} item(s) impacted • mastery ${Math.round(r.mastery)}%</small></div>`; | |||
ul.appendChild(li); | |||
}); | |||
} | |||
} | |||
function renderViewer(){ | |||
const q = QUESTIONS.find(x=>x.id===state.currentId) || QUESTIONS[0]; | |||
if (!q) return; | |||
const flagged = !!state.flagged[q.id]; | |||
const tag = statusTag(q.id); | |||
const w = itemWeight(q); | |||
const viewer = el("viewer"); | |||
viewer.innerHTML = ` | |||
<div class="header"> | |||
<div> | |||
<h2>Q${q.id} of ${QUESTIONS.length}</h2> | |||
<div class="prompt">${escapeHtml(q.stem)}</div> | |||
<div class="hint">Pick your answer, submit, and the logic will auto-reveal.</div> | |||
</div> | |||
<div class="meta"> | |||
<span class="badge">${escapeHtml(q.type.toUpperCase())}</span> | |||
<span class="badge">Diff ${q.difficulty}</span> | |||
<span class="badge">Weight ${w}</span> | |||
<span class="badge">${escapeHtml(q.topic)}</span> | |||
<span class="badge">${escapeHtml(tag.text)}</span> | |||
</div> | |||
</div> | |||
<div id="qBody"></div> | |||
<div class="actions"> | |||
<button class="btn" id="flagBtn">${flagged ? "Unflag" : "Flag"}</button> | |||
<button class="btn primary" id="submitBtn">Submit</button> | |||
<button class="btn" id="clearBtn">Clear response</button> | |||
</div> | |||
<div class="revealBox" id="revealBox" style="display:none"> | |||
<div class="rtitle">Answer + Logic (3 ways)</div> | |||
<div id="revealContent"></div> | |||
</div> | |||
`; | |||
const body = viewer.querySelector("#qBody"); | |||
body.appendChild(renderQuestionBody(q)); | |||
viewer.querySelector("#flagBtn").onclick = ()=>{ | |||
state.flagged[q.id] = !state.flagged[q.id]; | |||
saveState(); renderAll(true); | |||
}; | |||
viewer.querySelector("#clearBtn").onclick = ()=>{ | |||
delete state.answers[q.id]; | |||
delete state.scores[q.id]; | |||
rebuildTopicAgg(); | |||
saveState(); renderAll(true); | |||
}; | |||
viewer.querySelector("#submitBtn").onclick = ()=>{ | |||
const payload = readAnswerPayload(q); | |||
state.answers[q.id] = payload; | |||
const scored = scoreQuestion(q, payload); | |||
state.scores[q.id] = scored; | |||
rebuildTopicAgg(); | |||
saveState(); | |||
renderAll(true); | |||
const rb = el("viewer").querySelector("#revealBox"); | |||
rb.style.display = "block"; | |||
el("viewer").querySelector("#revealContent").innerHTML = renderReveal(q); | |||
}; | |||
if (state.scores[q.id]){ | |||
const rb = viewer.querySelector("#revealBox"); | |||
rb.style.display = "block"; | |||
viewer.querySelector("#revealContent").innerHTML = renderReveal(q); | |||
} | |||
} | |||
function next(){ | |||
const idx = QUESTIONS.findIndex(q=>q.id===state.currentId); | |||
if (idx < QUESTIONS.length-1){ | |||
state.currentId = QUESTIONS[idx+1].id; | |||
saveState(); renderAll(true); | |||
} | |||
} | |||
function prev(){ | |||
const idx = QUESTIONS.findIndex(q=>q.id===state.currentId); | |||
if (idx > 0){ | |||
state.currentId = QUESTIONS[idx-1].id; | |||
saveState(); renderAll(true); | |||
} | |||
} | |||
function renderAll(keepScroll=false){ | |||
renderSidebar(); | |||
renderProgress(); | |||
renderViewer(); | |||
renderReport(); | |||
if (!keepScroll){ | |||
window.scrollTo({top:0, behavior:"auto"}); | |||
} | |||
} | |||
/* ========================= | |||
Events | |||
========================= */ | |||
el("filterMode").addEventListener("change", ()=>renderSidebar()); | |||
el("topicFilter").addEventListener("change", ()=>renderSidebar()); | |||
el("searchBox").addEventListener("input", ()=>renderSidebar()); | |||
el("nextBtn").addEventListener("click", next); | |||
el("prevBtn").addEventListener("click", prev); | |||
el("resetBtn").addEventListener("click", ()=>{ | |||
if (confirm("Reset this Unit 1 session? This clears answers and progress.")) resetState(); | |||
}); | }); | ||
el("jumpReportBtn").addEventListener("click", ()=>{ | |||
el("reportCard").scrollIntoView({behavior:"smooth", block:"start"}); | |||
}); | |||
el("downloadBtn").addEventListener("click", ()=>{ | |||
const payload = { unit: CONFIG.unitName, exportedAt: new Date().toISOString(), state }; | |||
const blob = new Blob([JSON.stringify(payload,null,2)], {type:"application/json"}); | |||
const url = URL.createObjectURL(blob); | |||
const a = document.createElement("a"); | |||
a.href = url; | |||
a.download = "unit1_session.json"; | |||
a.click(); | |||
URL.revokeObjectURL(url); | |||
}); | |||
/* ========================= | |||
Init | |||
========================= */ | |||
(function init(){ | |||
rebuildTopicAgg(); | |||
renderAll(); | |||
})(); | |||
</script> | </script> | ||
</body> | |||
</html> | |||
</html> | |||
</ | |||
</ | |||
[[Category:Herzing University/Games]] | [[Category:Herzing University/Games]] | ||
[[Category:HU NSG 520 Pathophysiology and Pharmacology]] | [[Category:HU NSG 520 Pathophysiology and Pharmacology]] | ||
Revision as of 22:14, 17 January 2026
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Unit 1 Dosage Calc</title>
<style>
:root{
--bg:#0b1220; --panel:#0f1a2e; --card:#111f3a; --muted:#9fb0d0; --text:#e9f0ff;
--accent:#7aa2ff; --good:#3ddc97; --warn:#ffd166; --bad:#ff5c7a; --line:#223458;
}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;color:var(--text);
background:radial-gradient(1000px 600px at 15% 0%, #14224a 0%, rgba(20,34,74,0) 60%),
radial-gradient(900px 500px at 100% 20%, #1a2a59 0%, rgba(26,42,89,0) 55%),
linear-gradient(180deg,#070b14, #0b1220 35%, #070b14)}
.app{display:grid;grid-template-columns:350px 1fr;min-height:100vh}
aside{padding:18px;border-right:1px solid var(--line);background:rgba(15,26,46,.65);backdrop-filter: blur(8px)}
main{padding:18px 18px 32px}
h1{margin:4px 0 8px;font-size:18px}
h2{margin:0;font-size:16px}
.sub{color:var(--muted);font-size:12px;line-height:1.35}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.btn{border:1px solid var(--line);background:rgba(17,31,58,.9);color:var(--text);padding:10px 12px;border-radius:12px;cursor:pointer}
.btn:hover{border-color:#365089}
.btn.primary{background:rgba(122,162,255,.18);border-color:rgba(122,162,255,.45)}
.btn.danger{background:rgba(255,92,122,.12);border-color:rgba(255,92,122,.35)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}
.stat{border:1px solid var(--line);background:rgba(17,31,58,.7);border-radius:14px;padding:10px}
.stat .k{font-size:11px;color:var(--muted)}
.stat .v{font-size:18px;margin-top:4px}
.progressWrap{margin-top:14px;border:1px solid var(--line);border-radius:14px;padding:12px;background:rgba(17,31,58,.65)}
.bar{height:10px;border-radius:999px;background:#1b2a49;overflow:hidden;border:1px solid #223458}
.bar > div{height:100%;width:0;background:linear-gradient(90deg,var(--accent),#9bb8ff);transition:.2s}
.small{font-size:11px;color:var(--muted);margin-top:6px;display:flex;justify-content:space-between;gap:10px}
.filters{margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
select,input[type="search"]{width:100%;padding:10px 10px;border-radius:12px;border:1px solid var(--line);background:rgba(17,31,58,.7);color:var(--text);outline:none}
.list{margin-top:12px;max-height:34vh;overflow:auto;padding-right:6px}
.qitem{border:1px solid var(--line);border-radius:12px;padding:10px;margin-bottom:10px;background:rgba(17,31,58,.6);cursor:pointer}
.qitem:hover{border-color:#365089}
.qitem .top{display:flex;justify-content:space-between;gap:10px}
.qitem .t{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.25}
.tag{font-size:11px;color:var(--muted);border:1px solid var(--line);padding:4px 8px;border-radius:999px;white-space:nowrap}
.tag.good{border-color:rgba(61,220,151,.5);color:rgba(61,220,151,.95)}
.tag.bad{border-color:rgba(255,92,122,.5);color:rgba(255,92,122,.95)}
.tag.warn{border-color:rgba(255,209,102,.5);color:rgba(255,209,102,.95)}
.card{border:1px solid var(--line);border-radius:16px;padding:16px;background:rgba(17,31,58,.65)}
.header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}
.meta{display:flex;gap:8px;flex-wrap:wrap}
.badge{font-size:12px;padding:6px 10px;border-radius:999px;border:1px solid var(--line);background:rgba(15,26,46,.55)}
.prompt{margin:10px 0 0;color:var(--text);line-height:1.45}
.hint{margin:10px 0 0;color:var(--muted);font-size:12px}
.opts{margin-top:12px;display:grid;gap:10px}
.optCard{
border:1px solid var(--line);
border-radius:12px;
padding:12px;
background:rgba(15,26,46,.5);
cursor:pointer;
display:flex; gap:10px; align-items:flex-start;
user-select:none;
}
.optCard:hover{border-color:#365089}
.optMark{
width:18px;height:18px;border-radius:6px;
border:1px solid #365089;background:rgba(122,162,255,.06);
flex:0 0 auto;margin-top:2px;
}
.optCard.selected .optMark{
background:rgba(122,162,255,.35);
border-color:rgba(122,162,255,.8);
box-shadow:0 0 0 2px rgba(122,162,255,.12) inset;
}
.optText{flex:1}
.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.revealBox{margin-top:12px;border:1px dashed #365089;border-radius:14px;padding:12px;background:rgba(122,162,255,.06)}
.revealBox .rtitle{font-size:12px;color:var(--muted);margin-bottom:8px}
.split{display:grid;grid-template-columns:1.2fr .8fr;gap:12px}
.miniTable{width:100%;border-collapse:collapse;margin-top:10px}
.miniTable th,.miniTable td{padding:10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
.kpiRow{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-top:10px}
.miniList{margin:10px 0 0;padding:0;list-style:none}
.miniList li{padding:8px 10px;border:1px solid var(--line);border-radius:12px;background:rgba(15,26,46,.45);margin-bottom:8px}
.miniList small{color:var(--muted)}
.chip{display:inline-block;margin-left:8px;font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}
.chip.bad{border-color:rgba(255,92,122,.45);color:rgba(255,92,122,.95)}
.chip.warn{border-color:rgba(255,209,102,.45);color:rgba(255,209,102,.95)}
.caseBox{border:1px solid var(--line);border-radius:14px;padding:12px;background:rgba(15,26,46,.4);margin-top:12px}
.caseStep{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}
.caseStep:first-child{border-top:none;margin-top:0;padding-top:0}
textarea{width:100%;min-height:72px;resize:vertical;padding:10px;border-radius:12px;border:1px solid var(--line);background:rgba(15,26,46,.55);color:var(--text);outline:none}
.table{width:100%;border-collapse:collapse;margin-top:10px}
.table th,.table td{padding:10px;border-bottom:1px solid var(--line);text-align:left}
.err{margin-top:12px;border:1px solid rgba(255,92,122,.35);background:rgba(255,92,122,.08);padding:10px;border-radius:12px;color:#ffd0d9;font-size:12px;display:none}
.logicGrid{display:grid;gap:10px;margin-top:10px}
.logicBox{border:1px solid var(--line);background:rgba(15,26,46,.45);border-radius:12px;padding:10px}
.logicBox .ttl{font-size:12px;color:var(--muted);margin-bottom:6px}
.logicBox .txt{line-height:1.35}
@media (max-width: 980px){.app{grid-template-columns:1fr} aside{border-right:none;border-bottom:1px solid var(--line)} .split{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside>
<h1>Dosage Calculation Study Bot</h1>
<div class="sub">
Unit 1 Dosage Calculation ONLY
<br/>No Patho/Pharm content.
</div>
<div class="progressWrap">
<div class="bar"><div id="barFill"></div></div>
<div class="small">
<span id="progText">Progress: 0/30</span>
<span id="pctText">0%</span>
</div>
<div class="kpiRow">
<div class="small" style="margin:0"><span>Topics to revisit (partial/missed)</span><span id="reviewCount">0</span></div>
</div>
<ul class="miniList" id="reviewTopics"></ul>
</div>
<div class="statgrid">
<div class="stat"><div class="k">Weighted Score</div><div class="v" id="scoreV">0</div></div>
<div class="stat"><div class="k">Readiness</div><div class="v" id="bandV">—</div></div>
</div>
<div class="filters">
<select id="filterMode">
<option value="all">All</option>
<option value="missed">Missed (below 70%)</option>
<option value="partial">Partial (70–99%)</option>
<option value="flagged">Flagged</option>
<option value="unanswered">Unanswered</option>
</select>
<select id="topicFilter"></select>
</div>
<div style="margin-top:10px">
<input id="searchBox" type="search" placeholder="Search prompt / topic…" />
</div>
<div class="row" style="margin-top:12px">
<button class="btn" id="prevBtn">Prev</button>
<button class="btn primary" id="nextBtn">Next</button>
<button class="btn" id="jumpReportBtn">Report</button>
<button class="btn danger" id="resetBtn">Reset</button>
</div>
<div class="list" id="qList"></div>
</aside>
<main>
<div class="card" id="viewer"></div>
<div class="err" id="errBox"></div>
<div class="card" id="reportCard" style="margin-top:16px">
<div class="header">
<div>
<h2>End Report</h2>
<div class="sub">Weighted performance + a clean list of topics to revisit (partial or missed).</div>
</div>
<div class="meta">
<span class="badge" id="repWeighted">Weighted: 0</span>
<span class="badge" id="repPct">Percent: 0%</span>
<span class="badge" id="repBand">Band: —</span>
</div>
</div>
<div class="split" style="margin-top:12px">
<div class="caseBox">
<div style="display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap">
<div>
<div style="font-size:13px;color:var(--muted)">Concept mastery (by topic)</div>
<div class="sub">Percent is weighted by item format + difficulty.</div>
</div>
<button class="btn" id="downloadBtn">Download session JSON</button>
</div>
<table class="miniTable" id="topicTable">
<thead><tr><th>Topic</th><th>Mastery</th><th>Status</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="caseBox">
<div style="font-size:13px;color:var(--muted)">Topics to revisit (list)</div>
<div class="sub">Anything partial or missed appears here, sorted by frequency/priority.</div>
<ul class="miniList" id="revisitList"></ul>
</div>
</div>
</div>
</main>
</div>
<script>
/* =========================
Global error capture
========================= */
window.addEventListener("error", (e)=>{
const box = document.getElementById("errBox");
box.style.display = "block";
box.textContent = "Script error: " + (e.message || "Unknown") + (e.filename ? (" @ " + e.filename) : "");
});
/* =========================
CONFIG (editable)
========================= */
const CONFIG = {
unitName: "Unit 1",
passingHesiScore: 875,
readinessBands: [
{ name: "Needs Work", minPct: 0 },
{ name: "Borderline", minPct: 70 },
{ name: "Ready", minPct: 85 }
],
difficultyWeights: { 1: 1, 2: 2, 3: 3 },
formatMultipliers: {
mcq: 1,
sata: 1,
dropdown: 1,
case: 2,
matrix: 2,
bowtie: 3,
trends: 3
},
maxItemWeight: 6
};
/* =========================
QUESTIONS (Unit 1 ONLY)
========================= */
const QUESTIONS = [
// =========================
// MCQ (foundation + common HESI/NCLEX styles)
// =========================
{
id: 1, type: "mcq", topic: "Dose by Supply", difficulty: 2,
stem: "Order: Morphine 4 mg IV now. Available: 10 mg/5 mL. How many mL will the nurse administer?",
options: ["1 mL", "2 mL", "4 mL", "5 mL"],
correct: [1],
rationale: "Dimensional analysis: (4 mg ÷ 10 mg) × 5 mL = 2 mL.",
plain: "You need 4 mg. Your vial has 10 mg in every 5 mL. Since 4 is less than half of 10, you need less than half the liquid (2 mL).",
mnemonic: "D / H x Q (Desired / Have x Quantity)"
},
{
id: 2, type: "mcq", topic: "Tablet Calculation", difficulty: 1,
stem: "Order: Amoxicillin 500 mg PO now. Available: 250 mg/tablet. How many tablets will the nurse give?",
options: ["1", "2", "3", "4"],
correct: [1],
rationale: "500 ÷ 250 = 2 tablets.",
plain: "You need 500 total. Each pill is 250. 250 + 250 = 500.",
mnemonic: "Want / Got (What you want / What you got)"
},
{
id: 3, type: "mcq", topic: "IV Pump Rate (mL/hr)", difficulty: 1,
stem: "An IV order reads: 1000 mL to infuse over 8 hours. What is the pump rate?",
options: ["100 mL/hr", "125 mL/hr", "150 mL/hr", "175 mL/hr"],
correct: [1],
rationale: "1000 mL ÷ 8 hr = 125 mL/hr.",
plain: "The pump only speaks 'mL per hour'. Simply take the total bag size and divide by total hours.",
mnemonic: "Pump Rate = Total Volume / Total Time (Hours)"
},
{
id: 4, type: "mcq", topic: "gtt/min (Gravity)", difficulty: 2,
stem: "Infuse 500 mL over 4 hours. Tubing drop factor: 15 gtt/mL. What is the flow rate in gtt/min?",
options: ["20 gtt/min", "25 gtt/min", "31 gtt/min", "40 gtt/min"],
correct: [2],
rationale: "mL/min = 500 ÷ 240 = 2.083; gtt/min = 2.083 × 15 = 31.25 ≈ 31.",
plain: "Gravity drips are calculated in MINUTES. First convert 4 hours to 240 minutes. Then do (Vol x DF) / Minutes.",
mnemonic: "The Video Doctor Films Minutes (Total Vol x Drop Factor / Minutes)"
},
{
id: 5, type: "mcq", topic: "Weight-Based Dose (kg)", difficulty: 2,
stem: "Client weighs 154 lb. Order: enoxaparin 1 mg/kg. Available: 100 mg/mL. How many mL will the nurse administer?",
options: ["0.35 mL", "0.50 mL", "0.70 mL", "1.4 mL"],
correct: [2],
rationale: "154 lb ÷ 2.2 = 70 kg. Dose = 70 mg. Volume = 70 mg ÷ (100 mg/mL) = 0.70 mL.",
plain: "Step 1: Pounds to Kg (make it smaller). Step 2: Multiply Kg by dose. Step 3: Divide by concentration.",
mnemonic: "2.2 is the key (Lbs / 2.2 = Kg)"
},
{
id: 6, type: "mcq", topic: "Safe Dose Range (Peds)", difficulty: 3,
stem: "A child weighs 22 lb. Safe dose is 2–4 mg/kg/day. Provider orders 50 mg/day. What is the nurse’s priority action?",
options: [
"Administer as ordered",
"Clarify the order before giving",
"Split the dose into two doses",
"Give with food to reduce GI upset"
],
correct: [1],
rationale: "22 lb ÷ 2.2 = 10 kg. Safe range = 20–40 mg/day. Ordered 50 mg/day exceeds safe range.",
plain: "The baby is 10kg. The max safe dose is 4 mg x 10kg = 40mg. The doctor ordered 50mg. That's an overdose.",
mnemonic: "Safety First: Calculate the range BEFORE checking the order."
},
// =========================
// SATA (MCMA partial credit style)
// =========================
{
id: 7, type: "sata", topic: "Error Prevention", difficulty: 2,
stem: "Which actions reduce dosage calculation errors? (Select all that apply)",
options: [
"Convert units to one system before solving",
"Estimate whether the final answer is reasonable",
"Skip unit labels to save time",
"Double-check decimal placement",
"Use an independent double-check for high-alert meds"
],
correct: [0, 1, 3, 4],
rationale: "Unit consistency, reasonableness checks, decimal safety, and double-checks reduce medication errors.",
plain: "Make sure apples match apples (units), and ask 'Does this answer make sense?' before giving it.",
mnemonic: "The 6 Rights + 'Does it make sense?'"
},
{
id: 8, type: "sata", topic: "High-Alert Meds Safety", difficulty: 2,
stem: "Which medications are commonly treated as high-alert and require extra safeguards? (Select all that apply)",
options: ["Insulin", "Heparin", "Potassium chloride IV", "Acetaminophen", "Warfarin"],
correct: [0, 1, 2, 4],
rationale: "Insulin, heparin, IV KCl, and warfarin are commonly high-alert due to serious harm risk if misdosed.",
plain: "Think: Which drugs can kill a patient instantly if the math is wrong? Blood thinners, insulin, and IV potassium are top offenders.",
mnemonic: "PINCH (Potassium, Insulin, Narcotics, Chemo, Heparin)"
},
{
id: 9, type: "sata", topic: "Unit Conversions", difficulty: 2,
stem: "Which conversions are correct? (Select all that apply)",
options: [
"1 g = 1000 mg",
"1 mg = 1000 mcg",
"1 L = 100 mL",
"2.2 lb = 1 kg",
"1 tsp = 10 mL"
],
correct: [0, 1, 3],
rationale: "Correct: 1 g=1000 mg; 1 mg=1000 mcg; 2.2 lb=1 kg. 1 L=1000 mL; 1 tsp=5 mL.",
plain: "Remember your 1000s rule. Grams to milligrams is 1000. Milligrams to micrograms is 1000. But teaspoons are only 5.",
mnemonic: "King Henry Died By Drinking Chocolate Milk (Kilo, Hecto, Deka, Base, Deci, Centi, Milli)"
},
// =========================
// Dropdowns
// =========================
{
id: 10, type: "dropdown", topic: "Dose by Supply", difficulty: 1,
stem: "Complete the calculation.",
blanks: [
{ text: "Order: furosemide 40 mg IV. Available: 20 mg/2 mL. Give: ", options: ["2 mL", "4 mL", "6 mL", "8 mL"], correct: "4 mL" }
],
rationale: "20 mg in 2 mL → 10 mg/mL. Need 40 mg → 4 mL.",
plain: "If 2mL holds 20mg, then 1mL holds 10mg. You need 40mg, so you need 4mL.",
mnemonic: "Double the dose = Double the volume (if concentration stays same)"
},
{
id: 11, type: "dropdown", topic: "IV Pump Rate (mL/hr)", difficulty: 1,
stem: "Complete the calculation.",
blanks: [
{ text: "Infuse 250 mL over 2 hours. Pump rate: ", options: ["100", "125", "150", "175"], correct: "125" }
],
rationale: "250 ÷ 2 = 125 mL/hr.",
plain: "The pump needs to know 'how much per ONE hour'. Divide total (250) by hours (2).",
mnemonic: "Total Vol / Total Hours"
},
{
id: 12, type: "dropdown", topic: "Reconstitution/Concentration", difficulty: 2,
stem: "A vial is reconstituted to a total volume of 10 mL and contains 1 g total drug. Select the final concentration.",
blanks: [
{ text: "Concentration (mg/mL): ", options: ["10", "50", "100", "200"], correct: "100" }
],
rationale: "1 g = 1000 mg. 1000 mg ÷ 10 mL = 100 mg/mL.",
plain: "Always convert grams to mg first. 1 gram is huge (1000mg). Spread 1000mg across 10mL = 100 per mL.",
mnemonic: "1 g = 1 paperclip = 1000 tiny grains of sand (mg)"
},
// =========================
// MATRIX / GRID (2)
// =========================
{
id: 13, type: "matrix", topic: "Method Selection", difficulty: 1,
stem: "Match each scenario to the best calculation approach.",
rows: [
{ row: "IV gravity rate ordered in gtt/min", options: ["mL/hr pump", "gtt/min formula", "tablet calculation"], correct: "gtt/min formula" },
{ row: "Oral tablets available as mg/tablet", options: ["tablet calculation", "gtt/min formula", "titration protocol"], correct: "tablet calculation" },
{ row: "Weight-based medication order (mg/kg)", options: ["weight-based dosing", "tablet calculation", "unitless ratio only"], correct: "weight-based dosing" }
],
rationale: "Picking the right method prevents setup errors before the math even starts.",
plain: "Don't overcomplicate it. If it asks for drops, use the drop formula. If it involves weight, start with kg.",
mnemonic: "Match the formula to the 'Ask' (Unit)"
},
{
id: 14, type: "matrix", topic: "Dose Forms", difficulty: 2,
stem: "Match medication form to what you calculate directly.",
rows: [
{ row: "Liquid PO", options: ["mL to administer", "gtt/min only", "tablets only"], correct: "mL to administer" },
{ row: "IV pump infusion", options: ["mL/hr", "tablets", "mcg/min without conversion"], correct: "mL/hr" },
{ row: "Tablets/capsules", options: ["number of tablets", "gtt/min only", "mL/hr only"], correct: "number of tablets" }
],
rationale: "Dose form tells you what the final ‘answer unit’ must be.",
plain: "Pumps speak mL/hr. Syringes speak mL. Pill cups speak tablets. Know your output unit.",
mnemonic: "Pump = Hour. Gravity = Minute. Syringe = mL."
},
// =========================
// BOWTIE (2)
// =========================
{
id: 15, type: "bowtie", topic: "NGN Bowtie – Overdose Risk", difficulty: 3,
stem: "Bowtie: Identify the risk, contributing factors, and nursing actions.",
center: {
label: "Primary risk",
options: ["Medication overdose", "Medication underdose", "Allergic reaction", "Therapeutic effect only"],
correct: "Medication overdose"
},
left: {
label: "Contributing factors (pick 2)", pick: 2,
options: ["Decimal misplacement", "Incorrect unit conversion", "Patient education provided", "Medication reconciliation completed"],
correct: ["Decimal misplacement", "Incorrect unit conversion"]
},
right: {
label: "Priority actions (pick 2)", pick: 2,
options: ["Recalculate with units and estimate reasonableness", "Use independent double-check when indicated", "Administer quickly to avoid delay", "Skip labels to reduce clutter"],
correct: ["Recalculate with units and estimate reasonableness", "Use independent double-check when indicated"]
},
rationale: "Most critical dosing errors come from decimals and unit conversions; recalculation + double-checks prevent harm.",
plain: "A misplaced decimal can turn a dose of 1.0 into 10.0. That's 10x the dose. Always estimate: 'Does this look right?'",
mnemonic: "Leading Zero = Hero (0.5). Trailing Zero = No (5.0 -> 5)."
},
{
id: 16, type: "bowtie", topic: "NGN Bowtie – Heparin Safety", difficulty: 3,
stem: "Bowtie: Identify the risk, contributing factors, and nursing actions.",
center: {
label: "Primary risk",
options: ["Bleeding complication", "Dehydration", "Hypoglycemia", "Pain escalation only"],
correct: "Bleeding complication"
},
left: {
label: "Contributing factors (pick 2)", pick: 2,
options: ["Dose miscalculation", "No baseline labs reviewed", "Client prefers morning meds", "IV site is patent"],
correct: ["Dose miscalculation", "No baseline labs reviewed"]
},
right: {
label: "Priority actions (pick 2)", pick: 2,
options: ["Verify weight-based order and concentration", "Review relevant labs per protocol", "Increase rate without verification", "Document later to save time"],
correct: ["Verify weight-based order and concentration", "Review relevant labs per protocol"]
},
rationale: "Heparin errors can cause bleeding. Verify math + concentration and check protocol labs.",
plain: "Heparin is high stakes. Wrong math = bleeding out. Always check the PTT/labs and weight first.",
mnemonic: "Heparin = Hemorrhage Risk (H & H)"
},
// =========================
// TRENDS (2)
// =========================
{
id: 17, type: "trends", topic: "NGN Trends – Insulin Correction", difficulty: 2,
stem: "Trends: A client is on a sliding-scale insulin protocol. Identify the best action based on trend + protocol.",
table: {
headers: ["Time", "BG (mg/dL)", "Symptoms"],
rows: [
["0800", "310", "Thirsty"],
["1200", "260", "No symptoms"],
["1600", "220", "No symptoms"],
["2000", "180", "No symptoms"]
]
},
parts: [
{
type: "mcq",
prompt: "The trend indicates the client is moving toward:",
options: ["Worsening hyperglycemia", "Improving glycemic control", "Hypoglycemia risk", "No meaningful change"],
correct: [1]
},
{
type: "sata",
prompt: "Which nursing checks remain priority while insulin is being adjusted? (Select all that apply)",
options: ["Verify meal timing relative to insulin", "Monitor for hypoglycemia symptoms", "Hold all BG checks if improving", "Confirm insulin type/dose drawn up", "Encourage skipping meals"],
correct: [0, 1, 3]
}
],
rationale: "BG is trending down (improving). Still verify timing, dose/type, and monitor for hypoglycemia.",
plain: "Numbers are going down, which is good. But don't get lazy—insulin is dangerous. Confirm the dose and feed the patient.",
mnemonic: "Cold & Clammy = Need some Candy. Hot & Dry = Sugar High."
},
{
id: 18, type: "trends", topic: "NGN Trends – IV Fluids + Output", difficulty: 2,
stem: "Trends: A client is ordered IV fluids. Use the trend to identify the best interpretation and action.",
table: {
headers: ["Hour", "IV Rate (mL/hr)", "Urine Output (mL/hr)", "BP"],
rows: [
["0–1", "125", "15", "92/58"],
["1–2", "125", "20", "94/60"],
["2–3", "125", "28", "98/62"],
["3–4", "125", "35", "104/66"]
]
},
parts: [
{
type: "mcq",
prompt: "The trend most strongly suggests:",
options: ["Worsening hypoperfusion", "Improving perfusion response", "Fluid overload", "Medication error"],
correct: [1]
},
{
type: "dropdown",
prompt: "Best next nursing action:",
blanks: [
{ text: "Action: ", options: ["Stop fluids immediately", "Continue per order and reassess", "Increase rate without order", "Remove IV"], correct: "Continue per order and reassess" }
]
}
],
rationale: "BP and UO are improving at the current ordered rate; continue and reassess per protocol.",
plain: "Kidneys are waking up (output > 30 is the goal) and BP is rising. The fluids are working. Don't stop now.",
mnemonic: "Urine Output Goal > 30 mL/hr"
},
// =========================
// CASE STUDIES (4) – unfolding (3 steps each)
// =========================
{
id: 19, type: "case", topic: "Unfolding Case – Pediatric Acetaminophen", difficulty: 2,
stem: "A child weighs 18 kg. Provider orders acetaminophen 15 mg/kg/dose PO. Available: 160 mg/5 mL.",
steps: [
{
title: "Step 1 — Calculate Dose",
type: "mcq",
prompt: "What dose (mg) should the nurse give?",
options: ["180 mg", "240 mg", "270 mg", "300 mg"],
correct: [2],
rationale: "18 kg × 15 mg/kg = 270 mg.",
plain: "18 kg times 15 mg each = 270 total mg needed.",
mnemonic: "Kg x Dose/Kg = Total Dose"
},
{
title: "Step 2 — Convert to mL",
type: "mcq",
prompt: "How many mL will the nurse administer?",
options: ["5 mL", "6 mL", "7.5 mL", "8.5 mL"],
correct: [2],
rationale: "160 mg/5 mL = 32 mg/mL. 270 mg ÷ 32 = 8.4375 mL (not listed). Using ratio: (270/160)*5 = 8.4375.",
plain: "You need 270. Concentration is 32mg/mL. 270 / 32 is roughly 8.4. Closest safe option is 8.5 mL.",
mnemonic: "D/H x V"
},
{
title: "Step 3 — Safety Priority",
type: "mcq",
prompt: "Which check is most important before giving this dose?",
options: ["Last dose timing and total daily limit", "Ask the parent to administer at home", "Skip weight verification if charted", "Give an extra dose for faster relief"],
correct: [0],
rationale: "Acetaminophen toxicity risk requires timing + total daily dose limit checks.",
plain: "Tylenol hurts the liver if given too much/often. Check when the last dose was!",
mnemonic: "Liver Lover? Watch the Tylenol total."
}
]
},
{
id: 20, type: "case", topic: "Unfolding Case – Heparin Infusion", difficulty: 3,
stem: "Heparin infusion order: 18 units/kg/hr. Client weight: 80 kg. Bag: 25,000 units in 500 mL.",
steps: [
{
title: "Step 1 — Units per hour",
type: "mcq",
prompt: "How many units/hr should the client receive?",
options: ["960 units/hr", "1200 units/hr", "1440 units/hr", "1800 units/hr"],
correct: [2],
rationale: "80 × 18 = 1440 units/hr.",
plain: "Weight (80) x Rate (18) = 1440 units needed per hour.",
mnemonic: "Kg x Units/Kg = Total Units"
},
{
title: "Step 2 — Convert to mL/hr",
type: "mcq",
prompt: "What rate in mL/hr should the pump be set to?",
options: ["14.4 mL/hr", "28.8 mL/hr", "36 mL/hr", "57.6 mL/hr"],
correct: [1],
rationale: "Concentration: 25,000 ÷ 500 = 50 units/mL. Rate: 1440 ÷ 50 = 28.8 mL/hr.",
plain: "Bag concentration is 50 units in every mL. You need 1440 units. 1440 / 50 = 28.8.",
mnemonic: "Total Units / Concentration = Rate"
},
{
title: "Step 3 — Safety Check",
type: "sata",
prompt: "Which actions are priority for safe heparin administration? (Select all that apply)",
options: ["Confirm concentration on bag", "Use independent double-check if policy requires", "Increase rate if patient reports pain", "Monitor for bleeding", "Skip protocol labs if stable"],
correct: [0, 1, 3],
rationale: "Verify concentration, double-check, and monitor bleeding per protocol. Pain doesn’t justify rate changes; labs are not optional.",
plain: "Heparin is a high-alert med. Double check everything. Watch for blood.",
mnemonic: "High Alert = Double Check"
}
]
},
{
id: 21, type: "case", topic: "Unfolding Case – IV Antibiotic Volume", difficulty: 2,
stem: "Order: ceftriaxone 1 g IV. Vial: 1 g. After reconstitution, concentration is 100 mg/mL. IV push max volume per policy is 10 mL per syringe.",
steps: [
{
title: "Step 1 — Convert Dose",
type: "mcq",
prompt: "1 g equals how many mg?",
options: ["10 mg", "100 mg", "1000 mg", "10,000 mg"],
correct: [2],
rationale: "1 g = 1000 mg.",
plain: "Grams are big. Milligrams are small. 1 big gram = 1000 small mg.",
mnemonic: "G to mg = x1000"
},
{
title: "Step 2 — Calculate Volume",
type: "mcq",
prompt: "How many mL contains 1 g at 100 mg/mL?",
options: ["5 mL", "10 mL", "15 mL", "20 mL"],
correct: [1],
rationale: "1000 mg ÷ 100 mg/mL = 10 mL.",
plain: "You have 1000mg total. Each mL fits 100mg. You need 10 mL to hold it all.",
mnemonic: "Total / Concentration = Volume"
},
{
title: "Step 3 — Safety Decision",
type: "mcq",
prompt: "Given the policy limit of 10 mL per syringe, the nurse should:",
options: ["Proceed with 10 mL in one syringe", "Split into two syringes automatically", "Hold medication without notifying anyone", "Give IM instead"],
correct: [0],
rationale: "10 mL meets the max per syringe; proceed per policy and administration guidelines.",
plain: "Policy says max is 10mL. You have exactly 10mL. You are safe to proceed.",
mnemonic: "Know your max limits."
}
]
},
{
id: 22, type: "case", topic: "Unfolding Case – Gravity Drip Check", difficulty: 2,
stem: "Order: 1000 mL over 10 hours. Tubing: 20 gtt/mL. The nurse is using gravity tubing (no pump).",
steps: [
{
title: "Step 1 — mL/min",
type: "mcq",
prompt: "What is the rate in mL/min?",
options: ["1.0", "1.5", "1.67", "2.0"],
correct: [2],
rationale: "10 hr = 600 min. 1000 ÷ 600 = 1.67 mL/min.",
plain: "Convert 10 hours to 600 minutes. 1000 mL / 600 min = 1.67 mL/min.",
mnemonic: "Hours x 60 = Minutes"
},
{
title: "Step 2 — gtt/min",
type: "mcq",
prompt: "What is the flow rate in gtt/min?",
options: ["20", "28", "33", "40"],
correct: [2],
rationale: "1.67 × 20 = 33.4 ≈ 33 gtt/min.",
plain: "Take mL/min (1.67) and multiply by the drop factor (20). ~33 drops per minute.",
mnemonic: "mL/min x DF = gtt/min"
},
{
title: "Step 3 — Safety Habit",
type: "mcq",
prompt: "Best practice after setting the gtt/min is to:",
options: ["Recheck in 15 minutes and with any position change", "Assume it stays accurate all shift", "Turn the roller clamp fully open", "Silence alarms to prevent interruptions"],
correct: [0],
rationale: "Gravity flow rates drift with position and venous pressure; recheck is a safety standard.",
plain: "Gravity drips aren't robots. If the patient moves their arm, the rate changes. Recheck often.",
mnemonic: "Gravity is unreliable. Trust but verify."
}
]
},
// =========================
// More MCQ / SATA / Dropdown to reach 30 total
// =========================
{
id: 23, type: "mcq", topic: "mcg ↔ mg Conversion", difficulty: 2,
stem: "Order: levothyroxine 75 mcg PO. Available: 0.1 mg tablets. How many tablets will the nurse administer?",
options: ["0.5 tablet", "0.75 tablet", "1 tablet", "1.5 tablets"],
correct: [1],
rationale: "0.1 mg = 100 mcg. Need 75 mcg → 75/100 = 0.75 tablet.",
plain: "First, convert mg to mcg. 0.1 mg is 100 mcg. You need 75. 75 is three-quarters of 100.",
mnemonic: "0.1 mg = 100 mcg"
},
{
id: 24, type: "mcq", topic: "mEq Calculation (Basic)", difficulty: 3,
stem: "Order: potassium chloride 20 mEq IV. Available: 10 mEq/5 mL. How many mL will the nurse administer?",
options: ["5 mL", "10 mL", "15 mL", "20 mL"],
correct: [1],
rationale: "10 mEq in 5 mL → 2 mEq/mL. Need 20 mEq → 10 mL.",
plain: "You need 20 mEq. You have 10 mEq in every 5mL scoop. You need two scoops. 5 + 5 = 10 mL.",
mnemonic: "D/H x Q"
},
{
id: 25, type: "sata", topic: "Rounding Rules & Policy", difficulty: 2,
stem: "Which statements about rounding are safest for NCLEX/HESI-style dosing? (Select all that apply)",
options: [
"Follow facility policy for rounding (especially pediatrics/IV)",
"Round only at the end of the problem",
"Round aggressively early to save time",
"If options don’t match, recheck math and units first",
"Document your rounding method in high-risk situations"
],
correct: [0, 1, 3, 4],
rationale: "Don’t round early; confirm units; follow policy; document when needed. If options don’t match, it’s a red flag to recheck.",
plain: "Rounding early introduces error. Keep the long decimals in your calculator until the very final step.",
mnemonic: "Round at the End, not the Trend"
},
{
id: 26, type: "dropdown", topic: "Ratio/Proportion Setup", difficulty: 1,
stem: "Complete the statement.",
blanks: [
{ text: "If 5 mL contains 250 mg, then 10 mL contains: ", options: ["250 mg", "500 mg", "750 mg", "1000 mg"], correct: "500 mg" }
],
rationale: "Doubling the volume doubles the dose: 250 → 500 mg.",
plain: "If you double the liquid (5 to 10), you double the drug (250 to 500).",
mnemonic: "Direct Proportion: Double one, double the other."
},
{
id: 27, type: "mcq", topic: "IVPB Rate (mL/hr)", difficulty: 1,
stem: "An IVPB antibiotic is 100 mL to infuse over 30 minutes. What pump rate is needed?",
options: ["100 mL/hr", "150 mL/hr", "200 mL/hr", "250 mL/hr"],
correct: [2],
rationale: "30 minutes = 0.5 hr. 100 ÷ 0.5 = 200 mL/hr.",
plain: "You need 100 mL in half an hour. That means in a full hour, you'd need double that amount (200).",
mnemonic: "Half hour run? Double the rate."
},
{
id: 28, type: "mcq", topic: "Dose by Supply (Liquid)", difficulty: 2,
stem: "Order: diphenhydramine 25 mg PO. Available: 12.5 mg/5 mL. How many mL will the nurse administer?",
options: ["5 mL", "10 mL", "12 mL", "20 mL"],
correct: [1],
rationale: "25 mg is double 12.5 mg, so volume doubles: 5 mL → 10 mL.",
plain: "You need 25. You have 12.5. 12.5 is half of 25. So you need two doses. 5mL x 2 = 10mL.",
mnemonic: "D/H x V"
},
{
id: 29, type: "sata", topic: "Clinical Reasonableness Check", difficulty: 2,
stem: "A nurse calculates a dose volume of 25 mL for an IV push medication. Which actions are appropriate? (Select all that apply)",
options: [
"Recheck the concentration and units",
"Confirm route and whether dilution/IVPB is required",
"Administer anyway if patient is in pain",
"Consult policy/charge nurse if volume seems unsafe for IV push",
"Assess if the ordered dose exceeds safe range"
],
correct: [0, 1, 3, 4],
rationale: "Large IV push volumes are a red flag. Verify units/concentration, route/policy, and safe range before giving.",
plain: "Imagine pushing 25 mL into an IV line by hand. That's a huge syringe. Red flag! Stop and recheck.",
mnemonic: "Big Volume IV Push? Stop & Shush (Check it)"
},
{
id: 30, type: "mcq", topic: "Time to Infuse", difficulty: 2,
stem: "An IV is running at 75 mL/hr. How long will it take to infuse 450 mL?",
options: ["4 hours", "5 hours", "6 hours", "7 hours"],
correct: [2],
rationale: "Time = volume ÷ rate = 450 ÷ 75 = 6 hours.",
plain: "You have 450 total. You use 75 every hour. How many 75s fit into 450? (6).",
mnemonic: "Total / Rate = Time"
}
];
/* =========================
State + Storage (MOVED UP FOR SAFETY)
========================= */
const LS_KEY = "unit1_ngn_hesi_session_v5";
let state = loadState() || {
currentId: 1,
startedAt: Date.now(),
flagged: {},
answers: {},
scores: {},
topicAgg: {}
};
function saveState(){ localStorage.setItem(LS_KEY, JSON.stringify(state)); }
function loadState(){ try{ return JSON.parse(localStorage.getItem(LS_KEY)); }catch(e){ return null; } }
function resetState(){
localStorage.removeItem(LS_KEY);
state = {
currentId: 1,
startedAt: Date.now(),
flagged: {},
answers: {},
scores: {},
topicAgg: {}
};
renderAll();
}
// DEFINING 'el' HERE SO IT EXISTS BEFORE ANY RENDER FUNCTION USES IT
const el = (id)=>document.getElementById(id);
const clamp=(n,min,max)=>Math.max(min,Math.min(max,n));
const uniq=(arr)=>Array.from(new Set(arr));
function escapeHtml(s){
return String(s)
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
/* =========================
Scoring
========================= */
function itemWeight(q){
const d = CONFIG.difficultyWeights[q.difficulty] ?? 1;
const m = CONFIG.formatMultipliers[q.type] ?? 1;
return Math.min(CONFIG.maxItemWeight, d * m);
}
function computeStatus(pct){
if (pct >= 0.999) return "full";
if (pct >= 0.70) return "partial";
return "missed";
}
function mcqScore(selectedIdx, correctIdx){
const earned = (selectedIdx.length===1 && correctIdx.includes(selectedIdx[0])) ? 1 : 0;
return { earned, possible: 1 };
}
function sataScore(selectedIdx, correctIdx){
const correctSet = new Set(correctIdx);
let raw = 0;
for (const idx of selectedIdx){
raw += correctSet.has(idx) ? 1 : -1;
}
raw = Math.max(0, raw);
const possible = correctIdx.length;
const earned = Math.min(possible, raw);
return { earned, possible };
}
function dropdownScore(values, blanks){
let earned = 0;
for (let i=0;i<blanks.length;i++){
if ((values[i] ?? "") === blanks[i].correct) earned += 1;
}
return { earned, possible: blanks.length };
}
function matrixScore(map, rows){
let earned = 0;
for (let i=0;i<rows.length;i++){
if ((map[i] ?? "") === rows[i].correct) earned += 1;
}
return { earned, possible: rows.length };
}
function bowtieScore(payload, q){
let earned = 0;
const possible = 1 + q.left.pick + q.right.pick;
if ((payload.center ?? "") === q.center.correct) earned += 1;
const leftCorrect = new Set(q.left.correct);
let leftEarned = 0;
for (const v of (payload.left ?? [])){
leftEarned += leftCorrect.has(v) ? 1 : -1;
}
earned += clamp(leftEarned, 0, q.left.pick);
const rightCorrect = new Set(q.right.correct);
let rightEarned = 0;
for (const v of (payload.right ?? [])){
rightEarned += rightCorrect.has(v) ? 1 : -1;
}
earned += clamp(rightEarned, 0, q.right.pick);
return { earned, possible };
}
function trendsScore(payload, q){
let earned = 0, possible = 0;
q.parts.forEach((p, idx) => {
if (p.type === "mcq"){
const s = mcqScore([payload[idx]], p.correct);
earned += s.earned; possible += s.possible;
} else if (p.type === "sata"){
const s = sataScore(payload[idx] ?? [], p.correct);
earned += s.earned; possible += s.possible;
} else if (p.type === "dropdown"){
const s = dropdownScore(payload[idx] ?? [], p.blanks);
earned += s.earned; possible += s.possible;
}
});
return { earned, possible };
}
function caseScore(payload, q){
let earned = 0, possible = 0;
q.steps.forEach((s, idx) => {
if (s.type === "mcq"){
const r = mcqScore([payload[idx]], s.correct);
earned += r.earned; possible += r.possible;
} else if (s.type === "sata"){
const r = sataScore(payload[idx] ?? [], s.correct);
earned += r.earned; possible += r.possible;
} else if (s.type === "dropdown"){
const r = dropdownScore(payload[idx] ?? [], s.blanks);
earned += r.earned; possible += r.possible;
}
});
return { earned, possible };
}
function scoreQuestion(q, payload){
let earned=0, possible=0;
if (q.type==="mcq"){
({earned, possible} = mcqScore(payload || [], q.correct));
} else if (q.type==="sata"){
({earned, possible} = sataScore(payload || [], q.correct));
} else if (q.type==="dropdown"){
({earned, possible} = dropdownScore(payload || [], q.blanks));
} else if (q.type==="matrix"){
({earned, possible} = matrixScore(payload || {}, q.rows));
} else if (q.type==="bowtie"){
({earned, possible} = bowtieScore(payload || {}, q));
} else if (q.type==="trends"){
({earned, possible} = trendsScore(payload || [], q));
} else if (q.type==="case"){
({earned, possible} = caseScore(payload || [], q));
}
const pct = possible ? (earned/possible) : 0;
const status = computeStatus(pct);
const w = itemWeight(q);
const earnedW = pct * w;
const possibleW = w;
return { earned, possible, pct, status, earnedW, possibleW, at: Date.now() };
}
/* =========================
Topic aggregation + revisit tracker
========================= */
function bumpTopic(topic, earnedW, possibleW, status){
if (!state.topicAgg[topic]) state.topicAgg[topic] = { earned:0, possible:0, missedCount:0, partialCount:0 };
state.topicAgg[topic].earned += earnedW;
state.topicAgg[topic].possible += possibleW;
if (status === "missed") state.topicAgg[topic].missedCount += 1;
if (status === "partial") state.topicAgg[topic].partialCount += 1;
}
function rebuildTopicAgg(){
state.topicAgg = {};
for (const q of QUESTIONS){
const sc = state.scores[q.id];
if (!sc) continue;
bumpTopic(q.topic, sc.earnedW, sc.possibleW, sc.status);
}
}
function buildRevisitList(){
rebuildTopicAgg();
const out = [];
for (const [topic, agg] of Object.entries(state.topicAgg)){
const mastery = agg.possible ? (agg.earned/agg.possible)*100 : 0;
const count = agg.missedCount + agg.partialCount;
if (count <= 0) continue;
const worst = agg.missedCount > 0 ? "missed" : "partial";
out.push({ topic, mastery, count, worst });
}
out.sort((a,b)=> (a.worst===b.worst ? 0 : (a.worst==="missed" ? -1 : 1)) || (b.count-a.count) || (a.mastery-b.mastery));
return out;
}
/* =========================
Summary metrics
========================= */
function overallMastery(){
let e=0,p=0;
for (const q of QUESTIONS){
const sc = state.scores[q.id];
if (!sc) continue;
e += sc.earnedW; p += sc.possibleW;
}
return p ? (e/p)*100 : 0;
}
function weightedScoreOutOf1200(){
const pct = overallMastery();
const score = Math.round(300 + (pct/100) * 900);
return clamp(score, 300, 1200);
}
function statusTag(qid){
const sc = state.scores[qid];
if (!sc) return { text:"Unanswered", cls:"" };
if (sc.status === "full") return { text:"Full", cls:"good" };
if (sc.status === "partial") return { text:"Partial", cls:"warn" };
return { text:"Missed", cls:"bad" };
}
/* =========================
Clickable option engine
========================= */
function getSelection(q){ return state.answers[q.id]; }
function setSelection(q, payload){ state.answers[q.id] = payload; saveState(); }
function toggleSingle(q, idx){ setSelection(q, [idx]); }
function toggleMulti(q, idx){
const cur = getSelection(q) || [];
const set = new Set(cur);
if (set.has(idx)) set.delete(idx); else set.add(idx);
setSelection(q, [...set].sort((a,b)=>a-b));
}
/* =========================
Render helpers
========================= */
function renderOptionCards(q, isMulti){
const wrap = document.createElement("div");
wrap.className = "opts";
const selected = getSelection(q) || [];
q.options.forEach((opt, idx)=>{
const card = document.createElement("div");
const active = isMulti ? selected.includes(idx) : (selected[0]===idx);
card.className = "optCard" + (active ? " selected" : "");
card.tabIndex = 0;
card.setAttribute("role","button");
card.setAttribute("aria-pressed", active ? "true" : "false");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`;
card.onclick = ()=>{
if (isMulti) toggleMulti(q, idx);
else toggleSingle(q, idx);
renderAll(true);
};
card.onkeydown = (e)=>{
if (e.key==="Enter" || e.key===" "){
e.preventDefault();
card.click();
}
};
wrap.appendChild(card);
});
return wrap;
}
function renderQuestionBody(q){
const wrap = document.createElement("div");
const existing = getSelection(q);
if (q.type === "mcq"){
wrap.appendChild(renderOptionCards(q, false));
}
if (q.type === "sata"){
wrap.appendChild(renderOptionCards(q, true));
}
if (q.type === "dropdown"){
const box = document.createElement("div");
box.className = "caseBox";
const vals = existing || [];
q.blanks.forEach((b, i)=>{
const sel = document.createElement("select");
sel.dataset.blankIndex = i;
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
sel.value = vals[i] || "";
sel.onchange = ()=>{
const cur = (getSelection(q) || []).slice();
cur[i] = sel.value || "";
setSelection(q, cur);
};
const line = document.createElement("div");
line.style.marginTop = "8px";
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`;
line.appendChild(sel);
box.appendChild(line);
});
wrap.appendChild(box);
}
if (q.type === "matrix"){
const box = document.createElement("div");
box.className = "caseBox";
const table = document.createElement("table");
table.className = "table";
table.innerHTML = `<thead><tr><th>Row</th><th>Pick one</th></tr></thead>`;
const tb = document.createElement("tbody");
const map = existing || {};
q.rows.forEach((r, i)=>{
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.textContent = r.row;
const td2 = document.createElement("td");
const sel = document.createElement("select");
sel.innerHTML = `<option value="">Select…</option>` + r.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
sel.value = map[i] || "";
sel.onchange = ()=>{
const cur = Object.assign({}, (getSelection(q) || {}));
cur[i] = sel.value || "";
setSelection(q, cur);
};
td2.appendChild(sel);
tr.appendChild(td1); tr.appendChild(td2);
tb.appendChild(tr);
});
table.appendChild(tb);
box.appendChild(table);
wrap.appendChild(box);
}
if (q.type === "bowtie"){
const box = document.createElement("div");
box.className = "caseBox";
const payload = existing || { center:"", left:[], right:[] };
const center = document.createElement("div");
center.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:6px">${escapeHtml(q.center.label)}</div>`;
const cSel = document.createElement("select");
cSel.innerHTML = `<option value="">Select…</option>` + q.center.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
cSel.value = payload.center || "";
cSel.onchange = ()=>{
setSelection(q, { ...payload, center: cSel.value || "" });
};
center.appendChild(cSel);
const mkPickList = (side, cfg)=>{
const sec = document.createElement("div");
sec.className = "caseStep";
sec.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:6px">${escapeHtml(cfg.label)} (pick ${cfg.pick})</div>`;
cfg.options.forEach((o)=>{
const card = document.createElement("div");
const selected = (payload[side] || []).includes(o);
card.className = "optCard" + (selected ? " selected" : "");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(o)}</div>`;
card.onclick = ()=>{
const cur = new Set(payload[side] || []);
if (cur.has(o)) cur.delete(o);
else cur.add(o);
const arr = [...cur];
if (arr.length > cfg.pick) return;
const next = { ...payload, [side]: arr };
setSelection(q, next);
renderAll(true);
};
sec.appendChild(card);
});
return sec;
};
box.appendChild(center);
box.appendChild(mkPickList("left", q.left));
box.appendChild(mkPickList("right", q.right));
wrap.appendChild(box);
}
if (q.type === "trends"){
const box = document.createElement("div");
box.className = "caseBox";
const t = document.createElement("table");
t.className = "table";
const head = `<tr>${q.table.headers.map(h=>`<th>${escapeHtml(h)}</th>`).join("")}</tr>`;
const rows = q.table.rows.map(r=>`<tr>${r.map(c=>`<td>${escapeHtml(String(c))}</td>`).join("")}</tr>`).join("");
t.innerHTML = `<thead>${head}</thead><tbody>${rows}</tbody>`;
box.appendChild(t);
const payload = existing || [];
q.parts.forEach((p, idx)=>{
const sec = document.createElement("div");
sec.className = "caseStep";
sec.innerHTML = `<div style="font-weight:600;margin-bottom:8px">${escapeHtml(p.prompt)}</div>`;
if (p.type==="mcq"){
const chosen = payload[idx] ?? null;
p.options.forEach((opt, oi)=>{
const card = document.createElement("div");
const selected = chosen === oi;
card.className = "optCard" + (selected ? " selected" : "");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`;
card.onclick = ()=>{
const cur = (getSelection(q) || []).slice();
cur[idx] = oi;
setSelection(q, cur);
renderAll(true);
};
sec.appendChild(card);
});
} else if (p.type==="sata"){
const chosen = payload[idx] || [];
p.options.forEach((opt, oi)=>{
const selected = chosen.includes(oi);
const card = document.createElement("div");
card.className = "optCard" + (selected ? " selected" : "");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`;
card.onclick = ()=>{
const cur = (getSelection(q) || []).slice();
const set = new Set(cur[idx] || []);
if (set.has(oi)) set.delete(oi); else set.add(oi);
cur[idx] = [...set].sort((a,b)=>a-b);
setSelection(q, cur);
renderAll(true);
};
sec.appendChild(card);
});
} else if (p.type==="dropdown"){
const vals = payload[idx] || [];
p.blanks.forEach((b, bi)=>{
const sel = document.createElement("select");
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
sel.value = vals[bi] || "";
sel.onchange = ()=>{
const cur = (getSelection(q) || []).slice();
const part = (cur[idx] || []).slice();
part[bi] = sel.value || "";
cur[idx] = part;
setSelection(q, cur);
};
const line = document.createElement("div");
line.style.marginTop = "8px";
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`;
line.appendChild(sel);
sec.appendChild(line);
});
}
box.appendChild(sec);
});
wrap.appendChild(box);
}
if (q.type === "case"){
const box = document.createElement("div");
box.className = "caseBox";
box.innerHTML = `<div style="color:var(--muted);font-size:12px;margin-bottom:8px">Unfolding case (3 steps)</div>`;
const payload = existing || [];
q.steps.forEach((s, idx)=>{
const sec = document.createElement("div");
sec.className = "caseStep";
sec.innerHTML = `<div style="font-weight:700;margin-bottom:6px">${escapeHtml(s.title)}</div>
<div style="margin-bottom:10px">${escapeHtml(s.prompt)}</div>`;
if (s.type==="mcq"){
const chosen = payload[idx] ?? null;
s.options.forEach((opt, oi)=>{
const card = document.createElement("div");
const selected = chosen === oi;
card.className = "optCard" + (selected ? " selected" : "");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`;
card.onclick = ()=>{
const cur = (getSelection(q) || []).slice();
cur[idx] = oi;
setSelection(q, cur);
renderAll(true);
};
sec.appendChild(card);
});
} else if (s.type==="sata"){
const chosen = payload[idx] || [];
s.options.forEach((opt, oi)=>{
const selected = chosen.includes(oi);
const card = document.createElement("div");
card.className = "optCard" + (selected ? " selected" : "");
card.innerHTML = `<div class="optMark"></div><div class="optText">${escapeHtml(opt)}</div>`;
card.onclick = ()=>{
const cur = (getSelection(q) || []).slice();
const set = new Set(cur[idx] || []);
if (set.has(oi)) set.delete(oi); else set.add(oi);
cur[idx] = [...set].sort((a,b)=>a-b);
setSelection(q, cur);
renderAll(true);
};
sec.appendChild(card);
});
} else if (s.type==="dropdown"){
const vals = payload[idx] || [];
s.blanks.forEach((b, bi)=>{
const sel = document.createElement("select");
sel.innerHTML = `<option value="">Select…</option>` + b.options.map(o=>`<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
sel.value = vals[bi] || "";
sel.onchange = ()=>{
const cur = (getSelection(q) || []).slice();
const step = (cur[idx] || []).slice();
step[bi] = sel.value || "";
cur[idx] = step;
setSelection(q, cur);
};
const line = document.createElement("div");
line.style.marginTop = "8px";
line.innerHTML = `<div style="color:var(--muted);font-size:12px">${escapeHtml(b.text)}</div>`;
line.appendChild(sel);
sec.appendChild(line);
});
}
box.appendChild(sec);
});
wrap.appendChild(box);
}
return wrap;
}
/* =========================
Read payloads for scoring
========================= */
function readAnswerPayload(q){
const payload = getSelection(q);
if (q.type==="mcq") return payload || [];
if (q.type==="sata") return payload || [];
if (q.type==="dropdown") return payload || [];
if (q.type==="matrix") return payload || {};
if (q.type==="bowtie") return payload || { center:"", left:[], right:[] };
if (q.type==="trends") return payload || [];
if (q.type==="case") return payload || [];
return payload;
}
/* =========================
Reveal content (UPDATED TO USE DISTINCT FIELDS)
========================= */
function renderReveal(q){
const sc = state.scores[q.id];
const scoreLine = sc
? `<div class="sub">Score: ${Math.round(sc.pct*100)}% • Status: <strong>${escapeHtml(sc.status)}</strong></div>`
: `<div class="sub">Submit first to compute score.</div>`;
// UPDATED: Now uses direct fields for distinct logic
const nursingLogic = q.rationale || "Rationale not provided.";
const plainLogic = q.plain || "Plain speak explanation not provided.";
const mnemonic = q.mnemonic || "No specific mnemonic for this item.";
const keyBlock = (()=>{
if (!sc) return "";
if (q.type==="mcq" || q.type==="sata"){
const correctText = q.correct.map(i=>q.options[i]).map(escapeHtml);
return `<div style="margin-top:10px"><strong>Correct answer:</strong> ${correctText.join(q.type==="sata" ? " • " : "")}</div>`;
}
if (q.type==="dropdown"){
const key = q.blanks.map(b=>`${escapeHtml(b.text)} <strong>${escapeHtml(b.correct)}</strong>`).join("<br/>");
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${key}</div>`;
}
if (q.type==="matrix"){
const key = q.rows.map(r=>`${escapeHtml(r.row)} → <strong>${escapeHtml(r.correct)}</strong>`).join("<br/>");
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${key}</div>`;
}
if (q.type==="bowtie"){
return `<div style="margin-top:10px"><strong>Correct answer:</strong></div>
<div><small class="sub">${escapeHtml(q.center.label)}:</small> <strong>${escapeHtml(q.center.correct)}</strong></div>
<div style="margin-top:6px"><small class="sub">${escapeHtml(q.left.label)}:</small> <strong>${escapeHtml(q.left.correct.join(" • "))}</strong></div>
<div style="margin-top:6px"><small class="sub">${escapeHtml(q.right.label)}:</small> <strong>${escapeHtml(q.right.correct.join(" • "))}</strong></div>`;
}
if (q.type==="trends"){
const keyParts = q.parts.map((p, idx)=>{
if (p.type==="mcq") return `Part ${idx+1}: <strong>${escapeHtml(p.options[p.correct[0]])}</strong>`;
if (p.type==="sata") return `Part ${idx+1}: <strong>${escapeHtml(p.correct.map(i=>p.options[i]).join(" • "))}</strong>`;
if (p.type==="dropdown") return `Part ${idx+1}: <strong>${escapeHtml(p.blanks.map(b=>b.correct).join(" • "))}</strong>`;
return `Part ${idx+1}: <strong>—</strong>`;
}).join("<br/>");
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${keyParts}</div>`;
}
if (q.type==="case"){
const keySteps = q.steps.map((s, idx)=>{
if (s.type==="mcq") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.options[s.correct[0]])}</strong>`;
if (s.type==="sata") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.correct.map(i=>s.options[i]).join(" • "))}</strong>`;
if (s.type==="dropdown") return `${escapeHtml(s.title)} → <strong>${escapeHtml(s.blanks.map(b=>b.correct).join(" • "))}</strong>`;
return `${escapeHtml(s.title)} → <strong>—</strong>`;
}).join("<br/>");
return `<div style="margin-top:10px"><strong>Correct answer:</strong><br/>${keySteps}</div>`;
}
return "";
})();
return `
${scoreLine}
${keyBlock}
<div class="logicGrid">
<div class="logicBox">
<div class="ttl">Logic (nursing terminology)</div>
<div class="txt">${escapeHtml(nursingLogic)}</div>
</div>
<div class="logicBox">
<div class="ttl">Logic (plain talk)</div>
<div class="txt">${escapeHtml(plainLogic)}</div>
</div>
<div class="logicBox">
<div class="ttl">Mnemonic / memory hook</div>
<div class="txt">${escapeHtml(mnemonic)}</div>
</div>
</div>
`;
}
/* =========================
Render sidebar + progress + report + viewer
========================= */
function renderSidebar(){
const topics = uniq(QUESTIONS.map(q=>q.topic)).sort((a,b)=>a.localeCompare(b));
const tf = el("topicFilter");
tf.innerHTML = `<option value="all">All topics</option>` + topics.map(t=>`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join("");
const list = el("qList");
const mode = el("filterMode").value;
const topicSel = el("topicFilter").value;
const search = (el("searchBox").value || "").trim().toLowerCase();
list.innerHTML = "";
for (const q of QUESTIONS){
const sc = state.scores[q.id];
const flagged = !!state.flagged[q.id];
const tag = statusTag(q.id);
if (topicSel !== "all" && q.topic !== topicSel) continue;
if (search && !(q.stem.toLowerCase().includes(search) || q.topic.toLowerCase().includes(search))) continue;
if (mode === "flagged" && !flagged) continue;
if (mode === "unanswered" && sc) continue;
if (mode === "missed" && (!sc || sc.status !== "missed")) continue;
if (mode === "partial" && (!sc || sc.status !== "partial")) continue;
const div = document.createElement("div");
div.className = "qitem";
div.onclick = ()=>{ state.currentId = q.id; saveState(); renderAll(true); };
const flagChip = flagged ? `<span class="tag warn">Flag</span>` : "";
div.innerHTML = `
<div class="top">
<div><strong>Q${q.id}</strong> <span class="tag">${escapeHtml(q.type.toUpperCase())}</span></div>
<div style="display:flex;gap:8px;align-items:center;justify-content:flex-end">
${flagChip}
<span class="tag ${tag.cls}">${tag.text}</span>
</div>
</div>
<div class="t">${escapeHtml(q.topic)} • Diff ${q.difficulty}</div>
`;
list.appendChild(div);
}
}
function renderProgress(){
const answered = Object.keys(state.scores).length;
const total = QUESTIONS.length;
const pct = Math.round((answered/total)*100);
el("barFill").style.width = `${pct}%`;
el("progText").textContent = `Progress: ${answered}/${total}`;
el("pctText").textContent = `${pct}%`;
const score1200 = weightedScoreOutOf1200();
el("scoreV").textContent = score1200;
// Benchmark: passing HESI score is 875
el("bandV").textContent = (score1200 >= CONFIG.passingHesiScore) ? `PASS (≥${CONFIG.passingHesiScore})` : `NOT YET (<${CONFIG.passingHesiScore})`;
const revisit = buildRevisitList();
el("reviewCount").textContent = revisit.length;
const ul = el("reviewTopics");
ul.innerHTML = "";
revisit.slice(0,8).forEach(r=>{
const li = document.createElement("li");
li.innerHTML = `<strong>${escapeHtml(r.topic)}</strong>
<span class="chip ${r.worst==='missed'?'bad':'warn'}">${r.worst}</span>
<div><small>${r.count} item(s) impacted • mastery ${Math.round(r.mastery)}%</small></div>`;
ul.appendChild(li);
});
if (revisit.length > 8){
const li = document.createElement("li");
li.innerHTML = `<small>+ ${revisit.length-8} more… (see End Report)</small>`;
ul.appendChild(li);
}
const overallPct = overallMastery();
el("repWeighted").textContent = `Weighted: ${score1200}`;
el("repPct").textContent = `Percent: ${Math.round(overallPct)}%`;
el("repBand").textContent = `Benchmark: ${score1200 >= CONFIG.passingHesiScore ? "PASS" : "NOT YET"} (875)`;
}
function renderReport(){
rebuildTopicAgg();
const topics = uniq(QUESTIONS.map(q=>q.topic)).sort((a,b)=>a.localeCompare(b));
const tbody = el("topicTable").querySelector("tbody");
tbody.innerHTML = "";
topics.forEach(t=>{
const agg = state.topicAgg[t];
const mastery = agg && agg.possible ? (agg.earned/agg.possible)*100 : 0;
const status = mastery >= 85 ? "Strong" : (mastery >= 70 ? "Developing" : (agg ? "Needs Work" : "Not started"));
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(t)}</td>
<td>${Math.round(mastery)}%</td>
<td>${escapeHtml(status)}</td>
`;
tbody.appendChild(tr);
});
const rev = buildRevisitList();
const ul = el("revisitList");
ul.innerHTML = "";
if (rev.length === 0){
const li = document.createElement("li");
li.innerHTML = `<strong>No revisit topics yet.</strong><div><small>Answer items and this list will populate automatically.</small></div>`;
ul.appendChild(li);
} else {
rev.forEach(r=>{
const li = document.createElement("li");
li.innerHTML = `<strong>${escapeHtml(r.topic)}</strong>
<span class="chip ${r.worst==='missed'?'bad':'warn'}">${r.worst}</span>
<div><small>${r.count} item(s) impacted • mastery ${Math.round(r.mastery)}%</small></div>`;
ul.appendChild(li);
});
}
}
function renderViewer(){
const q = QUESTIONS.find(x=>x.id===state.currentId) || QUESTIONS[0];
if (!q) return;
const flagged = !!state.flagged[q.id];
const tag = statusTag(q.id);
const w = itemWeight(q);
const viewer = el("viewer");
viewer.innerHTML = `
<div class="header">
<div>
<h2>Q${q.id} of ${QUESTIONS.length}</h2>
<div class="prompt">${escapeHtml(q.stem)}</div>
<div class="hint">Pick your answer, submit, and the logic will auto-reveal.</div>
</div>
<div class="meta">
<span class="badge">${escapeHtml(q.type.toUpperCase())}</span>
<span class="badge">Diff ${q.difficulty}</span>
<span class="badge">Weight ${w}</span>
<span class="badge">${escapeHtml(q.topic)}</span>
<span class="badge">${escapeHtml(tag.text)}</span>
</div>
</div>
<div id="qBody"></div>
<div class="actions">
<button class="btn" id="flagBtn">${flagged ? "Unflag" : "Flag"}</button>
<button class="btn primary" id="submitBtn">Submit</button>
<button class="btn" id="clearBtn">Clear response</button>
</div>
<div class="revealBox" id="revealBox" style="display:none">
<div class="rtitle">Answer + Logic (3 ways)</div>
<div id="revealContent"></div>
</div>
`;
const body = viewer.querySelector("#qBody");
body.appendChild(renderQuestionBody(q));
viewer.querySelector("#flagBtn").onclick = ()=>{
state.flagged[q.id] = !state.flagged[q.id];
saveState(); renderAll(true);
};
viewer.querySelector("#clearBtn").onclick = ()=>{
delete state.answers[q.id];
delete state.scores[q.id];
rebuildTopicAgg();
saveState(); renderAll(true);
};
viewer.querySelector("#submitBtn").onclick = ()=>{
const payload = readAnswerPayload(q);
state.answers[q.id] = payload;
const scored = scoreQuestion(q, payload);
state.scores[q.id] = scored;
rebuildTopicAgg();
saveState();
renderAll(true);
const rb = el("viewer").querySelector("#revealBox");
rb.style.display = "block";
el("viewer").querySelector("#revealContent").innerHTML = renderReveal(q);
};
if (state.scores[q.id]){
const rb = viewer.querySelector("#revealBox");
rb.style.display = "block";
viewer.querySelector("#revealContent").innerHTML = renderReveal(q);
}
}
function next(){
const idx = QUESTIONS.findIndex(q=>q.id===state.currentId);
if (idx < QUESTIONS.length-1){
state.currentId = QUESTIONS[idx+1].id;
saveState(); renderAll(true);
}
}
function prev(){
const idx = QUESTIONS.findIndex(q=>q.id===state.currentId);
if (idx > 0){
state.currentId = QUESTIONS[idx-1].id;
saveState(); renderAll(true);
}
}
function renderAll(keepScroll=false){
renderSidebar();
renderProgress();
renderViewer();
renderReport();
if (!keepScroll){
window.scrollTo({top:0, behavior:"auto"});
}
}
/* =========================
Events
========================= */
el("filterMode").addEventListener("change", ()=>renderSidebar());
el("topicFilter").addEventListener("change", ()=>renderSidebar());
el("searchBox").addEventListener("input", ()=>renderSidebar());
el("nextBtn").addEventListener("click", next);
el("prevBtn").addEventListener("click", prev);
el("resetBtn").addEventListener("click", ()=>{
if (confirm("Reset this Unit 1 session? This clears answers and progress.")) resetState();
});
el("jumpReportBtn").addEventListener("click", ()=>{
el("reportCard").scrollIntoView({behavior:"smooth", block:"start"});
});
el("downloadBtn").addEventListener("click", ()=>{
const payload = { unit: CONFIG.unitName, exportedAt: new Date().toISOString(), state };
const blob = new Blob([JSON.stringify(payload,null,2)], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "unit1_session.json";
a.click();
URL.revokeObjectURL(url);
});
/* =========================
Init
========================= */
(function init(){
rebuildTopicAgg();
renderAll();
})();
</script>
</body>
</html>
</html>
[[Category:Herzing University/Games]]
[[Category:HU NSG 520 Pathophysiology and Pharmacology]]