feat: AMBOSS exam chrome, review as a study session, and no silent submissions

The exam player takes the window. The shell was sized against the header with
a number that did not include the navbar's own 32px of margin, so the block bar
— the one thing on the screen that must always be reachable — sat below the
fold and had to be scrolled to. Exam mode now hides the site chrome entirely
and is the viewport, which makes the arithmetic honest and matches what a board
looks like: item and block in a box at the left, the two arrows in the middle,
the tools at the right, the question-status rail down the side, and the clock,
Pause and End Block along the bottom.

Shortcuts is gone from the bar, and the labs open into the column beside the
question in both modes rather than a box over it.

Nothing is handed in behind the learner's back. The clock reaching zero stops
the block and says so; closing Time's Up submits, and the player stays put
showing the answers, which is the review. The server no longer settles an
expired attempt at all — listing sessions used to mark any paper whose clock
had run out, so opening a page could score a block the learner had walked away
from, and the first they knew of it was a result.

Reviewing an attempt is now the player with the answers in, not a dropdown and
a card. Same rail, same layout, same labs, same way out — and on a phone the
same burger opens the same question list, from one shared rule about which
routes are a session.

Also: the rule-out toggle sits beside its option instead of pinned to the far
edge of the card, so an option box is as wide as its own words; the voice
picker leaves the player, since a reader's voice is a setting and not a
decision to retake every session; figures carry no invented "Figure 1" — a
label is what prose refers to, and the backfill knew of no prose, so 346 of
them said only that an image was an image; and the landing page shows the two
modes happening rather than promising six things in a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 07:37:58 +02:00
parent f8a81ef937
commit bb72aa5f60
18 changed files with 935 additions and 279 deletions

View file

@ -9,7 +9,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import case, func
from app.services.attempt_expiry import active_key, load_saved, progress_key, settle_if_expired
from app.services.attempt_expiry import active_key, load_saved, progress_key
from app.services.knowledge_groups import Grouping, score_rows
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
@ -352,12 +352,12 @@ def get_progress(
r.setex(key, 7 * 24 * 3600, _json.dumps(saved))
return saved
# An unsuspended exam whose clock ran out is submitted with what was
# answered, and the score counts. The client is told so it can show the
# result rather than an empty resume.
if settle_if_expired(db, r, current_user.id, attempt, saved):
return {"expired_submitted": True, "attempt_id": attempt.id, "quiz_id": quiz_id}
# An exam whose clock ran out is handed back with no time on it, and
# nothing more. The player opens it, shows Time's Up, and submits when
# the learner closes that — which is the only moment anybody has said
# the block is over. Marking it here instead meant a paper could be
# taken in and scored by a request the learner never made, days after
# they last saw it, and the first they knew of it was a result.
return saved
except Exception:
logger.warning("Redis unavailable for progress retrieval", exc_info=True)

View file

@ -6,7 +6,6 @@ from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.services import site_settings
from app.services.attempt_expiry import settle_if_expired
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import plan_context_for_quizzes
from app.utils.upload_access import validate_image_attachments
@ -337,20 +336,17 @@ def list_quiz_sessions(
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
live_attempts = list(active.values())
keys = [f"quiz_progress:{current_user.id}:{a.id}" for a in live_attempts]
settled = []
for attempt, raw in zip(live_attempts, r.mget(keys)):
if not raw:
continue
saved = _json.loads(raw)
# An unsuspended exam whose time is up is a finished exam.
if settle_if_expired(db, r, current_user.id, attempt, saved):
settled.append(attempt)
continue
# Counted, not settled. Listing sessions used to hand in any
# exam whose clock had run out, so simply opening this page
# could mark a paper — the learner's next sight of a block they
# had walked away from was a score. An out-of-time block stays
# open until it is opened, where Time's Up is shown and closing
# it hands the paper in.
answered[attempt.id] = len(saved.get("answers", {}) or {})
# Moved after the loop: `active` must not change while it is read.
for attempt in settled:
active.pop(attempt.quiz_id, None)
finished.setdefault(attempt.quiz_id, []).append(attempt)
except Exception:
logger.warning("Redis unavailable for session progress", exc_info=True)

View file

@ -76,8 +76,12 @@ def main():
for question_id, asset, role, category in planned:
position = db.query(QuestionMedia).filter_by(
question_id=question_id, role=role).count()
# No label. A label is what the prose refers to — "as in Figure 2"
# — and a backfill knows of no prose that refers to anything. The
# numbers this used to invent were printed under every image on the
# site, telling a learner that an image was an image.
db.add(QuestionMedia(question_id=question_id, media_id=asset.id, role=role,
label=f"Figure {position + 1}", position=position))
position=position))
made += 1
# Keep the filename as provenance in the caption; it is the only
# record of which page of which PDF this came from.

View file

@ -0,0 +1,122 @@
"""Merge topics that are the same topic twice.
The tree was built from a model's per-question tags, so the same condition was
created once under each discipline that happened to mention it: Chlamydial
Infection exists under Ophthalmology, Infectious Disease and Neonatology, and a
learner filtering by any one of them sees a third of the questions.
What is kept is the cross-filing, not the duplicate. Questions move to one
surviving topic, and each one gains an extra category link to the parent of the
copy it came from so a chlamydial conjunctivitis question is still reachable
from Ophthalmology, without Ophthalmology owning a second copy of the disease.
Names are compared with punctuation and case removed but **digits kept**: a
normaliser that strips digits makes Trisomy 18 and Trisomy 21 the same row, and
Type 1 and Type 2 Diabetes with them. That is not a merge, it is a loss.
The survivor is the copy with the most questions; ties go to the lowest id, so
a rerun picks the same one.
Idempotent, and a dry run by default:
docker compose exec backend python -m scripts.merge_duplicate_categories
docker compose exec backend python -m scripts.merge_duplicate_categories --apply
"""
import re
import sys
from collections import defaultdict
from sqlalchemy import text as sa_text
from app.database import SessionLocal
#: Punctuation and case go; digits stay.
KEY = re.compile(r"[^a-z0-9]")
def normalise(name: str) -> str:
return KEY.sub("", (name or "").lower())
def main(apply: bool) -> int:
db = SessionLocal()
try:
rows = db.execute(sa_text("""
SELECT c.id, c.name, c.parent_id, c.system_id,
(SELECT count(*) FROM questions q
WHERE q.question_category_id = c.id AND q.deleted_at IS NULL) AS direct,
(SELECT count(*) FROM question_categories k WHERE k.parent_id = c.id) AS children
FROM question_categories c
""")).mappings().all()
groups: dict[str, list] = defaultdict(list)
for row in rows:
groups[normalise(row["name"])].append(row)
plans = []
for key, copies in groups.items():
if len(copies) < 2:
continue
# A topic with children is a branch of the tree, not a leaf that was
# duplicated; merging those would reparent somebody's subtree.
if any(c["children"] for c in copies):
continue
ordered = sorted(copies, key=lambda c: (-c["direct"], c["id"]))
plans.append((ordered[0], ordered[1:]))
moved = linked = removed = 0
print(f"{len(plans)} topics exist more than once\n")
for keep, drop in plans:
names = ", ".join(f"#{d['id']} ({d['direct']})" for d in drop)
print(f" {keep['name']:<30} keep #{keep['id']} ({keep['direct']}) ← {names}")
for loser in drop:
moved += loser["direct"]
removed += 1
print(f"\n{moved} questions move, {removed} duplicate topics go")
if not apply:
print("\ndry run. Pass --apply to write.")
return 0
for keep, drop in plans:
for loser in drop:
# The copy's parent is the association worth keeping: it is why
# somebody filed the disease there in the first place.
if loser["parent_id"] and loser["parent_id"] != keep["parent_id"]:
linked += db.execute(sa_text("""
INSERT INTO question_category_links (question_id, category_id)
SELECT q.id, :parent FROM questions q
WHERE q.question_category_id = :loser
ON CONFLICT DO NOTHING
"""), {"parent": loser["parent_id"], "loser": loser["id"]}).rowcount
db.execute(sa_text(
"UPDATE questions SET question_category_id = :keep WHERE question_category_id = :loser"),
{"keep": keep["id"], "loser": loser["id"]})
# An extra link to the survivor may already exist; the unique
# pair would refuse the move, so the duplicate row goes first.
db.execute(sa_text("""
DELETE FROM question_category_links a
WHERE a.category_id = :loser AND EXISTS (
SELECT 1 FROM question_category_links b
WHERE b.question_id = a.question_id AND b.category_id = :keep)
"""), {"loser": loser["id"], "keep": keep["id"]})
db.execute(sa_text(
"UPDATE question_category_links SET category_id = :keep WHERE category_id = :loser"),
{"keep": keep["id"], "loser": loser["id"]})
# The survivor keeps its own system unless it has none.
if not keep["system_id"] and loser["system_id"]:
db.execute(sa_text(
"UPDATE question_categories SET system_id = :s WHERE id = :id"),
{"s": loser["system_id"], "id": keep["id"]})
db.execute(sa_text("DELETE FROM blueprint_category_links WHERE category_id = :id"),
{"id": loser["id"]})
db.execute(sa_text("DELETE FROM question_categories WHERE id = :id"), {"id": loser["id"]})
db.commit()
print(f"\nmerged. {linked} cross-filings kept as extra links.")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main("--apply" in sys.argv))

View file

@ -365,11 +365,15 @@ class BuilderTests(unittest.TestCase):
with patch.dict(sys.modules, {"redis": redis}):
response = self.client.get(f"/attempts/progress?quiz_id={saved}")
self.assertEqual(response.status_code, 200, response.text)
# Time ran out: submitted, and the client is told so. It is a completed
# attempt that counts, not an "expired" one hidden from history.
self.assertEqual(response.json(), {"expired_submitted": True, "attempt_id": aid, "quiz_id": saved})
self.assertIsNotNone(self.db.get(QuizAttempt, aid).completed_at)
self.assertFalse(self.db.get(QuizAttempt, aid).expired)
# Out of time, and handed back unmarked: resuming shows the player its
# own saved answers so it can open on Time's Up. Nothing is graded by a
# request the learner did not make.
self.assertNotIn("expired_submitted", response.json())
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
# And when the learner does submit, it is still graded over the
# selected questions only — the parity this test is named for.
submitted = self.client.post(f"/attempts/{aid}/submit", json={"answers": []})
self.assertEqual(submitted.status_code, 200, submitted.text)
self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 1)
self.assertFalse(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).one().is_correct)

View file

@ -1,9 +1,10 @@
"""What happens to a session when its clock runs out, when it is deleted, and
when a learner resets everything.
Disposable SQLite; Redis is a Mock. The rule under test for expiry: an exam
closed without being suspended keeps running, and when time is up it is
submitted with what was answered and the score counts.
Disposable SQLite; Redis is a Mock. The rule under test for expiry: an exam's
clock runs only while the exam is on screen, and running out ends the block but
hands nothing in. The learner is shown Time's Up when they next open it, and
closing that submits so no request they did not make ever marks a paper.
"""
import json
import sys
@ -104,14 +105,19 @@ class SessionLifecycleTests(unittest.TestCase):
left)
self.assertEqual(left, 0.0)
def test_an_exam_closed_at_zero_is_submitted_on_the_next_look(self):
"""If the tab goes before the submit lands, the next page settles it."""
def test_an_exam_closed_at_zero_is_not_marked_by_looking_at_the_list(self):
"""Listing sessions reads; it does not hand papers in.
It used to settle any attempt whose clock had run out, so merely
opening the sessions page could mark a block and the learner's first
sight of an exam they had walked away from was a score.
"""
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid, time_left=0)
with patch.dict(sys.modules, {"redis": self.redis}):
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
self.assertEqual(rows[quiz_id]["state"], "completed")
self.assertIsNotNone(self.db.get(QuizAttempt, aid).completed_at)
self.assertNotEqual(rows[quiz_id]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_an_exam_with_time_on_it_is_left_alone(self):
quiz_id, aid = self.timed_quiz()
@ -122,35 +128,35 @@ class SessionLifecycleTests(unittest.TestCase):
self.assertNotEqual(rows[quiz_id]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_an_unsuspended_exam_that_ran_out_is_a_finished_exam_in_the_session_list(self):
def test_an_out_of_time_exam_stays_open_in_the_session_list(self):
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid)
# A second one, also out of time: settling the first must not disturb
# the pass over the rest (it once mutated the dict being iterated).
# A second one, also out of time: neither is touched, and the answered
# counts are still reported for both.
other_quiz, other_aid = self.timed_quiz()
self.save(self.bank.owner.id, other_aid)
with patch.dict(sys.modules, {"redis": self.redis}):
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
self.assertEqual(rows[other_quiz]["state"], "completed")
row = rows[quiz_id]
self.assertEqual(row["state"], "completed")
self.assertEqual(row["last_attempt_id"], aid)
self.assertEqual(row["attempts_count"], 1)
attempt = self.db.get(QuizAttempt, aid)
self.assertIsNotNone(attempt.completed_at)
self.assertFalse(attempt.expired)
# Graded on what was answered; the unanswered question counts against.
self.assertEqual(attempt.total_questions, 2)
self.assertNotIn(f"quiz_progress:{self.bank.owner.id}:{aid}", self.store)
for qid in (quiz_id, other_quiz):
self.assertNotEqual(rows[qid]["state"], "completed")
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
# The saved progress is still there for the player to open.
self.assertIn(f"quiz_progress:{self.bank.owner.id}:{aid}", self.store)
def test_resuming_an_expired_exam_reports_the_submission(self):
def test_resuming_an_out_of_time_exam_hands_back_the_paper_unmarked(self):
"""The player is given the block, not a verdict.
It opens on Time's Up because there is no time on it, and submits when
the learner closes that. Marking it here instead meant the answer sheet
was taken and scored by a request the learner never made.
"""
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid)
with patch.dict(sys.modules, {"redis": self.redis}):
response = self.client.get(f"/attempts/progress?quiz_id={quiz_id}")
self.assertEqual(response.json(), {"expired_submitted": True, "attempt_id": aid, "quiz_id": quiz_id})
# And the analysis exists for it straight away.
self.assertEqual(self.client.get(f"/attempts/{aid}/analysis").status_code, 200)
saved = self.client.get(f"/attempts/progress?quiz_id={quiz_id}").json()
self.assertNotIn("expired_submitted", saved)
self.assertEqual(saved["answers"], {"1": "yes"})
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
def test_a_suspended_exam_resumes_with_its_clock_held(self):
quiz_id, aid = self.timed_quiz()
@ -369,11 +375,17 @@ class HintRecordingTests(SessionLifecycleTests):
self.assertTrue(rows)
self.assertFalse(any(row.used_hint for row in rows))
def test_an_exam_that_runs_out_carries_the_tips_from_its_saved_progress(self):
def test_an_exam_that_runs_out_carries_the_tips_it_was_submitted_with(self):
"""A tip opened before answering is recorded whoever pressed submit.
The clock running out used to hand the paper in from the server, which
read the tips out of the saved progress. It is the player that submits
now from the Time's Up dialog — and it sends the same list.
"""
quiz_id, aid = self.timed_quiz()
self.save(self.bank.owner.id, aid, time_left=0, hints=[1])
with patch.dict(sys.modules, {"redis": self.redis}):
self.client.get("/quizzes/sessions")
self.client.post(f"/attempts/{aid}/submit", json={
"answers": [{"question_id": 1, "user_answer": "yes"}], "hints": [1]})
rows = {row.question_id: row for row in
self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid)}
self.assertTrue(rows[1].used_hint)

View file

@ -9,6 +9,7 @@ import ChooseObjective from './components/ChooseObjective'
import SearchOverlay from './components/SearchOverlay'
import ErrorBoundary from './components/ErrorBoundary'
import lazyPage from './utils/lazyPage'
import { isSessionPath } from './utils/session'
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
@ -63,7 +64,7 @@ function AppLayout() {
// Keyed by path so navigating away from a broken page clears the error.
const location = useLocation()
const [searching, setSearching] = useState(false)
const inSession = location.pathname.startsWith('/study/')
const inSession = isSessionPath(location.pathname)
useEffect(() => {
const onKey = (event) => {

View file

@ -8,7 +8,7 @@ const ROLES = [
{ key: 'stem', title: 'Question figures',
help: 'Shown beside the stem, before the answer. Nothing here may give the answer away.' },
{ key: 'explanation', title: 'Explanation figures',
help: 'Shown with the explanation, after the answer is in. Referred to by label — "as in Figure 2".' },
help: 'Shown with the explanation, after the answer is in. Give one a label only if the explanation refers to it by name.' },
]
const apiError = (err, fallback) => {
@ -24,8 +24,11 @@ const apiError = (err, fallback) => {
* any number of figures, each with a label the prose can refer to and until
* now there was no way to see or change them.
*
* The label is the point. "See the figure" is ambiguous the moment there are
* two, so every figure has one, falling back to its position.
* Adding an image and naming it are two separate things, and only the first is
* required. A label is what prose refers to "as in Figure 2" so it is
* worth writing when the explanation points at a particular image, and worth
* leaving empty when it does not. An invented "Figure 1" under every image
* tells a learner only that an image is an image.
*/
export default function FigureManager({ questionId }) {
const [figures, setFigures] = useState([])
@ -113,15 +116,16 @@ export default function FigureManager({ questionId }) {
{editing === figure.id ? (
<div className="fm-edit">
<label>
<span>Label</span>
<span>Label <small>optional</small></span>
<input value={draft.label} autoFocus maxLength={80}
aria-label={`Label for ${figure.label}`}
placeholder="Only if the text refers to it by name"
aria-label={`Label for image #${figure.media_id}`}
onChange={e => setDraft(d => ({ ...d, label: e.target.value }))} />
</label>
<label>
<span>Caption</span>
<textarea value={draft.caption} rows={2}
aria-label={`Caption for ${figure.label}`}
aria-label={`Caption for image #${figure.media_id}`}
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} />
</label>
<div className="fm-actions">
@ -133,7 +137,9 @@ export default function FigureManager({ questionId }) {
</div>
) : (
<>
<strong className="fm-label">{figure.label}</strong>
{/* Only what somebody wrote. An unlabelled figure is
the ordinary case, not a gap to apologise for. */}
{figure.label && <strong className="fm-label">{figure.label}</strong>}
{figure.caption
? <span className="fm-caption">{figure.caption}</span>
: <span className="fm-caption is-missing">
@ -149,20 +155,20 @@ export default function FigureManager({ questionId }) {
{editing !== figure.id && (
<div className="fm-actions">
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Move ${figure.label} up`} disabled={busy || index === 0}
aria-label={`Move image #${figure.media_id} up`} disabled={busy || index === 0}
onClick={() => move(role.key, index, -1)}></button>
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Move ${figure.label} down`}
aria-label={`Move image #${figure.media_id} down`}
disabled={busy || index === inRole.length - 1}
onClick={() => move(role.key, index, 1)}></button>
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Edit ${figure.label}`}
aria-label={`Edit image #${figure.media_id}`}
onClick={() => {
setEditing(figure.id)
setDraft({ label: figure.label || '', caption: figure.caption || '' })
}}>Edit</button>
<button type="button" className="btn btn-secondary btn-sm fm-remove"
aria-label={`Remove ${figure.label}`} disabled={busy}
aria-label={`Remove image #${figure.media_id}`} disabled={busy}
onClick={() => remove(figure)}>Remove</button>
</div>
)}

View file

@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom'
import ScrollStrip from './ScrollStrip'
import { useAuth } from '../context/AuthContext'
import { useSessionDrawer } from '../context/SessionDrawer'
import { isSessionPath } from '../utils/session'
import api from '../api/client'
import ExamSwitcher from './ExamSwitcher'
import GlobalSearch from './GlobalSearch'
@ -190,7 +191,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
const sessionDrawer = useSessionDrawer()
const [jobs, setJobs] = useState([])
const location = useLocation()
const inSession = location.pathname.startsWith('/study/')
const inSession = isSessionPath(location.pathname)
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Educators granted a category also manage questions, without a moderator role.
const [canManageQuestions, setCanManageQuestions] = useState(false)

View file

@ -28,6 +28,7 @@
/* The same slate as before, two steps up: dark enough to sit under the
page without the bar reading as a black band across the top. */
--navbar-bg: #1e293b;
--exam-bar: #1b3a63;
--navbar-fg: #e2e8f0;
--badge-radius: 12px;
}
@ -60,6 +61,7 @@
--expl-bg: #f9f4ea;
--expl-bd: #c4965a;
--navbar-bg: #33230f;
--exam-bar: #33230f;
--navbar-fg: #f0e8d8;
--badge-radius: 4px;
--font-body: 'Source Serif 4', Georgia, serif;

View file

@ -345,3 +345,146 @@
.lp-hero { padding: 72px 20px 64px; }
.lp-cta .btn { width: 100%; }
}
/* The two modes, played out
Two mock players side by side, animated off one shared loop. Everything
here is inert scenery: the "text" is coloured bars, because a stranger
reading a real stem on a landing page is a stranger reading a question they
will meet again later.
All motion sits inside `@media (prefers-reduced-motion: no-preference)` and
under `:not(.is-calm)`, so the panels render finished answered, marked,
explanation open when motion is not wanted. That is the state worth
looking at, so nothing is lost by never animating at all. */
.lp-modes {
display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 28px; align-items: start;
}
.lp-mode { margin: 0; }
.lp-mode figcaption { margin-bottom: 14px; }
.lp-mode figcaption strong { display: block; font-size: 1.05rem; font-weight: 700; }
.lp-mode figcaption span { display: block; margin-top: 4px; font-size: .88rem; line-height: 1.5; color: var(--text-muted); }
.lp-screen {
border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
background: var(--card-bg); box-shadow: 0 14px 36px rgba(15, 23, 42, .1);
}
.lp-screen-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg);
}
.lp-screen-bar.is-exam { background: var(--exam-bar); border-bottom: 0; color: #fff; }
.lp-pill { font-size: .68rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase;
padding: 3px 9px; border-radius: 11px; }
.lp-pill.is-study { background: #dff0e8; color: #327b64; }
.lp-block { font-size: .74rem; font-weight: 600; }
.lp-clock { margin-left: auto; font-size: .82rem; font-variant-numeric: tabular-nums; }
.lp-clock b { font-weight: 700; }
/* The seconds column: five digits stacked, the strip translated one digit at a
time behind a one-digit window. */
.lp-tick { display: inline-block; height: 1.1em; overflow: hidden; vertical-align: bottom; }
.lp-tick i { display: block; height: 1.1em; line-height: 1.1em; font-style: normal; }
.lp-screen-body { padding: 16px 14px; display: flex; flex-direction: column; gap: 8px; }
.lp-screen-split { display: grid; grid-template-columns: 54px minmax(0, 1fr); }
/* A line of "text". Width classes rather than random numbers so two panels
rendered side by side do not look like two different documents. */
.lp-line { display: block; height: 8px; border-radius: 4px; background: var(--border); }
.lp-line.w95 { width: 95%; } .lp-line.w92 { width: 92%; } .lp-line.w90 { width: 90%; }
.lp-line.w88 { width: 88%; } .lp-line.w78 { width: 78%; } .lp-line.w72 { width: 72%; }
.lp-line.w64 { width: 64%; } .lp-line.w62 { width: 62%; } .lp-line.w60 { width: 60%; }
.lp-line.w58 { width: 58%; } .lp-line.w55 { width: 55%; } .lp-line.w44 { width: 44%; }
.lp-line.w42 { width: 42%; } .lp-line.w40 { width: 40%; } .lp-line.w38 { width: 38%; }
.lp-line.w36 { width: 36%; } .lp-line.w28 { width: 28%; }
.lp-opts { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.lp-opts li {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; border: 1px solid var(--border); border-radius: 7px;
}
.lp-opts i {
display: inline-flex; align-items: center; justify-content: center; flex: none;
width: 21px; height: 21px; border-radius: 50%; border: 1px solid var(--border);
font-size: .66rem; font-style: normal; font-weight: 700; color: var(--text-muted);
}
.lp-opts li.is-picked { border-color: #496fa5; background: #e5ecf8; }
.lp-opts li.is-picked i { background: #496fa5; border-color: #496fa5; color: #fff; }
.lp-opts li.is-right { border-color: #77af99; background: #eef7f3; }
.lp-opts li.is-right i { background: #327b64; border-color: #327b64; color: #fff; }
.lp-opts li.is-wrong.is-picked { border-color: #d59aa8; background: #fbecf0; }
.lp-opts li.is-wrong.is-picked i { background: #a13c51; border-color: #a13c51; }
.lp-explain {
display: flex; flex-direction: column; gap: 7px;
margin-top: 12px; padding: 12px; border-radius: 8px;
background: var(--bg); border-left: 3px solid #327b64;
}
.lp-rail {
list-style: none; margin: 0; padding: 10px 0; display: flex; flex-direction: column; gap: 2px;
background: var(--exam-bar);
}
.lp-rail li {
display: flex; align-items: center; justify-content: center;
height: 26px; font-size: .72rem; font-weight: 600; color: rgba(255, 255, 255, .55);
}
.lp-rail li.is-done { color: #fff; }
.lp-rail li.is-here { background: rgba(255, 255, 255, .22); color: #fff; }
.lp-screen-foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 14px; background: var(--exam-bar);
}
.lp-screen-foot .lp-line { background: rgba(255, 255, 255, .45); }
.lp-end {
font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
padding: 5px 11px; border-radius: 5px; background: #b03047; color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
/* Study: the answer goes in, the marking arrives a beat later, then the
explanation. One 9s loop, so both panels stay in step. */
.lp-modes:not(.is-calm) .lp-mode-study .lp-opts li.is-picked {
animation: lp-pick 9s ease-in-out infinite;
}
.lp-modes:not(.is-calm) .lp-mode-study .lp-opts li.is-right {
animation: lp-mark 9s ease-in-out infinite;
}
.lp-modes:not(.is-calm) .lp-mode-study .lp-explain {
animation: lp-reveal 9s ease-in-out infinite;
}
/* Exam: the clock is the only thing that moves, because that is the
difference nothing is marked and nothing opens. */
.lp-modes:not(.is-calm) .lp-tick i:first-child {
animation: lp-count 9s steps(4, end) infinite;
}
.lp-modes:not(.is-calm) .lp-rail li.is-here { animation: lp-here 9s ease-in-out infinite; }
}
@keyframes lp-pick {
0%, 12% { border-color: var(--border); background: transparent; }
18%, 100% { border-color: #d59aa8; background: #fbecf0; }
}
@keyframes lp-mark {
0%, 30% { border-color: var(--border); background: transparent; }
38%, 100% { border-color: #77af99; background: #eef7f3; }
}
@keyframes lp-reveal {
0%, 44% { opacity: 0; transform: translateY(6px); }
56%, 92% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(6px); }
}
/* Four steps over the loop, so the window shows 9, 8, 7, 6 in turn. */
@keyframes lp-count {
from { transform: translateY(0); }
to { transform: translateY(-4.4em); }
}
@keyframes lp-here {
0%, 60% { background: rgba(255, 255, 255, .22); }
70%, 100% { background: rgba(255, 255, 255, .38); }
}
@media (max-width: 640px) {
.lp-modes { gap: 22px; }
}

View file

@ -40,7 +40,6 @@ const FIGURES = [
['topics', 'Topics'],
['systems', 'Systems'],
['articles', 'Articles'],
['exams', 'Exams'],
]
const FEATURES = [
@ -565,6 +564,97 @@ function AuthModal({ mode, onClose, onSwitch }) {
)
}
/**
* The two modes, played out rather than described.
*
* "Six things, each of which is doing something specific about a real problem
* with revising" was a sentence asking to be trusted. These are the same two
* claims shown happening: a study question marks itself the moment it is
* answered and opens its explanation, and an exam block keeps its answers to
* itself while the clock runs down.
*
* Everything moves on CSS keyframes off one shared loop, so there is no timer
* to drift and nothing to tear down. Under calm motion the animation is simply
* not applied: both panels render in their finished state, which is the state
* worth reading anyway.
*/
function ModeShowcase({ calm }) {
const cls = `lp-modes${calm ? ' is-calm' : ''}`
return (
<div className={cls}>
<figure className="lp-mode lp-mode-study">
<figcaption>
<strong>Study</strong>
<span>Marked as you answer, with the reasoning underneath.</span>
</figcaption>
{/* aria-hidden throughout: this is a picture of the product, and a
screen reader should hear the caption, not a mimed question. */}
<div className="lp-screen" aria-hidden="true">
<div className="lp-screen-bar">
<span className="lp-pill is-study">Study</span>
<span className="lp-line w40" />
</div>
<div className="lp-screen-body">
<span className="lp-line w95" />
<span className="lp-line w88" />
<span className="lp-line w60" />
<ul className="lp-opts">
<li><i>A</i><span className="lp-line w55" /></li>
<li className="is-picked is-wrong"><i>B</i><span className="lp-line w42" /></li>
<li className="is-right"><i>C</i><span className="lp-line w62" /></li>
<li><i>D</i><span className="lp-line w38" /></li>
</ul>
<div className="lp-explain">
<span className="lp-line w90" />
<span className="lp-line w72" />
</div>
</div>
</div>
</figure>
<figure className="lp-mode lp-mode-exam">
<figcaption>
<strong>Exam</strong>
<span>Nothing marked until the block ends, and the clock only runs while you are in it.</span>
</figcaption>
<div className="lp-screen is-exam" aria-hidden="true">
<div className="lp-screen-bar is-exam">
<span className="lp-block">Item 3 of 40</span>
{/* A real countdown rather than a still of one: the seconds
column scrolls, which is the only thing an exam block does
while you are reading it. */}
<span className="lp-clock">
<b>00</b>:<b>5</b>
<b className="lp-tick"><i>9</i><i>8</i><i>7</i><i>6</i><i>5</i></b>
</span>
</div>
<div className="lp-screen-split">
<ul className="lp-rail">
{Array.from({ length: 8 }, (_, i) => (
<li key={i} className={i < 3 ? 'is-done' : i === 3 ? 'is-here' : ''}>{i + 1}</li>
))}
</ul>
<div className="lp-screen-body">
<span className="lp-line w92" />
<span className="lp-line w78" />
<ul className="lp-opts is-plain">
<li><i>A</i><span className="lp-line w58" /></li>
<li className="is-picked"><i>B</i><span className="lp-line w44" /></li>
<li><i>C</i><span className="lp-line w64" /></li>
<li><i>D</i><span className="lp-line w36" /></li>
</ul>
</div>
</div>
<div className="lp-screen-foot">
<span className="lp-line w28" />
<span className="lp-end">End Block</span>
</div>
</div>
</figure>
</div>
)
}
// Main landing page
export default function LandingPage() {
@ -615,9 +705,9 @@ export default function LandingPage() {
<section className="lp-section">
<div className="lp-inner">
<div className="lp-head">
<h2>What you get</h2>
<p>Six things, each of which is doing something specific about a real problem with revising.</p>
<h2>Two ways to sit it</h2>
</div>
<ModeShowcase calm={calm} />
<div className="lp-grid">
{FEATURES.map(feature => (
<Reveal key={feature.title} className="lp-card">

View file

@ -70,7 +70,10 @@ describe('the figures', () => {
expect(figures.getByRole('listitem', { name: '673 topics' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '15 systems' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '8 articles' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '2 exams' })).toBeInTheDocument()
// Exams are not among them. The count is true and useless two blueprints
// loaded says something about the back office, not about the bank and a
// "2" beside four four-figure numbers read as a shortfall.
expect(figures.queryByRole('listitem', { name: /exams/i })).toBeNull()
// And the digits do arrive at the real number rather than stopping
// somewhere short of it. Longer than the default wait, because the count
// itself takes about a second.

View file

@ -457,7 +457,6 @@ export default function QuizPage() {
// The clock ran out: the answers are in, and the analysis waits behind an
// acknowledgement rather than replacing the exam without a word.
const [timeUp, setTimeUp] = useState(false)
const [afterTimeUp, setAfterTimeUp] = useState(null)
const timeUpRef = useRef(false)
// The attempt is closed: it was handed in, or the clock ran out and it was
// handed in for you. This is what "finished" means. It used to be read off
@ -714,10 +713,16 @@ export default function QuizPage() {
setAttemptId(saved.attempt_id || saved.attemptId)
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
if (saved.started_at) setStartedAt(saved.started_at)
// Restore timer calculate remaining from started_at + total_time
if (saved.total_time && saved.started_at) {
const elapsed = Math.floor((new Date() - new Date(saved.started_at)) / 1000)
const remaining = Math.max(0, saved.total_time - elapsed)
// What the player last saved, not what a wall clock would have spent. The
// exam's clock runs only while the exam is on screen, so an afternoon away
// from the tab is not an afternoon of the block computing it from
// `started_at` charged for exactly that, and disagreed with the server,
// which has read `time_left` first for some time now.
if (saved.total_time) {
const held = saved.time_left
const remaining = held != null
? Math.max(0, Number(held))
: Math.max(0, saved.total_time - Math.floor((new Date() - new Date(saved.started_at)) / 1000))
setTimeLeft(remaining)
setTotalTime(saved.total_time)
}
@ -727,7 +732,7 @@ export default function QuizPage() {
useEffect(() => {
if (!attemptId) return
const msg = progressError || (timeLeft !== null
? 'This exam is timed. Closing the tab leaves the clock running; when it runs out the exam is submitted with what you have answered. Suspend it to pause the clock.'
? 'This exam is timed. Your answers are saved, and the clock stops while the exam is off screen — nothing is handed in until you do it.'
: 'You have an in-progress quiz. Progress is saved while connected.')
const handler = (e) => { e.preventDefault(); e.returnValue = msg }
window.addEventListener('beforeunload', handler)
@ -762,12 +767,6 @@ export default function QuizPage() {
params: { quiz_id: id },
headers: { 'x-quiz-session': SESSION_ID },
})
if (progressRes.data?.expired_submitted) {
// The clock ran out while this was closed; the server submitted it
// with what was answered. The result is the thing to show.
navigate(`/sessions/${progressRes.data.attempt_id}`, { replace: true })
return
}
if (progressRes.data) {
await resumeQuiz(progressRes.data, voicesRes.data)
return
@ -805,10 +804,6 @@ export default function QuizPage() {
const aid = attemptRes.data.id
// A reused attempt may have newer progress from another tab/device.
const saved = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID } })
if (saved.data?.expired_submitted) {
navigate(`/sessions/${saved.data.attempt_id}`, { replace: true })
return
}
if (saved.data) {
await resumeQuiz(saved.data, voices)
return
@ -899,14 +894,16 @@ const timerStarted = timeLeft !== null
showToast('Five minutes left in this block.')
}, [timeLeft])
// Auto-submit when the timer expires during an active session never right
// after resume. "It submitted itself" is the one thing a learner must not
// have to infer, so the answers go in immediately and the screen waits.
// Nothing is handed in behind the learner's back. The clock reaching zero
// stops the block and says so; the paper goes in when they close that
// dialog, which is the same order a real block ends in. An exam left open
// and walked away from is still open when it is picked up again it shows
// this the moment it is back on screen rather than having been marked in the
// night by something the learner never saw.
useEffect(() => {
if (timeLeft !== 0) return
timeUpRef.current = true
setTimeUp(true)
handleSubmit(true)
}, [timeLeft])
/**
@ -1230,11 +1227,13 @@ const timerStarted = timeLeft !== null
// Everywhere else the session ends on its analysis: score, timing and
// what to do next. The answer-by-answer review is one link from there.
: `/sessions/${attemptId}`
const go = () => navigate(target, { state: { result: res.data } })
// When the clock ended it rather than the learner, the screen must not
// simply change underneath them: say so, and go when they acknowledge.
if (timeUpRef.current) setAfterTimeUp(() => go)
else go()
// A block the clock ended stays where it is. It has just been marked,
// the player is already showing the answers, and that is the review
// the same one a study session gives, on the questions still in front of
// them. Throwing them onto the analysis page instead would take the
// paper away at the moment it finally became readable; Exit goes there.
if (timeUpRef.current) return
navigate(target, { state: { result: res.data } })
} catch (err) {
const detail = err.response?.data?.detail
setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.')
@ -1459,7 +1458,7 @@ const timerStarted = timeLeft !== null
return (
<div className="quiz-bottom quiz-player is-boxed">
<div className={`quiz-bottom quiz-player is-boxed${examChrome ? ' is-exam-chrome' : ''}`}>
{/* The floating global-notes tab is gone. A note taken while sitting a
question is about that question, and there is a per-question note in
the toolbar below; a second, unrelated notepad floating over the same
@ -1523,10 +1522,15 @@ const timerStarted = timeLeft !== null
<div className="quiz-away-card">
<h2 id="timeup-heading">Time&apos;s Up</h2>
<p>You have run out of time to complete this question block.</p>
{/* Closing is the only thing left to do: it is already handed in
and marked, and this lands on the session's analysis. */}
{/* Closing is what hands it in. Until it is pressed the block is
simply stopped: nothing has been marked, and a learner who
comes back to a screen saying this has not already had an
answer sheet taken from them while they were away. */}
<button type="button" className="btn btn-primary" disabled={submitting}
onClick={() => { setTimeUp(false); afterTimeUp?.() }}>
onClick={async () => {
setTimeUp(false)
await handleSubmit(true)
}}>
{submitting ? 'Marking…' : 'Close'}
</button>
</div>
@ -1622,16 +1626,6 @@ const timerStarted = timeLeft !== null
and editing belong to the session list and the editor. */}
</div>
</div>
{voices.length > 1 && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
disabled={ttsActive}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.78rem', opacity: ttsActive ? 0.5 : 1, background: 'var(--input-bg)', color: 'var(--text)' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
)}
</div>
<div className="progress-bar" style={{ marginBottom: 16 }}>
@ -1747,13 +1741,21 @@ const timerStarted = timeLeft !== null
</button>
)}
<div className="quiz-top-actions">
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}> <span>Shortcuts</span></button>
{/* Both are the exam's. A study session has no clock to beat, and
the labs are on the question's own bar where the case is. */}
{/* The exam's own tools, and only the exam's: a study session has
no clock to beat and no calculator, and its labs are on the
question's own bar beside the case. The labs open into the
column next to the question in both modes the same panel,
so reference ranges are read against the stem rather than
over it. */}
{!isStudy && (
<>
<button type="button" className={labsOpen ? 'is-on' : ''}
title="Lab values" aria-label="Lab values" aria-pressed={labsOpen}
onClick={() => setLabsOpen(v => !v)}> <span>Lab values</span></button>
<button type="button" className={panel === 'note' ? 'is-on' : ''}
title="Notes" aria-label="Notes" aria-pressed={panel === 'note'}
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}> <span>Notes</span></button>
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}> <span>Calculator</span></button>
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}> <span>Lab values</span></button>
</>
)}
{/* An exam moves between items from the middle of this bar, and

View file

@ -362,20 +362,27 @@ describe('quiz player', () => {
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/favorites', { question_id: 1 }))
})
it('an exam that ran out while away was submitted by the server, and opens on its result', async () => {
it('an exam that ran out while away is not handed in until the learner closes Time\'s Up', async () => {
mode = 'exam'
quizModeVar = 'timed'
const originalGet = api.get.getMockImplementation()
api.get.mockImplementation((url, ...args) => {
// The server settled it: what comes back is the fact, not the saved answers.
if (url === '/attempts/progress') return Promise.resolve({ data: { expired_submitted: true, attempt_id: 50, quiz_id: 10 } })
// No clock left, and nothing marked: the block is stopped, not over.
if (url === '/attempts/progress') {
return Promise.resolve({ data: {
attempt_id: 50, quiz_id: 10, mode: 'exam', current_idx: 0, answers: {},
total_time: 600, time_left: 0, started_at: new Date().toISOString(),
} })
}
return originalGet(url, ...args)
})
mount()
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
// Nothing is submitted twice, and no question is shown as if still open.
expect(await screen.findByText("Time's Up")).toBeInTheDocument()
// Nothing has been submitted merely by opening it.
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
expect(screen.queryByText('Full first clinical question.')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() =>
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(true))
})
it('opens one option\'s reasoning on click, and closes it on the next click', async () => {
@ -648,6 +655,10 @@ describe('quiz player', () => {
mode = 'study'
await act(async () => { vi.advanceTimersByTime(61_000) })
expect(await screen.findByText(/You have run out of time/)).toBeInTheDocument()
// Stopped, not handed in: the clock running out ends the block, and
// closing the notice is what submits it.
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
// Nothing was answered and the block is still finished the case
// "everything is answered" got wrong.
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ answers: [] }))

View file

@ -324,6 +324,11 @@
only thing above the player is the header itself and the player is given
what is left of the window rather than a column in the middle of it. */
.app-main.is-session { width: 100%; padding: 0; }
/* The navbar carries 32px of margin under it everywhere else on the site. The
shell below is sized against the window minus the header, so that margin was
32px the arithmetic did not know about which is exactly how far off the
bottom of the screen the block bar ended up. */
body:has(.quiz-player.is-boxed) .navbar { margin-bottom: 0; }
.quiz-player.is-boxed {
/* Only the header remains above it: the section bar hides for the duration
@ -341,6 +346,7 @@
.quiz-player.is-boxed .quiz-sidebar { position: static; max-height: none; }
.quiz-footbar {
flex: none;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border); background: #fff;
@ -396,8 +402,12 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
Eliminate, then choose among what is left which is how anybody actually
works a five-option question. The control sits outside the option so that
striking one through is never mistaken for choosing it. */
.option-row { display: flex; align-items: stretch; gap: 6px; }
.option-row > .option { flex: 1; min-width: 0; }
.option-row { display: flex; align-items: flex-start; gap: 4px; }
/* `flex: 0 1 auto` rather than `1`: a short option gets a short box and the
toggle sits right after the words, a long one wraps and the toggle follows
the last line. Pinned to the right edge of the card it was a column of marks
with nothing visibly attaching them to an option. */
.option-row > .option { flex: 0 1 auto; width: auto; min-width: 0; max-width: 100%; }
.option.ruled-out .option-text { opacity: 0.4; text-decoration: line-through; }
.option.ruled-out .option-letter { opacity: 0.4; }
@ -561,3 +571,137 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
.quiz-labs { display: none; }
}
/* Exam mode
A block is sat the way a board is sat: the window is the exam and nothing
else. The site header goes with everything else, which is what finally
makes the arithmetic below honest the shell is the viewport, so the bar
at the foot of it cannot be pushed under the fold, which is what used to
happen whenever anything above the player was a pixel taller than the 52
this file had guessed at. */
.quiz-player.is-exam-chrome {
position: fixed; inset: 0; z-index: 400;
height: 100dvh; max-height: 100dvh;
background: var(--card-bg);
}
body:has(.quiz-player.is-exam-chrome) .navbar,
body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
/* The title, the mode pill and the progress bar are the session list's way of
describing a session. Inside one there is the item counter, which says the
same thing in the paper's own terms. */
.quiz-player.is-exam-chrome .quiz-header-card,
.quiz-player.is-exam-chrome > .progress-bar { display: none; }
/* The bar across the top, in the exam's colour. Item and block at the left in
a box of their own, the two arrows in the middle where the hand is, and the
tools at the right. */
.quiz-player.is-exam-chrome .quiz-topbar {
display: flex; align-items: center; gap: 18px;
margin: 0; padding: 8px 14px;
background: var(--exam-bar); color: #fff; border: 0; border-radius: 0;
}
.quiz-player.is-exam-chrome .quiz-block-meta {
flex: none; margin: 0; padding: 8px 14px;
display: flex; flex-direction: column; gap: 2px;
font-size: .82rem; line-height: 1.35; color: #fff;
background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .35);
}
.quiz-player.is-exam-chrome .quiz-block-meta strong { font-weight: 700; }
.quiz-player.is-exam-chrome .quiz-item-nav {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 14px;
}
.quiz-player.is-exam-chrome .quiz-item-nav button {
display: inline-flex; flex-direction: column; align-items: center; gap: 2px;
padding: 0; font-size: .78rem; font-weight: 600;
color: #fff; background: none; border: 0; cursor: pointer;
}
/* The arrow is the target; the word under it says which way. */
.quiz-player.is-exam-chrome .quiz-item-nav button::before {
content: '←'; display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; font-size: 1.05rem; line-height: 1;
border-radius: 50%; background: rgba(255, 255, 255, .22);
}
.quiz-player.is-exam-chrome .quiz-item-nav button:last-child::before { content: '→'; }
.quiz-player.is-exam-chrome .quiz-item-nav button:hover:not(:disabled)::before { background: rgba(255, 255, 255, .38); }
.quiz-player.is-exam-chrome .quiz-item-nav button:disabled { opacity: .45; cursor: default; }
.quiz-player.is-exam-chrome .quiz-item-count {
font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; color: #fff;
}
.quiz-player.is-exam-chrome .quiz-top-actions { flex: none; display: flex; gap: 4px; }
.quiz-player.is-exam-chrome .quiz-top-actions button {
display: inline-flex; flex-direction: column; align-items: center; gap: 3px;
min-width: 66px; padding: 4px 8px; font-size: .74rem; font-weight: 600;
color: #fff; background: none; border: 0; border-radius: 6px; cursor: pointer;
}
.quiz-player.is-exam-chrome .quiz-top-actions button:hover,
.quiz-player.is-exam-chrome .quiz-top-actions button.is-on { background: rgba(255, 255, 255, .18); }
.quiz-player.is-exam-chrome .quiz-top-actions button > span { display: block; }
/* The rail is the exam's question-status list: numbers and how each stands,
without the excerpt a study session shows. */
.quiz-player.is-exam-chrome .quiz-rail { background: var(--exam-bar); border: 0; border-radius: 0; }
.quiz-player.is-exam-chrome .quiz-rail,
.quiz-player.is-exam-chrome .quiz-rail-head span,
.quiz-player.is-exam-chrome .quiz-rail-item { color: #eef2f8; }
.quiz-player.is-exam-chrome .quiz-rail-head { border-bottom-color: rgba(255, 255, 255, .2); }
.quiz-player.is-exam-chrome .quiz-rail-head button { color: #eef2f8; }
.quiz-player.is-exam-chrome .quiz-rail-item:hover { background: rgba(255, 255, 255, .12); }
.quiz-player.is-exam-chrome .quiz-rail-item.is-active { background: rgba(255, 255, 255, .22); }
/* The block bar, in the same colour as the top so the exam is bracketed. */
.quiz-player.is-exam-chrome .quiz-footbar {
background: var(--exam-bar); border-top: 0; color: #fff; flex-wrap: nowrap;
}
.quiz-player.is-exam-chrome .quiz-block-time {
padding: 8px 14px; color: #fff;
background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .35);
}
.quiz-player.is-exam-chrome .quiz-block-time strong { color: #fff; }
.quiz-player.is-exam-chrome .quiz-exit,
.quiz-player.is-exam-chrome .quiz-block-pause {
color: #fff; background: none; border: 1px solid rgba(255, 255, 255, .4);
}
.quiz-player.is-exam-chrome .quiz-exit:hover,
.quiz-player.is-exam-chrome .quiz-block-pause:hover { background: rgba(255, 255, 255, .16); }
.quiz-player.is-exam-chrome .quiz-block-end {
background: #b03047; border-color: #b03047;
}
.quiz-player.is-exam-chrome .quiz-block-end:hover:not(:disabled) { background: #93253a; }
@media (max-width: 900px) {
.quiz-player.is-exam-chrome .quiz-topbar { gap: 8px; padding: 6px 8px; }
.quiz-player.is-exam-chrome .quiz-block-meta { font-size: .72rem; padding: 5px 8px; }
.quiz-player.is-exam-chrome .quiz-top-actions button { min-width: 0; font-size: 0; gap: 0; }
.quiz-player.is-exam-chrome .quiz-top-actions button > span { display: none; }
}
/* Reviewing a finished attempt
The player with the answers in. Everything it needs is already defined
above; what is left is the handful of marks a session being sat has no use
for how a question went, in the rail and along the bottom. */
.quiz-rail-item.is-skipped .quiz-rail-num { background: #f1f2f6; color: #9aa0aa; }
.quiz-player.is-review .quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; }
.quiz-rail-tally {
display: flex; flex-wrap: wrap; gap: 10px;
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);
font-size: .76rem; font-weight: 600;
}
.quiz-rail-tally .is-correct { color: #327b64; }
.quiz-rail-tally .is-wrong { color: #a13c51; }
.quiz-rail-tally .is-skipped { color: var(--text-subtle); }
.quiz-review-verdict { font-size: .8rem; font-weight: 700; color: var(--text-muted); }
.quiz-meta-pill.is-correct { background: #eef7f3; color: #327b64; }
.quiz-meta-pill.is-wrong { background: #fbecf0; color: #a13c51; }
.quiz-meta-pill.is-skipped { background: var(--border); color: var(--text-muted); }
/* The review's options are read, not pressed: no pointer, no hover. */
.quiz-player.is-review .question-card .option { cursor: default; }
.quiz-player.is-review .question-card .option:hover { background: white; }
.quiz-player.is-review .question-card .option.correct:hover { background: #eef7f3; }
.quiz-player.is-review .question-card .option.incorrect:hover { background: #fbecf0; }

View file

@ -1,11 +1,27 @@
import { uploadUrl } from '../utils/uploads'
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
import api from '../api/client'
import QuizTools from '../components/QuizTools'
import { useSessionDrawer } from '../context/SessionDrawer'
import useMediaQuery from '../hooks/useMediaQuery'
import QuizTools, { LabValues } from '../components/QuizTools'
import FigureStrip from '../components/FigureStrip'
import { uploadUrl } from '../utils/uploads'
import './QuizPlayer.css'
/**
* The answer-by-answer review of a finished attempt.
*
* It is a study session with the answers already in. Reviewing used to be its
* own page a dropdown listing every question by number and verdict, a card
* underneath it, and a layout that resembled nothing else on the site so
* moving from sitting a block to reading it back meant learning a second set
* of controls for the same act. It now wears the player: the rail on the left
* with every question and how it went, the question in the middle, the labs in
* a column beside it, and the way out along the bottom.
*
* The score, the bands and "what next" are the analysis page's job. This page
* is the questions.
*/
export default function ResultsPage() {
const { id } = useParams()
const location = useLocation()
@ -17,12 +33,26 @@ export default function ResultsPage() {
// ?q=3 means "open on question 3", which is how the analytics table links
// here: clicking a row should land on that question rather than at the top
// of a session you then have to page through to find it.
const [reviewIndex, setReviewIndex] = useState(() => {
const [current, setCurrent] = useState(() => {
const wanted = Number(searchParams.get('q'))
return Number.isInteger(wanted) && wanted > 0 ? wanted - 1 : 0
})
const [tool, setTool] = useState(null)
const [labsOpen, setLabsOpen] = useState(false)
const [railOpen, setRailOpen] = useState(() => {
try { return localStorage.getItem('pedshub.quizRail') !== 'closed' } catch { return true }
})
const [responseStats, setResponseStats] = useState(null)
const [navOpen, setNavOpen] = useState(false)
const hasRail = useMediaQuery('(min-width: 1151px)')
// On a phone the site's burger opens this attempt's questions, exactly as it
// does while one is being sat the rail has nowhere to live at that width,
// and a second hamburger for the same list is not an answer.
const { register: registerDrawer } = useSessionDrawer()
useEffect(() => {
if (hasRail) return undefined
return registerDrawer(() => setNavOpen(true))
}, [hasRail, registerDrawer])
const isCourseQuiz = result?.course_id != null
const reviewAllowed = result?.allow_review !== false
@ -37,187 +67,261 @@ export default function ResultsPage() {
}, [id])
useEffect(() => {
const total = result?.answers?.length
if (total && reviewIndex > total - 1) setReviewIndex(total - 1)
}, [result, reviewIndex])
try { localStorage.setItem('pedshub.quizRail', railOpen ? 'open' : 'closed') } catch { /* private mode */ }
}, [railOpen])
const reviewQuestion = result?.answers?.[reviewIndex]
const answers = result?.answers || []
useEffect(() => {
if (answers.length && current > answers.length - 1) setCurrent(answers.length - 1)
}, [answers.length, current])
const ans = answers[current]
useEffect(() => {
let active = true
setResponseStats(null)
if (reviewQuestion && result?.completed_at && reviewAllowed) api.get(`/study-tools/attempts/${result.id}/questions/${reviewQuestion.question_id}/responses`)
.then(r => { if (active) setResponseStats(r.data) }).catch(() => {})
if (ans && result?.completed_at && reviewAllowed) {
api.get(`/study-tools/attempts/${result.id}/questions/${ans.question_id}/responses`)
.then(r => { if (active) setResponseStats(r.data) }).catch(() => {})
}
return () => { active = false }
}, [result?.id, reviewQuestion?.question_id, reviewAllowed])
}, [result?.id, ans?.question_id, reviewAllowed])
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
if (!result) return null
const pct = result.percentage
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
const correct = result.answers?.length > 0 ? result.answers.filter(a => a.is_correct).length : result.score
const correct = answers.length > 0 ? answers.filter(a => a.is_correct).length : result.score
const total = result.total_questions
const back = isCourseQuiz && returnTo ? returnTo : `/study/${result.id}`
if (!reviewAllowed) {
return (
<div className="card" style={{ textAlign: 'center', margin: '32px auto', maxWidth: 520 }}>
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
</div>
)
}
if (!answers.length) {
return (
<div className="card" style={{ textAlign: 'center', margin: '32px auto', maxWidth: 520 }}>
<p style={{ color: 'var(--text-muted)', margin: 0 }}>This attempt has no recorded answers.</p>
</div>
)
}
const verdict = (a) => (a.is_correct ? 'Correct' : a.user_answer ? 'Incorrect' : 'Skipped')
return (
<div className="quiz-results">
<div className="quiz-bottom quiz-player is-boxed is-review">
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
{/* The score, the bands and "what next" are the analysis page's job.
Repeating them here gave the learner two different-looking verdicts on
the same attempt. This page is the answer-by-answer review, so it
opens with the way back to the analysis and nothing else.
A course quiz has no analysis page there the score card stays. */}
{isCourseQuiz ? (
<div className="card" style={{ marginBottom: 24 }}>
<div className="score-display">
<div className={`score-value ${scoreClass}`}>{pct}%</div>
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
{correct} of {total} correct
</div>
{returnTo && (
<div style={{ marginTop: 18 }}>
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
{/* The attempt, on a phone: the same list the rail holds, as a drawer. */}
{!hasRail && navOpen && (
<div className="quiz-drawer" onClick={e => e.target === e.currentTarget && setNavOpen(false)}>
<div className="quiz-drawer-panel" role="dialog" aria-modal="true" aria-label="Answer review">
<div className="quiz-drawer-head">
<button type="button" className="quiz-drawer-close" aria-label="Close"
onClick={() => setNavOpen(false)}></button>
<div className="quiz-drawer-tabs" role="tablist">
<button type="button" role="tab" aria-selected="true">Questions</button>
</div>
)}
</div>
<div className="quiz-drawer-title">
<span className="quiz-drawer-badge">Review</span>
<strong>{result.quiz_title || 'This session'}</strong>
<small>{correct}/{total}</small>
<span className="quiz-drawer-bar" aria-hidden="true">
<span style={{ width: `${total ? (correct / total) * 100 : 0}%` }} />
</span>
</div>
<div className="quiz-rail-list quiz-drawer-list">
{answers.map((row, i) => (
<button type="button" key={row.question_id}
className={`quiz-rail-item${i === current ? ' is-active' : ''}${row.is_correct ? ' is-done' : ''}${row.user_answer || row.is_correct ? '' : ' is-skipped'}`}
onClick={() => { setCurrent(i); setNavOpen(false) }}>
<span className="quiz-rail-num">{i + 1}</span>
<span className="quiz-rail-text">{row.question_text}</span>
</button>
))}
</div>
</div>
</div>
) : (
<div className="results-crumb">
<Link to={`/study/${result.id}`}>&larr; Session analysis</Link>
<h1>Answer review</h1>
<span className="results-crumb-score">{correct} of {total} correct &middot; {pct}%</span>
</div>
)}
{/* Question review — only if allowed */}
{reviewAllowed && result.answers && result.answers.length > 0 && (
<>
{/* Summary row */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{[
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
].map(s => (
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
<div className={`quiz-layout${railOpen ? '' : ' is-rail-closed'}${labsOpen ? ' has-labs' : ''}`}>
{!railOpen && (
<button type="button" className="quiz-rail-reopen" aria-label="Show the session questions"
onClick={() => setRailOpen(true)}></button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="quiz-topbar">
<p className="quiz-question-select is-static">
<small>Question</small><strong>{current + 1}</strong> of {answers.length}
</p>
<div className="quiz-top-actions">
<button type="button" className={labsOpen ? 'is-on' : ''} aria-pressed={labsOpen}
onClick={() => setLabsOpen(v => !v)}> <span>Lab values</span></button>
</div>
</div>
{ans && (
<div className="question-card">
{/* Where this question sits in the tree. During a session it is
hidden it names the answer's own topic but the answer is
in, so it is the obvious place to read next from. */}
<nav className="quiz-breadcrumbs" aria-label="Question categories">
{ans.category_breadcrumbs?.length
? ans.category_breadcrumbs.map((category, index) => (
<span key={category.id}>{index > 0 && ' '}
<Link to={`/study/new?category=${category.id}`}>{category.name}</Link>
</span>))
: <span>Uncategorized</span>}
</nav>
<div className="quiz-qmeta">
<span className={`quiz-meta-pill is-${ans.is_correct ? 'correct' : ans.user_answer ? 'wrong' : 'skipped'}`}>
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
</span>
</div>
<div className="quiz-stem" role="heading" aria-level={3}>{ans.question_text}</div>
{ans.figures?.some(f => f.role === 'stem')
? <FigureStrip figures={ans.figures.filter(f => f.role === 'stem')} attemptId={id} size="full" />
: ans.image_path && (
<img src={uploadUrl(ans.image_path, id)} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />)}
{ans.options?.length > 0 && (
<div className="options" style={{ marginTop: 16 }}>
{ans.options.map((opt, i) => {
const isCorrect = opt === ans.correct_answer
const isUser = opt === ans.user_answer
const stat = responseStats?.options?.[i]
return (
<div key={i}
className={`option${isCorrect ? ' correct' : ''}${isUser && !isCorrect ? ' incorrect' : ''}${isUser ? ' selected' : ''}`}>
<span className="option-letter">{i + 1}</span>
<span className="option-text">
{opt}
{responseStats?.sample_size > 0 && stat && (
<span className="quiz-response-stat">
<span className="quiz-response-track">
<span style={{ width: `${stat.percentage}%` }} />
</span>
<span>{stat.percentage}%</span>
<span>{stat.count}/{responseStats.sample_size}</span>
</span>
)}
</span>
{isCorrect && <span className="option-status option-status-correct">
{isUser ? '✓ Your answer' : '✓ Correct answer'}</span>}
{isUser && !isCorrect && <span className="option-status option-status-wrong"> Your answer</span>}
</div>
)
})}
</div>
)}
{/* Typed rather than chosen: there is no option to mark up, so
what was written and what was wanted are set side by side. */}
{!ans.options?.length && (
<div style={{ marginTop: 16, fontSize: '0.9rem' }}>
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
</div>
{!ans.is_correct && (
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
<strong>Correct answer:</strong> {ans.correct_answer}
</div>
)}
</div>
)}
<div className="quiz-review-tabs">
<span>Preferred response</span>
{ans.page_reference && <span className="quiz-source-page">Source page {ans.page_reference}</span>}
</div>
{responseStats && (
<p className="quiz-stats-note">
{responseStats.sample_size
? `${responseStats.sample_size} recorded answers. ${responseStats.basis}`
: 'No response statistics available yet.'}
</p>
)}
{(ans.explanation || ans.explanation_image_path) && (
<div className="explanation" style={{ marginTop: 16 }}>
{ans.explanation && <><strong>Explanation:</strong> <span style={{ whiteSpace: 'pre-line' }}>{ans.explanation}</span></>}
{ans.figures?.some(f => f.role === 'explanation') && (
<FigureStrip figures={ans.figures.filter(f => f.role === 'explanation')}
attemptId={id} size="compact" label="Figures" />
)}
{!ans.figures?.some(f => f.role === 'explanation') && ans.explanation_image_path && (
<div style={{ marginTop: 12 }}>
<img src={uploadUrl(ans.explanation_image_path, id)} alt="Explanation illustration"
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => { e.currentTarget.style.display = 'none' }} />
</div>
)}
</div>
)}
</div>
)}
</div>
{labsOpen && (
<aside className="quiz-labs" aria-label="Lab values">
<div className="quiz-labs-head">
<strong>Lab values</strong>
<button type="button" aria-label="Close lab values" onClick={() => setLabsOpen(false)}></button>
</div>
<div className="quiz-labs-body"><LabValues /></div>
</aside>
)}
<div className="quiz-sidebar quiz-rail">
<div className="quiz-rail-head">
<span>Session questions</span>
<button type="button" className="quiz-rail-hide" aria-label="Hide the session questions"
onClick={() => setRailOpen(false)}></button>
</div>
<div className="quiz-rail-list">
{answers.map((row, i) => (
<button type="button" key={row.question_id}
className={`quiz-rail-item${i === current ? ' is-active' : ''}${row.is_correct ? ' is-done' : ''}${row.user_answer || row.is_correct ? '' : ' is-skipped'}`}
aria-current={i === current ? 'true' : undefined}
onClick={() => setCurrent(i)}>
<span className="quiz-rail-num">{i + 1}</span>
{/* Every question is readable now, so the rail reads like a
study session's: the stem, not just a number. */}
<span className="quiz-rail-text">{row.question_text}</span>
</button>
))}
</div>
<div className="quiz-topbar">
<label>Question Review<select aria-label="Review question" value={reviewIndex} onChange={e => setReviewIndex(Number(e.target.value))}>{result.answers.map((answer, index) => <option key={answer.question_id} value={index}>{index + 1} of {result.answers.length} · {answer.is_correct ? 'Correct' : answer.user_answer ? 'Incorrect' : 'Skipped'}</option>)}</select></label>
<div className="quiz-top-actions"><button type="button" onClick={() => setTool('calculator')}>Calculator</button><button type="button" onClick={() => setTool('labs')}>Lab values</button>
<button type="button" disabled={reviewIndex === 0} onClick={() => setReviewIndex(value => value - 1)}> Previous</button><button type="button" disabled={reviewIndex === result.answers.length - 1} onClick={() => setReviewIndex(value => value + 1)}>Next </button>
</div>
<div className="quiz-rail-tally">
<span className="is-correct">{answers.filter(a => a.is_correct).length} correct</span>
<span className="is-wrong">{answers.filter(a => !a.is_correct && a.user_answer).length} incorrect</span>
<span className="is-skipped">{answers.filter(a => !a.user_answer).length} skipped</span>
</div>
{result.answers.slice(reviewIndex, reviewIndex + 1).map(ans => {
const idx = reviewIndex
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return (
<div className={`review-card ${cardClass}`} key={ans.question_id}>
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' '}<Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
{/* Question header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Question {idx + 1}
</span>
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
{ans.question_text}
</p>
</div>
<span style={{
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
}}>
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
</span>
</div>
{ans.figures?.some(f => f.role === 'stem')
? <FigureStrip figures={ans.figures.filter(f => f.role === 'stem')} attemptId={id} size="full" />
: ans.image_path && <img src={uploadUrl(ans.image_path, id)} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
{/* Numbered answers */}
{ans.options && ans.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{ans.options.map((opt, i) => {
const isCorrect = opt === ans.correct_answer
const isUser = opt === ans.user_answer
const isWrong = isUser && !isCorrect
const letter = i + 1
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
fontSize: '0.73rem', fontWeight: 700,
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
}}>{letter}</span>
<span style={{ flex: 1, fontSize: '1rem' }}>{opt}
{responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat"><span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%`, background: isCorrect ? '#71b298' : '#444' }} /></span><span>{responseStats.options[i].percentage}%</span><span>{responseStats.options[i].count}/{responseStats.sample_size}</span></span>}
</span>
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Correct answer</span>}
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
</div>
)
})}
</div>
)}
{/* Fill-blank */}
{(!ans.options || ans.options.length === 0) && (
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
</div>
{!ans.is_correct && (
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
<strong>Correct answer:</strong> {ans.correct_answer}
</div>
)}
</div>
)}
<div className="quiz-review-tabs"><span>Preferred response</span>{ans.page_reference && <span className="quiz-source-page">Source page {ans.page_reference}</span>}</div>
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}</p>}
{/* Explanation */}
{(ans.explanation || ans.explanation_image_path) && (
<div className="explanation">
<strong>Explanation</strong>
{ans.explanation && <div style={{ marginTop: 8 }}>{ans.explanation}</div>}
{ans.figures?.some(f => f.role === 'explanation') && (
<FigureStrip figures={ans.figures.filter(f => f.role === 'explanation')}
attemptId={id} size="compact" label="Figures" />
)}
{!ans.figures?.some(f => f.role === 'explanation') && ans.explanation_image_path && (
<div style={{ marginTop: 12 }}>
<img src={uploadUrl(ans.explanation_image_path, id)} alt="Explanation illustration"
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.currentTarget.style.display = 'none'} />
</div>
)}
</div>
)}
</div>
)
})}
</>
)}
{!reviewAllowed && (
<div className="card" style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
</div>
)}
</div>
<div className="quiz-footbar">
<Link to={back} className="btn btn-secondary btn-sm quiz-exit">
{isCourseQuiz && returnTo ? '← Back to course' : '← Session analysis'}
</Link>
<div className="quiz-nav-controls">
<button type="button" className="btn btn-secondary" disabled={current === 0}
onClick={() => setCurrent(v => v - 1)}> Previous</button>
<span className="quiz-review-verdict">{verdict(ans)}</span>
<button type="button" className="btn btn-primary" disabled={current === answers.length - 1}
onClick={() => setCurrent(v => v + 1)}>Next </button>
</div>
<span className="quiz-block-time">{correct} of {total} correct · {pct}%</span>
</div>
</div>
)
}

View file

@ -0,0 +1,11 @@
/**
* Which routes are "inside a session".
*
* Sitting a block and reading one back are the same screen with different
* things on it, and both want the whole window: no section bar, no centred
* column, and on a phone a burger that opens the question list rather than the
* site menu. Two files decided this separately and disagreed the moment review
* became a player, so they now ask here.
*/
export const isSessionPath = (pathname = '') =>
pathname.startsWith('/study/') || pathname.startsWith('/results/')