feat: sample smart-links quiz, bank feedback polish, performance hierarchy

Sample quiz demonstrates option explanations, key points with article links and linked cards. Bank study modal shows per-option explanations and key points. Performance shows main categories with an expandable hierarchy. AMBOSS-style picker polish (chevrons, search box, switch, auto title). Mobile spacing fixes. 97 frontend tests pass.
This commit is contained in:
Daniel 2026-09-09 03:31:54 +02:00
parent 271f0c9054
commit 236547e646
8 changed files with 122 additions and 18 deletions

View file

@ -191,9 +191,11 @@ def performance_by_category(db: Session = Depends(get_db), user: User = Depends(
if is_correct:
counts[cid][1] += 1
names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()}
parents = {cat.id: cat.parent_id for cat in db.query(QuestionCategory).all()}
categories = [{
"category_id": cid,
"name": names.get(cid, "Uncategorized"),
"parent_id": parents.get(cid),
"answered": answered,
"correct": correct,
"accuracy": round(100 * correct / answered, 1) if answered else 0,

View file

@ -119,6 +119,22 @@ def main():
]
db.commit()
# Sample quiz demonstrating the full study feedback (option explanations,
# key points with article links, linked cards).
from app.models.quiz import Quiz
from app.models.quiz_question_link import QuizQuestionLink
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
sample_quiz = db.query(Quiz).filter(Quiz.title == "Sample — smart links demo").first()
if not sample_quiz:
sample_quiz = Quiz(title="Sample — smart links demo", user_id=moderator, mode="learning",
questions_count=1, is_published=1, origin="sample")
db.add(sample_quiz)
db.flush()
if not db.query(QuizQuestionLink.question_id).filter_by(quiz_id=sample_quiz.id, question_id=sample_q.id).first():
db.add(QuizQuestionLink(quiz_id=sample_quiz.id, question_id=sample_q.id, position=0))
db.commit()
print("Created sample smart-links quiz")
# ── Sample deck and cards ───────────────────────────────────────
deck = db.query(FlashcardDeck).filter_by(title="Sample cards", user_id=moderator).first()
if not deck:
@ -165,6 +181,11 @@ def main():
if bank_ids and not db.query(FlashcardQuestionLink.id).filter_by(
flashcard_id=card.id, question_id=bank_ids[1 % len(bank_ids)]).first():
db.add(FlashcardQuestionLink(flashcard_id=card.id, question_id=bank_ids[1 % len(bank_ids)]))
# Link the first sample card to the sample smart-link question as well.
if cards and not db.query(FlashcardQuestionLink.id).filter_by(
flashcard_id=cards[0].id, question_id=sample_q.id).first():
db.add(FlashcardQuestionLink(flashcard_id=cards[0].id, question_id=sample_q.id))
print(f"Sample card linked to the sample question")
db.commit()
# ── Deep-link lab references to the sample sections ─────────────

View file

@ -1,26 +1,62 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import api from '../api/client'
export default function CategoryPerformance() {
const [data, setData] = useState(null)
const [showAll, setShowAll] = useState(false)
useEffect(() => {
api.get('/study-tools/performance-by-category')
.then(res => setData(res.data))
.catch(() => setData(null))
}, [])
if (!data || !data.categories?.length) return null
const rows = data?.categories || []
const main = useMemo(() => rows.filter(row => !row.parent_id).sort((a, b) => b.answered - a.answered), [rows])
const childrenOf = useMemo(() => {
const map = {}
for (const row of rows) { (map[row.parent_id] ||= []).push(row) }
return map
}, [rows])
if (!data || !rows.length) return null
const renderRow = (row) => (
<div className="category-performance-row" key={row.category_id}>
<span className="cp-name">{row.name}</span>
<span className="cp-track"><span className="cp-fill" style={{ width: `${row.accuracy}%` }} /></span>
<span className="cp-accuracy">{row.accuracy}%</span>
<span className="cp-count">{row.correct}/{row.answered}</span>
</div>
)
const renderBranch = (parentId, depth = 0) => {
const branch = (childrenOf[parentId] || []).sort((a, b) => b.answered - a.answered)
if (!branch.length) return null
return branch.map(row => (
<div key={row.category_id} style={{ marginLeft: depth > 0 ? 16 : 0 }}>
{renderRow(row)}
{renderBranch(row.category_id, depth + 1)}
</div>
))
}
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 className="cp-heading">
<h2 style={{ margin: 0 }}>Performance by category</h2>
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={showAll} onClick={() => setShowAll(v => !v)}>
{showAll ? 'Show main categories' : `Show all (${rows.length})`}
</button>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: '4px 0 12px' }}>{data.basis}</p>
{!showAll && main.map(renderRow)}
{showAll && (
<div className="cp-all">
{main.map(row => (
<div key={row.category_id}>
{renderRow(row)}
{renderBranch(row.category_id, 1)}
</div>
))}
</div>
))}
)}
</div>
)
}

View file

@ -32,3 +32,17 @@ details[open] > .custom-test-branch::before { content: '▾ '; }
.custom-test-filters-body { display: none; }
.custom-test-filters-body.open { display: block; }
}
.custom-test-search { position: relative; margin-bottom: 8px; }
.custom-test-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: .8rem; opacity: .6; pointer-events: none; }
.custom-test-search input { width: 100%; padding: 7px 10px 7px 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: .84rem; }
.custom-test-search input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); }
.custom-test-chevron { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 7px; transition: transform .15s ease; vertical-align: middle; }
details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45deg); }
.custom-test-title-input { font-size: 1.15rem; font-weight: 600; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; }
.custom-test-adaptive { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.custom-test-adaptive input { position: absolute; opacity: 0; width: 0; height: 0; }
.custom-test-switch { width: 38px; height: 21px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s ease; flex-shrink: 0; }
.custom-test-switch span { position: absolute; top: 2px; left: 2px; width: 17px; height: 17px; border-radius: 50%; background: #fff; transition: transform .15s ease; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.custom-test-adaptive input:checked + .custom-test-switch { background: var(--primary); }
.custom-test-adaptive input:checked + .custom-test-switch span { transform: translateX(17px); }
.custom-test-branch { display: flex; align-items: center; }

View file

@ -12,7 +12,7 @@ export default function CustomQuizPage() {
const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
const [state, setState] = useState('all')
const [shared, setShared] = useState(false)
const [title, setTitle] = useState('My Custom Test')
const [title, setTitle] = useState(() => `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`)
const [mode, setMode] = useState('learning')
const [time, setTime] = useState('')
const [count, setCount] = useState(20)
@ -96,7 +96,7 @@ export default function CustomQuizPage() {
if (!kids.length) return <li key={cat.id}>{node}</li>
return <li key={cat.id}>
<details open={!!catSearch || categoryIds.includes(cat.id) || descendantSelected(cat)}>
<summary className="custom-test-branch">{node}</summary>
<summary className="custom-test-branch"><span className="custom-test-chevron" aria-hidden="true" />{node}</summary>
{renderTree(cat.id)}
</details>
</li>
@ -116,15 +116,18 @@ export default function CustomQuizPage() {
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}>
<h2>Topics</h2>
<p>Parent categories include all their subcategories.</p>
<input type="search" value={catSearch} onChange={e => setCatSearch(e.target.value)}
placeholder="Search topics…" aria-label="Search topics" className="input" />
<div className="custom-test-search">
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
<input type="search" value={catSearch} onChange={e => setCatSearch(e.target.value)}
placeholder="Search topics…" aria-label="Search topics" className="input" />
</div>
{renderTree(0)}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
</div>
</aside>
<div className="custom-test-main">
<div className="custom-test-settings card">
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} /></label>
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} className="custom-test-title-input" /></label>
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
<option value="all">All</option><option value="unused">Unused</option>
<option value="incorrect">Incorrect</option><option value="bookmarked">Bookmarked</option>
@ -133,7 +136,7 @@ export default function CustomQuizPage() {
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select></label>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> Adaptive session</label>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> <span className="custom-test-switch" aria-hidden="true"><span /></span> Adaptive session</label>
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>
<label>Mode<select value={mode} onChange={e => setMode(e.target.value)}>

View file

@ -47,7 +47,7 @@ describe('CustomQuizPage', () => {
expect(params.get('is_shared')).toBe('true')
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
title: 'My Custom Test', category_ids: [1, 2], state: 'incorrect', count: 10,
title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, category_ids: [1, 2], state: 'incorrect', count: 10,
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
difficulty: null, algorithm: 'random',
})

View file

@ -65,6 +65,9 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}>{opt}</span>
{hasAnswered && question.option_explanations?.[opt] && (
<span style={{ flexBasis: '100%', fontSize: '0.8rem', color: 'var(--text-muted)' }}>{question.option_explanations[opt]}</span>
)}
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
@ -85,6 +88,22 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
)}
</div>
)}
{answered && (question.key_points || []).length > 0 && (
<div className="explanation" style={{ marginTop: 12 }}>
<strong>Key points</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: 18, fontSize: '0.85rem' }}>
{question.key_points.map((point, i) => (
<li key={i}>
{point.text}
{point.article_id && (
<Link to={`/articles/${point.article_id}${point.article_section_id ? `?section=${point.article_section_id}` : ''}`}
style={{ marginLeft: 8, color: 'var(--primary)', textDecoration: 'none', fontWeight: 600, fontSize: '0.8rem' }}>📖 Read more</Link>
)}
</li>
))}
</ul>
</div>
)}
<QuestionReadingLinks questionId={question.id} />
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>

View file

@ -83,3 +83,12 @@
.quiz-key-points li { font-size: .86rem; }
.quiz-key-point-link { margin-left: 8px; color: var(--primary); text-decoration: none; font-size: .8rem; font-weight: 600; }
.quiz-key-point-link:hover { text-decoration: underline; }
.quiz-player .quiz-header-card > div { gap: 10px; }
.quiz-topbar { gap: 10px; }
.quiz-nav-controls { gap: 8px; }
.quiz-review-button { margin-left: auto; }
@media (max-width: 640px) {
.quiz-topbar { flex-wrap: wrap; }
.quiz-header-card { padding-bottom: 12px; }
.quiz-response-stat { font-size: .7rem; }
}