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:
parent
f8a81ef937
commit
bb72aa5f60
18 changed files with 935 additions and 279 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
122
backend/scripts/merge_duplicate_categories.py
Normal file
122
backend/scripts/merge_duplicate_categories.py
Normal 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))
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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: [] }))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}`}>← Session analysis</Link>
|
||||
<h1>Answer review</h1>
|
||||
<span className="results-crumb-score">{correct} of {total} correct · {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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
11
frontend/src/utils/session.js
Normal file
11
frontend/src/utils/session.js
Normal 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/')
|
||||
Loading…
Reference in a new issue