/* Student quiz SPA. * * Visit /?sid=. If no cookie, render the join form. If cookie, open * the student WS and follow server messages through the lifecycle: * lobby → question_open → submitted → question_closed → … → session_ended * * The server is authoritative for state transitions and scoring. The * client only animates the UI for whatever message the server sent. */ const app = document.querySelector("#app"); const params = new URLSearchParams(window.location.search); const sid = params.get("sid"); const store = { me: null, ws: null, currentQuestion: null, submitted: null, pickedAnswer: null, deadlineMs: null, }; let countdownTimer = null; function escapeText(value) { return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); } function setView(markup) { app.innerHTML = `
${markup}
`; } 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.ok) { const error = new Error(await response.text()); error.status = response.status; throw error; } return response.json(); } function showAskInstructor() { setView(`

Ask your instructor for the link

This quiz link is missing or no longer valid.

`); } async function boot() { if (!sid) { showAskInstructor(); return; } try { await api(`/api/session/${sid}`); } catch { showAskInstructor(); return; } try { store.me = await api(`/api/session/${sid}/me`); } catch (err) { if (err.status === 401) { renderJoin(); return; } showAskInstructor(); return; } connect(); } function renderJoin(error = null) { setView(`

Join the quiz

Enter your student ID and name. The cookie is per-device; clear it to switch.

${error ? `

${escapeText(error)}

` : ""}
`); document.querySelector("#join-form").addEventListener("submit", async (event) => { event.preventDefault(); const submit = event.submitter || event.currentTarget.querySelector("button"); submit.disabled = true; const data = new FormData(event.currentTarget); try { await api(`/api/session/${sid}/join`, { method: "POST", body: JSON.stringify({ student_id: data.get("student_id"), name: data.get("name"), }), }); store.me = await api(`/api/session/${sid}/me`); connect(); } catch (err) { submit.disabled = false; renderJoin(err.message || "Could not join."); } }); } function connect() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`); store.ws = ws; ws.addEventListener("message", (event) => { try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); } }); ws.addEventListener("close", () => { // session_reset already drove a re-boot; suppress the generic // "disconnected" screen so it doesn't briefly flash on top of the // "Re-joining…" interstitial. if (store.resetting) return; stopCountdown(); setView(`

Disconnected

Your connection dropped.

`); }); } function handleMessage(message) { switch (message.type) { case "state": return renderState(message); case "question_open": return renderQuestion(message); case "submit_ack": return renderSubmitted(message); case "question_closed": return renderReveal(message); case "between_questions": return renderBetween(message); case "session_ended": return renderFinished(message); case "session_reset": return handleSessionReset(); case "error": return renderError(message); } } function handleSessionReset() { // Instructor cleared everyone out. Tear local state down and re-boot; // /api/session//me will return 401 (with cookie cleared by the // server) and we'll land cleanly on the join form. store.resetting = true; stopCountdown(); store.me = null; store.currentQuestion = null; store.submitted = null; store.pickedAnswer = null; if (store.ws) { try { store.ws.close(); } catch {} store.ws = null; } setView(`

Session reset

Your instructor reset the session. Re-joining…

`); setTimeout(() => { store.resetting = false; boot(); }, 600); } function renderState(message) { store.currentQuestion = null; store.submitted = null; store.pickedAnswer = null; stopCountdown(); if (message.state === "lobby") { setView(`

${escapeText(message.title || "Live quiz")}

You're in.

Hi ${escapeText(store.me?.name || "")}. Waiting for your instructor to start.

`); } else if (message.state === "finished") { // Edge case: rejoin after the quiz already ended. Render a friendly // placeholder and wait for a session_ended payload. setView(`

Quiz finished

Final results coming through…

`); } } function renderQuestion(message) { store.currentQuestion = message; store.submitted = null; store.pickedAnswer = null; store.deadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000); setView(`
Question ${message.question_idx + 1}

${escapeText(message.text)}

${["A","B","C","D"].map((k) => ` `).join("")}
`); document.querySelectorAll("[data-answer]").forEach((btn) => { btn.addEventListener("click", () => submitAnswer(btn.dataset.answer)); }); startCountdown(); } function submitAnswer(answer) { if (!store.ws || !store.currentQuestion || store.submitted || store.pickedAnswer) return; store.pickedAnswer = answer; document.querySelectorAll("[data-answer]").forEach((btn) => { btn.disabled = true; if (btn.dataset.answer === answer) btn.classList.add("picked"); }); store.ws.send(JSON.stringify({ type: "submit", question_idx: store.currentQuestion.question_idx, answer, })); } function renderSubmitted(message) { store.submitted = message; const seconds = (message.elapsed_ms / 1000).toFixed(1); setView(`

Question ${message.question_idx + 1}

+${message.score}

submitted in ${seconds}s

Waiting for the reveal…

`); } function renderReveal(message) { stopCountdown(); const q = store.currentQuestion; const yourAnswer = message.your_answer ?? null; const correct = message.correct; const won = yourAnswer === correct; const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (message.histogram[k] || 0), 0) + (message.histogram.missed || 0); const denom = Math.max(1, totalSubmitters); setView(`
Q${message.question_idx + 1} ${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}
${q?.text ? `

${escapeText(q.text)}

` : ""}
    ${["A","B","C","D"].map((k) => { const isCorrect = k === correct; const isYours = k === yourAnswer; let cls = ""; if (isCorrect) cls += " correct"; if (isYours && !isCorrect) cls += " wrong-pick"; if (isYours) cls += " yours"; return `
  1. ${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""} ${escapeText(q?.options?.[k] || "")} ${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)
  2. `; }).join("")}
${message.explanation ? `

${escapeText(message.explanation)}

` : ""}
Your score+${message.your_score || 0}
Total${message.your_total ?? 0}
Rank${message.your_rank ?? "—"}

Top 5

${renderBoard(message.top5)}
`); } function renderBetween(message) { setView(`

Up next

Question ${(message.next_idx ?? 0) + 1}

Total${message.your_total ?? 0}
Rank${message.your_rank ?? "—"}
${renderBoard(message.top5)}
`); } function renderFinished(message) { stopCountdown(); setView(`
Quiz complete
Your total${message.your_total ?? 0}
Rank${message.your_rank ?? "—"}
Correct${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}

Final top 5

${renderBoard(message.final_top5)}

Thanks for playing.

`); } function renderBoard(rows = []) { if (!rows || !rows.length) return `

No scores yet.

`; // The server marks the requesting student's row with `is_you: true` so // we can highlight by id without other students' ids ever crossing the // wire. Falls back to name match only if the server didn't mark anything // (older payloads pre-migration). const anyMarked = rows.some((r) => r.is_you); const myName = store.me?.name; return `
    ${rows.map((r) => { const isYou = anyMarked ? !!r.is_you : (myName && r.name && r.name === myName); return `
  1. ${r.rank} ${escapeText(r.name)} ${r.score}
  2. `; }).join("")}
`; } function renderError(message) { setView(`

Server message

${escapeText(message.message || message.code || "Something went wrong.")}

`); } 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.deadlineMs) return; const remaining = Math.max(0, store.deadlineMs - 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();