feat: completion over a chosen time range, and a way back out of Tools

"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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 01:35:14 +02:00
parent 06433195bb
commit 4e272e6ef0
7 changed files with 288 additions and 2 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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; }

View file

@ -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 (
<section className="an-completion">
<div className="an-completion-head">
<h2>Completion</h2>
<label>
<span className="sr-only">Time range</span>
<select value={days ?? ''} aria-label="Time range"
onChange={e => setDays(e.target.value ? Number(e.target.value) : null)}>
{RANGES.map(range => (
<option key={range.label} value={range.days ?? ''}>{range.label}</option>
))}
</select>
</label>
</div>
{error ? <p className="an-empty" role="alert">{error}</p> : (
<div className="an-completion-grid">
<div className="an-stat">
<span className="an-stat-value">
{data ? data.answered : '—'}
{data && <span className="an-stat-of">/{data.bank_total}</span>}
</span>
<span className="an-stat-label">Questions answered</span>
</div>
<div className="an-stat">
<span className="an-stat-value">
{data?.percent_correct !== null && data?.percent_correct !== undefined
? `${data.percent_correct}%` : '—'}
</span>
<span className="an-stat-label">Answered correctly</span>
</div>
<div className="an-stat">
<span className="an-stat-value">{clock(data?.seconds_per_question)}</span>
<span className="an-stat-label">Time per question</span>
</div>
<div className="an-stat">
<span className="an-stat-value">{hours(data?.seconds_total)}</span>
<span className="an-stat-label">Total time spent</span>
</div>
</div>
)}
</section>
)
}
function FocusRow({ row, onPractise }) {
const [open, setOpen] = useState(false)
const coverageLabel = `${row.seen_questions}/${row.available}`
@ -222,7 +307,10 @@ export default function AnalysisPage() {
<p className="an-basis">{data.basis}</p>
</>
) : (
<CategoryPerformance />
<>
<Completion />
<CategoryPerformance />
</>
)}
</>
)}

View file

@ -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(<MemoryRouter><AnalysisPage /></MemoryRouter>)
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: {} })
})

View file

@ -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); }

View file

@ -48,6 +48,10 @@ export default function ToolsPage() {
return (
<div className="tools">
<div className="tools-head">
{/* 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. */}
<Link className="tools-back" to="/settings?s=tools"> Settings</Link>
<h1>Tools</h1>
<p>
A document goes in, a model proposes questions, you read them, and the