/* ============================================================ The 5 Rights of Medication — immersive coaching sim ------------------------------------------------------------ Flow: intro → for each of 3 patients: [MAR warm-up → stations] → summary Feedback ("correct / incorrect, and why") appears in a speech bubble above the preceptor's head. Edit mode (preceptor/author only): move blocks, rewrite text, toggle bold, and adjust font size. ============================================================ */ const { useState, useEffect, useRef, useContext, createContext, createElement } = React; const C = window.COURSE; /* ---------- author tweaks (host-persisted) ---------- */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": ["#13796f", "#1aa395", "#4fd3c0", "#0c302d"], "passMark": 80, "shuffle": true, "motion": true }/*EDITMODE-END*/; /* Accent themes — each recolors the teal family + the two gradient vars. */ const THEMES = [ { name: "Teal", chip: ["#13796f","#1aa395","#4fd3c0","#0c302d"], vars: {"--teal":"#13796f","--teal-bright":"#1aa395","--teal-deep":"#0c302d","--teal-soft":"#e4f0ee","--mint":"#4fd3c0","--av-grad":"linear-gradient(150deg,#1fb6a6,#0f5e57)","--deep-grad":"linear-gradient(150deg,#0f3d39,#0c302d)"} }, { name: "Ocean", chip: ["#1f6fb2","#2a8fd0","#5cb8e8","#10233b"], vars: {"--teal":"#1f6fb2","--teal-bright":"#2a8fd0","--teal-deep":"#10233b","--teal-soft":"#e6eef7","--mint":"#5cb8e8","--av-grad":"linear-gradient(150deg,#3aa0dc,#155a95)","--deep-grad":"linear-gradient(150deg,#123a5c,#10233b)"} }, { name: "Forest", chip: ["#2f8f4e","#3fae63","#6fd48f","#12331f"], vars: {"--teal":"#2f8f4e","--teal-bright":"#3fae63","--teal-deep":"#12331f","--teal-soft":"#e6f2ea","--mint":"#6fd48f","--av-grad":"linear-gradient(150deg,#43b56a,#1f6b3c)","--deep-grad":"linear-gradient(150deg,#164326,#12331f)"} }, { name: "Plum", chip: ["#6d4bb0","#8a63d0","#a98be8","#241a42"], vars: {"--teal":"#6d4bb0","--teal-bright":"#8a63d0","--teal-deep":"#241a42","--teal-soft":"#ece6f6","--mint":"#a98be8","--av-grad":"linear-gradient(150deg,#9a6fe0,#5a3aa0)","--deep-grad":"linear-gradient(150deg,#2f2255,#241a42)"} } ]; /* ---------- icons ---------- */ const Ic = { check: (p) => , x: (p) => , alert: (p) => , pill: (p) => , clip: (p) => , edit: () => , refresh: () => , sound: () => , mute: () => , mic: (p) => , play: (p) => , stopsq: (p) => , trash: (p) => , download: () => , upload: () => , print: (p) => , exit: (p) => }; /* ============================================================ RECORDED AUDIO — author records the preceptor's lines from the mic; clips are stored as Blobs in IndexedDB and auto-played when the matching slide / feedback appears. ============================================================ */ const AudioDB = { _db: null, open() { if (this._db) return Promise.resolve(this._db); return new Promise((res, rej) => { const r = indexedDB.open("mac_audio_v1", 1); r.onupgradeneeded = () => { if (!r.result.objectStoreNames.contains("clips")) r.result.createObjectStore("clips"); }; r.onsuccess = () => { this._db = r.result; res(this._db); }; r.onerror = () => rej(r.error); }); }, async put(key, blob) { const db = await this.open(); return new Promise((res, rej) => { const tx = db.transaction("clips", "readwrite"); tx.objectStore("clips").put(blob, key); tx.oncomplete = res; tx.onerror = () => rej(tx.error); }); }, async get(key) { const db = await this.open(); return new Promise((res, rej) => { const tx = db.transaction("clips", "readonly"); const q = tx.objectStore("clips").get(key); q.onsuccess = () => res(q.result || null); q.onerror = () => rej(q.error); }); }, async del(key) { const db = await this.open(); return new Promise((res, rej) => { const tx = db.transaction("clips", "readwrite"); tx.objectStore("clips").delete(key); tx.oncomplete = res; tx.onerror = () => rej(tx.error); }); }, async keys() { const db = await this.open(); return new Promise((res, rej) => { const tx = db.transaction("clips", "readonly"); const q = tx.objectStore("clips").getAllKeys(); q.onsuccess = () => res(q.result || []); q.onerror = () => rej(q.error); }); } }; /* Ask the browser to keep our recordings instead of evicting them. */ if (navigator.storage && navigator.storage.persist) { navigator.storage.persist().catch(() => {}); } /* base64 data-URI <-> Blob helpers (for the bundled audio file) */ function dataURItoBlob(uri) { const comma = uri.indexOf(","); const meta = uri.slice(0, comma), b64 = uri.slice(comma + 1); const mime = (meta.match(/data:([^;]+)/) || [])[1] || "audio/webm"; const bin = atob(b64), arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return new Blob([arr], { type: mime }); } function blobToDataURI(blob) { return new Promise((res) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.readAsDataURL(blob); }); } /* Resolve a clip: live recording (IndexedDB) first, then bundled file. */ async function resolveClip(key) { try { const b = await AudioDB.get(key); if (b) return b; } catch (e) {} const bundled = window.NARRATION_AUDIO && window.NARRATION_AUDIO[key]; if (bundled) { try { return dataURItoBlob(bundled); } catch (e) {} } return null; } async function hasClip(key) { try { const b = await AudioDB.get(key); if (b) return true; } catch (e) {} return !!(window.NARRATION_AUDIO && window.NARRATION_AUDIO[key]); } /* Export every recording (live + bundled) to a narration-audio.js download. */ async function exportAudioPack() { const map = {}; if (window.NARRATION_AUDIO) Object.assign(map, window.NARRATION_AUDIO); let keys = []; try { keys = await AudioDB.keys(); } catch (e) {} for (const k of keys) { try { const b = await AudioDB.get(k); if (b) map[k] = await blobToDataURI(b); } catch (e) {} } const count = Object.keys(map).length; if (!count) { alert("No recordings yet, record some lines in edit mode first."); return; } const body = "/* Narration audio for the Medication Administration course.\n " + count + " clip(s). Generated " + new Date().toLocaleString() + ".\n Hand this file back to have it bundled into the course. */\nwindow.NARRATION_AUDIO = " + JSON.stringify(map, null, 0) + ";\n"; const blob = new Blob([body], { type: "application/javascript" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "narration-audio.js"; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } /* Import a narration-audio.js (or .json) pack back into the live editor. */ function pickAndImportAudio(onDone) { const input = document.createElement("input"); input.type = "file"; input.accept = ".js,.json,application/javascript,application/json,text/javascript"; input.onchange = () => { const f = input.files && input.files[0]; if (f) importAudioPack(f, onDone); }; input.click(); } function importAudioPack(file, onDone) { const fr = new FileReader(); fr.onload = async () => { let map = null; try { const txt = String(fr.result); const m = txt.match(/window\.NARRATION_AUDIO\s*=\s*(\{[\s\S]*\})\s*;?\s*$/); map = m ? JSON.parse(m[1]) : JSON.parse(txt); } catch (e) { alert("Couldn't read that file, expected an exported narration-audio.js."); return; } let n = 0; for (const k of Object.keys(map)) { try { await AudioDB.put(k, dataURItoBlob(map[k])); n++; } catch (e) {} } Object.assign(window.NARRATION_AUDIO || (window.NARRATION_AUDIO = {}), map); alert("Imported " + n + " recording(s)."); onDone && onDone(); }; fr.readAsText(file); } const Player = { el: null, url: null, token: 0, stop() { this.token++; if (this.el) { this.el.onended = null; try { this.el.pause(); } catch (e) {} this.el = null; } if (this.url) { URL.revokeObjectURL(this.url); this.url = null; } }, async play(key) { return this.playSeq([key]); }, async playSeq(keys) { this.stop(); const my = this.token; const blobs = []; for (const k of keys) { const b = await resolveClip(k); if (b) blobs.push(b); } if (my !== this.token || !blobs.length) return; let i = 0; const next = () => { if (my !== this.token || i >= blobs.length) return; const b = blobs[i++]; if (this.url) URL.revokeObjectURL(this.url); this.url = URL.createObjectURL(b); this.el = new Audio(this.url); this.el.onended = next; this.el.play().catch(() => {}); }; next(); } }; /* ---------- persistence ---------- */ const load = (k, fb) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch(e){ return fb; } }; const save = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch(e){} }; const initials = (name) => name.split(/[\s,]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase(); /* ---------- entrance reveal ---------- */ function useShown(){ const [shown, setShown] = useState(false); useEffect(() => { const id = setTimeout(() => setShown(true), 40); return () => clearTimeout(id); }, []); return shown ? " in" : ""; } /* ---------- deterministic per-question choice order ---------- The correct answer is authored first in every question, which made it always land in slot "A". This returns a STABLE shuffled list of the original choice indices, seeded by the question key — so the order is identical across renders, reloads and narration playback, but the correct answer is spread across A/B/C/D instead of always first. Each choice keeps its ORIGINAL index for audio / feedback / text-edit lookups; only its on-screen position and letter change. */ function choiceOrder(key, n, shuffle) { if (n <= 1) return [0]; // When the author turns shuffling off, keep the authored order (correct-first). if (shuffle === false) return Array.from({ length: n }, (_, i) => i); // Fixed salt chosen so the 14 correct answers spread evenly across // A/B/C/D (4/4/4/2) instead of clustering — keeps the order stable while // breaking the "always A" pattern. const seed = key + "#63"; // fnv-1a hash → 32-bit seed let h = 2166136261; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); } // mulberry32 PRNG const rng = () => { h |= 0; h = (h + 0x6D2B79F5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; let order = Array.from({ length: n }, (_, i) => i); // Fisher-Yates shuffle — uniform spread of the correct answer across slots. for (let i = n - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } return order; } /* ---------- confetti burst (self-contained, no dependency) ---------- Fired once when a learner passes (≥ pass mark) on the summary screen. Two cannons launch from the bottom-left and bottom-right corners, arc inward and upward, then drift down slowly. Honors prefers-reduced-motion by simply not animating. */ function fireConfetti() { if (typeof document === "undefined") return; if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const cv = document.createElement("canvas"); cv.style.cssText = "position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:6000;"; document.body.appendChild(cv); const ctx = cv.getContext("2d"); const DPR = Math.min(window.devicePixelRatio || 1, 2); let W = window.innerWidth, H = window.innerHeight; const resize = () => { W = window.innerWidth; H = window.innerHeight; cv.width = W * DPR; cv.height = H * DPR; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); }; resize(); window.addEventListener("resize", resize); const colors = ["#13796f", "#1aa395", "#4fd3c0", "#f3c969", "#e7a13d", "#2f9e44", "#ffffff"]; const parts = []; // Two corner cannons: dir +1 fires up-and-right, dir -1 fires up-and-left. const cannon = (ox, oy, dir, count) => { for (let i = 0; i < count; i++) { const ang = (Math.PI / 180) * (52 + Math.random() * 40); // 52–92° above horizontal const speed = 11 + Math.random() * 13; parts.push({ x: ox + dir * Math.random() * 30, y: oy + Math.random() * 12, vx: dir * Math.cos(ang) * speed, vy: -Math.sin(ang) * speed, g: 0.13 + Math.random() * 0.06, w: 6 + Math.random() * 7, h: 9 + Math.random() * 8, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.32, color: colors[(Math.random() * colors.length) | 0], sway: Math.random() * Math.PI * 2 }); } }; cannon(0, H, 1, 130); cannon(W, H, -1, 130); const start = performance.now(), DURATION = 7000, FADE = 1600; let raf; const frame = (t) => { const elapsed = t - start; ctx.clearRect(0, 0, W, H); let alive = false; for (const p of parts) { p.sway += 0.05; p.vy += p.g; p.vx *= 0.992; p.x += p.vx + Math.sin(p.sway) * 0.6; p.y += p.vy; p.rot += p.vr; const fade = elapsed > DURATION - FADE ? Math.max(0, (DURATION - elapsed) / FADE) : 1; if (p.y < H + 30 && fade > 0) alive = true; ctx.save(); ctx.globalAlpha = fade; ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.restore(); } if (elapsed < DURATION && alive) raf = requestAnimationFrame(frame); else { cancelAnimationFrame(raf); window.removeEventListener("resize", resize); cv.remove(); } }; raf = requestAnimationFrame(frame); } /* ============================================================ EDIT CONTEXT — text, position, and per-element formatting ============================================================ */ const EditContext = createContext(null); const useEdit = () => useContext(EditContext); function EditableText({ id, value, tag = "span", className, style }) { const { editMode, getText, setText, getStyle, setActiveFmt, activeFmt } = useEdit(); const text = getText(id, value); const fmt = getStyle(id); const ref = useRef(null); useEffect(() => { if (ref.current && document.activeElement !== ref.current && ref.current.textContent !== text) { ref.current.textContent = text; } }); const fmtStyle = {}; if (fmt.weight) fmtStyle.fontWeight = fmt.weight; if (fmt.size) fmtStyle.fontSize = fmt.size + "px"; return createElement(tag, { ref, "data-fmt-id": id, className: (className || "") + (editMode ? " editable-on" : "") + (editMode && activeFmt === id ? " editable-active" : ""), style: { ...style, ...fmtStyle }, contentEditable: editMode, suppressContentEditableWarning: true, spellCheck: false, onFocus: editMode ? () => setActiveFmt(id) : undefined, // Autosave: commit on every keystroke (not just on blur) so nothing is // lost if the page is refreshed or closed while a field is still focused. // The useEffect above guards on document.activeElement, so committing // mid-edit never resets the caret position. onInput: editMode ? (e) => setText(id, e.currentTarget.textContent) : undefined, onBlur: editMode ? (e) => setText(id, e.currentTarget.textContent) : undefined }); } function Movable({ id, className, style, children }) { const { editMode, getPos, setPos } = useEdit(); const pos = getPos(id); const drag = useRef(null); const onDown = (e) => { if (!editMode) return; e.preventDefault(); e.stopPropagation(); drag.current = { sx: e.clientX, sy: e.clientY, ox: pos.x, oy: pos.y }; const move = (ev) => { const d = drag.current; if (!d) return; setPos(id, { x: d.ox + (ev.clientX - d.sx), y: d.oy + (ev.clientY - d.sy) }); }; const up = () => { drag.current = null; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; const base = style && style.transform ? style.transform + " " : ""; return (
{editMode && ⠿} {children}
); } /* floating format toolbar — bold + size for the selected text block */ function FormatBar() { const { activeFmt, getStyle, setStyle } = useEdit(); if (!activeFmt) return null; const fmt = getStyle(activeFmt); const node = document.querySelector('[data-fmt-id="' + (window.CSS && CSS.escape ? CSS.escape(activeFmt) : activeFmt) + '"]'); const baseSize = fmt.size || (node ? Math.round(parseFloat(getComputedStyle(node).fontSize)) : 14); const isBold = (fmt.weight || 0) >= 700; const bumpSize = (d) => setStyle(activeFmt, { size: Math.max(9, Math.min(64, baseSize + d)) }); return (
e.preventDefault()}> Format: selected text
{baseSize}px
); } /* ============================================================ AUDIO RECORDER — shown in edit mode next to a narration line. Records from the mic, stores the clip under `id`, and offers play / re-record / delete. ============================================================ */ function AudioRec({ id, label }) { const { editMode, audioVer, bumpAudio } = useEdit(); const [has, setHas] = useState(false); const [rec, setRec] = useState(false); const [err, setErr] = useState(false); const mr = useRef(null); useEffect(() => { let on = true; hasClip(id).then((h) => { if (on) setHas(!!h); }).catch(() => {}); return () => { on = false; }; }, [id, audioVer]); if (!editMode) return null; const start = async (e) => { e && e.stopPropagation(); setErr(false); try { Player.stop(); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const m = new MediaRecorder(stream); const chunks = []; m.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); }; m.onstop = async () => { const blob = new Blob(chunks, { type: m.mimeType || "audio/webm" }); try { await AudioDB.put(id, blob); } catch (e2) {} stream.getTracks().forEach((t) => t.stop()); setRec(false); setHas(true); bumpAudio(); }; m.start(); mr.current = m; setRec(true); } catch (e2) { setErr(true); setRec(false); } }; const stop = (e) => { e && e.stopPropagation(); if (mr.current && mr.current.state !== "inactive") mr.current.stop(); }; const play = (e) => { e && e.stopPropagation(); Player.play(id); }; const del = async (e) => { e && e.stopPropagation(); try { await AudioDB.del(id); } catch (e2) {} setHas(false); bumpAudio(); }; return ( e.stopPropagation()}> {rec ? : } {has && !rec && } {has && !rec && } {err && mic blocked} ); } /* ============================================================ SCENE — hospital background + preceptor figure ============================================================ */ function Scene() { const t = C.trainer; return (
); } /* ============================================================ SPEECH BUBBLE — preceptor's "correct / incorrect, and why" ============================================================ */ function SpeechBubble({ result, ok, onContinue, onRetry, ctaLabel, audioId, fbId }) { const [shown, setShown] = useState(false); useEffect(() => { const id = setTimeout(() => setShown(true), 30); return () => clearTimeout(id); }, []); const t = C.trainer; return (
{t.avatar || initials(t.name)}
{t.name.split(",")[0]} {ok ? : } {ok ? "Correct" : "Not yet"}

{audioId && }
{ok ? : }
); } /* ============================================================ TOP BAR — 3 patient pills ============================================================ */ function TopBar({ patientIndex, screen, editMode, narrate, saveStatus, onToggleNarrate, onToggleEdit, onResetLayout, onRestart, onExportAudio, onImportAudio }) { const reached = screen === "summary" ? C.patients.length : (screen === "intro" ? -1 : patientIndex); return (
{Ic.clip({ s: 19 })}
{C.title}Medication safety · new-nurse training
{C.patients.map((p, i) => { const state = i < reached ? "done" : i === reached ? "current" : ""; return (
{i < reached ? : i + 1}
{p.name.split(" ")[0]}
{i < C.patients.length - 1 &&
} ); })}
{editMode && ( {saveStatus === "saving" ? "Saving…" : "All changes saved"} )} {screen !== "intro" && } {editMode && } {editMode && } {editMode && }
); } /* ============================================================ MAR — top-left. mode "quiz" blanks one field; "live" highlights. ============================================================ */ function MarPanel({ patient, mode, missing, highlight }) { const p = patient; const hl = highlight || []; const isQuiz = mode === "quiz"; const shown = useShown(); const nkda = /nkda|no known/i.test(p.allergy); const gap = (cls) => ; return (
{Ic.clip({ s: 17 })}
Medication Administration Record
{isQuiz ? "MAR · practice copy" : "MAR · 0800 Pass"}
{isQuiz && Spot the gap}
{isQuiz && missing === "allergy" ?
Allergy · {gap("wide")}
:
Allergy ·
}
MedicationSched
{p.mar.map((m) => (
{m.flag && !isQuiz &&
{m.flag} allergy
}
{" · "} {isQuiz && missing === "route" ? gap() : }
{isQuiz && missing === "sched" ? {gap()}{gap("block")} : }
))}
{Ic.pill({ c: "#13796f", s: 13 })}
); } /* ============================================================ QUESTION CARD — shared by warm-up and stations ============================================================ */ function QuestionCard({ patient, kind, chip, chipTone, stepLabel, question, picked, eliminated, onChoose, shuffle }) { const shown = useShown(); const cardKey = patient.id + "-" + kind + "-" + (stepLabel || ""); const elim = eliminated || []; return (
{initials(patient.name)}
{patient.name}
{patient.room}
{stepLabel}
{Ic.pill({ c: chipTone === "amber" ? "#7a5410" : "#0c302d", s: 14 })} {chip} {question.context && (

)}

{choiceOrder(cardKey, question.choices.length, shuffle).map((orig, pos) => { const c = question.choices[orig]; const isPicked = picked && picked.i === orig; const isElim = !isPicked && elim.includes(orig); let cls = "choice"; if (isPicked) cls += c.correct ? " correct" : " wrong"; else if (isElim) cls += " eliminated"; else if (picked) cls += " dim"; const disabled = !!picked || isElim; const btn = ( ); return (
{btn}
); })}
); } /* ============================================================ INTRO ============================================================ */ function Intro({ onStart }) { const i = C.intro, t = C.trainer; const shown = useShown(); return (

{t.avatar || initials(t.name)}

{C.checkpoints.map((c, n) => (
{c.k}
))}
{i.rights.map((r, n) => ( {n + 1} ))}
{Ic.alert()}

); } /* ============================================================ SUMMARY ============================================================ */ function Summary({ wrong, total, onRestart, onEnd, passMark }) { const s = C.summary; const shown = useShown(); const correctPct = total ? Math.round(((total - wrong) / total) * 100) : 0; const PASS_MARK = passMark || 80; // % correct needed to pass const pass = correctPct >= PASS_MARK; const correct = total - wrong; const [name, setName] = useState(() => load("mac_print_name", "")); useEffect(() => save("mac_print_name", name), [name]); const dateStr = new Date().toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); // Celebrate a passing score once, when the summary first appears. useEffect(() => { if (pass) { const id = setTimeout(fireConfetti, 350); return () => clearTimeout(id); } }, []); return (
{pass ? : } {pass ? Passed. {correctPct}% correct. Nicely done. : {correctPct}%, not yet. You need {PASS_MARK}% or higher to pass. Review the five rights below, then try again.}
{pass ?

:

} {pass ?

:

}
{wrong}/ {total} {wrong === 1 ? "question" : "questions"} answered
incorrectly
{correctPct}%correct
{s.rights.map((r, i) => (

))}

Remember

    {s.remember.map((r, i) =>
  • )}
{pass ?
setName(e.target.value)} maxLength={60} />
:
}
{pass && ReactDOM.createPortal( , document.body)}
); } /* ============================================================ DONE — clean closing screen after the learner ends the course ============================================================ */ function Done({ onRestart }) { const shown = useShown(); return (
{Ic.check({ s: 34 })}

); } /* ============================================================ APP — state machine ============================================================ */ function App() { const saved = load("mac_state_v4", {}); const [screen, setScreen] = useState(saved.screen || "intro"); // intro | warmup | station | summary | done const [pi, setPi] = useState(saved.pi || 0); // patient index const [si, setSi] = useState(saved.si || 0); // station index const [di, setDi] = useState(saved.di || 0); // decision index // Set of question keys the learner answered incorrectly at least once. // A question counts as ONE wrong regardless of how many times it was missed. const [wrongKeys, setWrongKeys] = useState(saved.wrongKeys || []); const [picked, setPicked] = useState(null); // Original indices of choices the learner has already tried and got wrong on // the CURRENT question — greyed out and disabled so they aren't re-picked. const [eliminated, setEliminated] = useState([]); // Total number of distinct questions in the course (1 warm-up MAR check // per patient + every station decision). const totalQuestions = C.patients.reduce( (n, p) => n + 1 + p.stations.reduce((m, st) => m + st.decisions.length, 0), 0); const [editMode, setEditMode] = useState(false); const [activeFmt, setActiveFmt] = useState(null); const [audioVer, setAudioVer] = useState(0); const [edits, setEdits] = useState(() => load("mac_edits_v4", { text: {}, pos: {}, style: {} })); const [narrate, setNarrate] = useState(() => load("mac_narrate_v1", true)); const [saveStatus, setSaveStatus] = useState("saved"); // "saved" | "saving" const [tw, setTweak] = useTweaks(TWEAK_DEFAULTS); /* Apply the chosen accent theme to the CSS variables. */ useEffect(() => { const root = document.documentElement; const theme = THEMES.find((t) => JSON.stringify(t.chip) === JSON.stringify(tw.accent)) || THEMES[0]; Object.entries(theme.vars).forEach(([k, v]) => root.style.setProperty(k, v)); }, [tw.accent]); /* Entrance-motion toggle — kills the slide-in transforms when off. */ useEffect(() => { document.body.classList.toggle("no-motion", !tw.motion); }, [tw.motion]); useEffect(() => save("mac_state_v4", { screen, pi, si, di, wrongKeys }), [screen, pi, si, di, wrongKeys]); /* --- AUTOSAVE: every text rewrite, move, or format change writes through to localStorage immediately, with a brief "Saving…/Saved" status. --- */ const firstEdits = useRef(true); useEffect(() => { if (firstEdits.current) { firstEdits.current = false; return; } setSaveStatus("saving"); save("mac_edits_v4", edits); const id = setTimeout(() => setSaveStatus("saved"), 350); return () => clearTimeout(id); }, [edits]); /* --- Safety net: if the tab is hidden or closed while a field is still focused, force-flush the latest edits (incl. the focused field's current text) so the very last keystroke is never lost. --- */ const editsRef = useRef(edits); editsRef.current = edits; useEffect(() => { const flush = () => { let e = editsRef.current; const el = document.activeElement; if (el && el.getAttribute && el.hasAttribute && el.hasAttribute("data-fmt-id")) { const fid = el.getAttribute("data-fmt-id"); e = { ...e, text: { ...e.text, [fid]: el.textContent } }; } save("mac_edits_v4", e); }; document.addEventListener("visibilitychange", flush); window.addEventListener("pagehide", flush); window.addEventListener("beforeunload", flush); return () => { document.removeEventListener("visibilitychange", flush); window.removeEventListener("pagehide", flush); window.removeEventListener("beforeunload", flush); }; }, []); useEffect(() => { if (!editMode) setActiveFmt(null); }, [editMode]); useEffect(() => save("mac_narrate_v1", narrate), [narrate]); const editApi = { editMode, getText: (id, def) => (edits.text[id] !== undefined ? edits.text[id] : def), setText: (id, val) => setEdits((e) => ({ ...e, text: { ...e.text, [id]: val } })), getPos: (id) => edits.pos[id] || { x: 0, y: 0 }, setPos: (id, p) => setEdits((e) => ({ ...e, pos: { ...e.pos, [id]: p } })), getStyle: (id) => edits.style[id] || {}, setStyle: (id, partial) => setEdits((e) => ({ ...e, style: { ...e.style, [id]: { ...(e.style[id] || {}), ...partial } } })), activeFmt, setActiveFmt, audioVer, bumpAudio: () => setAudioVer((x) => x + 1) }; const resetLayout = () => setEdits((e) => ({ ...e, pos: {}, style: {} })); const patient = C.patients[pi]; const start = () => { setScreen("warmup"); setPi(0); setSi(0); setDi(0); setPicked(null); setEliminated([]); }; const restart = () => { Player.stop(); setScreen("intro"); setPi(0); setSi(0); setDi(0); setWrongKeys([]); setPicked(null); setEliminated([]); }; // End course → clean closing screen (keeps score/edits; does not reset). const endCourse = () => { Player.stop(); setScreen("done"); }; // Stable identity for the question currently on screen. const questionKey = () => screen === "warmup" ? (patient.id + "-warmup") : (patient.id + "-st" + si + "-d" + di); const choose = (i) => { if (picked) return; const q = screen === "warmup" ? patient.marQuiz : patient.stations[si].decisions[di]; const c = q.choices[i]; setPicked({ i }); if (!c.correct) { const k = questionKey(); setEliminated((prev) => prev.includes(i) ? prev : [...prev, i]); setWrongKeys((prev) => prev.includes(k) ? prev : [...prev, k]); } }; // Retry keeps the eliminated (already-tried-wrong) choices greyed out. const retry = () => setPicked(null); const cont = () => { setPicked(null); setEliminated([]); if (screen === "warmup") { setScreen("station"); setSi(0); setDi(0); return; } // station flow const st = patient.stations[si]; if (di < st.decisions.length - 1) { setDi(di + 1); return; } if (si < patient.stations.length - 1) { setSi(si + 1); setDi(0); return; } // finished this patient if (pi < C.patients.length - 1) { setPi(pi + 1); setSi(0); setDi(0); setScreen("warmup"); return; } setScreen("summary"); }; // active question + feedback context for the speech bubble let activeQ = null, chip = null, chipTone = "teal", stepLabel = "", kind = ""; if (screen === "warmup") { activeQ = patient.marQuiz; kind = "warmup"; chip = "MAR check · " + patient.index; chipTone = "amber"; stepLabel = "Warm-up"; } else if (screen === "station") { const st = patient.stations[si]; activeQ = st.decisions[di]; kind = "station"; chip = st.right; stepLabel = "Step " + (si + 1) + " of " + patient.stations.length; } const lastStepOfPatient = screen === "station" && si === patient.stations.length - 1 && di === patient.stations[si].decisions.length - 1; const isLastPatient = pi === C.patients.length - 1; let ctaLabel = "Continue"; if (screen === "warmup") ctaLabel = "Start the pass"; else if (lastStepOfPatient) ctaLabel = isLastPatient ? "Finish & review" : "Next patient"; const chosen = picked && activeQ ? activeQ.choices[picked.i] : null; const cardKey = activeQ ? (patient.id + "-" + kind + "-" + (stepLabel || "")) : null; const feedbackAudioId = (cardKey && picked) ? ("aud:" + cardKey + "-c" + picked.i + "-fb") : null; const feedbackBaseId = (cardKey && picked) ? (cardKey + "-c" + picked.i + "-fb") : null; const toggleNarrate = () => setNarrate((v) => { const nv = !v; if (!nv) Player.stop(); return nv; }); /* --- play recorded narration for the scenario + question + options when a slide starts --- */ useEffect(() => { if (!narrate || picked) { return; } if (screen !== "warmup" && screen !== "station" || !cardKey) return; const keys = ["aud:" + cardKey + "-ctx", "aud:" + cardKey + "-prompt"] .concat(choiceOrder(cardKey, activeQ.choices.length, tw.shuffle).map((orig) => "aud:" + cardKey + "-c" + orig)); const id = setTimeout(() => Player.playSeq(keys), 460); return () => { clearTimeout(id); Player.stop(); }; }, [narrate, screen, pi, si, di]); /* --- play recorded feedback when the bubble appears --- */ useEffect(() => { if (!narrate || !chosen || !feedbackAudioId) return; const id = setTimeout(() => Player.play(feedbackAudioId), 120); return () => clearTimeout(id); }, [narrate, chosen]); /* --- play recorded greeting on the intro --- */ useEffect(() => { if (!narrate || screen !== "intro") return; const id = setTimeout(() => Player.play("aud:greeting"), 540); return () => { clearTimeout(id); Player.stop(); }; }, [narrate, screen]); return (
setEditMode(!editMode)} onResetLayout={resetLayout} onRestart={restart} onExportAudio={exportAudioPack} onImportAudio={() => pickAndImportAudio(() => setAudioVer((x) => x + 1))} /> {editMode && (
{Ic.edit()} Edit mode. Click any text to rewrite · drag ⠿ to move a block · select text to bold & resize · {Ic.mic({ s: 12 })} record your voice next to each line · drop your own background & preceptor photos · autosaved on this device as you go.
)} {editMode && } {(screen === "warmup" || screen === "station") && } {screen === "intro" && } {screen === "summary" && } {screen === "done" && } {activeQ && ( )} {chosen && ( )} t.chip)} onChange={(v) => setTweak("accent", v)} /> setTweak("motion", v)} /> setTweak("passMark", v)} /> setTweak("shuffle", v)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();