From 27a167977426a76f6dad40369b1b8342d39810cb Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 23:03:09 +0200 Subject: [PATCH] fix: leaving and resuming take one press; session actions sit with the result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- docs/TODO.md | 55 ++++++++++++ docs/study-recommendations.md | 80 +++++++++++++++++ frontend/src/components/AnalysisShell.css | 17 +++- frontend/src/index.css | 5 ++ frontend/src/pages/AnalysisSessionPage.css | 28 ++++++ frontend/src/pages/AnalysisSessionPage.jsx | 73 ++++++++------- .../src/pages/AnalysisSessionPage.test.jsx | 10 +-- frontend/src/pages/QuizPage.jsx | 88 ++++++++----------- frontend/src/pages/QuizPage.test.jsx | 4 +- 9 files changed, 268 insertions(+), 92 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 46c440c..1fd7704 100644 --- a/docs/TODO.md +++ b/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 diff --git a/docs/study-recommendations.md b/docs/study-recommendations.md index fc9befd..f6367ad 100644 --- a/docs/study-recommendations.md +++ b/docs/study-recommendations.md @@ -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. diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css index 066b749..4d1f955 100644 --- a/frontend/src/components/AnalysisShell.css +++ b/frontend/src/components/AnalysisShell.css @@ -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 { diff --git a/frontend/src/index.css b/frontend/src/index.css index 37bf012..8a8c06c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css index f2b74d5..6233fdf 100644 --- a/frontend/src/pages/AnalysisSessionPage.css +++ b/frontend/src/pages/AnalysisSessionPage.css @@ -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; } +} diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index 8d85fdf..0e5184d 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -131,36 +131,6 @@ export default function AnalysisSessionPage() {

Your performance for {data.title}

-
- {/* Nothing sat yet: the only thing to offer is the sitting. */} - {data.not_started ? ( - Start this session - ) : ( - <> - Review answers - {!data.completed_at && ( - Resume - )} - {data.completed_at && data.quiz_id && ( - Retake - )} - - )} - {/* Deleting is destructive and irreversible, so it asks first — - inline, because a browser confirm() is not something this - codebase uses. */} - {!data.not_started && (confirmDelete ? ( - <> - - - - ) : ( - - ))} -
{data.plan && (
@@ -211,7 +181,7 @@ export default function AnalysisSessionPage() {
-
+

{data.title}

@@ -221,6 +191,47 @@ export default function AnalysisSessionPage() {
  • {skipped} unanswered
  • + + {/* 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. */} +
    + {data.not_started ? ( + + Start session + + ) : ( + <> + {data.completed_at && data.quiz_id && ( + + Repeat session + + )} + Review answers + {!data.completed_at && ( + + Resume session + + )} + {/* 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 ? ( + + + + + ) : ( + + )} + + )} +
    diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index 5fbb197..cbb8752 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -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() }) }) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index cd04d39..a831c94 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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 } {submitError &&
    {submitError}
    } {progressError &&
    {progressError}
    } - {/* In-app leave confirmation */} - {leaveTarget && ( -
    -
    -
    -

    Suspend quiz?

    -

    - We will save your current answers before leaving, so you can resume from here. - {timeLeft !== null && ( - <>

    - Timer will pause while you are away and resume when you return.
    - - 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. - - - )} -

    -
    - - -
    -
    -
    - )} {toast && (
    {timeLeft !== null && } - {restartConfirm ? ( @@ -1654,7 +1638,7 @@ const timerStarted = timeLeft !== null scrolls inside it, rather than the whole page scrolling. */}
    + onClick={() => leaveNow()}>Exit session {quizNavigation('bottom')} {answeredCount > 0 && (