From 4e272e6ef04acb0360b04c689cbc719386b0cc9b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 01:35:14 +0200 Subject: [PATCH] feat: completion over a chosen time range, and a way back out of Tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "How am I doing" and "how was I doing last month" are different questions, and a single lifetime figure cannot answer both. Analysis now carries a Completion panel on the Performance tab: questions answered against the bank, how many were right, time per question, total time — over 7 days, 30 days, 3 months, or everything. GET /study-tools/completion?days=N does the counting. It leaves out what would not be a measurement: repetitions (you already know that answer), course quizzes (they belong to their course), and expired attempts. A question left blank is not a wrong answer, so the percentage is out of what was answered, not out of what was set. Nothing answered reports nothing rather than 0%. The Tools workbench has no menu of its own by design, which left no way back; it now opens onto Settings where it was reached from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/study_tools.py | 56 ++++++++++++++- backend/tests/test_study_tools.py | 87 +++++++++++++++++++++++ frontend/src/pages/AnalysisPage.css | 22 ++++++ frontend/src/pages/AnalysisPage.jsx | 90 +++++++++++++++++++++++- frontend/src/pages/AnalysisPage.test.jsx | 26 +++++++ frontend/src/pages/ToolsPage.css | 5 ++ frontend/src/pages/ToolsPage.jsx | 4 ++ 7 files changed, 288 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 830be31..74ba6ce 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -1,6 +1,6 @@ """Educator-maintained lab references and authorized question response statistics.""" from collections import Counter, defaultdict -from datetime import datetime +from datetime import datetime, timedelta from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query @@ -242,6 +242,60 @@ 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), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """How much of the bank has been worked through, and at what pace. + + Over a window, because "how am I doing" and "how was I doing last month" + are different questions and one figure cannot answer both. No window means + everything. + + Repetitions are excluded for the same reason they are excluded everywhere + else: sitting a question you have already seen the answer to is practice, + not a measurement. + """ + since = datetime.utcnow() - timedelta(days=days) if days else None + rows = db.query( + AttemptAnswer.is_correct, AttemptAnswer.seconds_spent, AttemptAnswer.user_answer, + ).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)), + *([QuizAttempt.completed_at >= since] if since else []), + ).all() + + answered = [row for row in rows if row.user_answer] + correct = sum(1 for row in answered if row.is_correct) + timed = [row.seconds_spent for row in answered if row.seconds_spent] + bank = db.query(func.count(Question.id)).filter( + Question.deleted_at.is_(None)).scalar() or 0 + + return { + "days": days, + "answered": len(answered), + "bank_total": bank, + # Out of what was answered, not out of what was set — an unanswered + # question is not a wrong answer. + "percent_correct": round(100 * correct / len(answered), 1) if answered else None, + "seconds_per_question": round(sum(timed) / len(timed)) if timed else None, + "seconds_total": sum(timed) if timed else 0, + } + + @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 4b8c9e3..ce5794b 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -268,3 +268,90 @@ class BlueprintRelevanceTests(unittest.TestCase): # so an unmapped topic keeps the bank-share figure and says so. blueprint_weight = {1: 10.0} self.assertIsNone(blueprint_weight.get(99)) + + +class CompletionTests(unittest.TestCase): + """How much has been worked through, over a window the learner chooses. + + The figures answer four different questions, and each one has a rule about + what does not count: a repetition is practice rather than a measurement, a + course quiz belongs to its course, and a question left blank is not a wrong + answer. + """ + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(study_tools.router, prefix='/study-tools') + + def tearDown(self): + self.bank.tearDown() + + def sat(self, qid, correct, ago_days, seconds, answered=True, quiz_id=1): + from datetime import datetime, timedelta + + from app.models.attempt import AttemptAnswer, QuizAttempt + attempt = QuizAttempt(user_id=1, quiz_id=quiz_id, total_questions=1, score=int(correct), + completed_at=datetime.utcnow() - timedelta(days=ago_days)) + self.bank.db.add(attempt) + self.bank.db.flush() + self.bank.db.add(AttemptAnswer( + attempt_id=attempt.id, question_id=qid, is_correct=correct, + # A question left blank is stored as an empty answer, not a missing + # row — the same shape the player submits. + user_answer=('yes' if correct else 'no') if answered else '', + seconds_spent=seconds)) + self.bank.db.commit() + + def get(self, **params): + response = self.client.get('/study-tools/completion', params=params) + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def test_the_window_decides_which_answers_are_counted(self): + self.sat(1, True, ago_days=2, seconds=60) + self.sat(2, False, ago_days=2, seconds=120) + self.sat(3, True, ago_days=200, seconds=30) + + recent = self.get(days=30) + self.assertEqual(recent['answered'], 2) + self.assertEqual(recent['percent_correct'], 50.0) + self.assertEqual(recent['seconds_per_question'], 90) + self.assertEqual(recent['seconds_total'], 180) + + forever = self.get() + self.assertIsNone(forever['days']) + self.assertEqual(forever['answered'], 3) + self.assertEqual(forever['percent_correct'], 66.7) + self.assertEqual(forever['seconds_total'], 210) + + def test_a_blank_answer_is_not_a_wrong_answer(self): + self.sat(1, True, ago_days=1, seconds=40) + self.sat(2, False, ago_days=1, seconds=0, answered=False) + data = self.get(days=30) + self.assertEqual(data['answered'], 1) + self.assertEqual(data['percent_correct'], 100.0) + + def test_repetitions_and_course_quizzes_are_left_out(self): + from app.models.course import Course + from app.models.quiz import Quiz + module = Course(title='Neonatology', user_id=1) + self.bank.db.add(module) + self.bank.db.flush() + again = Quiz(title='Repeat of test', user_id=1, is_repetition=1) + course = Quiz(title='Course quiz', user_id=1, course_id=module.id) + self.bank.db.add_all([again, course]) + self.bank.db.commit() + self.sat(1, True, ago_days=1, seconds=50) + self.sat(2, True, ago_days=1, seconds=50, quiz_id=again.id) + self.sat(3, True, ago_days=1, seconds=50, quiz_id=course.id) + self.assertEqual(self.get(days=30)['answered'], 1) + + def test_nothing_answered_reports_nothing_rather_than_zero_per_cent(self): + data = self.get(days=7) + self.assertEqual(data['answered'], 0) + self.assertIsNone(data['percent_correct']) + self.assertIsNone(data['seconds_per_question']) + self.assertEqual(data['seconds_total'], 0) + self.assertGreater(data['bank_total'], 0) diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index d430e7f..017affb 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -118,3 +118,25 @@ color: var(--text-muted); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; } + +/* ── Completion ─────────────────────────────────────────────────────── + Over a window, because "how am I doing" and "how was I doing last month" + are different questions and one lifetime figure cannot answer both. */ +.an-completion { margin: 24px 0; } +.an-completion-head { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; margin-bottom: 10px; +} +.an-completion-head h2 { margin: 0; font-size: 1.05rem; font-weight: 650; } +.an-completion-head select { + padding: 7px 10px; font: inherit; font-size: 0.84rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.an-completion-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; background: var(--border); + border: 1px solid var(--border); border-radius: 12px; overflow: hidden; +} +.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; } diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 1addc9a..1a34e2f 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -7,6 +7,91 @@ import './AnalysisPage.css' const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' } +const RANGES = [ + { days: 7, label: 'Last 7 days' }, + { days: 30, label: 'Last 30 days' }, + { days: 90, label: 'Last 3 months' }, + { days: null, label: 'All time' }, +] + +const clock = (seconds) => { + if (!seconds) return '—' + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + return m ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s` +} + +const hours = (seconds) => { + if (!seconds) return '—' + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return `${h}h ${String(m).padStart(2, '0')}m` +} + +/** + * How much has been worked through, and at what pace. + * + * Over a window, because "how am I doing" and "how was I doing last month" are + * different questions and one lifetime figure cannot answer both. + */ +function Completion() { + const [days, setDays] = useState(30) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let live = true + setError('') + api.get('/study-tools/completion', { params: days ? { days } : {} }) + .then(res => { if (live) setData(res.data) }) + .catch(() => { if (live) setError('Could not load your completion') }) + return () => { live = false } + }, [days]) + + return ( +
+
+

Completion

+ +
+ {error ?

{error}

: ( +
+
+ + {data ? data.answered : '—'} + {data && /{data.bank_total}} + + Questions answered +
+
+ + {data?.percent_correct !== null && data?.percent_correct !== undefined + ? `${data.percent_correct}%` : '—'} + + Answered correctly +
+
+ {clock(data?.seconds_per_question)} + Time per question +
+
+ {hours(data?.seconds_total)} + Total time spent +
+
+ )} +
+ ) +} + function FocusRow({ row, onPractise }) { const [open, setOpen] = useState(false) const coverageLabel = `${row.seen_questions}/${row.available}` @@ -222,7 +307,10 @@ export default function AnalysisPage() {

{data.basis}

) : ( - + <> + + + )} )} diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx index e697b76..e59871e 100644 --- a/frontend/src/pages/AnalysisPage.test.jsx +++ b/frontend/src/pages/AnalysisPage.test.jsx @@ -85,3 +85,29 @@ it('switches to the performance tab', async () => { expect(screen.getByTestId('category-performance')).toBeInTheDocument() expect(screen.queryByText('Cardiology')).not.toBeInTheDocument() }) + +it('reports completion over a window, and changes the window', async () => { + const completion = (over = {}) => ({ + days: 30, answered: 120, bank_total: 330, correct: 78, + percent_correct: 65.0, seconds_per_question: 74, seconds_total: 8880, ...over, + }) + api.get.mockImplementation((url, config) => + url === '/study-tools/completion' + ? Promise.resolve({ data: completion(config?.params?.days ? {} : { days: null, answered: 400, seconds_total: 30000 }) }) + : Promise.resolve({ data: payload() })) + + render() + await userEvent.click(await screen.findByRole('tab', { name: 'Performance' })) + + const panel = (await screen.findByText('Completion')).closest('.an-completion') + expect(within(panel).getByText('120')).toBeInTheDocument() + expect(within(panel).getByText('/330')).toBeInTheDocument() + expect(within(panel).getByText('65%')).toBeInTheDocument() + expect(within(panel).getByText('1m 14s')).toBeInTheDocument() // time per question + expect(within(panel).getByText('2h 28m')).toBeInTheDocument() // total time + + // All time is a different question, and asks it without a day count. + await userEvent.selectOptions(within(panel).getByLabelText('Time range'), '') + await waitFor(() => expect(within(panel).getByText('400')).toBeInTheDocument()) + expect(api.get).toHaveBeenCalledWith('/study-tools/completion', { params: {} }) +}) diff --git a/frontend/src/pages/ToolsPage.css b/frontend/src/pages/ToolsPage.css index ced58ea..71c1158 100644 --- a/frontend/src/pages/ToolsPage.css +++ b/frontend/src/pages/ToolsPage.css @@ -1,5 +1,10 @@ .tools { max-width: 900px; margin: 0 auto; padding-bottom: 64px; } .tools-head { margin-bottom: 24px; } +.tools-back { + display: inline-block; margin-bottom: 10px; + font-size: 0.85rem; text-decoration: none; color: var(--primary); +} +.tools-back:hover { text-decoration: underline; } .tools-head h1 { margin: 0 0 8px; font-size: 1.5rem; font-weight: 700; } .tools-head p { margin: 0; font-size: 0.92rem; line-height: 1.65; color: var(--text-muted); max-width: 66ch; } .tools-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); } diff --git a/frontend/src/pages/ToolsPage.jsx b/frontend/src/pages/ToolsPage.jsx index 42fc7b2..5e7536e 100644 --- a/frontend/src/pages/ToolsPage.jsx +++ b/frontend/src/pages/ToolsPage.jsx @@ -48,6 +48,10 @@ export default function ToolsPage() { return (
+ {/* The way back. This page is a workbench rather than part of the + study flow, and without a trail out of it you are somewhere that + looks like a different application. */} + ← Settings

Tools

A document goes in, a model proposes questions, you read them, and the