Adds an optional roster.json (set of allowed student IDs) loaded at startup. add_participant() raises StudentIdNotInRoster when the gate is on and the supplied id is not present; route returns 403 with a clear message and logs a roster_reject audit event. Names are NOT checked against the roster: the join form asks for a current name as a soft deterrent, but the only hard check is the id. Includes a deploy/build_roster.py helper that turns class_register attendance.xlsx into roster.json. Bootstrap env file now exports QUIZ_ROSTER_PATH; missing file disables the gate (legacy behaviour). Also drops the user-facing "The cookie is per-device." line from the join card — students don't need to know the implementation; replaced with "Enter your registered student ID and your current full name."
567 lines
19 KiB
JavaScript
567 lines
19 KiB
JavaScript
/* 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;
|
||
|
||
/* 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 = `<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 registered student ID and your current full name.</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>` : ""}
|
||
<details class="join-disclaimer">
|
||
<summary>Before you join — please read</summary>
|
||
<ul>
|
||
<li><b>Use only your own student ID.</b> Using another student's ID is academic misconduct and is logged.</li>
|
||
<li>If you see <em>"This student ID is already in use"</em>, <b>do not retry</b>. Tell the instructor and they will reset your slot.</li>
|
||
<li>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).</li>
|
||
<li>Also mark your attendance on paper at end of this lecture. The paper attendance will be cross-referenced with the record on this website.</li>
|
||
</ul>
|
||
</details>
|
||
<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;
|
||
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(`
|
||
<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) => {
|
||
const text = message.options[k] || "";
|
||
return `
|
||
<button class="answer-btn" data-option="${escapeText(k)}" data-answer-text="${escapeText(text)}">
|
||
<span class="answer-text">${escapeText(text)}</span>
|
||
</button>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</article>
|
||
`);
|
||
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(`
|
||
<div class="card narrow center">
|
||
<p class="eyebrow">Question ${message.question_idx + 1}</p>
|
||
<h1 class="big-score">${seconds}<small class="unit">s</small></h1>
|
||
<p class="muted">answer recorded</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 letterless">
|
||
${["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="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.total_questions ?? 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();
|