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 (
+ {error}Completion
+
+
{data.basis}
> ) : ( -A document goes in, a model proposes questions, you read them, and the