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:
parent
06433195bb
commit
4e272e6ef0
7 changed files with 288 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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: {} })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue