Files
quiz/static/quiz.js
ameer ec8d83aea8 feat(student): auto-reconnect with backoff + WS-open retry
Replace the manual "Disconnected → Reconnect button" screen with a small
top-banner that retries the WS up to 8 times (500ms → 5s, ~27s budget).
On open, snapshot replay restores the question card and countdown so a
brief network blip is invisible to the student. After the retry budget
exhausts, fall back to a manual "Reload" card.

Same path covers initial WS-open failures (transient TLS hiccups on the
Aliyun edge), since the first connect() and subsequent reconnects share
the schedule. Auth-related closes (1008) still hard-reload immediately
so an invalidated cookie lands on the join form, not in a retry loop.

Submits are now also gated on ws.readyState === OPEN; clicks during a
reconnect are silent no-ops, and the question re-renders fresh once the
server replays state.
2026-05-03 15:05:41 +08:00

485 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Student quiz SPA.
*
* Visit /?sid=<id>. 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;
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[c]);
}
function setView(markup) {
app.innerHTML = `<section class="centered-shell">${markup}</section>`;
}
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(`
<div class="card narrow">
<h1>Ask your instructor for the link</h1>
<p class="muted">This quiz link is missing or no longer valid.</p>
</div>
`);
}
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(`
<form id="join-form" class="card narrow stack">
<header class="card-header">
<h1>Join the quiz</h1>
<p class="muted">Enter your student ID and name. The cookie is per-device; clear it to switch.</p>
</header>
<label class="field">
<span>Student ID</span>
<input name="student_id" autocomplete="username" required autofocus>
</label>
<label class="field">
<span>Name</span>
<input name="name" autocomplete="name" required>
</label>
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
<button type="submit" class="btn primary block">Join</button>
</form>
`);
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("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(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">We couldn't reconnect after several tries. Reload to try again.</p>
<button class="btn primary block" onclick="window.location.reload()">Reload</button>
</div>
`);
}
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/<sid>/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(`
<div class="card narrow center">
<h1>Session reset</h1>
<p class="muted">Your instructor reset the session. Re-joining…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
setTimeout(() => { store.resetting = false; boot(); }, 600);
}
function renderState(message) {
store.currentQuestion = null;
store.submitted = null;
store.pickedAnswer = null;
stopCountdown();
if (message.state === "lobby") {
setView(`
<div class="card narrow center">
<p class="eyebrow">${escapeText(message.title || "Live quiz")}</p>
<h1>You're in.</h1>
<p class="muted">Hi <b>${escapeText(store.me?.name || "")}</b>. Waiting for your instructor to start.</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
} 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(`
<div class="card narrow center">
<h1>Quiz finished</h1>
<p class="muted">Final results coming through…</p>
</div>
`);
}
}
function renderQuestion(message) {
store.currentQuestion = message;
store.submitted = null;
store.pickedAnswer = null;
store.deadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
setView(`
<article class="card quiz-card">
<div class="question-head">
<span class="qnum">Question ${message.question_idx + 1}</span>
<span id="countdown" class="countdown">—</span>
</div>
<div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid">
${["A","B","C","D"].map((k) => `
<button class="answer-btn" data-answer="${k}">
<span class="answer-key">${k}</span>
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
</button>
`).join("")}
</div>
</article>
`);
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
});
startCountdown();
}
function submitAnswer(answer) {
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 = 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(`
<div class="card narrow center">
<p class="eyebrow">Question ${message.question_idx + 1}</p>
<h1 class="big-score">+${fmtScore(message.score)}</h1>
<p class="muted">submitted in ${seconds}s</p>
<p class="muted small">Waiting for the reveal…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
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(`
<article class="card reveal-card">
<div class="question-head">
<span class="qnum">Q${message.question_idx + 1}</span>
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
</div>
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
<ol class="options reveal student-reveal">
${["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 `
<li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
</li>
`;
}).join("")}
</ol>
${message.explanation ? `<p class="explanation">${escapeText(message.explanation)}</p>` : ""}
<div class="reveal-stats">
<div class="stat"><span class="muted">Your score</span><b>+${fmtScore(message.your_score || 0)}</b></div>
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
<h3>Top 5</h3>
${renderBoard(message.top5)}
</article>
`);
}
function renderBetween(message) {
setView(`
<div class="card narrow center">
<p class="eyebrow">Up next</p>
<h1>Question ${(message.next_idx ?? 0) + 1}</h1>
<div class="reveal-stats">
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
${renderBoard(message.top5)}
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderFinished(message) {
stopCountdown();
setView(`
<article class="card celebration-card">
<div class="celebration-banner">Quiz complete</div>
<div class="reveal-stats">
<div class="stat big"><span class="muted">Your total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div>
</div>
<h3>Final top 5</h3>
${renderBoard(message.final_top5)}
<p class="muted small">Thanks for playing.</p>
</article>
`);
}
function renderBoard(rows = []) {
if (!rows || !rows.length) return `<p class="muted small">No scores yet.</p>`;
// 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 `
<ol class="leaderboard">
${rows.map((r) => {
const isYou = anyMarked
? !!r.is_you
: (myName && r.name && r.name === myName);
return `
<li class="${isYou ? "is-you" : ""}">
<span class="rank">${r.rank}</span>
<span class="who"><b>${escapeText(r.name)}</b></span>
<span class="score">${fmtScore(r.score)}</span>
</li>
`;
}).join("")}
</ol>
`;
}
function renderError(message) {
setView(`
<div class="card narrow center">
<h1>Server message</h1>
<p class="muted">${escapeText(message.message || message.code || "Something went wrong.")}</p>
</div>
`);
}
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();