fix: the session owns the menu button, and study loses the exam's review

Four things, all from one screenshot pair.

The Review button in a study session was inherited from the exam player.
Reviewing a block before handing it in is an exam idea; a study session
has nothing to hand in — it keeps going until every question is answered
and at that point it *is* the review. The review link, the top-bar
button and the rail button are exam-only now, and a study session whose
questions are all answered says "Finish session" and submits rather than
opening a dialog to ask a second time.

The drawer's "Qbank" pointed at /questions, which has never been a route
— /questions/:id is the editor. It went nowhere. It points at
/question-bank, and Collections and AI Mode join the list.

While a session is open on a narrow screen, the navbar burger now opens
that session's questions instead of the site menu, which is a tab inside
the same drawer. Two menu buttons an inch apart, one of which leaves the
session you are sitting, is the wrong offer. The player claims the button
only while it has no rail, and hands it back when it leaves.

And the drawer says what AMBOSS's does: a Review badge once everything is
answered, the mode in the title, a progress bar under the count, and the
session and question clocks pinned beneath the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 04:12:48 +02:00
parent 1c1f327d4a
commit 56ecbb0889
12 changed files with 525 additions and 28 deletions

View file

@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Query
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -11,7 +13,7 @@ from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.models.invite import InviteCode
from app.services import invites, site_settings
from app.services import ai_service, invites, site_settings
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user, get_password_hash
@ -146,6 +148,13 @@ class LiteLLMSearchRequest(BaseModel):
mode: str | None = None
#: Short, unambiguous, and clinical enough that a medical model has no excuse.
#: Used to make a voice speak and a transcriber listen.
TEST_PHRASE = "Inspiratory stridor at rest."
log = logging.getLogger(__name__)
def _proxy_models(base: str, key: str | None, mode: str | None = None) -> tuple[list[str], bool]:
"""What the proxy will serve, and whether the answer knows about modes.
@ -308,10 +317,17 @@ def test_model(
raise HTTPException(status_code=404, detail="Model config not found")
if model.task == "tts":
# Not a failure. A voice is tested by hearing it, and the page offers
# that; saying so in red taught administrators to distrust a working
# configuration.
return {"message": f"{model.model_id} is a voice — press Preview to hear it."}
# A voice is tested by making it speak. The old answer was an
# instruction ("press Preview") returned as an error, which taught
# administrators to distrust a working configuration.
try:
audio = ai_service.generate_tts_audio(
TEST_PHRASE, model_id=model.model_id, api_key=model.api_key or None)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
if not audio:
raise HTTPException(status_code=502, detail=f"{model.model_id} returned no audio")
return {"message": f"{model.model_id} spoke {len(audio):,} bytes of audio"}
if model.task == "stt":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
@ -325,15 +341,33 @@ def test_model(
status_code=404,
detail=f"{model.model_id} is not served by the proxy"
+ (" as a transcription model" if by_mode else ""))
return {"message": f"{model.model_id} is served by the proxy" + (
" for speech transcription" if by_mode
# The route that says what a model is for is not open to this
# key, so this is presence, not suitability.
else " — the proxy would not say whether it transcribes")}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
raise HTTPException(status_code=502, detail=str(e)[:300])
# Presence is not proof. A voice we already have says a known phrase,
# and the model is asked what it heard — the only test that shows
# transcription actually working end to end.
spoken = None
try:
tts_id, tts_key = ai_service.get_model_for_task(db, "tts")
if tts_id:
spoken = ai_service.generate_tts_audio(TEST_PHRASE, model_id=tts_id, api_key=tts_key)
except Exception:
log.warning("Could not synthesise audio to test %s", model.model_id, exc_info=True)
if not spoken:
return {"message": f"{model.model_id} is served by the proxy. No voice is "
"configured, so it could not be given anything to hear."}
try:
heard = ai_service.transcribe_audio(
spoken, filename="test.mp3", content_type="audio/mpeg",
model_id=model.model_id, api_key=model.api_key or None)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)[:300])
if not heard:
raise HTTPException(status_code=502, detail=f"{model.model_id} heard nothing")
return {"message": f"{model.model_id} heard “{heard.strip()}"}
try:
import litellm

View file

@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { SessionDrawerProvider } from './context/SessionDrawer'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
@ -238,7 +239,9 @@ export default function App() {
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<SessionDrawerProvider>
<AppRoutes />
</SessionDrawerProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import ScrollStrip from './ScrollStrip'
import { useAuth } from '../context/AuthContext'
import { useSessionDrawer } from '../context/SessionDrawer'
import api from '../api/client'
import ExamSwitcher from './ExamSwitcher'
import GlobalSearch from './GlobalSearch'
@ -185,6 +186,8 @@ export default function Navbar({ onSignIn, onRegister }) {
// The section bar is wanted when you decide to go elsewhere and in the way
// every other moment, so it leaves on the way down and returns on the way up.
const sectionBarHidden = useHidingBar()
// Registered by the quiz player while it is on screen without a rail.
const sessionDrawer = useSessionDrawer()
const [jobs, setJobs] = useState([])
const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@ -258,11 +261,16 @@ export default function Navbar({ onSignIn, onRegister }) {
<FeedbackBadge />
<JobsBadge jobs={jobs} />
<AccountMenu user={user} onLogout={logout} />
{/* While a session is open on a narrow screen, this button
belongs to the session: it opens the list of questions, and
the site menu is a tab inside that drawer. Two menu buttons
an inch apart, one of which leaves the session you are
sitting, is the wrong offer. */}
<button
className="nav-burger"
onClick={() => setMenuOpen(v => !v)}
aria-label="Menu"
aria-expanded={menuOpen}
onClick={() => (sessionDrawer.opener ? sessionDrawer.opener() : setMenuOpen(v => !v))}
aria-label={sessionDrawer.opener ? 'Session menu' : 'Menu'}
aria-expanded={sessionDrawer.opener ? undefined : menuOpen}
>
<span style={{
display: 'block', width: 22, height: 2, background: 'currentColor',

View file

@ -0,0 +1,38 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
/**
* While a session is open, the menu button belongs to the session.
*
* On a narrow screen the quiz player has no rail, so the list of questions
* lives behind the same burger that would otherwise open the site menu. Two
* menu buttons an inch apart, one of which leaves the session you are sitting,
* is the wrong offer so the player takes the button over while it is on
* screen and hands back the site menu as a tab inside its own drawer.
*
* The player registers an opener; the navbar asks whether one exists. Nothing
* else needs to know.
*/
const Context = createContext({ opener: null, register: () => () => {} })
export function SessionDrawerProvider({ children }) {
const [opener, setOpener] = useState(null)
const current = useRef(null)
const register = useCallback((fn) => {
current.current = fn
setOpener(() => fn)
return () => {
// Only the registration still in force may clear it: a player unmounting
// after another has claimed the button must not take it away.
if (current.current === fn) {
current.current = null
setOpener(null)
}
}
}, [])
const value = useMemo(() => ({ opener, register }), [opener, register])
return <Context.Provider value={value}>{children}</Context.Provider>
}
export const useSessionDrawer = () => useContext(Context)

View file

@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { expect, it, vi } from 'vitest'
import { SessionDrawerProvider, useSessionDrawer } from './SessionDrawer'
/** Stands in for the navbar burger. */
function Burger() {
const { opener } = useSessionDrawer()
return (
<button onClick={() => (opener ? opener() : document.body.setAttribute('data-site-menu', 'open'))}>
{opener ? 'Session menu' : 'Menu'}
</button>
)
}
/** Stands in for the quiz player. */
function Player({ claim, onOpen }) {
const { register } = useSessionDrawer()
useEffect(() => (claim ? register(onOpen) : undefined), [claim, register, onOpen])
return null
}
it('hands the button to a session that claims it, and back when it leaves', async () => {
const onOpen = vi.fn()
const { rerender } = render(
<SessionDrawerProvider><Burger /><Player claim onOpen={onOpen} /></SessionDrawerProvider>)
await userEvent.click(screen.getByRole('button', { name: 'Session menu' }))
expect(onOpen).toHaveBeenCalled()
expect(document.body.dataset.siteMenu).toBeUndefined()
// The player leaves; the site menu is the button's job again.
rerender(<SessionDrawerProvider><Burger /></SessionDrawerProvider>)
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
expect(document.body.dataset.siteMenu).toBe('open')
document.body.removeAttribute('data-site-menu')
})
it('a player that never claims it leaves the site menu alone', async () => {
const onOpen = vi.fn()
render(<SessionDrawerProvider><Burger /><Player claim={false} onOpen={onOpen} /></SessionDrawerProvider>)
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
expect(onOpen).not.toHaveBeenCalled()
expect(document.body.dataset.siteMenu).toBe('open')
document.body.removeAttribute('data-site-menu')
})

View file

@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client'
/**
* Speaking instead of typing.
*
* Two ways to do it, and the browser's own is tried first: `SpeechRecognition`
* transcribes as you talk, costs nothing, and needs no round trip. Where it is
* missing Firefox, and every browser on iOS that is not Safari the audio
* is recorded and sent to our own transcriber instead, which is slower but
* works everywhere a microphone does.
*
* The fallback is also taken when the browser's recogniser fails mid-sentence,
* because a half-finished dictation is worse than a slow one.
*/
/** Read when asked, not when this module loads: a constant captured at import
time cannot be seen to be right or wrong. */
const recognition = () => (typeof window === 'undefined'
? null
: window.SpeechRecognition || window.webkitSpeechRecognition || null)
/** Recording needs both halves; iOS Safari shipped one without the other for
years, and a button that throws is worse than a button that is not there. */
const canRecord = () => typeof window !== 'undefined'
&& typeof window.MediaRecorder !== 'undefined'
&& Boolean(navigator?.mediaDevices?.getUserMedia)
/** Whether anything here can work at all. */
export const canDictate = () => Boolean(recognition()) || canRecord()
export default function useDictation({ onText, onError } = {}) {
const [listening, setListening] = useState(false)
const [transcribing, setTranscribing] = useState(false)
const recogniser = useRef(null)
const recorder = useRef(null)
const chunks = useRef([])
const stream = useRef(null)
const heard = useRef('')
const cleanup = useCallback(() => {
stream.current?.getTracks().forEach(track => track.stop())
stream.current = null
recorder.current = null
recogniser.current = null
setListening(false)
}, [])
useEffect(() => cleanup, [cleanup])
/** Record, then send it to our own transcriber. */
const record = useCallback(async () => {
if (!canRecord()) {
onError?.('This browser cannot record audio. Try typing instead.')
cleanup()
return
}
try {
stream.current = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch {
onError?.('No microphone. Check the permission for this site.')
return
}
chunks.current = []
const media = new MediaRecorder(stream.current)
recorder.current = media
media.ondataavailable = event => { if (event.data.size) chunks.current.push(event.data) }
media.onstop = async () => {
const blob = new Blob(chunks.current, { type: media.mimeType || 'audio/webm' })
cleanup()
if (!blob.size) return
setTranscribing(true)
try {
const form = new FormData()
form.append('file', blob, 'dictation.webm')
const res = await api.post('/tts/transcribe', form)
if (res.data?.text) onText?.(res.data.text)
} catch (err) {
const detail = err?.response?.data?.detail
onError?.(typeof detail === 'string' ? detail : 'Could not transcribe that')
} finally {
setTranscribing(false)
}
}
media.start()
setListening(true)
}, [cleanup, onError, onText])
const start = useCallback(async () => {
if (listening || transcribing) return
const Engine = recognition()
if (!Engine) { await record(); return }
heard.current = ''
const engine = new Engine()
engine.lang = navigator.language || 'en-US'
engine.interimResults = false
engine.continuous = true
engine.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i += 1) {
if (event.results[i].isFinal) heard.current += event.results[i][0].transcript
}
}
engine.onerror = (event) => {
cleanup()
// Refused permission is the learner's answer, not a fault to route around.
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
onError?.('No microphone. Check the permission for this site.')
return
}
if (canRecord()) record()
else onError?.('Could not hear anything. Try typing instead.')
}
engine.onend = () => {
setListening(false)
const text = heard.current.trim()
if (text) onText?.(text)
}
recogniser.current = engine
engine.start()
setListening(true)
}, [cleanup, listening, onError, onText, record, transcribing])
const stop = useCallback(() => {
if (recogniser.current) { recogniser.current.stop(); return }
if (recorder.current?.state === 'recording') recorder.current.stop()
else cleanup()
}, [cleanup])
return { listening, transcribing, start, stop, toggle: () => (listening ? stop() : start()) }
}

View file

@ -0,0 +1,112 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import useDictation, { canDictate } from './useDictation'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { post: vi.fn() } }))
function Harness({ onError }) {
const { listening, transcribing, toggle } = useDictation({
onText: text => { document.title = text }, onError,
})
return (
<button onClick={toggle}>
{transcribing ? 'transcribing' : listening ? 'listening' : 'idle'}
</button>
)
}
let engines = []
const withBrowserRecogniser = () => {
engines = []
window.SpeechRecognition = function Recogniser() {
const self = this
self.start = vi.fn(() => { engines.push(self) })
self.stop = vi.fn(() => self.onend?.())
engines.push(self)
return self
}
}
const withMediaRecorderOnly = () => {
delete window.SpeechRecognition
delete window.webkitSpeechRecognition
const track = { stop: vi.fn() }
navigator.mediaDevices = { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) }
window.MediaRecorder = function Recorder() {
const self = this
self.state = 'recording'
self.mimeType = 'audio/webm'
self.start = vi.fn()
self.stop = vi.fn(() => {
self.ondataavailable?.({ data: new Blob(['x'], { type: 'audio/webm' }) })
self.onstop?.()
})
return self
}
}
beforeEach(() => { vi.clearAllMocks(); document.title = '' })
afterEach(() => {
delete window.SpeechRecognition
delete window.webkitSpeechRecognition
delete window.MediaRecorder
delete navigator.mediaDevices
})
it('uses the browser recogniser when there is one, and never calls the server', async () => {
withBrowserRecogniser()
render(<Harness />)
await userEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveTextContent('listening')
const engine = engines[engines.length - 1]
engine.onresult({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: 'What causes stridor' } }] })
await userEvent.click(screen.getByRole('button'))
await waitFor(() => expect(document.title).toBe('What causes stridor'))
// Free and local: nothing was uploaded.
expect(api.post).not.toHaveBeenCalled()
})
it('records and sends to our own transcriber where the browser has none', async () => {
withMediaRecorderOnly()
api.post.mockResolvedValue({ data: { text: 'A neonate with bilious vomiting' } })
render(<Harness />)
await userEvent.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('listening'))
await userEvent.click(screen.getByRole('button'))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tts/transcribe', expect.any(FormData)))
await waitFor(() => expect(document.title).toBe('A neonate with bilious vomiting'))
})
it('falls back to recording when the browser recogniser fails mid-sentence', async () => {
withBrowserRecogniser()
const track = { stop: vi.fn() }
navigator.mediaDevices = { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) }
window.MediaRecorder = function Recorder() { this.start = vi.fn(); this.state = 'recording'; return this }
render(<Harness />)
await userEvent.click(screen.getByRole('button'))
engines[engines.length - 1].onerror({ error: 'network' })
// A half-finished dictation is worse than a slow one.
await waitFor(() => expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled())
})
it('a refused microphone is an answer, not a fault to route around', async () => {
withBrowserRecogniser()
navigator.mediaDevices = { getUserMedia: vi.fn() }
const onError = vi.fn()
render(<Harness onError={onError} />)
await userEvent.click(screen.getByRole('button'))
engines[engines.length - 1].onerror({ error: 'not-allowed' })
expect(onError).toHaveBeenCalledWith('No microphone. Check the permission for this site.')
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled()
})
it('offers nothing at all where neither exists', () => {
delete window.SpeechRecognition
delete window.webkitSpeechRecognition
delete navigator.mediaDevices
expect(canDictate()).toBe(false)
})

View file

@ -104,3 +104,23 @@
}
.ai-practise:hover:not(:disabled) { background: var(--option-sel-bg); }
.ai-practise:disabled { opacity: 0.6; cursor: default; }
/* Speaking instead of typing. Red while it is listening, because a microphone
you have forgotten is on is the one thing this must never be. */
.ai-mic {
flex-shrink: 0; width: 42px; height: 42px; font-size: 1rem; cursor: pointer;
border: 1px solid var(--border); border-radius: 10px;
background: var(--card-bg); color: var(--text-muted);
}
.ai-mic:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.ai-mic.is-live {
background: var(--wrong-bg); border-color: var(--wrong-fg); color: var(--wrong-fg);
animation: ai-mic-pulse 1.4s ease-in-out infinite;
}
.ai-mic:disabled { opacity: 0.6; cursor: default; }
@keyframes ai-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.35); }
50% { box-shadow: 0 0 0 6px rgba(220, 38, 38, 0); }
}
@media (prefers-reduced-motion: reduce) { .ai-mic.is-live { animation: none; } }

View file

@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import api from '../api/client'
import useDictation, { canDictate } from '../hooks/useDictation'
import './AiModePage.css'
const apiError = (err, fallback) => {
@ -86,6 +87,12 @@ export default function AiModePage() {
const [practising, setPractising] = useState(null)
const endRef = useRef(null)
const navigate = useNavigate()
// Speaking instead of typing. The browser's own recogniser where there is
// one, our transcriber where there is not.
const dictation = useDictation({
onText: text => setDraft(d => (d ? `${d} ${text}` : text)),
onError: setError,
})
const loadThreads = useCallback(() => api.get('/ai/conversations')
.then(res => { setThreads(res.data || []); return res.data || [] })
@ -236,6 +243,15 @@ export default function AiModePage() {
placeholder="Ask about anything in your library…"
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) send(e) }} />
{canDictate() && (
<button type="button" className={`ai-mic${dictation.listening ? ' is-live' : ''}`}
disabled={dictation.transcribing}
aria-pressed={dictation.listening}
aria-label={dictation.listening ? 'Stop dictating' : 'Dictate your question'}
onClick={dictation.toggle}>
{dictation.transcribing ? '…' : dictation.listening ? '■' : '🎤'}
</button>
)}
<button className="btn btn-primary" type="submit" disabled={sending || !draft.trim()}>
{sending ? 'Asking…' : 'Ask'}
</button>

View file

@ -9,6 +9,7 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import useAwayDetector from '../hooks/useAwayDetector'
import { useSessionDrawer } from '../context/SessionDrawer'
import FigureStrip from '../components/FigureStrip'
import FeedbackForm from '../components/FeedbackForm'
import ShareSession from '../components/ShareSession'
@ -412,7 +413,18 @@ export default function QuizPage() {
// The session rail is the navigator whenever there is room for it; the
// dropdown only exists for screens too narrow to show it. Matches the
// 1150px breakpoint in QuizPlayer.css that hides the rail.
const sessionDrawer = useSessionDrawer()
const hasRail = useMediaQuery('(min-width: 1151px)')
// The site's burger opens this session's questions while there is no rail.
// Only while there is no rail: on a desktop the list is already beside the
// question, and taking the button over there would hide the site menu for
// no reason.
const { register: registerDrawer } = sessionDrawer
useEffect(() => {
if (hasRail) return undefined
return registerDrawer(() => { setDrawerTab('questions'); setNavOpen(true) })
}, [hasRail, registerDrawer])
const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null)
@ -1227,11 +1239,15 @@ const timerStarted = timeLeft !== null
)}
{isLast ? (
// The end of the block. The dialog it opens names how many are still
// unanswered and that they count against you, which is the warning
// worth giving before anything is handed in.
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
{isStudy ? 'Review & Complete' : 'End block'}
// The end of the block. In an exam the dialog names how many are still
// unanswered before anything is handed in the warning worth giving.
// A study session with everything answered has nothing left to warn
// about, so it finishes rather than asking twice.
<button className="btn btn-primary" disabled={submitting}
onClick={() => (isStudy && answeredCount >= totalCount
? handleSubmit(false)
: setShowReview(true))}>
{isStudy ? 'Finish session' : 'End block'}
</button>
) : (
/* Skip, when nothing has been chosen. Moving on from a question you
@ -1417,8 +1433,10 @@ const timerStarted = timeLeft !== null
<div className="quiz-drawer" onClick={e => e.target === e.currentTarget && setNavOpen(false)}>
<div className="quiz-drawer-panel" role="dialog" aria-modal="true" aria-label="Session">
<div className="quiz-drawer-head">
{/* The same burger that opened it closes it: on a phone this
drawer is what that button does while a session is open. */}
<button type="button" className="quiz-drawer-close" aria-label="Close"
onClick={() => setNavOpen(false)}></button>
onClick={() => setNavOpen(false)}></button>
<div className="quiz-drawer-tabs" role="tablist">
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
onClick={() => setDrawerTab('menu')}>Main menu</button>
@ -1430,17 +1448,35 @@ const timerStarted = timeLeft !== null
{drawerTab === 'questions' ? (
<>
<div className="quiz-drawer-title">
<strong>{quiz.title}</strong>
{/* Everything answered means there is nothing left to sit;
what you are doing now is reading it back. */}
{answeredCount >= totalCount && totalCount > 0 && (
<span className="quiz-drawer-badge">Review</span>
)}
<strong>{isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title}</strong>
<small>{answeredCount}/{totalCount}</small>
<span className="quiz-drawer-bar" aria-hidden="true">
<span style={{ width: `${totalCount ? (answeredCount / totalCount) * 100 : 0}%` }} />
</span>
</div>
<div className="quiz-rail-list quiz-drawer-list">
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
</div>
{/* How long this is taking, where it is being read the same
two figures the desktop shows beside the explanation. */}
<div className="quiz-drawer-foot">
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
answered={answeredCount} paused={clockPaused}
onTogglePause={() => setClockPaused(v => !v)} />
</div>
</>
) : (
<nav className="quiz-drawer-menu" aria-label="Main menu">
{/* Qbank pointed at /questions, which has never been a route
/questions/:id is the editor. It went nowhere. */}
{[
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/questions', 'Qbank'],
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/question-bank', 'Qbank'],
['/collections', 'Collections'], ['/ai', 'AI Mode'],
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
['/flashcards', 'Cards'], ['/settings', 'Settings'],
].map(([to, label]) => (
@ -1473,7 +1509,10 @@ const timerStarted = timeLeft !== null
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}> <span>Shortcuts</span></button>
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}> <span>Calculator</span></button>
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}> <span>Lab values</span></button>
<button type="button" className="quiz-review-button" onClick={() => setShowReview(true)}>Review & Complete</button>
{!isStudy && (
<button type="button" className="quiz-review-button"
onClick={() => setShowReview(true)}>Review & Complete</button>
)}
<button type="button" aria-label="Previous question" disabled={currentIdx === 0} onClick={() => safeNavigate(currentIdx - 1)}></button>
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next </button>
</div>
@ -1821,7 +1860,7 @@ const timerStarted = timeLeft !== null
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginRight: 4 }} />{answeredCount} answered</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--border)', marginRight: 4 }} />{totalCount - answeredCount} remaining</span>
</div>
{answeredCount > 0 && !isLast && (
{!isStudy && answeredCount > 0 && !isLast && (
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
onClick={() => setShowReview(true)} disabled={submitting}>
Review & Complete
@ -1838,7 +1877,12 @@ const timerStarted = timeLeft !== null
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => leaveNow()}>Exit session</button>
{quizNavigation('bottom')}
{answeredCount > 0 && (
{/* Reviewing a block before handing it in is an exam idea. A study
session has nothing to hand in: it keeps going until every
question is answered, and at that point it is the review. This
button sat in both because the study player inherited the exam
player's bar. */}
{!isStudy && answeredCount > 0 && (
<button className="btn btn-secondary btn-sm quiz-review-link"
onClick={() => setShowReview(true)} disabled={submitting}>
Review ({answeredCount}/{totalCount})

View file

@ -532,6 +532,32 @@ describe('quiz player', () => {
}
})
it('a study session offers no review to hand in', async () => {
await begin()
fireEvent.keyDown(window, { key: '1' })
// Reviewing a block before handing it in is an exam idea. Study keeps
// going until every question is answered, and then it is the review.
expect(screen.queryByRole('button', { name: /^Review \(/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Review & Complete' })).not.toBeInTheDocument()
})
it('an exam still offers it, because a block is handed in', async () => {
await begin(false)
fireEvent.keyDown(window, { key: '1' })
expect(screen.getByRole('button', { name: /^Review \(1\/2\)/ })).toBeInTheDocument()
})
it('a finished study session submits without asking twice', async () => {
await begin()
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
await findStem('Full second clinical question.')
fireEvent.keyDown(window, { key: '1' })
// Everything answered: there is nothing left to warn about.
await userEvent.click(screen.getAllByRole('button', { name: 'Finish session' })[0])
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
})
it('starts timed quizzes in exam mode without a mode prompt', async () => {
quizModeVar = 'timed'
await begin(false)

View file

@ -406,11 +406,29 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
.quiz-drawer-tabs button[aria-selected="true"] { background: var(--option-sel-bg); color: var(--primary); }
.quiz-drawer-title {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 12px 14px 8px; flex: none;
display: grid; grid-template-columns: 1fr auto; align-items: baseline;
gap: 4px 10px; padding: 12px 14px 10px; flex: none;
}
.quiz-drawer-title strong { font-size: 0.9rem; font-weight: 650; }
.quiz-drawer-title small { font-size: 0.78rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
/* Nothing left to sit: what you are doing now is reading it back. */
.quiz-drawer-badge {
grid-column: 1 / -1; justify-self: start;
padding: 3px 10px; margin-bottom: 2px; border-radius: 20px;
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
background: var(--bg); color: var(--text-muted); border: 1px solid var(--border);
}
.quiz-drawer-bar {
grid-column: 1 / -1; height: 3px; margin-top: 6px; border-radius: 2px;
background: var(--border); overflow: hidden;
}
.quiz-drawer-bar span { display: block; height: 100%; background: var(--primary); }
/* How long this is taking, pinned under the list rather than scrolling with it. */
.quiz-drawer-foot {
flex: none; padding: 10px 14px; border-top: 1px solid var(--border);
}
.quiz-drawer-foot .quiz-clock { flex-wrap: wrap; }
/* The list is the only thing that scrolls. */
.quiz-drawer-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }