const app = document.querySelector("#app"); const params = new URLSearchParams(window.location.search); const sid = params.get("sid"); let ws = null; let me = null; let activeQuestion = null; let submitted = null; let countdownTimer = null; function html(strings, ...values) { return strings.map((part, index) => part + (values[index] ?? "")).join(""); } function escapeText(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); } function setView(markup) { app.innerHTML = `
${markup}
`; } function askForLink() { setView(html`

Ask your instructor for the link

This quiz link is missing or no longer valid.

`); } async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", headers: {"Content-Type": "application/json", ...(options.headers || {})}, ...options, }); if (!response.ok) throw new Error(await response.text()); return response.json(); } async function boot() { if (!sid) { askForLink(); return; } try { await api(`/api/session/${sid}`); } catch { askForLink(); return; } try { me = await api(`/api/session/${sid}/me`); connect(); } catch { renderJoin(); } } function renderJoin(error = "") { setView(html`

Join Quiz

${error ? `

${escapeText(error)}

` : ""}
`); document.querySelector("#join-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); try { await api(`/api/session/${sid}/join`, { method: "POST", body: JSON.stringify({ student_id: form.get("student_id"), name: form.get("name"), }), }); me = await api(`/api/session/${sid}/me`); connect(); } catch { renderJoin("Could not join this session."); } }); } function connect() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`); ws.addEventListener("message", (event) => handleMessage(JSON.parse(event.data))); ws.addEventListener("close", () => { clearInterval(countdownTimer); setView(html`

Connection closed

Refresh the page to reconnect.

`); }); } function handleMessage(message) { if (message.type === "state") renderState(message); if (message.type === "question_open") renderQuestion(message); if (message.type === "submit_ack") renderSubmitted(message); if (message.type === "question_closed") renderReveal(message); if (message.type === "between_questions") renderBetween(message); if (message.type === "session_ended") renderFinished(message); if (message.type === "error") renderError(message.message); } function renderState(message) { activeQuestion = null; submitted = null; clearInterval(countdownTimer); if (message.state === "lobby") { setView(html`

${escapeText(message.title)}

You are in. Waiting for instructor to start.

${escapeText(me?.name || "")}

`); } } function renderQuestion(message) { activeQuestion = message; submitted = null; const buttons = Object.entries(message.options).map(([key, value]) => ( `` )).join(""); setView(html`
Question ${message.question_idx + 1}

${escapeText(message.text)}

${buttons}
`); document.querySelectorAll("[data-answer]").forEach((button) => { button.addEventListener("click", () => submitAnswer(button.dataset.answer)); }); startCountdown(message); } function startCountdown(message) { clearInterval(countdownTimer); const endAt = Date.now() + message.remaining_ms; const total = message.time_limit * 1000; const tick = () => { const remaining = Math.max(0, endAt - Date.now()); const timer = document.querySelector("#timer"); const fill = document.querySelector("#bar-fill"); if (timer) timer.textContent = `${Math.ceil(remaining / 1000)}s`; if (fill) fill.style.width = `${Math.max(0, Math.min(100, remaining / total * 100))}%`; }; tick(); countdownTimer = setInterval(tick, 250); } function submitAnswer(answer) { if (!ws || !activeQuestion || submitted) return; ws.send(JSON.stringify({type: "submit", question_idx: activeQuestion.question_idx, answer})); document.querySelectorAll("[data-answer]").forEach((button) => button.disabled = true); } function renderSubmitted(message) { submitted = message; const seconds = (message.elapsed_ms / 1000).toFixed(1); setView(html`

Submitted

Submitted in ${seconds}s, +${message.score} pts.

Wait for the reveal.

`); } function renderReveal(message) { clearInterval(countdownTimer); const rows = Object.entries(message.histogram).map(([key, value]) => ( `
${key}${value}
` )).join(""); const board = renderBoard(message.top5); setView(html`

Correct answer: ${escapeText(message.correct)}

Reveal

Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.

${message.explanation ? `

${escapeText(message.explanation)}

` : ""}
${rows}
${board}

Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.

`); } function renderBetween(message) { setView(html`

Next question coming up

Your rank: ${message.your_rank ?? "pending"}

Total: ${message.your_total ?? 0}

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

Quiz finished

Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.

Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.

${renderBoard(message.final_top5)}
`); } function renderBoard(rows = []) { if (!rows.length) return "

No scores yet.

"; return `
    ${rows.map((row) => ( `
  1. ${row.rank}. ${escapeText(row.name)}${row.score}
  2. ` )).join("")}
`; } function renderError(message) { setView(html`

Quiz message

${escapeText(message)}

`); } boot();