fix: leaving and resuming take one press; session actions sit with the result

Exit asked "Suspend quiz?" and offered Stay. Nobody presses Exit by
accident, nothing is lost — the answers are saved and the clock pauses,
which the dialog was explaining rather than deciding — and a
confirmation for a reversible act is a step, not a safeguard. One press
now, and if the save fails it stays put and says so, because leaving
then would lose the answers.

Resume and Start go straight in for the same reason. The overview that
asks whether you meant it belongs to opening a link to a session you
have not seen, not to a button you came to this page and chose.

The actions moved out of the page heading and into the card that holds
the result — Repeat session, Review answers, Resume session — because a
row of buttons beside the title puts the decision as far from the
figures it follows from as the layout allows.

Delete session stays, but not as a red button next to Resume. It throws
away answers the analysis is built from, and it is the one thing here
AMBOSS has no need of — sessions are made freely, so a mis-made one is
clutter worth removing. It is a quiet control at the end of the row.

The session rail was sticky at top:0 with height:100vh, which started it
underneath the 98px header and ran it past the bottom of the window, so
the end of the list and the scroll that would reach it were never on
screen. It measures from --app-header now, and a wheel over the list
moves the list rather than carrying on into the page.

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 23:03:09 +02:00
parent c9abc4f310
commit 27a1679774
9 changed files with 268 additions and 92 deletions

View file

@ -301,6 +301,61 @@ Captured so nothing is lost while the article writing runs.
toggle, "Continue your study", and a study-analysis donut. The current
dashboard becomes this; a separate signed-out landing page comes later.
## Analysis, as specified 2026-09-11 (evening)
Sessions is just sessions. **Every general measure of performance lives on
Analysis**, which has three tabs.
### Performance
- [ ] **Readiness** — two cards. One is the headline score (AMBOSS calls it
EPC, an equated percent correct); the other is peer comparison. Both say
what is still needed before they mean anything — "complete 32 more
questions" — rather than showing a number built on four answers.
- [ ] **Next step: adaptive session** — a card with a question-count select and
one button. It repeats on the Recommendations tab; it is the thing the
whole page is for.
- [ ] **Performance over time** — a line of the headline score by date, with an
honest empty state ("complete more questions to unlock this chart")
rather than a chart drawn through two points.
- [ ] **Analysis panel beside it**, with **All attempts / Latest attempt**
tabs: a donut split correct / correct-with-hints / incorrect, a sentence
naming attempts and unique questions, and the three figures written out
beneath. This is the pattern to copy for our own session analysis.
- [ ] **Completion** — four figures (questions answered n/total, answered
correctly, time per question, total time spent) with an **Edit time
range** control, so "this month" and "all time" are different questions.
### Recommendations
- [ ] **Your knowledge profile** — topics ranked by score, lowest marked
**FOCUS AREA**, under three tabs: **Articles, Systems, Disciplines**.
Columns: Topic, score, **Relevance**, Status, Action. A row expands to
questions completed with a bar, answered-correctly with a
correct/hints/incorrect bar, and a Start Qbank button for that topic.
- [ ] **Relevance is the ABP content specification weight.** This is the part
we can do properly and AMBOSS cannot explain: a topic's relevance is the
share of the real paper its domain accounts for, which
`exam_blueprints.weight` already holds. Cardiology at 5% and
rheumatology at 2% are not equally worth an hour, and the page should
say so from the board's own numbers rather than from a guess.
### Session Analysis
- [ ] **A rail of latest sessions** on the left, collapsible — mode, title,
answered/total and a progress bar each — with that session's results in
the main area: the same four figures, the same donut, and study
recommendations under Articles / Disciplines / Systems tabs.
- [ ] **Remove the overall figures from the session view.** Now that the
Performance tab holds them, a session page showing lifetime totals
answers a question nobody asked while standing in front of one session.
### Not us
- [ ] **No AI Mode for clinical content.** PedsHub is for learning; the
clinical tool is app.pedshub.com. AI Mode answers about the corpus a
learner is studying, and nothing here is for use at a bedside.
## Collections, as shown 2026-09-11 (evening)
- [ ] **A collections page.** Favorites and the question libraries in one

View file

@ -132,3 +132,83 @@ Four steps:
The first two are defects and are worth fixing. The last two are honest
limitations of the current design and would be the substance of a better one.
---
## 3. What the adaptive session should be
Written 2026-09-11, after the exam blueprint landed. The design below is not
built; this is the argument for it, so the decision is on paper before the code
is.
### The thing that was missing
Every version of this so far has ranked questions by **how badly you are doing**
and nothing else. That is half a question. The other half is **how much it
matters**, and until today there was nothing in the database that could answer
it — so the code guessed, by treating every category as equally worth an hour.
`exam_blueprints.weight` answers it now. The ABP publishes that preventive care
is 12% of a general paediatrics paper and rheumatology is 2%. Six points of
weakness in preventive care costs six times what the same weakness costs in
rheumatology, and a session that does not know this will spend your evening in
the wrong place while looking perfectly reasonable.
### The score
For each topic, one number — the marks you would expect to gain by studying it:
```
expected_gain = relevance × headroom × confidence
```
* **relevance** — the domain's published weight, divided among the topics under
it. From the blueprint. This is the part that is a fact rather than a model.
* **headroom**`1 readiness`. How much of that share you are currently
losing. Readiness is the shrunk accuracy the recommendations page already
computes, *not* raw accuracy: one wrong answer out of one must not read as
"you know nothing about neonatology".
* **confidence** — how much the estimate can be trusted, `n / (n + k)`. A topic
you have answered twice cannot outrank one you have answered forty times on
the strength of a bad afternoon. This is what stops the session chasing noise.
Sort topics by expected gain; fill the session from the top, damped as now so
one topic cannot take the whole session.
### Then, within a topic: difficulty that moves
The current session filters by difficulty and then ignores it. It should walk:
start near the learner's demonstrated level for that topic, step up after two
right, step down after one wrong. The point is not to be hard, it is to sit
where the information is — a question you would get right nine times in ten
teaches nothing, and neither does one you would get right once in ten.
### And a floor on coverage
A pure gain ranking will never show you a topic you are already good at, which
is how people arrive at an exam having forgotten something they knew in March.
Reserve a share of every session — a fifth, say — for **spaced return**: topics
you were right about, longest ago first. This is the one part that should not be
optimised, because its whole purpose is to be unwelcome.
### What has to be fixed first
The two defects named in §2 are load-bearing here, not tidying:
* **`.limit(2000)`** means a third of the bank is invisible to selection. A
scheme that reasons carefully about which question matters most, over an
arbitrary two-thirds of the questions, is a scheme that reasons carefully
about the wrong set.
* **The O(answers × candidates) accuracy scan** is already the slowest part of
building a session. This design asks for per-topic readiness *and* per-topic
difficulty, which makes it worse. It needs to become one grouped query.
### What this deliberately does not do
No item-response theory, no per-question difficulty estimated from other
learners' answers. Both would be better with enough data and worse without it,
and a bank this size with a handful of learners does not have it. The weights
are published, the readiness is measured, and the arithmetic above can be
explained to a learner in two sentences — which is the point, because a session
that cannot say why it chose a question is asking to be trusted rather than
earning it.

View file

@ -22,7 +22,13 @@
/* ── Rail ─────────────────────────────────────────────────────────── */
.ax-rail {
position: sticky; top: 0; height: 100vh;
/* Stuck below the header, exactly as tall as what is left of the window.
At top:0 and 100vh it began underneath the header and ran past the bottom
of the screen, so the end of the list and the scroll that would reach
it were never on screen. The list inside scrolls on its own; the page
behind it scrolls separately. */
position: sticky; top: var(--app-header, 98px);
height: calc(100dvh - var(--app-header, 98px));
display: flex; flex-direction: column; min-width: 0;
background: var(--card-bg); border-right: 1px solid var(--border);
/* Reach the left edge of the window without leaving the grid. */
@ -74,7 +80,14 @@
}
@media (min-width: 900px) { .ax-rail-search { font-size: 0.84rem; } }
.ax-rail-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.ax-rail-list {
list-style: none; margin: 0; padding: 0;
flex: 1; min-height: 0;
overflow-y: auto; -webkit-overflow-scrolling: touch;
/* A wheel over the list moves the list and stops there, rather than
carrying on into the page once the list reaches its end. */
overscroll-behavior: contain;
}
.ax-rail-list > li { border-bottom: 1px solid var(--border); }
.ax-rail-list > li:last-child { border-bottom: 0; }
.ax-rail-list a {

View file

@ -122,6 +122,11 @@ html, body { overflow-x: hidden; max-width: 100%; }
/* The footer belongs at the bottom of the window, not wherever the content
happens to stop. A loading page is a spinner, and without this the footer
sat mid-screen with the page background below it. */
:root {
/* The sticky header: the navbar plus the section bar beneath it. Anything
that pins itself below the header measures from here rather than guessing. */
--app-header: 98px;
}
.app-shell { display: flex; flex-direction: column; min-height: 100dvh; }
.app-main { flex: 1 0 auto; width: 100%; }
.app-shell > .site-footer { flex: none; }

View file

@ -104,3 +104,31 @@
.an-plan a { color: var(--primary); text-decoration: none; font-weight: 600; }
.an-plan a:hover { text-decoration: underline; }
.an-plan-links { display: flex; gap: 16px; }
/* What to do about the result, beneath the result
These were a row of buttons beside the page heading a blue Resume and
a red Delete session floating above everything, as far from the figures
they follow from as the layout allowed. */
.an-result { display: flex; flex-direction: column; }
.an-actions {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-top: auto; padding-top: 18px;
}
.an-actions .btn { flex: 0 1 auto; }
.an-confirm { display: inline-flex; gap: 8px; flex-wrap: wrap; }
/* Offered, not urged: a quiet control at the end of the row rather than a
red button competing with Resume. */
.an-remove {
margin-left: auto; padding: 6px 4px;
font: inherit; font-size: 0.79rem;
background: none; border: 0; cursor: pointer;
color: var(--text-subtle); text-decoration: underline;
text-underline-offset: 3px;
}
.an-remove:hover { color: var(--wrong-fg); }
@media (max-width: 560px) {
.an-actions .btn { flex: 1 1 auto; text-align: center; }
.an-remove { margin-left: 0; width: 100%; text-align: center; }
}

View file

@ -131,36 +131,6 @@ export default function AnalysisSessionPage() {
<div className="an-main">
<div className="an-head">
<h1>Your performance for <span>{data.title}</span></h1>
<div className="an-head-actions">
{/* Nothing sat yet: the only thing to offer is the sitting. */}
{data.not_started ? (
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Start this session</Link>
) : (
<>
<Link className="btn btn-secondary btn-sm" to={`/results/${data.attempt_id ?? attemptId}`}>Review answers</Link>
{!data.completed_at && (
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Resume</Link>
)}
{data.completed_at && data.quiz_id && (
<Link className="btn btn-secondary btn-sm" to={`/study/${data.quiz_id}?restart=1`}>Retake</Link>
)}
</>
)}
{/* Deleting is destructive and irreversible, so it asks first
inline, because a browser confirm() is not something this
codebase uses. */}
{!data.not_started && (confirmDelete ? (
<>
<button type="button" className="btn btn-sm an-danger" disabled={deleting}
onClick={deleteSession}>{deleting ? 'Deleting…' : 'Delete for good'}</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setConfirmDelete(false)}>Keep</button>
</>
) : (
<button type="button" className="btn btn-secondary btn-sm an-danger"
onClick={() => setConfirmDelete(true)}>Delete session</button>
))}
</div>
</div>
{data.plan && (
<div className="an-plan">
@ -211,7 +181,7 @@ export default function AnalysisSessionPage() {
</div>
<div className="an-split">
<section className="an-card">
<section className="an-card an-result">
<h2>{data.title}</h2>
<div className="an-donut-wrap">
<Donut correct={correct} incorrect={incorrect} skipped={skipped} />
@ -221,6 +191,47 @@ export default function AnalysisSessionPage() {
<li><i className="is-none" />{skipped} unanswered</li>
</ul>
</div>
{/* What to do about it, beneath the thing it is about. These were
a row of buttons beside the page heading, which put the
decision as far as possible from the result it follows from. */}
<div className="an-actions">
{data.not_started ? (
<Link className="btn btn-primary" to={`/study/${data.quiz_id}?start=1`}>
Start session
</Link>
) : (
<>
{data.completed_at && data.quiz_id && (
<Link className="btn btn-secondary" to={`/study/${data.quiz_id}?restart=1`}>
Repeat session
</Link>
)}
<Link className="btn btn-secondary"
to={`/results/${data.attempt_id ?? attemptId}`}>Review answers</Link>
{!data.completed_at && (
<Link className="btn btn-primary" to={`/study/${data.quiz_id}?start=1`}>
Resume session
</Link>
)}
{/* Deleting a session throws away answers the analysis is
built from, so it is not a button sitting next to Resume.
It is still offered: sessions are made freely here, and a
mis-made one is clutter worth removing. */}
{confirmDelete ? (
<span className="an-confirm">
<button type="button" className="btn btn-sm an-danger" disabled={deleting}
onClick={deleteSession}>{deleting ? 'Deleting…' : 'Delete for good'}</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setConfirmDelete(false)}>Keep</button>
</span>
) : (
<button type="button" className="an-remove"
onClick={() => setConfirmDelete(true)}>Delete session</button>
)}
</>
)}
</div>
</section>
<section className="an-card">

View file

@ -72,8 +72,8 @@ describe('a session nobody has sat', () => {
it('offers the sitting, and nothing that makes no sense yet', async () => {
mountQuiz()
expect(await screen.findByRole('link', { name: 'Start this session' }))
.toHaveAttribute('href', '/study/3')
expect(await screen.findByRole('link', { name: 'Start session' }))
.toHaveAttribute('href', '/study/3?start=1')
expect(screen.queryByRole('link', { name: 'Review answers' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete session' })).not.toBeInTheDocument()
})
@ -93,7 +93,7 @@ describe('a session that has been sat', () => {
const figures = (await screen.findByText("2/2")).closest('.an-figures')
expect(within(figures).getByText('50%')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Review answers' })).toHaveAttribute('href', '/results/91')
expect(screen.getByRole('link', { name: 'Retake' })).toHaveAttribute('href', '/study/3?restart=1')
expect(screen.getByRole('link', { name: 'Repeat session' })).toHaveAttribute('href', '/study/3?restart=1')
expect(screen.getByRole('button', { name: 'Delete session' })).toBeInTheDocument()
// Nothing outstanding, so no "still unanswered" note.
expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument()
@ -141,7 +141,7 @@ describe('a session left part way', () => {
it('counts what is outstanding and offers to resume', async () => {
mountAttempt()
expect(await screen.findByText(/1 of 2 questions still unanswered/)).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/study/3')
expect(screen.queryByRole('link', { name: 'Retake' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Resume session' })).toHaveAttribute('href', '/study/3?start=1')
expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument()
})
})

View file

@ -508,7 +508,6 @@ export default function QuizPage() {
} catch { }
}, [id, manualHighlights])
const [leaveTarget, setLeaveTarget] = useState(null)
// Suspending is not abandoning: it ends on the session's own analysis, where
// what has been answered so far is scored and the Resume button sits. A
@ -524,6 +523,40 @@ export default function QuizPage() {
}, [])
const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/')
/**
* Leave, saving on the way out.
*
* This used to ask "Suspend quiz?" and offer Stay. Nobody presses Exit by
* accident, nothing is lost the answers are saved and the clock pauses,
* which is what the dialog was explaining rather than deciding and a
* confirmation for a reversible act is a step, not a safeguard. It says
* what happened afterwards instead.
*/
const leaveNow = useCallback(async () => {
if (attemptId && quizMode) {
try {
await api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
suspended: true,
}, { headers: { 'x-quiz-session': SESSION_ID } })
} catch {
// Staying put is the safe failure: leaving now would lose the answers.
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
return
}
}
navigate(exitTarget())
}, [attemptId, quizMode, id, answers, currentIdx, selectedVoice, timeLeft,
startedAt, totalTime, navigate, returnTo])
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
@ -1176,55 +1209,6 @@ const timerStarted = timeLeft !== null
</QuizDialog>}
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
{/* In-app leave confirmation */}
{leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 420, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
We will save your current answers before leaving, so you can resume from here.
{timeLeft !== null && (
<><br/><br/>
<strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
<span style={{ fontSize: '0.8rem' }}>
Closing the tab without suspending keeps the clock running. When it runs out, the exam is submitted with what you have answered and you will see your score.
</span>
</>
)}
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
<button className="btn btn-primary" onClick={async () => {
// Save with suspended=true so the timer pauses on the server side
if (attemptId && quizMode) {
try {
await api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
suspended: true,
}, { headers: { 'x-quiz-session': SESSION_ID } })
} catch {
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
setLeaveTarget(null)
return
}
}
const target = leaveTarget
setLeaveTarget(null)
navigate(target)
}}>Suspend &amp; Leave</button>
</div>
</div>
</div>
)}
{toast && (
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
@ -1256,7 +1240,7 @@ const timerStarted = timeLeft !== null
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(exitTarget())} title="Save progress and exit">
<button className="btn btn-secondary btn-sm" onClick={() => leaveNow()} title="Save progress and exit">
Suspend
</button>
{restartConfirm ? (
@ -1654,7 +1638,7 @@ const timerStarted = timeLeft !== null
scrolls inside it, rather than the whole page scrolling. */}
<div className="quiz-footbar">
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => setLeaveTarget(exitTarget())}>Exit session</button>
onClick={() => leaveNow()}>Exit session</button>
{quizNavigation('bottom')}
{answeredCount > 0 && (
<button className="btn btn-secondary btn-sm quiz-review-link"

View file

@ -209,7 +209,6 @@ describe('quiz player', () => {
api.post.mockImplementation((url, ...args) => url === '/attempts/progress' && failSaving ? Promise.reject(new Error('Cache outage')) : originalPost(url, ...args))
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
failSaving = false
@ -221,8 +220,9 @@ describe('quiz player', () => {
it('suspends onto the session analysis rather than the session list', async () => {
await begin(false)
fireEvent.keyDown(window, { key: '1' })
// One press. Nothing is lost by leaving the answers are saved and the
// clock pauses so there is nothing to confirm.
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
// Leaving a session part-way through should land where what you answered
// is scored and Resume sits not on a list of every session you own.
expect(await screen.findByText('Submitted results')).toBeInTheDocument()