diff --git a/app/room.py b/app/room.py index dd54c2b..139810c 100644 --- a/app/room.py +++ b/app/room.py @@ -586,7 +586,11 @@ class RoomManager: (sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score), ) await db.commit() - await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx)) + # Skip live histogram build when there's no instructor listening + # — same rationale as broadcast_presence. Submit storm should not + # be paying for DB work that nobody consumes. + if self.instructor_clients.get(sid): + await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx)) await self.broadcast_presence(sid) await self.broadcast_projectors(sid) return {"type": "submit_ack", "question_idx": qidx, "answer": stored_answer, "score": score, "elapsed_ms": elapsed_ms} @@ -719,7 +723,16 @@ class RoomManager: async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]: you_id = identity["student_id"] if identity else None - msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5, you_student_id=you_id)} + pool = await self.get_pool_for_session(sid) + msg = { + "type": "session_ended", + "final_top5": await self.leaderboard(sid, limit=5, you_student_id=you_id), + # Total questions in the pool — clients use this as the + # denominator on the "Correct X / Y" display so missed + # questions are visibly counted as wrong (X stays low), + # rather than hiding behind a smaller denominator. + "total_questions": question_count(pool), + } if identity: student = identity["student_id"] msg.update(await self.student_summary(sid, student)) @@ -941,6 +954,14 @@ class RoomManager: } async def broadcast_presence(self, sid: str) -> None: + # Skip the (DB-heavy) message build when no instructor is listening. + # The presence_message touches participants + question_events + + # student_events + submissions; on a 50-student submit storm + # those queries ran for every submit even if no admin was on + # the WS, eating budget that mattered to the time-limited + # question close. + if not self.instructor_clients.get(sid): + return await self.broadcast_instructors(sid, await self.presence_message(sid)) # ---- Projector (public big-screen view) ------------------------------- diff --git a/static/projector.js b/static/projector.js index 7d80a8e..404fdd5 100644 --- a/static/projector.js +++ b/static/projector.js @@ -576,19 +576,23 @@ function renderScoreArea(dist) { `; }).join(""); - // X-axis tick labels at each bucket centre + // X-axis tick labels at each bucket centre. With 10 buckets across the + // 1000-unit-wide SVG these read cleanly at projector scale; the SVG + // stretches but the text rotates if we wanted, here it's horizontal + // because the labels are short ("0.0-1.0" etc.). const xLabels = buckets.map((b, i) => { const cx = (xEdge(i) + xEdge(i + 1)) / 2; - return `${escapeText(b.label)}`; + return `${escapeText(b.label)}`; }).join(""); - // Per-bucket count labels above each top, only if non-zero - const dataLabels = buckets.map((b, i) => { + // Per-bucket data points (small circles at the top of each band) — no + // numeric labels above them. With small N the count labels collide + // with the median tag and with each other when bars are short; the + // x-axis labels + bottom legend (n / mean / max) carry that info now. + const dataPoints = buckets.map((b, i) => { if (b.count === 0) return ""; const cx = (xEdge(i) + xEdge(i + 1)) / 2; - const cy = yFor(b.count) - 8; - return `${b.count} - `; + return ``; }).join(""); // Median tag — find the bucket containing the cumulative midpoint @@ -622,15 +626,15 @@ function renderScoreArea(dist) { ${yGrid} - Score band (out of ${(dist.max_total || 0).toFixed(1)}) + Score band (out of ${(dist.max_total || 0).toFixed(1)}) Students - ${dataLabels} + ${xLabels} + ${dataPoints} ${medianMarks}
- 10 score bands · ${n} buckets n = ${total} · mean ${mean.toFixed(2)} · max ${(dist.max_total || 0).toFixed(1)}
diff --git a/static/quiz.js b/static/quiz.js index c8ea306..39d18dc 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -415,11 +415,16 @@ function submitAnswer(optionKey, 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(`

Question ${message.question_idx + 1}

-

+${fmtScore(message.score)}

-

submitted in ${seconds}s

+

${seconds}s

+

answer recorded

Waiting for the reveal…

@@ -492,7 +497,7 @@ function renderFinished(message) {
Your total${fmtScore(message.your_total)}
Rank${message.your_rank ?? "—"}
-
Correct${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}
+
Correct${message.questions_correct ?? 0} / ${message.total_questions ?? message.questions_answered ?? 0}

Final top 5

${renderBoard(message.final_top5)} diff --git a/static/style.css b/static/style.css index bcb785a..83636b8 100644 --- a/static/style.css +++ b/static/style.css @@ -1155,6 +1155,16 @@ h2.question-text.small { letter-spacing: -0.04em; line-height: 1; } +/* Unit suffix (e.g. "s" after a duration) — small, muted, baseline-sized + * so it reads as a tag, not part of the number. */ +.big-score .unit { + font-size: 0.32em; + color: var(--muted); + letter-spacing: 0; + margin-left: 6px; + vertical-align: 0.55em; + font-weight: 500; +} .spinner { width: 22px; diff --git a/tests/conftest.py b/tests/conftest.py index 1298d8e..1c8764b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,10 +16,14 @@ CANONICAL_SID = "main" @pytest.fixture def sample_pool(): + # 8 s per question gives the load-simulation room to drive 50 sequential + # WS submits without the autoclose timer racing them on busy CI / dev + # boxes. Tests that don't care about the timer simply close questions + # explicitly; the larger default doesn't slow them down. return { "title": "Sample Quiz", "score_fn": "linear_decay", - "time_limit_default": 2, + "time_limit_default": 8, "session_id": CANONICAL_SID, "questions": [ { @@ -27,7 +31,7 @@ def sample_pool(): "text": "First question?", "options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"}, "correct": "B", - "time_limit": 2, + "time_limit": 8, "explanation": "B is correct.", }, { @@ -35,28 +39,28 @@ def sample_pool(): "text": "Second question?", "options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"}, "correct": "C", - "time_limit": 2, + "time_limit": 8, }, { "id": "q3", "text": "Third question?", "options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"}, "correct": "A", - "time_limit": 2, + "time_limit": 8, }, { "id": "q4", "text": "Fourth question?", "options": {"A": "North", "B": "South", "C": "East", "D": "West"}, "correct": "D", - "time_limit": 2, + "time_limit": 8, }, { "id": "q5", "text": "Fifth question?", "options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"}, "correct": "A", - "time_limit": 1, + "time_limit": 8, }, ], } diff --git a/tests/test_pool.py b/tests/test_pool.py index e9d6420..e76e6fe 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -7,7 +7,7 @@ def test_pool_validation_accepts_well_formed_pool(sample_pool): pool = parse_pool_json(sample_pool) assert pool["title"] == "Sample Quiz" assert pool["score_fn"] == "linear_decay" - assert question_time_limit(pool, 0) == 2 + assert question_time_limit(pool, 0) == 8 assert get_question(pool, 0)["correct"] == "B" public = public_question_payload(pool, 0) assert "correct" not in public