overhaul: single-session deployment + redesigned frontend
Backend simplification:
- The server now loads ONE pool JSON from $QUIZ_POOL_PATH at startup and
upserts a single canonical session. The session id comes from the pool
JSON's optional "session_id" field, falling back to $QUIZ_SESSION_ID.
- The multi-quiz / multi-session CRUD API is gone:
DELETED GET/POST /admin/api/quizzes
DELETED POST /admin/api/quizzes/upload
DELETED GET/POST /admin/api/sessions
DELETED GET /admin/login (HTML stub)
DELETED GET /admin/api/sessions/{sid}/csv (replaced by /admin/api/csv)
Replaced with a single-session control surface:
GET /admin/ — serves admin.html unconditionally
GET /admin/api/state — admin-gated; pool meta + state + QR + join URL
POST /admin/api/reset — admin-gated; wipe submissions + back to lobby
POST /admin/logout — clear admin cookie
GET /admin/api/csv — single-session results
WS /ws/instructor/{sid} — kept; new commands "next" + "reset"
- Instructor "Next" button is now a single state-driving command
(RoomManager.advance_to_next): from lobby it opens Q0; from question_open
it closes the current Q and opens the next; from question_closed it
opens the next; if past the last question it ends the session.
- New RoomManager.reset wipes submissions, participants, and per-question
state, then broadcasts a clean lobby.
- Student GET / now redirects to /?sid=<canonical> when no sid is given,
so the QR / share URL is fully deterministic.
Frontend rewrite (functional baseline; visual polish to follow):
- /admin/ is now a single SPA: GET /admin/api/state decides login form
vs dashboard. No separate /admin/login URL bar.
- Admin dashboard is state-driven with one primary action per state.
QR code, join URL, and live participant list are always visible on the
left so the operator can leave the page on a projector.
- Student answer buttons are big and tappable; reveal screen highlights
correct/wrong choice + shows score, total, and rank.
- Static admin/student SPAs share a CSS palette with light/dark support.
Tests rewritten around the single canonical session id.
The auto-bootstrapped session lets each test fixture skip the old
quiz/session creation dance. 39/39 tests pass.
Cleanup:
- Deleted CODEX_PROMPT.md, IMPLEMENTATION_REPORT.md, NOTES.md, SPEC.md,
static/observer.html (obsolete codex-build artifacts and the unused
observer view).
- .gitignore now blocks /pool.json (the runtime file the operator drops
on the server) and the leftover .codex_done / codex_run.log / etc.
- bootstrap.sh seeds /opt/quiz/pool.json from examples/pool_example.json
on first deploy so a fresh box reaches a usable state without manual
intervention; .env now includes QUIZ_POOL_PATH.
This commit is contained in:
406
static/quiz.js
406
static/quiz.js
@@ -1,223 +1,361 @@
|
||||
/* 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");
|
||||
|
||||
let ws = null;
|
||||
let me = null;
|
||||
let activeQuestion = null;
|
||||
let submitted = null;
|
||||
const store = {
|
||||
me: null,
|
||||
ws: null,
|
||||
currentQuestion: null,
|
||||
submitted: null,
|
||||
pickedAnswer: null,
|
||||
deadlineMs: 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) => ({
|
||||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
})[c]);
|
||||
}
|
||||
|
||||
function setView(markup) {
|
||||
app.innerHTML = `<section class="shell">${markup}</section>`;
|
||||
}
|
||||
|
||||
function askForLink() {
|
||||
setView(html`<div class="panel center">
|
||||
<h1>Ask your instructor for the link</h1>
|
||||
<p>This quiz link is missing or no longer valid.</p>
|
||||
</div>`);
|
||||
app.innerHTML = `<section class="centered-shell">${markup}</section>`;
|
||||
}
|
||||
|
||||
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());
|
||||
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) {
|
||||
askForLink();
|
||||
showAskInstructor();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/session/${sid}`);
|
||||
} catch {
|
||||
askForLink();
|
||||
showAskInstructor();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
me = await api(`/api/session/${sid}/me`);
|
||||
connect();
|
||||
} catch {
|
||||
renderJoin();
|
||||
store.me = await api(`/api/session/${sid}/me`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
renderJoin();
|
||||
return;
|
||||
}
|
||||
showAskInstructor();
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
function renderJoin(error = "") {
|
||||
setView(html`<div class="panel narrow">
|
||||
<h1>Join Quiz</h1>
|
||||
<form id="join-form" class="stack">
|
||||
<label>Student ID <input name="student_id" autocomplete="username" required></label>
|
||||
<label>Name <input name="name" autocomplete="name" required></label>
|
||||
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
|
||||
<button class="primary" type="submit">Join</button>
|
||||
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>
|
||||
</div>`);
|
||||
`);
|
||||
document.querySelector("#join-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
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: form.get("student_id"),
|
||||
name: form.get("name"),
|
||||
student_id: data.get("student_id"),
|
||||
name: data.get("name"),
|
||||
}),
|
||||
});
|
||||
me = await api(`/api/session/${sid}/me`);
|
||||
store.me = await api(`/api/session/${sid}/me`);
|
||||
connect();
|
||||
} catch {
|
||||
renderJoin("Could not join this session.");
|
||||
} catch (err) {
|
||||
submit.disabled = false;
|
||||
renderJoin(err.message || "Could not join.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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)));
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
|
||||
store.ws = ws;
|
||||
ws.addEventListener("message", (event) => {
|
||||
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
clearInterval(countdownTimer);
|
||||
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></div>`);
|
||||
stopCountdown();
|
||||
setView(`
|
||||
<div class="card narrow center">
|
||||
<h1>Disconnected</h1>
|
||||
<p class="muted">Your connection dropped.</p>
|
||||
<button class="btn primary block" onclick="window.location.reload()">Reconnect</button>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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 "error": return renderError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderState(message) {
|
||||
activeQuestion = null;
|
||||
submitted = null;
|
||||
clearInterval(countdownTimer);
|
||||
store.currentQuestion = null;
|
||||
store.submitted = null;
|
||||
store.pickedAnswer = null;
|
||||
stopCountdown();
|
||||
if (message.state === "lobby") {
|
||||
setView(html`<div class="panel center">
|
||||
<h1>${escapeText(message.title)}</h1>
|
||||
<p class="status">You are in. Waiting for instructor to start.</p>
|
||||
<p>${escapeText(me?.name || "")}</p>
|
||||
<div class="spinner"></div>
|
||||
</div>`);
|
||||
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) {
|
||||
activeQuestion = message;
|
||||
submitted = null;
|
||||
const buttons = Object.entries(message.options).map(([key, value]) => (
|
||||
`<button class="answer" data-answer="${key}"><strong>${key}</strong><span>${escapeText(value)}</span></button>`
|
||||
)).join("");
|
||||
setView(html`<article class="panel quiz-panel">
|
||||
<div class="topline"><span>Question ${message.question_idx + 1}</span><span id="timer"></span></div>
|
||||
<div class="bar"><span id="bar-fill"></span></div>
|
||||
<h1>${escapeText(message.text)}</h1>
|
||||
<div class="answers">${buttons}</div>
|
||||
</article>`);
|
||||
document.querySelectorAll("[data-answer]").forEach((button) => {
|
||||
button.addEventListener("click", () => submitAnswer(button.dataset.answer));
|
||||
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(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);
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
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);
|
||||
if (!store.ws || !store.currentQuestion || store.submitted || store.pickedAnswer) 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) {
|
||||
submitted = message;
|
||||
store.submitted = message;
|
||||
const seconds = (message.elapsed_ms / 1000).toFixed(1);
|
||||
setView(html`<div class="panel center">
|
||||
<h1>Submitted</h1>
|
||||
<p class="score">Submitted in ${seconds}s, +${message.score} pts.</p>
|
||||
<p>Wait for the reveal.</p>
|
||||
</div>`);
|
||||
setView(`
|
||||
<div class="card narrow center">
|
||||
<p class="eyebrow">Question ${message.question_idx + 1}</p>
|
||||
<h1 class="big-score">+${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) {
|
||||
clearInterval(countdownTimer);
|
||||
const rows = Object.entries(message.histogram).map(([key, value]) => (
|
||||
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
|
||||
)).join("");
|
||||
const board = renderBoard(message.top5);
|
||||
setView(html`<article class="panel">
|
||||
<p class="status">Correct answer: ${escapeText(message.correct)}</p>
|
||||
<h1>Reveal</h1>
|
||||
<p>Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.</p>
|
||||
${message.explanation ? `<p>${escapeText(message.explanation)}</p>` : ""}
|
||||
<div class="histogram">${rows}</div>
|
||||
${board}
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
|
||||
</article>`);
|
||||
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>+${message.your_score || 0}</b></div>
|
||||
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</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(html`<div class="panel center">
|
||||
<h1>Next question coming up</h1>
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}</p>
|
||||
<p>Total: ${message.your_total ?? 0}</p>
|
||||
${renderBoard(message.top5)}
|
||||
</div>`);
|
||||
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>${message.your_total ?? 0}</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) {
|
||||
setView(html`<div class="panel center celebration">
|
||||
<h1>Quiz finished</h1>
|
||||
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
|
||||
<p>Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.</p>
|
||||
${renderBoard(message.final_top5)}
|
||||
</div>`);
|
||||
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>${message.your_total ?? 0}</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.length) return "<p>No scores yet.</p>";
|
||||
return `<ol class="leaderboard">${rows.map((row) => (
|
||||
`<li><span>${row.rank}. ${escapeText(row.name)}</span><strong>${row.score}</strong></li>`
|
||||
)).join("")}</ol>`;
|
||||
if (!rows || !rows.length) return `<p class="muted small">No scores yet.</p>`;
|
||||
return `
|
||||
<ol class="leaderboard">
|
||||
${rows.map((r) => `
|
||||
<li>
|
||||
<span class="rank">${r.rank}</span>
|
||||
<span class="who"><b>${escapeText(r.name)}</b></span>
|
||||
<span class="score">${r.score}</span>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ol>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
|
||||
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`;
|
||||
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
|
||||
if (remaining <= 0) stopCountdown();
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user