From 8c28cc4e9b297f78b7651b09db08a9d985b47770 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 01:39:17 +0200 Subject: [PATCH] feat: all attempts vs latest attempt, with the donut shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question got wrong in March and right in September is 50% by one count and 100% by another, and both are true. The Performance tab now says which it is answering: All attempts is every answer ever given — how much work has been done — and Latest attempt keeps only the most recent answer to each question — what is known now. GET /study-tools/answer-split returns both splits plus the session and unique-question counts, under the same exclusions as everything else that measures: no repetitions, no course quizzes, no expired attempts. A blank is its own slice, never folded into incorrect. The ring itself moves out of AnalysisSessionPage into components/Donut so the session view and the lifetime view cannot drift apart. Its legend gains .is-answered, which the session page had been asking for without anything defining it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/study_tools.py | 64 ++++++++++++++++-- backend/tests/test_study_tools.py | 39 +++++++++++ frontend/src/components/Donut.css | 13 ++++ frontend/src/components/Donut.jsx | 50 ++++++++++++++ frontend/src/pages/AnalysisPage.css | 25 +++++++ frontend/src/pages/AnalysisPage.jsx | 78 +++++++++++++++++++++- frontend/src/pages/AnalysisPage.test.jsx | 37 ++++++++++ frontend/src/pages/AnalysisSessionPage.css | 11 --- frontend/src/pages/AnalysisSessionPage.jsx | 43 +----------- 9 files changed, 300 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/Donut.css create mode 100644 frontend/src/components/Donut.jsx diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 74ba6ce..33a757f 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -242,12 +242,6 @@ def _category_rollup(categories): return ancestry -class CompletionWindow(BaseModel): - """How far back to count. Days rather than dates: "this month" and "all - time" are the questions people actually ask of their own progress.""" - days: int | None = None - - @router.get("/completion") def completion( days: int | None = Query(None, ge=1, le=3650), @@ -296,6 +290,64 @@ def completion( } +def _split(rows) -> dict: + """Correct / incorrect / unanswered, with the percentage out of answered.""" + correct = sum(1 for row in rows if row.user_answer and row.is_correct) + incorrect = sum(1 for row in rows if row.user_answer and not row.is_correct) + blank = sum(1 for row in rows if not row.user_answer) + answered = correct + incorrect + return { + "correct": correct, + "incorrect": incorrect, + "unanswered": blank, + "answered": answered, + "total": len(rows), + "percent_correct": round(100 * correct / answered, 1) if answered else None, + } + + +@router.get("/answer-split") +def answer_split( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """The same answers counted two ways. + + *All attempts* is every answer ever given: it says how much work has been + done. *Latest attempt* keeps only the most recent answer to each question: + it says what is known now. A learner who got a question wrong in March and + right in September is at 50% by the first measure and 100% by the second, + and both are true statements about different questions. + + Repetitions, course quizzes and expired attempts are left out, as + everywhere else that measures rather than counts practice. + """ + rows = db.query( + AttemptAnswer.question_id, AttemptAnswer.is_correct, AttemptAnswer.user_answer, + QuizAttempt.id.label("attempt_id"), QuizAttempt.completed_at, + ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id + ).join(Quiz, Quiz.id == QuizAttempt.quiz_id + ).filter( + QuizAttempt.user_id == user.id, + QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), + Quiz.course_id.is_(None), + or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)), + ).order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc()).all() + + # Ordered oldest first, so the last write per question is the latest one. + latest: dict[int, object] = {} + for row in rows: + latest[row.question_id] = row + + return { + "all": _split(rows), + "latest": _split(list(latest.values())), + "attempts": len({row.attempt_id for row in rows}), + "unique_questions": len(latest), + } + + @router.get("/recommendations") def study_recommendations( group: Literal["articles", "disciplines", "systems"] = "disciplines", diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index ce5794b..d0d4b57 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -355,3 +355,42 @@ class CompletionTests(unittest.TestCase): self.assertIsNone(data['seconds_per_question']) self.assertEqual(data['seconds_total'], 0) self.assertGreater(data['bank_total'], 0) + + +class AnswerSplitTests(CompletionTests): + """The same answers counted two ways: everything done, and what is known now.""" + + def split(self): + response = self.client.get('/study-tools/answer-split') + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def test_getting_it_wrong_then_right_reads_differently_by_basis(self): + self.sat(1, False, ago_days=90, seconds=40) + self.sat(1, True, ago_days=1, seconds=30) + + data = self.split() + self.assertEqual(data['attempts'], 2) + self.assertEqual(data['unique_questions'], 1) + # Half the work was wrong; what is known now is right. + self.assertEqual(data['all'], { + 'correct': 1, 'incorrect': 1, 'unanswered': 0, + 'answered': 2, 'total': 2, 'percent_correct': 50.0}) + self.assertEqual(data['latest'], { + 'correct': 1, 'incorrect': 0, 'unanswered': 0, + 'answered': 1, 'total': 1, 'percent_correct': 100.0}) + + def test_a_blank_is_its_own_slice_and_not_a_wrong_answer(self): + self.sat(1, True, ago_days=1, seconds=20) + self.sat(2, False, ago_days=1, seconds=0, answered=False) + data = self.split() + self.assertEqual(data['all']['unanswered'], 1) + self.assertEqual(data['all']['incorrect'], 0) + self.assertEqual(data['all']['total'], 2) + self.assertEqual(data['all']['percent_correct'], 100.0) + + def test_nothing_sat_yet(self): + data = self.split() + self.assertEqual(data['attempts'], 0) + self.assertEqual(data['unique_questions'], 0) + self.assertIsNone(data['all']['percent_correct']) diff --git a/frontend/src/components/Donut.css b/frontend/src/components/Donut.css new file mode 100644 index 0000000..124577d --- /dev/null +++ b/frontend/src/components/Donut.css @@ -0,0 +1,13 @@ +/* The ring and the key beside it. Lives with the component so a page that + renders one does not have to know which stylesheet drew it. */ +.an-donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } +.an-donut { width: 150px; height: 150px; flex-shrink: 0; } +.an-donut-figure { font-size: 22px; font-weight: 700; fill: var(--text); } +.an-donut-label { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--text-subtle); } +.an-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.85rem; } +.an-legend li { display: flex; align-items: center; gap: 8px; } +.an-legend i { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; } +.an-legend .is-right { background: var(--correct-fg); } +.an-legend .is-wrong { background: var(--wrong-fg); } +.an-legend .is-answered { background: var(--primary); } +.an-legend .is-none { background: var(--border); } diff --git a/frontend/src/components/Donut.jsx b/frontend/src/components/Donut.jsx new file mode 100644 index 0000000..baf92f2 --- /dev/null +++ b/frontend/src/components/Donut.jsx @@ -0,0 +1,50 @@ +import './Donut.css' + +/** + * Correct / incorrect / unanswered as one ring. + * + * Shared, because a session and a lifetime are the same picture at different + * scales, and two drawings of it would eventually disagree. + * + * `answered` is for work that is not marked yet — an exam still running has no + * score, and a nought in the middle would read as one. + */ +export default function Donut({ correct, incorrect, answered = 0, skipped, graded = true }) { + const total = correct + incorrect + answered + skipped + if (!total) return null + const circumference = 2 * Math.PI * 54 + const slice = (n) => (n / total) * circumference + let offset = 0 + const arcs = [ + { value: correct, colour: 'var(--correct-fg)' }, + { value: incorrect, colour: 'var(--wrong-fg)' }, + { value: answered, colour: 'var(--primary)' }, + { value: skipped, colour: 'var(--border)' }, + ] + const label = graded + ? `${correct} correct, ${incorrect} incorrect, ${skipped} unanswered` + : `${answered} answered, ${skipped} unanswered — not marked yet` + return ( + + {arcs.map((arc, i) => { + const length = slice(arc.value) + const dash = `${length} ${circumference - length}` + const node = ( + + ) + offset += length + return node + })} + {/* A percentage here would be a score, and an exam still running has + none. It says how far through it is instead. */} + + {graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`} + + + {graded ? 'correct' : 'answered'} + + + ) +} diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index 017affb..d126607 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -140,3 +140,28 @@ } .an-completion-grid .an-stat { background: var(--card-bg); padding: 18px 14px; } .an-stat-of { font-size: 0.9rem; color: var(--text-muted); font-weight: 400; } + +/* ── The two panels above the category table ────────────────────────── + Completion says how much has been done; Analysis says how it went. They + read together, so they sit side by side until there is no room. */ +.an-perf-top { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 18px; align-items: start; +} +.an-perf-top .an-completion { margin: 24px 0 0; } +.an-split-card { + margin-top: 24px; padding: 16px 18px 18px; + border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg); +} +.an-split-card .an-basis { margin-top: 14px; } +.an-basis-tabs { display: flex; gap: 4px; background: var(--surface-2, var(--border)); padding: 3px; border-radius: 9px; } +.an-basis-tabs button { + border: 0; background: none; color: var(--text-muted); cursor: pointer; + font: inherit; font-size: 0.8rem; padding: 6px 11px; border-radius: 7px; +} +.an-basis-tabs button.is-on { background: var(--card-bg); color: var(--text); font-weight: 600; } + +@media (max-width: 900px) { + .an-perf-top { grid-template-columns: minmax(0, 1fr); } + .an-split-card { margin-top: 0; } +} diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 1a34e2f..de164b2 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' import CategoryPerformance from '../components/CategoryPerformance' +import Donut from '../components/Donut' import AnalysisShell from '../components/AnalysisShell' import './AnalysisPage.css' @@ -92,6 +93,78 @@ function Completion() { ) } +const BASES = [ + { key: 'all', label: 'All attempts' }, + { key: 'latest', label: 'Latest attempt' }, +] + +/** + * The same answers counted two ways. + * + * All attempts is every answer ever given — how much work has been done. + * Latest attempt keeps only the most recent answer to each question — what is + * known now. A question got wrong in March and right in September is 50% by + * one measure and 100% by the other, and neither figure is a lie. + */ +function AnswerSplit() { + const [basis, setBasis] = useState('all') + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let live = true + api.get('/study-tools/answer-split') + .then(res => { if (live) setData(res.data) }) + .catch(() => { if (live) setError('Could not load your answers') }) + return () => { live = false } + }, []) + + const split = data?.[basis] + + return ( +
+
+

Analysis

+
+ {BASES.map(option => ( + + ))} +
+
+ + {error ?

{error}

+ : !split ?

Loading…

+ : split.total === 0 ? ( +

+ Nothing sat yet. Answer some questions and this fills in. +

+ ) : ( + <> +
+ +
    +
  • {split.correct} correct
  • +
  • {split.incorrect} incorrect
  • +
  • {split.unanswered} unanswered
  • +
+
+

+ {basis === 'all' + ? `${data.attempts} session${data.attempts === 1 ? '' : 's'}, ${data.unique_questions} unique question${data.unique_questions === 1 ? '' : 's'}.` + : `Your most recent answer to each of ${data.unique_questions} question${data.unique_questions === 1 ? '' : 's'}.`} + {' '}Unanswered questions are not counted as wrong: the percentage + is out of what you answered. +

+ + )} +
+ ) +} + function FocusRow({ row, onPractise }) { const [open, setOpen] = useState(false) const coverageLabel = `${row.seen_questions}/${row.available}` @@ -308,7 +381,10 @@ export default function AnalysisPage() { ) : ( <> - +
+ + +
)} diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx index e59871e..e6d24ba 100644 --- a/frontend/src/pages/AnalysisPage.test.jsx +++ b/frontend/src/pages/AnalysisPage.test.jsx @@ -111,3 +111,40 @@ it('reports completion over a window, and changes the window', async () => { await waitFor(() => expect(within(panel).getByText('400')).toBeInTheDocument()) expect(api.get).toHaveBeenCalledWith('/study-tools/completion', { params: {} }) }) + +it('counts the same answers two ways: all attempts and the latest one', async () => { + const split = { + all: { correct: 1, incorrect: 1, unanswered: 0, answered: 2, total: 2, percent_correct: 50.0 }, + latest: { correct: 1, incorrect: 0, unanswered: 0, answered: 1, total: 1, percent_correct: 100.0 }, + attempts: 2, unique_questions: 1, + } + api.get.mockImplementation(url => + Promise.resolve({ data: url === '/study-tools/answer-split' ? split : payload() })) + + render() + await userEvent.click(await screen.findByRole('tab', { name: 'Performance' })) + + const panel = (await screen.findByText('Analysis')).closest('.an-split-card') + expect(within(panel).getByText('1 correct')).toBeInTheDocument() + expect(within(panel).getByText('1 incorrect')).toBeInTheDocument() + expect(within(panel).getByText(/2 sessions, 1 unique question\./)).toBeInTheDocument() + + // Wrong in March, right in September: the latest answer is the one that says + // what is known now. + await userEvent.click(within(panel).getByRole('tab', { name: 'Latest attempt' })) + expect(within(panel).getByText('0 incorrect')).toBeInTheDocument() + expect(within(panel).getByText(/most recent answer to each of 1 question/)).toBeInTheDocument() +}) + +it('says plainly when nothing has been sat', async () => { + const empty = { + all: { correct: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null }, + latest: { correct: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null }, + attempts: 0, unique_questions: 0, + } + api.get.mockImplementation(url => + Promise.resolve({ data: url === '/study-tools/answer-split' ? empty : payload() })) + render() + await userEvent.click(await screen.findByRole('tab', { name: 'Performance' })) + expect(await screen.findByText(/Nothing sat yet/)).toBeInTheDocument() +}) diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css index 4f0c5cd..84a80e6 100644 --- a/frontend/src/pages/AnalysisSessionPage.css +++ b/frontend/src/pages/AnalysisSessionPage.css @@ -19,16 +19,6 @@ .an-card h2 { margin: 0 0 12px; font-size: 1rem; } .an-note { margin: 0; font-size: 0.85rem; color: var(--text-muted); } -.an-donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } -.an-donut { width: 150px; height: 150px; flex-shrink: 0; } -.an-donut-figure { font-size: 22px; font-weight: 700; fill: var(--text); } -.an-donut-label { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--text-subtle); } -.an-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.85rem; } -.an-legend li { display: flex; align-items: center; gap: 8px; } -.an-legend i { width: 11px; height: 11px; border-radius: 50%; } -.an-legend .is-right { background: var(--correct-fg); } -.an-legend .is-wrong { background: var(--wrong-fg); } -.an-legend .is-none { background: var(--border); } .an-recs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } .an-recs li { display: grid; grid-template-columns: 1fr 90px 46px; align-items: center; gap: 10px; font-size: 0.85rem; } @@ -161,5 +151,4 @@ td.an-col-q { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } } /* Answered, but not yet marked — an exam that is still running. */ -.an-legend .is-answered { background: var(--primary); } .an-status.is-answered { background: var(--option-sel-bg); color: var(--primary); } diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index 6e43ae7..656675d 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import AnalysisShell from '../components/AnalysisShell' +import Donut from '../components/Donut' import RepeatSession from '../components/RepeatSession' import api from '../api/client' import './AnalysisSessionPage.css' @@ -19,48 +20,6 @@ const hours = (seconds) => { return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m` } -/** Correct / incorrect / unanswered as one ring. */ -function Donut({ correct, incorrect, answered = 0, skipped, graded = true }) { - const total = correct + incorrect + answered + skipped - if (!total) return null - const circumference = 2 * Math.PI * 54 - const slice = (n) => (n / total) * circumference - let offset = 0 - const arcs = [ - { value: correct, colour: 'var(--correct-fg)' }, - { value: incorrect, colour: 'var(--wrong-fg)' }, - // Neither right nor wrong yet: an exam that is still running is not marked. - { value: answered, colour: 'var(--primary)' }, - { value: skipped, colour: 'var(--border)' }, - ] - const label = graded - ? `${correct} correct, ${incorrect} incorrect, ${skipped} unanswered` - : `${answered} answered, ${skipped} unanswered — not marked yet` - return ( - - {arcs.map((arc, i) => { - const length = slice(arc.value) - const dash = `${length} ${circumference - length}` - const node = ( - - ) - offset += length - return node - })} - {/* A percentage here would be a score, and an exam still running has - none. It says how far through it is instead. */} - - {graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`} - - - {graded ? 'correct' : 'answered'} - - - ) -} - const SORTS = { position: (a, b) => a.position - b.position, slowest: (a, b) => (b.seconds_spent ?? -1) - (a.seconds_spent ?? -1),