/* 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, }; // WS reconnect with exponential backoff. Total budget is ~27s across 8 // attempts (500ms, 1s, 2s, 4s, then 5s × 4), which covers typical mobile // hand-off and Aliyun-edge TLS hiccups without giving up too quickly. const RECONNECT = { attempt: 0, maxAttempts: 8, baseDelayMs: 500, maxDelayMs: 5000, timer: null, }; let countdownTimer = null; /* Tab-blur audit. We POST a server event whenever the student * backgrounds the page (visibilitychange) or moves focus away from the * window (blur). Both are debounced so a rapid alt-tab roundtrip * doesn't spam events. The server records each event in `student_events` * and surfaces a count to the instructor presence panel. * * We only ping during a question_open state — switching tabs between * questions is fine and we don't want to noise the audit. */ const FOCUS = { lastBlur: 0, lastHidden: 0, debounceMs: 1500, }; function postEvent(kind) { if (!sid || !store.currentQuestion || store.submitted) return; // Use sendBeacon when leaving the page so the event survives the // navigation; otherwise fetch with credentials so the cookie rides. const body = JSON.stringify({ kind, question_idx: store.currentQuestion.question_idx }); const url = `/api/session/${sid}/event`; if (kind === "visibility_hidden" && navigator.sendBeacon) { const blob = new Blob([body], { type: "application/json" }); navigator.sendBeacon(url, blob); return; } fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body, keepalive: true, }).catch(() => {}); } function onBlur() { const now = Date.now(); if (now - FOCUS.lastBlur < FOCUS.debounceMs) return; FOCUS.lastBlur = now; postEvent("blur"); } function onVisibility() { const now = Date.now(); if (document.visibilityState === "hidden") { if (now - FOCUS.lastHidden < FOCUS.debounceMs) return; FOCUS.lastHidden = now; postEvent("visibility_hidden"); } else if (document.visibilityState === "visible") { postEvent("visibility_visible"); } } window.addEventListener("blur", onBlur); document.addEventListener("visibilitychange", onVisibility); function fmtScore(value) { // Scores are floats on a 0.05 grid in [0, 1]. Display as a fixed // two-decimal string so users see e.g. "0.85" instead of // "0.8500000000000001" when float math drifts in the leaderboard sum. const n = Number(value || 0); return n.toFixed(2); } 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 registered student ID and your current full name.

${error ? `

${escapeText(error)}

` : ""}
Before you join — please read
  • Use only your own student ID. Using another student's ID is academic misconduct and is logged.
  • If you see "This student ID is already in use", do not retry. Tell the instructor and they will reset your slot.
  • Do not clear your cookies during the quiz. Clearing them locks you out and recovery requires a reset by instructor, and mark all the previous questions as missed (0 marks).
  • Also mark your attendance on paper at end of this lecture. The paper attendance will be cross-referenced with the record on this website.
`); 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; let msg = err.message || "Could not join."; // The /join endpoint returns the FastAPI default JSON error envelope // ({"detail": "..."}) — surface the human-readable detail rather // than the raw JSON blob in the alert. try { const parsed = JSON.parse(msg); if (parsed && parsed.detail) msg = parsed.detail; } catch {} renderJoin(msg); } }); } 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("open", () => { clearReconnectState(); }); ws.addEventListener("message", (event) => { try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); } }); ws.addEventListener("close", (event) => { // session_reset already drove a re-boot; suppress the reconnect path // so it doesn't fight with the "Re-joining…" interstitial. if (store.resetting) return; stopCountdown(); // 1008 = policy violation (server rejected the cookie / session). // Retrying won't help; reload so /me re-checks auth and we land on // the join form (or "ask your instructor") cleanly. if (event.code === 1008) { window.location.reload(); return; } scheduleReconnect(); }); ws.addEventListener("error", () => { // The "close" event will fire next; reconnect handling lives there. }); } function scheduleReconnect() { if (store.resetting) return; if (RECONNECT.attempt >= RECONNECT.maxAttempts) { showReconnectFailed(); return; } RECONNECT.attempt += 1; const delay = Math.min( RECONNECT.baseDelayMs * Math.pow(2, RECONNECT.attempt - 1), RECONNECT.maxDelayMs ); showReconnectingBanner(`Reconnecting… (${RECONNECT.attempt}/${RECONNECT.maxAttempts})`); RECONNECT.timer = setTimeout(connect, delay); } function clearReconnectState() { if (RECONNECT.timer) { clearTimeout(RECONNECT.timer); RECONNECT.timer = null; } RECONNECT.attempt = 0; hideReconnectingBanner(); } function showReconnectingBanner(text) { let el = document.querySelector("#reconnect-banner"); if (!el) { el = document.createElement("div"); el.id = "reconnect-banner"; el.className = "reconnect-banner"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); document.body.appendChild(el); } el.textContent = text; el.hidden = false; } function hideReconnectingBanner() { const el = document.querySelector("#reconnect-banner"); if (el) el.hidden = true; } function showReconnectFailed() { hideReconnectingBanner(); setView(`

Disconnected

We couldn't reconnect after several tries. Reload to try again.

`); } 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(); clearReconnectState(); 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) => { const text = message.options[k] || ""; return ` `; }).join("")}
`); document.querySelectorAll("[data-option]").forEach((btn) => { btn.addEventListener("click", () => submitAnswer(btn.dataset.option, btn.dataset.answerText)); }); startCountdown(); } function submitAnswer(optionKey, optionText) { if (!store.currentQuestion || store.submitted || store.pickedAnswer) return; // Drop the click silently if the WS isn't open right now (mid-reconnect // or already torn down). On reconnect the server replays question_open // for the same qidx, which re-renders the card with buttons re-enabled, // so the student just clicks again. if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return; store.pickedAnswer = optionKey; document.querySelectorAll("[data-option]").forEach((btn) => { btn.disabled = true; if (btn.dataset.option === optionKey) btn.classList.add("picked"); }); // The wire format carries the option's full text. The server resolves // it back to the canonical letter; if the text doesn't match (e.g. a // student tries to circumvent the UI and send a fabricated string) // the submission is recorded with score=0 and locked in. store.ws.send(JSON.stringify({ type: "submit", question_idx: store.currentQuestion.question_idx, answer: optionText, })); } function renderSubmitted(message) { store.submitted = message; const seconds = (message.elapsed_ms / 1000).toFixed(1); // Deliberately hide the score until the instructor reveals — leaks // correctness otherwise (any positive score = correct, zero = wrong), // which short-circuits the "stop and think" beat the reveal pause is // there to enforce. Show response time as the engagement signal // instead. setView(`

Question ${message.question_idx + 1}

${seconds}s

answer recorded

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. ${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+${fmtScore(message.your_score || 0)}
Total${fmtScore(message.your_total)}
Rank${message.your_rank ?? "—"}

Top 5

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

Up next

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

Total${fmtScore(message.your_total)}
Rank${message.your_rank ?? "—"}
${renderBoard(message.top5)}
`); } function renderFinished(message) { stopCountdown(); setView(`
Quiz complete
Your total${fmtScore(message.your_total)}
Rank${message.your_rank ?? "—"}
Correct${message.questions_correct ?? 0} / ${message.total_questions ?? 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)} ${fmtScore(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();