|
|
| (10 intermediate revisions by the same user not shown) |
| Line 1: |
Line 1: |
| <!-- Embedded HTML Study Application -->
| | {{#widget:520Unit1DosageCalc}} |
| <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:HU NSG 520 Pathophysiology and Pharmacology]] |
| [[Category:Herzing University/Games]] | | [[Category:Herzing University/Games]] |