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:
parent
c9abc4f310
commit
27a1679774
9 changed files with 268 additions and 92 deletions
55
docs/TODO.md
55
docs/TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 & 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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue