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:
parent
271f0c9054
commit
236547e646
8 changed files with 122 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ─────────────
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue