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 && (