/* 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: [],
presence: [], // presence_update.rows — richer than roster
orphanDuplicates: [], // presence_update.orphan_duplicate_joins
currentQIdx: null, // tracked for "answered current?" rendering
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 fmtScore(value) {
// Scores are floats on a 0.05 grid in [0, 1]; sums can run up to N
// (one per question). Always render as fixed two-decimal so the
// leaderboard reads "0.85" / "1.20" / "5.00" cleanly.
return Number(value || 0).toFixed(2);
}
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 = `
`;
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;
// state derives from session (server-authoritative); endedPayload short-
// circuits to "finished" for the post-final render where we may not
// have re-fetched session.state yet.
const state = store.endedPayload ? "finished" : session.state;
app.innerHTML = `
${store.notice ? `
${escapeText(store.notice)}
` : ""}
${renderDuplicateJoinAlerts()}
${renderJoinPanel()}
${renderPresencePanel()}
${renderStatePanel(state)}
`;
document.querySelector("#logout-btn").addEventListener("click", logout);
bindStateActions();
bindPresenceActions();
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 ? `
` : "
QR unavailable
"}
${escapeText(session.join_url)}
Copy
Session id: ${escapeText(session.sid)}
`;
}
function renderPresencePanel() {
const presence = store.presence || [];
const rosterCount = (store.roster || []).length;
const connected = presence.filter((p) => p.connected).length;
const idleStaleMs = 30_000;
const now = Date.now();
// Newest-first so late joiners stay visible at the top.
const ordered = presence.slice().reverse();
if (!ordered.length) {
return `
Joined ${rosterCount}
No students have joined yet. Share the QR or URL.
`;
}
const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx;
const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open";
return `
Presence ${connected}/${presence.length}
${ordered.map((row, i) => {
const lastSeen = row.last_seen_ms || 0;
const stale = !row.connected && lastSeen && (now - lastSeen) > idleStaleMs;
const dotState = row.connected ? "is-online" : (stale ? "is-stale" : "is-offline");
const blur = row.blur_count || 0;
const hidden = row.hidden_count || 0;
const dupCount = row.duplicate_join_attempts?.count || 0;
const answered = row.answered_current;
const fresh = i < 3 && row.connected ? "is-fresh" : "";
return `
${escapeText(row.name)}
${escapeText(row.student_id)}
${isQuestionOpen
? `${answered ? "✓" : "·"} `
: ""}
${blur > 0 ? `${blur}↗ ` : ""}
${hidden > 0 ? `${hidden}◌ ` : ""}
${dupCount > 0 ? `!${dupCount} ` : ""}
×
`;
}).join("")}
connected
idle
dropped
`;
}
function renderDuplicateJoinAlerts() {
const orphans = store.orphanDuplicates || [];
if (!orphans.length) return "";
// An orphan attempt is a duplicate-join on a student_id that no real
// participant currently holds — surface separately because it suggests
// someone is probing student_ids that aren't even claimed yet.
return `
Suspicious join attempts
${orphans.map((o) => `
${escapeText(o.student_id)}
${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""}
`).join("")}
No real participant holds these IDs yet. If a student claims one of them and asks for help, you can clear it from the presence list.
`;
}
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
Start quiz →
`;
}
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) =>
`${k} ${escapeText(q.options[k] || "")} `
).join("")}
${renderLiveHistogram()}
Stop early
`;
}
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 `
`;
}).join("")}
`;
}
function renderQuestionClosed() {
const c = store.closedPayload;
const q = store.currentQuestion;
if (!c || !q) {
return ``;
}
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 `
${k}${correct ? " ✓" : ""}
${escapeText(q.options[k] || "")}
${c.histogram[k] || 0}
`;
}).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 `
`;
}).join("")}
${c.histogram.missed ? `
—
${c.histogram.missed} missed ` : ""}
Top so far
${renderLeaderboardList(store.leaderboard.slice(0, 10))}
${isLast ? "Finish quiz →" : "Next question →"}
Finish now
`;
}
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)}
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) => `
${r.rank}
${escapeText(r.name)} ${r.student_id ? `${escapeText(r.student_id)} ` : ""}
${fmtScore(r.score)}
`).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);
}
function bindPresenceActions() {
document.querySelectorAll("[data-clear-student]").forEach((btn) => {
btn.addEventListener("click", async () => {
const studentId = btn.dataset.clearStudent;
if (!studentId) return;
if (!confirm(`Clear ${studentId}? Their submissions and presence row will be removed; they can then re-join with the same ID.`)) return;
btn.disabled = true;
try {
await api(`/admin/api/students/${encodeURIComponent(studentId)}`, { method: "DELETE" });
} catch (err) {
alert(err.message || "Could not clear student.");
btn.disabled = false;
}
// Server pushes presence_update so the row will disappear naturally.
});
});
}
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 "presence_update":
store.presence = message.rows || [];
store.orphanDuplicates = message.orphan_duplicate_joins || [];
store.currentQIdx = message.current_question_idx ?? null;
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();