/* ============================================================
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
setStyle(activeFmt, { weight: isBold ? 500 : 700 })}>B
bumpSize(-1)}>A−
{baseSize}px
bumpSize(1)}>A+
);
}
/* ============================================================
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
? Stop
: {Ic.mic({ s: 13 })}{label || (has ? "Re-record" : "Record")} }
{has && !rec && {Ic.play()} }
{has && !rec && {Ic.trash()} }
{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
? {ctaLabel || "Continue"} →
: {Ic.refresh()} Try this step again }
);
}
/* ============================================================
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"}
)}
{narrate ? Ic.sound() : Ic.mute()} {narrate ? "Audio on" : "Audio off"}
{screen !== "intro" && {Ic.refresh()} Restart }
{Ic.edit()} {editMode ? "Editing" : "Edit"}
{editMode && Reset layout }
{editMode && {Ic.download()} Export audio }
{editMode && {Ic.upload()} Import audio }
);
}
/* ============================================================
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 ·
}
Medication Sched
{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 = (
onChoose(orig)} style={{ "--d": (0.32 + pos * 0.09) + "s" }}>
{isPicked ? (c.correct ? : ) : (isElim ? : String.fromCharCode(65 + pos))}
);
return (
);
})}
);
}
/* ============================================================
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) => (
))}
{i.rights.map((r, n) => (
{n + 1}
))}
→
);
}
/* ============================================================
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
?
:
{Ic.refresh()}
{Ic.exit()}
}
{pass && ReactDOM.createPortal(
{Ic.check({ s: 30 })}
Certificate of Completion
The 5 Rights of Medication
Safe Medication Administration · New-Nurse Training
This certifies that
{name.trim() || "_______________________________"}
has successfully completed the medication administration course, demonstrating correct application of the five rights across three patient passes.
{correctPct}% Score
{correct}/{total} Correct
PASSED Result · {PASS_MARK}% required
{s.rights.map((r, i) =>
✓ {r.t}
)}
{C.trainer.name}
{C.trainer.role}
, document.body)}
);
}
/* ============================================================
DONE — clean closing screen after the learner ends the course
============================================================ */
function Done({ onRestart }) {
const shown = useShown();
return (
{Ic.check({ s: 34 })}
{Ic.refresh()}
);
}
/* ============================================================
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( );