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:
parent
5add9f23dd
commit
f723a2fea4
6 changed files with 69 additions and 11 deletions
14
docs/TODO.md
14
docs/TODO.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue