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."""
|
"""Educator-maintained lab references and authorized question response statistics."""
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -242,6 +242,60 @@ def _category_rollup(categories):
|
||||||
return ancestry
|
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")
|
@router.get("/recommendations")
|
||||||
def study_recommendations(
|
def study_recommendations(
|
||||||
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
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.
|
# so an unmapped topic keeps the bank-share figure and says so.
|
||||||
blueprint_weight = {1: 10.0}
|
blueprint_weight = {1: 10.0}
|
||||||
self.assertIsNone(blueprint_weight.get(99))
|
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);
|
color: var(--text-muted); background: var(--bg);
|
||||||
border: 1px solid var(--border); border-radius: 8px;
|
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 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 }) {
|
function FocusRow({ row, onPractise }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const coverageLabel = `${row.seen_questions}/${row.available}`
|
const coverageLabel = `${row.seen_questions}/${row.available}`
|
||||||
|
|
@ -222,7 +307,10 @@ export default function AnalysisPage() {
|
||||||
<p className="an-basis">{data.basis}</p>
|
<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.getByTestId('category-performance')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Cardiology')).not.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 { max-width: 900px; margin: 0 auto; padding-bottom: 64px; }
|
||||||
.tools-head { margin-bottom: 24px; }
|
.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 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-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); }
|
.tools-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ export default function ToolsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="tools">
|
<div className="tools">
|
||||||
<div className="tools-head">
|
<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>
|
<h1>Tools</h1>
|
||||||
<p>
|
<p>
|
||||||
A document goes in, a model proposes questions, you read them, and the
|
A document goes in, a model proposes questions, you read them, and the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue