feat: performance by category dashboard

Accuracy per category from completed non-expired general-bank answers, counting each question in its primary and additional categories. 56 backend and 90 frontend tests pass.
This commit is contained in:
Daniel 2026-09-08 16:03:45 +02:00
parent cdb1ab7468
commit 01337c8c25
6 changed files with 133 additions and 0 deletions

View file

@ -11,6 +11,7 @@ from app.database import get_db
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.lab_reference import LabReference
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
@ -101,6 +102,44 @@ def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User =
db.commit()
@router.get("/performance-by-category")
def performance_by_category(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Accuracy per category from the user's completed, non-expired general-bank answers.
A question counts in its primary category and every additional category link."""
from collections import defaultdict
from app.models.question_category import QuestionCategoryLink
rows = db.query(AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id).join(
QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).join(
Question, Question.id == AttemptAnswer.question_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),
).all()
extra: dict[int, set[int]] = defaultdict(set)
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
extra[qid].add(cid)
counts: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for qid, is_correct, primary in rows:
categories = extra.get(qid, set()) | ({primary} if primary else set())
for cid in categories:
counts[cid][0] += 1
if is_correct:
counts[cid][1] += 1
names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()}
categories = [{
"category_id": cid,
"name": names.get(cid, "Uncategorized"),
"answered": answered,
"correct": correct,
"accuracy": round(100 * correct / answered, 1) if answered else 0,
} for cid, (answered, correct) in counts.items()]
categories.sort(key=lambda row: (-row["answered"], row["name"]))
return {
"total_answered": sum(row["answered"] for row in categories),
"categories": categories,
"basis": "Your completed, non-expired general test answers; a question counts in every category it belongs to.",
}
@router.get("/attempts/{attempt_id}/questions/{question_id}/responses")
def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()

View file

@ -151,6 +151,24 @@ class StudyToolTests(unittest.TestCase):
response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json()
self.assertEqual(response['sample_size'], 0)
def test_performance_by_category_expands_links_and_excludes_irrelevant(self):
quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
self.bank.answer(1, True, quiz_id=quiz)
self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded.
quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id']
self.bank.answer(2, False, quiz_id=quiz2)
self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Owner edits own question.
quiz3 = self.bank.generate(category_ids=[3], count=1).json()['id'] # Private owner test: own attempts still count.
self.bank.answer(3, True, quiz_id=quiz3)
self.bank.answer(5, True, quiz_id=2) # Course quiz excluded.
data = self.client.get('/study-tools/performance-by-category').json()
by_id = {row['category_id']: row for row in data['categories']}
self.assertEqual([by_id[1]['answered'], by_id[1]['correct'], by_id[1]['accuracy']], [1, 1, 100.0])
self.assertEqual([by_id[2]['answered'], by_id[2]['correct'], by_id[2]['accuracy']], [2, 1, 50.0])
self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1])
self.assertEqual(data['total_answered'], 4)
self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first.
def test_lab_reference_permissions_validation_and_publication(self):
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
export default function CategoryPerformance() {
const [data, setData] = useState(null)
useEffect(() => {
api.get('/study-tools/performance-by-category')
.then(res => setData(res.data))
.catch(() => setData(null))
}, [])
if (!data || !data.categories?.length) return null
return (
<div className="card" data-testid="category-performance">
<h2 style={{ margin: '0 0 4px' }}>Performance by category</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: '0 0 14px' }}>{data.basis}</p>
{data.categories.map(category => (
<div className="category-performance-row" key={category.category_id}>
<span className="cp-name">{category.name}</span>
<span className="cp-track"><span className="cp-fill" style={{ width: `${category.accuracy}%` }} /></span>
<span className="cp-accuracy">{category.accuracy}%</span>
<span className="cp-count">{category.correct}/{category.answered}</span>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import CategoryPerformance from './CategoryPerformance'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
beforeEach(() => { vi.resetAllMocks() })
describe('category performance', () => {
it('renders accuracy rows with counts and the stated basis', async () => {
api.get.mockResolvedValue({ data: {
total_answered: 3,
basis: 'Your completed, non-expired general test answers.',
categories: [
{ category_id: 2, name: 'Clinical reasoning', answered: 2, correct: 1, accuracy: 50 },
{ category_id: 1, name: 'Pediatrics', answered: 1, correct: 1, accuracy: 100 },
],
} })
render(<CategoryPerformance />)
expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
expect(screen.getByText('Clinical reasoning')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
expect(screen.getByText('1/2')).toBeInTheDocument()
expect(screen.getByText('100%')).toBeInTheDocument()
expect(screen.getByText(/completed, non-expired/)).toBeInTheDocument()
})
it('renders nothing without category data', async () => {
api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } })
const { container } = render(<CategoryPerformance />)
await waitFor(() => expect(api.get).toHaveBeenCalled())
await new Promise(resolve => setTimeout(resolve, 0))
expect(container.querySelector('[data-testid="category-performance"]')).toBeNull()
})
})

View file

@ -735,3 +735,14 @@ body {
.lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
.lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; }
.lesson-content strong { font-weight: 700; }
.category-performance-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(80px, 2fr) 64px 84px; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .86rem; }
.category-performance-row:last-child { border-bottom: none; }
.cp-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cp-track { height: 8px; background: var(--bg, #e2e8f0); border-radius: 999px; overflow: hidden; display: block; }
.cp-fill { display: block; height: 100%; background: var(--primary, #2563eb); border-radius: 999px; }
.cp-accuracy { font-weight: 700; text-align: right; }
.cp-count { color: var(--text-muted); text-align: right; }
@media (max-width: 560px) {
.category-performance-row { grid-template-columns: 1fr 56px; }
.cp-track { grid-column: 1 / -1; grid-row: 2; }
}

View file

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import api from '../api/client'
import LineChart from '../components/LineChart'
import InProgressQuizzes from '../components/InProgressQuizzes'
import CategoryPerformance from '../components/CategoryPerformance'
import MyNote from '../components/MyNote'
import { useAuth } from '../context/AuthContext'
@ -78,6 +79,8 @@ export default function DashboardPage() {
<InProgressQuizzes />
<CategoryPerformance />
<MyNote variant="card" />
{/* Performance graph with dropdown */}