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 = ``;
}
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``);
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) => (
`- ${row.rank}. ${escapeText(row.name)}${row.score}
`
)).join("")}
`;
}
function renderError(message) {
setView(html`Quiz message
${escapeText(message)}
`);
}
boot();