fix: opening a session no longer launches it

Every link from the history went to /quizzes/:id, which auto-started. Clicking
"Board Review IX" out of curiosity began a 243-question exam, clock running.
That is why the links looked broken: they worked exactly as built, and what they
did was wrong.

Landing on a session now shows it — mode, length, and what the clock does — and
starts when asked. The session rows and the analysis rail open the session's own
analysis rather than the raw answer list, which is what a rail of past sessions
is for.

Sessions carry no category, so the row no longer prints one; that field was
still showing the retired "PREP" label.

Twenty tests asserted the old behaviour by mounting the player and waiting for
the stem. They now start the session the way a person does, which is the point:
the assertion was encoding a bug.

Settings is on the list. Before restyling it, the question is what belongs there
at all — several things have drifted in because there was nowhere else to put
them.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 03:25:37 +02:00
parent 5add9f23dd
commit f723a2fea4
6 changed files with 69 additions and 11 deletions

View file

@ -18,9 +18,11 @@ Captured so nothing is lost while the article writing runs.
table with time and peer statistics. Time per question is now recorded
(`attempt_answers.seconds_spent`); answers from before that read "—"
rather than claiming zero.
- [ ] **Return to a session with Resume, not an immediate start** — opening a
part-finished session currently restarts it. It should offer Resume and
Repeat, as the reference does.
- [x] **Return to a session rather than being launched into it**`/quizzes/:id`
auto-started, so every link from the history fired a 243-question exam on
click. It now shows the session — mode, length, what the clock does — and
starts when asked. Session rows and the analysis rail open the session's
analysis rather than the raw answer list.
- [ ] **An unsuspended exam keeps running** — closing an exam-mode session
should let the clock continue and show the score when it expires, rather
than quietly pausing.
@ -34,6 +36,12 @@ Captured so nothing is lost while the article writing runs.
- [ ] **Adaptive session** — questions ordered by what would help most, with an
explanation of how it decides.
### Settings
- [ ] **Revamp the settings page** — called out as the worst screen in the app.
Work out what belongs there at all before restyling it: account, theme,
exam objective, voice, integrations, data. Several of those have drifted
in because there was nowhere else to put them.
### Editor and figures
- [x] **Rich editing on the question page** — no new platform needed: Milkdown
is already installed and used for articles, courses and the quick modal.

View file

@ -107,8 +107,10 @@ export default function AnalysisPage() {
: <ul className="an-rail-list">
{sessions.map(row => (
<li key={row.quiz_id}>
<Link to={row.state === 'completed' && row.last_attempt_id
? `/results/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
{/* The session's own analysis, not the raw answer list
that is what this rail is for. */}
<Link to={row.last_attempt_id
? `/analysis/session/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
<span className="an-rail-title">
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
</span>

View file

@ -370,6 +370,8 @@ const SESSION_ID = getQuizSessionId()
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
// True when we are showing the session rather than sitting it.
const [showOverview, setShowOverview] = useState(false)
const [searchParams] = useSearchParams()
const returnTo = searchParams.get('return_to')
const restartRequested = searchParams.get('restart') === '1'
@ -637,7 +639,12 @@ export default function QuizPage() {
await resumeQuiz(progressRes.data, voicesRes.data)
return
}
if (!returnTo && quizRes.data) {
// Nothing saved and nothing asked for: show the session rather than
// launching it. Opening a link should not commit you to a
// 243-question exam before you have seen what it is.
if (!returnTo && quizRes.data && !searchParams.get('start')) {
setShowOverview(true)
} else if (!returnTo && quizRes.data) {
await startAttempt(quizRes.data.mode === 'timed' ? 'exam' : 'study', null, null)
}
} catch {
@ -964,6 +971,27 @@ const timerStarted = timeLeft !== null
</div>
) : returnTo ? (
<CourseQuizStart quiz={quiz} onStart={startQuiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
) : showOverview ? (
<div className="qz-overview card">
<h1>{quiz.title}</h1>
<p className="qz-overview-meta">
{quiz.mode === 'timed' ? 'Exam mode' : 'Study mode'}
{' · '}{quiz.questions_count || quiz.questions_per_attempt} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} minutes` : ''}
</p>
<p className="qz-overview-note">
{quiz.mode === 'timed'
? 'The clock starts when you begin and does not stop for a break.'
: 'Each answer is marked as you go, with the explanation.'}
</p>
<div className="qz-overview-actions">
<button className="btn btn-primary" onClick={() => {
setShowOverview(false)
startQuiz(quiz.mode === 'timed' ? 'exam' : 'study', selectedVoice, null)
}}>Start session</button>
<Link className="btn btn-secondary" to="/sessions">Back to history</Link>
</div>
</div>
) : (
<div className="card" style={{ textAlign: 'center' }}>
<div className="spinner" style={{ margin: '0 auto 16px' }} />

View file

@ -61,6 +61,10 @@ const findStem = async (text) => {
async function begin(study = true) {
quizModeVar = study ? 'learning' : 'timed'
mount()
// Landing on a session shows it rather than launching it, so every test that
// wants to sit one starts it the way a person would.
const start = await screen.findByRole('button', { name: 'Start session' })
await userEvent.click(start)
await findStem('Full first clinical question.')
}
@ -159,7 +163,8 @@ describe('quiz player', () => {
await screen.findByRole('button', { name: 'Retry resume' })
expect(api.post).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
// Retry loads the quiz and starts it straight away in its own mode no re-ask.
// Retry loads the session; starting it is still the reader's decision.
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
expect(await findStem('Full first clinical question.')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
})
@ -345,7 +350,8 @@ describe('quiz player', () => {
})
it('starts timed quizzes in exam mode without a mode prompt', async () => {
mount()
quizModeVar = 'timed'
await begin(false)
expect(await findStem('Full first clinical question.')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
expect(screen.queryByRole('button', { name: /Study Mode|Exam Mode/ })).not.toBeInTheDocument()
@ -380,6 +386,7 @@ describe('quiz player', () => {
return originalPost(url, ...args)
})
mount()
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
await findStem('Full first clinical question.')
await screen.findByRole('button', { name: 'Make shareable' })
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))

View file

@ -218,3 +218,11 @@
@media (max-width: 640px) {
.quiz-clock { width: 100%; margin-right: 0; margin-bottom: 8px; }
}
/* Landing on a session shows you what it is before it starts. Opening a link
should not commit you to a 243-question exam. */
.qz-overview { max-width: 560px; margin: 32px auto; text-align: center; }
.qz-overview h1 { margin: 0 0 6px; font-size: 1.3rem; }
.qz-overview-meta { margin: 0 0 10px; font-size: 0.86rem; color: var(--text-muted); }
.qz-overview-note { margin: 0 0 18px; font-size: 0.85rem; color: var(--text-subtle); }
.qz-overview-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }

View file

@ -171,9 +171,14 @@ export default function SessionsPage() {
{shown.map(row => (
<li key={row.quiz_id} className={`sx-row is-${row.state}`}>
<div className="sx-row-main">
<Link className="sx-title" to={`/quizzes/${row.quiz_id}`}>{row.title}</Link>
{/* The title opens what there is to see: the analysis
once it has been sat, the session itself otherwise.
It used to launch the test on click. */}
<Link className="sx-title" to={row.last_attempt_id
? `/analysis/session/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
{row.title}
</Link>
<span className="sx-meta">
{row.category_name && <>{row.category_name} · </>}
{row.mode === 'learning' ? 'Study' : row.mode === 'timed' ? 'Exam' : row.mode}
{' · '}{row.questions_per_attempt || row.questions_count} questions
{row.attempts_count > 1 && <> · {row.attempts_count} attempts</>}
@ -200,7 +205,7 @@ export default function SessionsPage() {
<Link className="btn btn-primary btn-sm" to={`/quizzes/${row.quiz_id}`}>Resume</Link>
)}
{row.last_attempt_id && (
<Link className="btn btn-secondary btn-sm" to={`/results/${row.last_attempt_id}`}>Review</Link>
<Link className="btn btn-secondary btn-sm" to={`/analysis/session/${row.last_attempt_id}`}>Review</Link>
)}
{row.state === 'not_started' && (
<Link className="btn btn-secondary btn-sm" to={`/quizzes/${row.quiz_id}`}>Start</Link>