/* Quiz admin SPA. * * Single page, no router. boot() decides between login form and dashboard * based on whether GET /admin/api/state returns 200 (authed) or 401. * * The dashboard is state-driven: a single primary action button per * session state (Start / Stop early / Next / Finish / Reset). The QR * code, join URL, and participant list are always visible on the left * so the operator can leave the page on a projector. */ const app = document.querySelector("#admin-app"); const store = { session: null, // /admin/api/state response ws: null, roster: [], currentQuestion: null, histogram: null, totalCount: 0, submittedCount: 0, closedPayload: null, // last question_closed message leaderboard: [], endedPayload: null, notice: null, questionDeadlineMs: null, }; let countdownTimer = null; function escapeText(value) { return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); } async function api(path, options = {}) { const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers; const response = await fetch(path, { credentials: "same-origin", ...options, headers, }); if (response.status === 401) { const error = new Error("unauthorized"); error.status = 401; throw error; } if (!response.ok) { const error = new Error(await response.text()); error.status = response.status; throw error; } const contentType = response.headers.get("content-type") || ""; return contentType.includes("json") ? response.json() : response.text(); } async function boot() { try { store.session = await api("/admin/api/state"); store.notice = null; renderDashboard(); connectWS(); } catch (err) { if (err.status === 401) { renderLogin(); } else if (err.status === 503) { renderUnavailable(err.message || "Session not initialised on the server."); } else { renderUnavailable(err.message || "Could not load admin state."); } } } function renderUnavailable(detail) { app.innerHTML = `

Quiz unavailable

${escapeText(detail)}

Verify QUIZ_POOL_PATH on the server points at a valid pool JSON, then restart quiz.service.

`; } function renderLogin(error = null) { app.innerHTML = `

Quiz admin

Sign in to control the live session.

${error ? `

${escapeText(error)}

` : ""}
`; document.querySelector("#login-form").addEventListener("submit", async (event) => { event.preventDefault(); const submit = event.submitter || event.currentTarget.querySelector("button"); submit.disabled = true; const password = new FormData(event.currentTarget).get("password"); try { await api("/admin/login", { method: "POST", body: JSON.stringify({ password }) }); await boot(); } catch (err) { submit.disabled = false; renderLogin(err.status === 401 ? "Wrong password." : "Could not sign in."); } }); } function renderDashboard() { const session = store.session; if (!session) return; const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state); app.innerHTML = `

${escapeText(session.title)}

${escapeText(session.pool_meta.question_count)} questions · ${escapeText(session.pool_meta.score_fn.replace(/_/g, " "))} · ${escapeText(session.pool_meta.time_limit_default)} s default

${escapeText(stateLabel(state))}
${store.notice ? `
${escapeText(store.notice)}
` : ""}
${renderStatePanel(state)}
`; document.querySelector("#logout-btn").addEventListener("click", logout); bindStateActions(); if (state === "question_open") startCountdown(); } function stateLabel(state) { return ({ lobby: "Lobby", question_open: "Question live", question_closed: "Reveal", between_questions: "Between", finished: "Finished", })[state] || state || "—"; } function renderJoinPanel() { const session = store.session; return `

Join

${session.qr_url ? `Join QR` : "
QR unavailable
"}
${escapeText(session.join_url)}

Session id: ${escapeText(session.sid)}

`; } function renderRosterPanel() { const r = store.roster || []; // Newest-first so late joiners are visible at the top of the list. The // first three are tagged so the CSS can warm their dot — gives the // operator a quick "yes the room is live" cue without an explicit log. const ordered = r.slice().reverse(); return `

Joined ${r.length}

${ordered.length ? `` : `

No students have joined yet. Share the QR or URL.

`}
`; } function renderStatePanel(state) { if (state === "lobby") return renderLobby(); if (state === "question_open") return renderQuestionOpen(); if (state === "question_closed" || state === "between_questions") return renderQuestionClosed(); if (state === "finished") return renderFinished(); return `

Unknown state: ${escapeText(state)}

`; } function renderLobby() { const total = store.session.pool_meta.question_count; const joined = (store.roster || []).length; return `

02 Pre-flight

Ready to start.

When you start, question 1 of ${total} opens for everyone in the room. Late joiners can still hop in mid-question; they get whatever time remains on the clock.

Joined${joined}
Questions${total}
Per question${store.session.pool_meta.time_limit_default}s
`; } function renderQuestionOpen() { const q = store.currentQuestion; if (!q) { return `

Waiting for question to broadcast…

`; } const total = store.session.pool_meta.question_count; const idx = q.question_idx; return `
Q${idx + 1} / ${total}

${escapeText(q.text)}

    ${["A","B","C","D"].map((k) => `
  1. ${k}${escapeText(q.options[k] || "")}
  2. ` ).join("")}
${renderLiveHistogram()}
`; } function renderLiveHistogram() { if (!store.histogram) return `

Awaiting the first submission…

`; const h = store.histogram; const submitted = store.submittedCount || 0; const total = Math.max(1, store.totalCount || 0); // While nobody has submitted yet, suppress the bar rows — empty bars // read as broken rather than "no data". Show a calm awaiting line. if (submitted === 0) { return `
0 submitted ${store.totalCount ? `of ${store.totalCount} joined` : ""}

Bars appear once the first answer comes in.

`; } return `
${submitted} submitted ${store.totalCount ? `of ${store.totalCount} joined` : ""} ${h.pending != null && h.pending > 0 ? `${h.pending} pending` : ""}
${["A","B","C","D"].map((k) => { const v = h[k] || 0; const pct = Math.round(100 * v / total); return `
${k}
${v}
`; }).join("")}
`; } function renderQuestionClosed() { const c = store.closedPayload; const q = store.currentQuestion; if (!c || !q) { return `

Reveal pending…

`; } const total = store.session.pool_meta.question_count; const idx = q.question_idx; const isLast = idx >= total - 1; const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (c.histogram[k] || 0), 0) + (c.histogram.missed || 0); const denom = Math.max(1, totalSubmitters); return `
Q${idx + 1} / ${total} Closed

${escapeText(q.text)}

    ${["A","B","C","D"].map((k) => { const correct = k === c.correct; return `
  1. ${k}${correct ? " ✓" : ""} ${escapeText(q.options[k] || "")} ${c.histogram[k] || 0}
  2. `; }).join("")}
${c.explanation ? `

${escapeText(c.explanation)}

` : ""}
${["A","B","C","D"].map((k) => { const v = c.histogram[k] || 0; const pct = Math.round(100 * v / denom); const correct = k === c.correct; return `
${k}
${v} (${pct}%)
`; }).join("")} ${c.histogram.missed ? `
${c.histogram.missed} missed
` : ""}

Top so far

${renderLeaderboardList(store.leaderboard.slice(0, 10))}
`; } function renderFinished() { const total = store.session.pool_meta.question_count; return `

That's a wrap.

${total} question${total === 1 ? "" : "s"} complete. Final standings below; download the CSV when you're ready to grade.

Final leaderboard

${renderLeaderboardList(store.leaderboard)}
Download CSV

Reset clears all submissions and the participant list, then returns to the lobby. The QR / join URL stays the same.

`; } function renderLeaderboardList(rows) { if (!rows || !rows.length) return `

No scores yet.

`; return `
    ${rows.map((r) => `
  1. ${r.rank} ${escapeText(r.name)}${r.student_id ? `${escapeText(r.student_id)}` : ""} ${r.score}
  2. `).join("")}
`; } function bindStateActions() { document.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", () => onAction(btn.dataset.action, btn)); }); const copy = document.querySelector("#copy-url"); if (copy) copy.addEventListener("click", copyJoinUrl); } async function onAction(action, btn) { if (action === "reset") { if (!confirm("Reset clears all participants and submissions. Continue?")) return; btn.disabled = true; try { await api("/admin/api/reset", { method: "POST" }); // Server pushes a state=lobby broadcast over WS; rerender once the // message lands, plus optimistically clear local accumulators. store.roster = []; store.histogram = null; store.currentQuestion = null; store.closedPayload = null; store.endedPayload = null; store.leaderboard = []; store.session.state = "lobby"; store.session.current_question_idx = null; renderDashboard(); } catch (err) { alert(err.message || "Reset failed."); btn.disabled = false; } return; } if (!store.ws || store.ws.readyState !== WebSocket.OPEN) { store.notice = "Reconnecting to live channel…"; renderDashboard(); connectWS(); return; } const msg = ({ next: { type: "next" }, close: { type: "close_question" }, end: { type: "end_session" }, })[action]; if (msg) { btn.disabled = true; store.ws.send(JSON.stringify(msg)); } } async function logout() { try { await api("/admin/logout", { method: "POST" }); } catch {} if (store.ws) store.ws.close(); store.ws = null; store.session = null; renderLogin(); } function copyJoinUrl() { const url = store.session?.join_url; if (!url) return; navigator.clipboard.writeText(url).then(() => { const btn = document.querySelector("#copy-url"); if (!btn) return; const original = btn.textContent; btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = original; }, 1500); }); } function connectWS() { if (store.ws) { try { store.ws.close(); } catch {} } const sid = store.session.sid; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`); store.ws = ws; ws.addEventListener("message", (event) => { try { handleWSMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); } }); ws.addEventListener("close", () => { store.notice = "Live connection dropped. Trying to reconnect…"; renderDashboard(); setTimeout(() => { if (store.session) connectWS(); }, 2000); }); ws.addEventListener("open", () => { if (store.notice && store.notice.startsWith("Live connection")) { store.notice = null; renderDashboard(); } }); } function handleWSMessage(message) { switch (message.type) { case "state": store.session.state = message.state; store.session.current_question_idx = message.current_question_idx; if (message.state === "lobby") { store.currentQuestion = null; store.closedPayload = null; store.endedPayload = null; store.histogram = null; } renderDashboard(); break; case "lobby_update": store.roster = message.participants || []; renderDashboard(); break; case "question_open": store.session.state = "question_open"; store.session.current_question_idx = message.question_idx; store.currentQuestion = message; store.closedPayload = null; store.histogram = null; store.submittedCount = 0; store.totalCount = 0; store.questionDeadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000); renderDashboard(); break; case "live_histogram": store.histogram = message.histogram; store.submittedCount = message.submitted_count; store.totalCount = message.total_count; patchHistogramOnly(); break; case "question_closed": store.session.state = "question_closed"; store.closedPayload = message; store.histogram = message.histogram; stopCountdown(); renderDashboard(); break; case "between_questions": // Not currently emitted by the new advance_to_next; safe to ignore. break; case "full_leaderboard": store.leaderboard = message.leaderboard || []; renderDashboard(); break; case "session_ended": store.session.state = "finished"; store.endedPayload = message; stopCountdown(); renderDashboard(); break; case "error": store.notice = `Server error: ${message.message || message.code || "unknown"}`; renderDashboard(); break; } } function patchHistogramOnly() { // Update histogram without re-rendering the entire dashboard, so the // countdown bar doesn't flicker. const target = document.querySelector(".question-card"); if (!target) { renderDashboard(); return; } const live = target.querySelector(".hist.live"); const replacement = renderLiveHistogram(); if (live) { const wrap = document.createElement("div"); wrap.innerHTML = replacement; live.replaceWith(wrap.firstElementChild); } else { // No histogram yet; do a full render. renderDashboard(); } } function startCountdown() { stopCountdown(); countdownTimer = setInterval(tickCountdown, 250); tickCountdown(); } function stopCountdown() { if (countdownTimer) clearInterval(countdownTimer); countdownTimer = null; } function tickCountdown() { const el = document.querySelector("#countdown"); const fill = document.querySelector("#qbar-fill"); if (!el || !fill || !store.questionDeadlineMs) return; const remaining = Math.max(0, store.questionDeadlineMs - Date.now()); const limit = (store.currentQuestion?.time_limit ?? 60) * 1000; el.textContent = `${Math.ceil(remaining / 1000)}s`; el.classList.toggle("urgent", remaining > 0 && remaining <= 10000); fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`; if (remaining <= 0) stopCountdown(); } boot();