fix: auto-start quizzes in their mode, drop quiz code and verbose stats note

Timed quizzes start as exams and learning quizzes as study without a second mode prompt; reopening resumes automatically. Removed quiz code display from in-progress list and the verbose statistics basis sentence. Lab rows keep logical age order per test. 93 frontend tests pass.
This commit is contained in:
Daniel 2026-09-08 19:07:27 +02:00
parent 0c03a6f286
commit a1e9340004
5 changed files with 39 additions and 114 deletions

View file

@ -59,7 +59,8 @@ def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user
if not include_drafts:
query = query.filter(LabReference.is_published.is_(True))
# ponytail: bounded personal-project table; add pagination before exceeding 500 entries.
entries = query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all()
# Insertion order keeps each test's age rows in their logical sequence.
entries = query.order_by(LabReference.group, LabReference.name, LabReference.id).limit(500).all()
card_rows = db.query(LabReferenceCardLink.lab_reference_id, Flashcard.id, Flashcard.front, Flashcard.deck_id,
FlashcardDeck.title).join(
Flashcard, Flashcard.id == LabReferenceCardLink.flashcard_id,

View file

@ -192,6 +192,11 @@ class StudyToolTests(unittest.TestCase):
self.assertEqual(created.status_code, 201, created.text)
entry_id = created.json()['id']
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/999').status_code, 404)
second = self.client.post('/study-tools/lab-values', json={**payload, 'age_group': 'z', 'article_id': None, 'article_section_id': None})
self.assertEqual(second.status_code, 201, second.text)
# Same-name rows keep insertion (logical age) order, not alphabetical age order.
names_in_order = [r['name'] + ':' + r['age_group'] for r in self.client.get('/study-tools/lab-values').json() if r['name'] == 'Deep linked']
self.assertEqual(names_in_order, ['Deep linked:a', 'Deep linked:z'])
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], True)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], False)
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)

View file

@ -16,12 +16,6 @@ export default function InProgressQuizzes() {
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
}
const copyQuizCode = async (code) => {
try {
await navigator.clipboard.writeText(String(code))
} catch {}
}
if (inProgress.length === 0) return null
return (
@ -40,15 +34,6 @@ export default function InProgressQuizzes() {
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
</div>
<div style={{ marginTop: 5, display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<span>Quiz code</span>
<code style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 6, padding: '2px 7px', color: 'var(--text)' }}>
{a.quiz_code || a.quiz_id}
</code>
<button className="btn btn-secondary btn-sm" type="button" onClick={() => copyQuizCode(a.quiz_code || a.quiz_id)}>
Copy
</button>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>

View file

@ -361,86 +361,7 @@ function CourseQuizStart({ quiz, onStart, onShareChanged }) {
)
}
function ModeSelectScreen({ quiz, voices, onStart, onShareChanged }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('')
const [startingMode, setStartingMode] = useState('')
const handleStart = async (mode) => {
if (mode === 'exam' && customTimer && (!Number.isInteger(Number(customTimer)) || Number(customTimer) < 1)) {
setStartError('Enter a positive whole number of minutes, or leave the timer blank.')
return
}
const timerMinutes = mode === 'exam' && customTimer ? Number(customTimer) : null
setStartError('')
setStartingMode(mode)
try {
await onStart(mode, selectedVoice, timerMinutes)
} catch {
setStartError('Could not start the quiz. Try again.')
} finally {
setStartingMode('')
}
}
return (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
<ShareLinkBadge quiz={quiz} onShareChanged={onShareChanged} />
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</p>
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => (
<button type="button" key={mode} onClick={() => !startingMode && handleStart(mode)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
</button>
))}
</div>
{startError && (
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
{startError}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
<input type="number" min={1} value={customTimer}
onChange={e => setCustomTimer(e.target.value)}
placeholder="No time limit"
onClick={e => e.stopPropagation()}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border)', fontSize: '0.9rem', width: '100%', background: 'var(--input-bg)', color: 'var(--text)' }} />
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>Auto-submits when timer expires. Timer pauses if you leave and resumes when you come back.</p>
</div>
{voices.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
</select>
</div>
)}
</div>
</div>
)
}
// Mode selection prompt removed: general quizzes start in their own mode automatically.
function getQuizSessionId() {
const key = 'pedshub_quiz_session_id'
try {
@ -681,7 +602,8 @@ export default function QuizPage() {
const def = voicesRes.data.find(v => v.is_default)
if (def) setSelectedVoice(def.id)
// Check for saved progress and auto-resume
// Check for saved progress and auto-resume; otherwise start straight away
// in the quiz's own mode no second mode prompt.
try {
const progressRes = await api.get('/attempts/progress', {
params: { quiz_id: id },
@ -689,6 +611,10 @@ export default function QuizPage() {
})
if (progressRes.data) {
await resumeQuiz(progressRes.data, voicesRes.data)
return
}
if (!returnTo && quizRes.data) {
await startAttempt(quizRes.data.mode === 'timed' ? 'exam' : 'study', null, null)
}
} catch {
setResumeError('Could not restore your saved attempt. Retry resume before starting; your saved answers have not been replaced.')
@ -700,8 +626,7 @@ export default function QuizPage() {
return () => clearInterval(timerRef.current)
}, [id, resumeRetry])
const startQuiz = async (mode, voice, timerMinutes = null) => {
if (hasStarted.current || resumeError || loading) return
const startAttempt = async (mode, voice, timerMinutes = null) => {
hasStarted.current = true
setSelectedVoice(voice)
setStarting(true)
@ -754,6 +679,11 @@ export default function QuizPage() {
finally { setStarting(false) }
}
const startQuiz = async (mode, voice, timerMinutes = null) => {
if (hasStarted.current || resumeError || loading) return
return startAttempt(mode, voice, timerMinutes)
}
const timerStarted = timeLeft !== null
useEffect(() => {
if (!timerStarted) return
@ -934,7 +864,10 @@ const timerStarted = timeLeft !== null
) : returnTo ? (
<CourseQuizStart quiz={quiz} onStart={startQuiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
) : (
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
<div className="card" style={{ textAlign: 'center' }}>
<div className="spinner" style={{ margin: '0 auto 16px' }} />
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Starting</div>
</div>
)}
</div>
)
@ -942,7 +875,6 @@ const timerStarted = timeLeft !== null
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
const quizCode = quiz.quiz_code || quiz.id || id
const quizNavigation = (position = 'bottom') => (
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
<button className="btn btn-secondary"
@ -1297,7 +1229,7 @@ const timerStarted = timeLeft !== null
{isStudy && answers[current.id] && (
<>
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}</p>}
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet'}</p>}
{statsError && <p className="quiz-stats-note">{statsError}</p>}
{(current.explanation || current.explanation_image_path) && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>

View file

@ -15,6 +15,7 @@ const questions = [
{ id: 2, question_text: 'Full second clinical question.', question_type: 'mcq', options: ['Third answer', 'Fourth answer'], category_breadcrumbs: [] },
]
let mode
let quizModeVar = 'timed'
let showModal
let close
beforeAll(() => {
@ -28,9 +29,10 @@ beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mode = 'exam'
quizModeVar = 'timed'
api.get.mockImplementation(url => {
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
id: 10, title: 'Personal test', mode: 'timed', questions_count: 2, user_id: 1, time_limit_minutes: null,
id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1, time_limit_minutes: null,
attempt_mode: url.includes('attempt_id=') ? mode : null,
questions: questions.map(q => url.includes('attempt_id=') && mode === 'study' ? { ...q, correct_answer: q.options[0], explanation: 'Full explanation, preserved without shortening.' } : q),
} })
@ -49,8 +51,8 @@ function mount(entry = '/quizzes/10') {
render(<MemoryRouter initialEntries={[entry]}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
}
async function begin(study = true) {
quizModeVar = study ? 'learning' : 'timed'
mount()
await userEvent.click(await screen.findByRole('button', { name: study ? /Study Mode/ : /Exam Mode/ }))
await screen.findByText('Full first clinical question.')
}
@ -85,10 +87,11 @@ describe('quiz player', () => {
})
mount()
await screen.findByRole('button', { name: 'Retry resume' })
expect(screen.queryByRole('button', { name: /Study Mode/ })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
expect(await screen.findByRole('button', { name: /Study Mode/ })).toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
// Retry loads the quiz and starts it straight away in its own mode no re-ask.
expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
})
it('restores progress when Start reuses an attempt updated by another tab', async () => {
@ -167,7 +170,7 @@ describe('quiz player', () => {
await begin()
fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText('No response statistics available yet.')).toBeInTheDocument()
expect(await screen.findByText('No response statistics available yet')).toBeInTheDocument()
expect(screen.queryByText('0%')).not.toBeInTheDocument()
})
@ -220,13 +223,11 @@ describe('quiz player', () => {
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'First answer' }] })
})
it('rejects an invalid timer before creating an attempt', async () => {
it('starts timed quizzes in exam mode without a mode prompt', async () => {
mount()
await screen.findByRole('button', { name: /Exam Mode/ })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-1' } })
await userEvent.click(screen.getByRole('button', { name: /Exam Mode/ }))
expect(await screen.findByText(/Enter a positive whole number of minutes/)).toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled()
expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
expect(screen.queryByRole('button', { name: /Study Mode|Exam Mode/ })).not.toBeInTheDocument()
})
it('retains answers and saved progress when submission fails', async () => {
@ -256,6 +257,7 @@ describe('quiz player', () => {
return originalPost(url, ...args)
})
mount()
await screen.findByText('Full first clinical question.')
await screen.findByRole('button', { name: 'Make shareable' })
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
await screen.findByRole('button', { name: 'Copy share link' })