diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py
index a55ab59..cd576ca 100644
--- a/backend/app/routers/attempts.py
+++ b/backend/app/routers/attempts.py
@@ -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)
diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py
index 6ba6ea1..73218f2 100644
--- a/backend/app/routers/quizzes.py
+++ b/backend/app/routers/quizzes.py
@@ -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)
diff --git a/backend/scripts/backfill_question_figures.py b/backend/scripts/backfill_question_figures.py
index 433b924..30fc08e 100644
--- a/backend/scripts/backfill_question_figures.py
+++ b/backend/scripts/backfill_question_figures.py
@@ -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.
diff --git a/backend/scripts/merge_duplicate_categories.py b/backend/scripts/merge_duplicate_categories.py
new file mode 100644
index 0000000..e67357c
--- /dev/null
+++ b/backend/scripts/merge_duplicate_categories.py
@@ -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))
diff --git a/backend/tests/test_quiz_builder.py b/backend/tests/test_quiz_builder.py
index 60d52c1..df74815 100644
--- a/backend/tests/test_quiz_builder.py
+++ b/backend/tests/test_quiz_builder.py
@@ -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)
diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py
index 3819907..79adf30 100644
--- a/backend/tests/test_session_lifecycle.py
+++ b/backend/tests/test_session_lifecycle.py
@@ -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)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 122204f..a948361 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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) => {
diff --git a/frontend/src/components/FigureManager.jsx b/frontend/src/components/FigureManager.jsx
index 73f691f..2341d2b 100644
--- a/frontend/src/components/FigureManager.jsx
+++ b/frontend/src/components/FigureManager.jsx
@@ -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 ? (
) : (
<>
- {figure.label}
+ {/* Only what somebody wrote. An unlabelled figure is
+ the ordinary case, not a gap to apologise for. */}
+ {figure.label && {figure.label}}
{figure.caption
? {figure.caption}
:
@@ -149,20 +155,20 @@ export default function FigureManager({ questionId }) {
{editing !== figure.id && (
)}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 02eb01d..a979467 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -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)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 56587da..1bcdbe1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css
index 6709fb4..7a45d19 100644
--- a/frontend/src/pages/LandingPage.css
+++ b/frontend/src/pages/LandingPage.css
@@ -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; }
+}
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
index a1e6c17..7cd340d 100644
--- a/frontend/src/pages/LandingPage.jsx
+++ b/frontend/src/pages/LandingPage.jsx
@@ -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 (
+
+
+
+ Study
+ Marked as you answer, with the reasoning underneath.
+
+ {/* aria-hidden throughout: this is a picture of the product, and a
+ screen reader should hear the caption, not a mimed question. */}
+
+
+ Study
+
+
+
+
+
+
+
+
A
+
B
+
C
+
D
+
+
+
+
+
+
+
+
+
+
+
+ Exam
+ Nothing marked until the block ends, and the clock only runs while you are in it.
+
+
+
+ Item 3 of 40
+ {/* 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. */}
+
+ 00:5
+ 98765
+
+
+
+
+ {Array.from({ length: 8 }, (_, i) => (
+
{i + 1}
+ ))}
+
+
+
+
+
+
A
+
B
+
C
+
D
+
+
+
+
+
+ End Block
+
+
+
+
+ )
+}
+
// ── Main landing page ─────────────────────────────────────────────────────────
export default function LandingPage() {
@@ -615,9 +705,9 @@ export default function LandingPage() {
-
What you get
-
Six things, each of which is doing something specific about a real problem with revising.
+
Two ways to sit it
+
{FEATURES.map(feature => (
diff --git a/frontend/src/pages/LandingPage.test.jsx b/frontend/src/pages/LandingPage.test.jsx
index 3ff3780..cac07a5 100644
--- a/frontend/src/pages/LandingPage.test.jsx
+++ b/frontend/src/pages/LandingPage.test.jsx
@@ -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.
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index 2f192b7..7fe41eb 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -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 (
-
+
{/* 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
Time's Up
You have run out of time to complete this question block.
- {/* 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. */}
@@ -1622,16 +1626,6 @@ const timerStarted = timeLeft !== null
and editing belong to the session list and the editor. */}
-
- {/* 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 && (
<>
+
+
-
>
)}
{/* An exam moves between items from the middle of this bar, and
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 6da6e1f..4247c2e 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -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: [] }))
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css
index 084ffad..e261e02 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -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; }
diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx
index 1d33b6c..6d685d3 100644
--- a/frontend/src/pages/ResultsPage.jsx
+++ b/frontend/src/pages/ResultsPage.jsx
@@ -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
{tool && 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 ? (
-
-
-
{pct}%
-
- {correct} of {total} correct
-
- {returnTo && (
-
- Back to Course
+ {/* The attempt, on a phone: the same list the rail holds, as a drawer. */}
+ {!hasRail && navOpen && (
+
+ {/* 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. */}
+
+
+
+ )}
+
+ {/* 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 && (
+
+
+ Your answer: {ans.user_answer || not answered}
+
+
+ {isCourseQuiz && returnTo ? '← Back to course' : '← Session analysis'}
+
+
+
+ {verdict(ans)}
+
+
+ {correct} of {total} correct · {pct}%
+
)
}
diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js
new file mode 100644
index 0000000..9df3958
--- /dev/null
+++ b/frontend/src/utils/session.js
@@ -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/')