import { optionLetter } from '../utils/options' import { uploadUrl } from '../utils/uploads' import QuestionReadingLinks from '../components/QuestionReadingLinks' import Difficulty from '../components/Difficulty' import { useState, useEffect, useRef, useCallback, Suspense } from 'react' import lazyPage from '../utils/lazyPage' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import RichText from '../components/RichText' import { mergeTextRanges } from '../utils/highlightOffsets' import { useAuth } from '../context/AuthContext' import api from '../api/client' import useMediaQuery from '../hooks/useMediaQuery' import { useClaimSessionChrome } from '../context/SessionChrome' 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' import MoreMenu from '../components/MoreMenu' import '../components/Feedback.css' import QuizTools, { QuizDialog, LabValues } from '../components/QuizTools' import './QuizPlayer.css' //: Seconds left when the block says so. Long enough to finish the question in //: front of you and go back for one more; short enough to mean something. const FIVE_MINUTES = 300 const TeachChat = lazyPage(() => import('../components/TeachChat')) const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] const QUESTION_HIGHLIGHT_WORDS = 8 const OPTION_HIGHLIGHT_WORDS = 7 const TTS_PRELOAD_AHEAD = 5 function removeTextRange(ranges, removeRange) { const next = [] ranges.forEach(range => { if (removeRange.end <= range.start || removeRange.start >= range.end) { next.push(range) return } if (removeRange.start > range.start) next.push({ start: range.start, end: removeRange.start }) if (removeRange.end < range.end) next.push({ start: removeRange.end, end: range.end }) }) return mergeTextRanges(next) } function getSpeechChunkRange(text, maxWords, activeChunk) { if (activeChunk === null || activeChunk === undefined) return null const words = [] const re = /\S+/g let match while ((match = re.exec(text || ''))) words.push({ start: match.index, end: match.index + match[0].length }) const startWord = activeChunk * maxWords if (!words[startWord]) return null const endWord = Math.min(startWord + maxWords - 1, words.length - 1) return { start: words[startWord].start, end: words[endWord].end } } function getManualHighlightSelection(selection = window.getSelection?.()) { if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null const offsetFromNode = (node, offset) => { const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node const span = element?.closest?.('[data-manual-highlight-id]') if (!span) return null let charOffset = offset if (node.nodeType !== Node.TEXT_NODE) { charOffset = offset <= 0 ? 0 : Number(span.dataset.end || span.dataset.start || 0) - Number(span.dataset.start || 0) } return { id: span.dataset.manualHighlightId, offset: Number(span.dataset.start || 0) + charOffset, } } const range = selection.getRangeAt(0) const start = offsetFromNode(range.startContainer, range.startOffset) const end = offsetFromNode(range.endContainer, range.endOffset) if (!start || !end || start.id !== end.id) return null const ordered = start.offset <= end.offset ? { start: start.offset, end: end.offset } : { start: end.offset, end: start.offset } if (ordered.end <= ordered.start) return null return { id: start.id, ...ordered } } function splitSpeechChunks(text, maxWords) { const words = (text || '').trim().split(/\s+/).filter(Boolean) if (!words.length) return [] const chunks = [] for (let i = 0; i < words.length; i += maxWords) { chunks.push(words.slice(i, i + maxWords).join(' ')) } return chunks } function questionStem(question) { return (question?.question_text || '').replace('[IMAGE]', '').trim() } function getQuestionSpeechSegments(question, index) { if (!question) return [] const segments = [] splitSpeechChunks(questionStem(question), QUESTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => { segments.push({ type: 'question', chunkIndex, text: chunkIndex === 0 ? `Question ${index + 1}. ${chunk}` : chunk, }) }) if (question.options?.length) segments.push({ type: 'meta', text: 'Options.' }) ;(question.options || []).forEach((option, i) => { splitSpeechChunks(option, OPTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => { segments.push({ type: 'option', index: i, chunkIndex, text: chunkIndex === 0 ? `${OPTION_LETTERS[i] || i + 1}. ${chunk}` : chunk, }) }) }) return segments } function buildQuestionSpeechText(question, index) { const segments = getQuestionSpeechSegments(question, index) return segments.map(s => s.text).join(' ') } function getActiveSpeechSegment(audio, segments) { if (!segments.length) return null if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0 const weights = segments.map(segment => Math.max(12, segment.text.length)) const total = weights.reduce((sum, weight) => sum + weight, 0) const target = (audio.currentTime / audio.duration) * total let cursor = 0 for (let i = 0; i < weights.length; i += 1) { cursor += weights[i] if (target <= cursor) return i } return segments.length - 1 } function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange, getAudio, autoPlay, onEnded }) { const [state, setState] = useState('idle') // idle | loading | playing const audioRef = useRef(null) useEffect(() => { return () => { audioRef.current?.pause() onActiveChange?.(false) onSegmentChange?.(null) } }, []) useEffect(() => { if (autoPlay && state === 'idle') speak() }, [autoPlay]) const setStateAndNotify = (s) => { setState(s) onActiveChange?.(s !== 'idle') if (s === 'idle') onSegmentChange?.(null) } const speak = async () => { if (state === 'playing') { audioRef.current?.pause() setStateAndNotify('idle') return } if (state === 'loading') return try { setStateAndNotify('loading') let url let revokeOnDone = false if (getAudio) { url = (await getAudio(text, voice))?.url } else { const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' }) url = URL.createObjectURL(res.data) revokeOnDone = true } if (!url) throw new Error('No audio returned') const audio = new Audio(url) audioRef.current = audio const updateSegment = () => { const activeIndex = getActiveSpeechSegment(audio, segments) onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null) } audio.onloadedmetadata = updateSegment audio.ontimeupdate = updateSegment audio.onended = () => { setStateAndNotify('idle'); onEnded?.(); if (revokeOnDone) URL.revokeObjectURL(url) } audio.onerror = () => { setStateAndNotify('idle'); if (revokeOnDone) URL.revokeObjectURL(url) } await audio.play() setStateAndNotify('playing') updateSegment() } catch { setStateAndNotify('idle') } } const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : 'πŸ”Š Listen' const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff' const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3' return ( ) } const clock = (seconds) => { const s = Math.max(0, Math.round(seconds)) return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` } // Hours as well as minutes, for the one figure a candidate looks at most. A // block clock reading 89:00 is a different amount of time depending on how // long you thought the block was, and it is read at a glance. const blockClock = (seconds) => { const s = Math.max(0, Math.round(seconds)) return [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60] .map(part => String(part).padStart(2, '0')).join(':') } /** * Session time, time on this question, and the running average. * * Shown in study mode as well as exam mode: knowing you have spent four minutes * on one question is exactly as useful when nothing is counting down, and it is * the number that tells you whether you are learning or stuck. It can be paused, * because time spent making tea is not time spent thinking. */ function SessionClock({ sessionSeconds, questionSeconds, answered, paused, onTogglePause }) { const average = answered > 0 ? sessionSeconds / answered : null return (
{Math.floor(sessionSeconds / 3600)}h {String(Math.floor((sessionSeconds % 3600) / 60)).padStart(2, '0')}m session {clock(questionSeconds)} question {average != null && ( {clock(average)} average )} {paused && paused}
) } function TimerDisplay({ seconds, total }) { const pct = total > 0 ? (seconds / total) * 100 : 100 const mins = Math.floor(seconds / 60), secs = seconds % 60 const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444' return (
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
) } /** The player's β‹― menu: the shared one, keeping its own look. */ function MoreActions({ children }) { return ( {children} ) } // Mode selection prompt removed: general quizzes start in their own mode automatically. function getQuizSessionId() { const key = 'pedshub_quiz_session_id' try { const existing = localStorage.getItem(key) if (existing) return existing const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}` localStorage.setItem(key, created) return created } catch { return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}` } } // Stable per-device session ID lets the same app/webview resume after restart. const SESSION_ID = getQuizSessionId() export default function QuizPage() { const { id } = useParams() const navigate = useNavigate() // True when we are showing the session rather than sitting it. const [showOverview, setShowOverview] = useState(false) const [searchParams] = useSearchParams() const restartRequested = searchParams.get('restart') === '1' const { user } = useAuth() const isModerator = user?.role === 'admin' || user?.role === 'moderator' const [quiz, setQuiz] = useState(null) const [voices, setVoices] = useState([]) const [selectedVoice, setSelectedVoice] = useState('') const [ttsActive, setTtsActive] = useState(false) const [readThrough, setReadThrough] = useState(false) const [quizMode, setQuizMode] = useState(null) const [answers, setAnswers] = useState({}) const [currentIdx, setCurrentIdx] = useState(0) const [loading, setLoading] = useState(true) const [starting, setStarting] = useState(false) const [submitting, setSubmitting] = useState(false) const [attemptId, setAttemptId] = useState(null) const [timeLeft, setTimeLeft] = useState(null) const [totalTime, setTotalTime] = useState(null) const [toast, setToast] = useState('') const [navOpen, setNavOpen] = useState(false) const navOpenRef = useRef(false) useEffect(() => { navOpenRef.current = navOpen }) const [drawerTab, setDrawerTab] = useState('questions') // 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)') // Only once a session is actually being sat. The screen that asks // whether to start one is an ordinary card and wants the ordinary page. useClaimSessionChrome(!!quizMode) // 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 // Toggling, so the button that opened the drawer also shuts it. Read // through a ref rather than closed over, so the registration does not have // to be torn down and remade every time the drawer moves. return registerDrawer(() => { if (navOpenRef.current) { setNavOpen(false); return } setDrawerTab('questions') setNavOpen(true) }) }, [hasRail, registerDrawer]) const [expandedImagePath, setExpandedImagePath] = useState('') const [imageZoom, setImageZoom] = useState(1) const [startedAt, setStartedAt] = useState(null) // Wall-clock seconds spent in this session and on the question in front of // you. Kept here rather than derived from startedAt so pausing can stop them. const [sessionSeconds, setSessionSeconds] = useState(0) const [questionSeconds, setQuestionSeconds] = useState(0) const [clockPaused, setClockPaused] = useState(false) // Asked before the session closes. Leaving is not destructive β€” the answers // are saved and it can be resumed β€” but it is not what a mis-aimed thumb // should do either. const [leaving, setLeaving] = useState(false) // Said once, when five minutes are left. A block that ends without warning // ends on whatever question you happened to be reading. const [fiveLeft, setFiveLeft] = useState(false) const warnedRef = useRef(false) // The clock ran out: the answers are in, and the analysis waits behind an // acknowledgement rather than replacing the exam without a word. const [timeUp, setTimeUp] = useState(false) const timeUpRef = useRef(false) // The attempt is closed: it was handed in, or the clock ran out and it was // handed in for you. This is what "finished" means. It used to be read off // the answer count, which called a block finished only once every question // had an answer β€” an exam that ran out with nothing answered is just as // over, and that was the one case the old reading got wrong. const [attemptClosed, setAttemptClosed] = useState(false) // Seconds spent on each question, banked when you leave it. Without this the // analysis can report a total but never a per-question time. const [questionTimes, setQuestionTimes] = useState({}) // Questions where a tip was opened before the answer went in. Right after a // nudge is still right β€” it is counted as correct β€” but it is not the same // as right, and the analysis says which. const [hints, setHints] = useState([]) const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) const [tool, setTool] = useState(null) // Labs sit beside the question rather than over it: a reference range is // read while re-reading the case, and a dialog covers the thing it is for. const [labsOpen, setLabsOpen] = useState(false) // The rail is a sidebar, not furniture: on a long stem the question wants // the width, and the list is still one press away. Remembered, because // somebody who put it away meant it. const [railOpen, setRailOpen] = useState(() => { try { return localStorage.getItem('pedshub.quizRail') !== 'closed' } catch { return true } }) useEffect(() => { try { localStorage.setItem('pedshub.quizRail', railOpen ? 'open' : 'closed') } catch { /* private browsing */ } }, [railOpen]) // Which of the per-question panels is open. One at a time: they sit in the // same place under the stem, and two at once would push the options off screen. const [panel, setPanel] = useState(null) const [note, setNote] = useState('') const [noteSaved, setNoteSaved] = useState(true) const [folders, setFolders] = useState([]) // Searching the folders you have and naming one you do not, in the same box. const [folderQuery, setFolderQuery] = useState('') const [folderBusy, setFolderBusy] = useState(false) const [showReview, setShowReview] = useState(false) const [responseStats, setResponseStats] = useState(null) const [statsError, setStatsError] = useState('') const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0') const [showAllExplanations, setShowAllExplanations] = useState(false) // Questions whose answer was asked for rather than given. Being shown the // answer is not answering: these stay out of `answers`, so the rail, the // count and what is handed in all still say the question is unanswered. const [revealed, setRevealed] = useState(() => new Set()) // Which options have had their reasoning opened by clicking them. Separate // from the show-all toggle so one does not fight the other. const [openExplanations, setOpenExplanations] = useState(() => new Set()) const toggleOptionExplanation = (index) => setOpenExplanations(prev => { const next = new Set(prev) if (next.has(index)) next.delete(index) else next.add(index) return next }) const toggleStats = () => setShowStats(value => { localStorage.setItem('pedshub_show_stats', value ? '0' : '1') return !value }) const [submitError, setSubmitError] = useState('') const [resumeError, setResumeError] = useState('') const [resumeRetry, setResumeRetry] = useState(0) const [progressError, setProgressError] = useState('') const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) // Indexes the learner has opened, so the rail reveals text gradually. const [seenIndexes, setSeenIndexes] = useState(() => new Set([0])) const ttsCacheRef = useRef(new Map()) const autoAdvanceRef = useRef(null) const savedHighlightSelectionRef = useRef(null) const autoHighlightTimerRef = useRef(null) const showToast = (msg) => { setToast(msg) clearTimeout(toastRef.current) toastRef.current = setTimeout(() => setToast(''), 3000) } const adjustImageZoom = (delta) => { setImageZoom(z => Math.max(1, Math.min(4, z + delta))) } useEffect(() => { try { const saved = localStorage.getItem(`quiz-highlights:${id}`) setManualHighlights(saved ? JSON.parse(saved) : {}) } catch { setManualHighlights({}) } }, [id]) useEffect(() => { try { localStorage.setItem(`quiz-highlights:${id}`, JSON.stringify(manualHighlights)) } catch { } }, [id, manualHighlights]) // Suspending is not abandoning: it ends on the session's own analysis, where // what has been answered so far is scored and the Resume button sits. //: Withheld until the server says otherwise. Defaulting to allowed made the //: tutor's button appear for a moment on every session and then vanish on a //: site that has it switched off, which reads as a bug rather than a policy. const [tutorAllowed, setTutorAllowed] = useState(false) useEffect(() => { let live = true api.get('/teach/policy') .then(res => { if (live) setTutorAllowed(res.data?.in_quiz !== false) }) .catch(() => {}) return () => { live = false } }, []) const exitTarget = () => (attemptId ? `/sessions/${attemptId}` : '/') const questions = quiz?.questions || [] // ?q=3 means "open on question 3". The analytics table links here that way: // clicking a row in a session you have not finished should put you on that // question, ready to answer it, rather than back at the start. const wantedQuestion = Number(searchParams.get('q')) const jumped = useRef(false) useEffect(() => { if (jumped.current || !questions.length) return if (!Number.isInteger(wantedQuestion) || wantedQuestion < 1) return jumped.current = true setCurrentIdx(Math.min(wantedQuestion, questions.length) - 1) }, [questions.length, wantedQuestion]) const current = questions[currentIdx] const isStudy = quizMode === 'study' // Away from the desk is not time spent on the question, whether or not the // tab is still in front. Only while a session is actually being sat. const { asking: askingStillHere, away, confirmHere } = useAwayDetector({ enabled: !!attemptId && !!quizMode }) const clockStopped = clockPaused || askingStillHere || away const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => { if (!selected || !current) return const [questionKey, fieldKey] = selected.id.split('::') if (questionKey !== String(current.id)) return const existing = manualHighlights[current.id]?.[fieldKey] || [] const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end) setManualHighlights(prev => { const questionRanges = prev[current.id] || {} const currentRanges = questionRanges[fieldKey] || [] const nextRanges = removeExisting ? removeTextRange(currentRanges, selected) : mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }]) const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges } if (!nextRanges.length) delete nextQuestion[fieldKey] const next = { ...prev, [current.id]: nextQuestion } if (!Object.keys(nextQuestion).length) delete next[current.id] return next }) window.getSelection?.().removeAllRanges() savedHighlightSelectionRef.current = null }, [current?.id, manualHighlights]) const captureHighlightSelection = useCallback(() => { const selected = getManualHighlightSelection() if (!selected || !current) return const [questionKey] = selected.id.split('::') if (questionKey !== String(current.id)) return savedHighlightSelectionRef.current = selected clearTimeout(autoHighlightTimerRef.current) autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450) }, [current?.id, applyManualHighlightSelection]) useEffect(() => { document.addEventListener('selectionchange', captureHighlightSelection) document.addEventListener('mouseup', captureHighlightSelection) document.addEventListener('touchend', captureHighlightSelection) return () => { clearTimeout(autoHighlightTimerRef.current) document.removeEventListener('selectionchange', captureHighlightSelection) document.removeEventListener('mouseup', captureHighlightSelection) document.removeEventListener('touchend', captureHighlightSelection) } }, [captureHighlightSelection]) const fetchTtsAudio = useCallback(async (text, voice) => { const cleanText = (text || '').trim() if (!cleanText) return null const key = `${voice || 'default'}::${cleanText}` const cached = ttsCacheRef.current.get(key) if (cached?.url) return cached if (cached?.promise) return cached.promise const promise = api.post('/tts/speak', { text: cleanText, voice: voice || null }, { responseType: 'blob' }) .then(res => { const entry = { url: URL.createObjectURL(res.data) } ttsCacheRef.current.set(key, entry) return entry }) .catch(err => { if (ttsCacheRef.current.get(key)?.promise === promise) ttsCacheRef.current.delete(key) throw err }) ttsCacheRef.current.set(key, { promise }) return promise }, []) useEffect(() => { return () => { clearTimeout(autoAdvanceRef.current) ttsCacheRef.current.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url) }) ttsCacheRef.current.clear() } }, []) useEffect(() => { setActiveReadSegment(null) setTtsActive(false) setTyped('') setOpenExplanations(new Set()) savedHighlightSelectionRef.current = null clearTimeout(autoHighlightTimerRef.current) if (!readThrough) setActiveReadSegment(null) clearTimeout(autoAdvanceRef.current) }, [currentIdx, readThrough]) useEffect(() => { if (!quizMode || !questions.length || !voices.length) return for (let index = currentIdx; index <= Math.min(currentIdx + TTS_PRELOAD_AHEAD, questions.length - 1); index += 1) { const q = questions[index] if (!q) return fetchTtsAudio(buildQuestionSpeechText(q, index), selectedVoice).catch(() => {}) } }, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio]) const resumeQuiz = useCallback(async (saved, availableVoices = []) => { const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0 const savedAnswers = saved.answers || {} const aid = saved.attempt_id || saved.attemptId if (!aid) return const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}`) const mode = quizRes.data.attempt_mode === 'study' ? 'study' : 'exam' setQuiz(quizRes.data) hasStarted.current = true setQuizMode(mode) setAnswers(savedAnswers) // A tip read before the break was still read; closing the tab does not // unsee it. setHints((saved.hints || []).map(Number).filter(Number.isFinite)) setCurrentIdx(savedIdx) setAttemptId(saved.attempt_id || saved.attemptId) if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice) if (saved.started_at) setStartedAt(saved.started_at) // What the player last saved, not what a wall clock would have spent. The // exam's clock runs only while the exam is on screen, so an afternoon away // from the tab is not an afternoon of the block β€” computing it from // `started_at` charged for exactly that, and disagreed with the server, // which has read `time_left` first for some time now. if (saved.total_time) { const held = saved.time_left const remaining = held != null ? Math.max(0, Number(held)) : Math.max(0, saved.total_time - Math.floor((new Date() - new Date(saved.started_at)) / 1000)) setTimeLeft(remaining) setTotalTime(saved.total_time) } }, [id]) // Warn before tab/window close when mid-quiz useEffect(() => { if (!attemptId) return const msg = progressError || (timeLeft !== null ? 'This exam is timed. Your answers are saved, and the clock stops while the exam is off screen β€” nothing is handed in until you do it.' : 'You have an in-progress quiz. Progress is saved while connected.') const handler = (e) => { e.preventDefault(); e.returnValue = msg } window.addEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler) }, [attemptId, timeLeft, progressError]) useEffect(() => { const load = async () => { setLoading(true) setResumeError('') try { const [quizRes, voicesRes, favoritesRes] = await Promise.all([ api.get(`/quizzes/${id}`), api.get('/tts/voices').catch(() => ({ data: [] })), api.get('/favorites').catch(() => ({ data: [] })), ]) setQuiz(quizRes.data) setVoices(voicesRes.data) setFavorites(favoritesRes.data) const def = voicesRes.data.find(v => v.is_default) if (def) setSelectedVoice(def.id) // Check for saved progress and auto-resume; otherwise start straight away // in the quiz's own mode β€” no second mode prompt. try { // ?restart=1 (Repeat from the session list) always begins a new attempt. if (restartRequested) { await startAttempt(quizRes.data.mode === 'timed' ? 'exam' : 'study', null, null, true) return } const progressRes = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID }, }) if (progressRes.data) { await resumeQuiz(progressRes.data, voicesRes.data) return } // Nothing saved and nothing asked for: show the session rather than // launching it. Opening a link should not commit you to a // 243-question exam before you have seen what it is. if (quizRes.data && !searchParams.get('start')) { setShowOverview(true) } else if (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.') } } catch { navigate('/') } finally { setLoading(false) } } load() return () => clearInterval(timerRef.current) }, [id, resumeRetry, restartRequested]) const startAttempt = async (mode, voice, timerMinutes = null, fresh = false) => { hasStarted.current = true setSelectedVoice(voice) setStarting(true) // DON'T set quizMode yet β€” wait for data to load first // (prevents mobile race condition where quiz renders without correct_answer) try { // Start attempt first (may select random question subset) const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}${fresh ? '&fresh=true' : ''}`) mode = attemptRes.data.mode || mode setAttemptId(attemptRes.data.id) const aid = attemptRes.data.id // A reused attempt may have newer progress from another tab/device. const saved = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID } }) if (saved.data) { await resumeQuiz(saved.data, voices) return } // Fetch quiz with attempt_id for question pool filtering let quizData = quiz const studyParam = mode === 'study' ? '&study=true' : '' const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`) quizData = quizRes.data setQuiz(quizData) const mins = timerMinutes || quizData.time_limit_minutes const now = new Date().toISOString() setStartedAt(now) if (mode === 'exam' && mins) { const secs = mins * 60 setTimeLeft(secs); setTotalTime(secs) } // NOW set mode β€” quiz data is fully loaded, safe to render setQuizMode(mode) api.post('/attempts/progress', { quiz_id: parseInt(id), attempt_id: aid, answers: {}, current_idx: 0, mode, voice: voice || null, time_left: mode === 'exam' && mins ? mins * 60 : null, started_at: now, total_time: mode === 'exam' && mins ? mins * 60 : null, }, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.')) } catch (err) { hasStarted.current = false throw err } finally { setStarting(false) } } const startQuiz = async (mode, voice, timerMinutes = null) => { if (hasStarted.current || resumeError || loading) return return startAttempt(mode, voice, timerMinutes) } useEffect(() => { setSeenIndexes(prev => (prev.has(currentIdx) ? prev : new Set(prev).add(currentIdx))) }, [currentIdx]) const timerStarted = timeLeft !== null /** * The exam clock runs only while the exam is on screen. * * It used to tick on a wall clock, so an hour away from the tab spent an * hour of the exam on questions you were never shown. Time you were not * given the questions for is not time you used β€” and it is what makes the * per-question figures mean anything at all. */ useEffect(() => { if (!timerStarted) return undefined const start = () => { clearInterval(timerRef.current) if (document.hidden || clockStopped) return timerRef.current = setInterval(() => { setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } if (t - 1 <= FIVE_MINUTES && !warnedRef.current) { warnedRef.current = true setFiveLeft(true) } return t - 1 }) }, 1000) } start() document.addEventListener('visibilitychange', start) return () => { clearInterval(timerRef.current) document.removeEventListener('visibilitychange', start) } }, [timerStarted, clockStopped]) // A warning with time to act on it. Said once, at five minutes: an exam that // ends without notice is a scramble, and one that nags is a distraction. const warnedAt = useRef(null) useEffect(() => { if (timeLeft === null || warnedAt.current) return if (timeLeft > 300 || timeLeft <= 0) return warnedAt.current = true showToast('Five minutes left in this block.') }, [timeLeft]) // Nothing is handed in behind the learner's back. The clock reaching zero // stops the block and says so; the paper goes in when they close that // dialog, which is the same order a real block ends in. An exam left open // and walked away from is still open when it is picked up again β€” it shows // this the moment it is back on screen rather than having been marked in the // night by something the learner never saw. useEffect(() => { if (timeLeft !== 0) return timeUpRef.current = true setTimeUp(true) }, [timeLeft]) /** * A closed block is served with its answers; an open one never is. * * The exam player is sent questions with no correct option and no * explanation β€” that is the integrity rule, and it is enforced on the server * rather than by hiding what the page already holds. So review cannot simply * un-hide anything: once the attempt is completed the same request returns * the marked version, and this asks for it. A study session was served the * answers at the start, so there is nothing here for it to fetch. */ useEffect(() => { if (!attemptClosed || !attemptId) return undefined if (questions.some(question => question.correct_answer)) return undefined let live = true api.get(`/quizzes/${id}?attempt_id=${attemptId}`) .then(res => { if (live && res.data) setQuiz(res.data) }) .catch(() => { /* The unmarked questions stay on screen, which is honest */ }) return () => { live = false } }, [attemptClosed, attemptId, id]) const saveProgressNow = useCallback((overrides = {}) => { if (!attemptId || !quizMode) return Promise.resolve() return api.post('/attempts/progress', { quiz_id: parseInt(id), attempt_id: attemptId, answers, hints, current_idx: currentIdx, mode: quizMode, voice: selectedVoice || null, time_left: timeLeft, started_at: startedAt, total_time: totalTime, ...overrides, }, { headers: { 'x-quiz-session': SESSION_ID } }) .then(() => setProgressError('')) .catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.')) }, [id, answers, hints, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime]) // Save progress to Redis (survives logout/browser change) const saveProgressRef = useRef(null) useEffect(() => { if (!attemptId || !quizMode) return clearTimeout(saveProgressRef.current) saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500) return () => clearTimeout(saveProgressRef.current) }, [saveProgressNow, attemptId, quizMode]) useEffect(() => { if (!attemptId || !quizMode) return const flush = () => { saveProgressNow() } const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() } window.addEventListener('pagehide', flush) document.addEventListener('visibilitychange', flushWhenHidden) return () => { window.removeEventListener('pagehide', flush) document.removeEventListener('visibilitychange', flushWhenHidden) } }, [attemptId, quizMode, saveProgressNow]) // Same rule for the session and per-question clocks: away from the screen is // not time spent on the question. useEffect(() => { if (clockStopped || !attemptId) return undefined let tick = null const start = () => { clearInterval(tick) if (document.hidden) return tick = setInterval(() => { setSessionSeconds(v => v + 1) setQuestionSeconds(v => v + 1) }, 1000) } start() document.addEventListener('visibilitychange', start) return () => { clearInterval(tick) document.removeEventListener('visibilitychange', start) } }, [clockStopped, attemptId]) // Bank the time on the question you are leaving, then start the next at zero. const leavingRef = useRef({ id: null, seconds: 0 }) leavingRef.current = { id: current?.id, seconds: questionSeconds } useEffect(() => { const { id, seconds } = leavingRef.current return () => { if (id && seconds > 0) { setQuestionTimes(prev => ({ ...prev, [id]: (prev[id] || 0) + seconds })) } } }, [current?.id]) useEffect(() => { setQuestionSeconds(0) }, [current?.id]) useEffect(() => { if (!current?.id) return let live = true setPanel(null) api.get(`/questions/detail/${current.id}/note`) .then(res => { if (live) { setNote(res.data?.content || ''); setNoteSaved(true) } }) .catch(() => { if (live) { setNote(''); setNoteSaved(true) } }) return () => { live = false } }, [current?.id]) useEffect(() => { api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([])) }, []) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) // Reset one question rather than the whole attempt: a misclick should cost // the answer you just gave, not the nineteen before it. A question you only // asked to see the answer to is put back the same way, because otherwise one // press would close it for the rest of the session. const resetQuestion = (questionId) => { setAnswers(prev => { const next = { ...prev } delete next[questionId] return next }) setRevealed(prev => { if (!prev.has(questionId)) return prev const next = new Set(prev) next.delete(questionId) return next }) setTyped('') } /** * Show me the answer. * * The other direction from resetQuestion, and the same shape: it opens one * question's answer where that one closes it again. Until now the only way * to read an explanation was to choose an option, so a learner who was stuck * had to guess first β€” and a guess entered to unlock the explanation is a * wrong answer in the score, in the rail and in every figure the analysis * draws afterwards. Asking is not answering, so nothing is recorded. */ const revealAnswer = (questionId) => { setRevealed(prev => { if (prev.has(questionId)) return prev const next = new Set(prev) next.add(questionId) return next }) setTyped('') } const saveNote = async (questionId, content) => { setNoteSaved(false) try { await api.put(`/questions/detail/${questionId}/note`, { content }) setNoteSaved(true) } catch { setNoteSaved(false) } } const saveToFolder = async (collectionId, questionId) => { try { await api.put(`/collections/${collectionId}/questions/${questionId}`) setFolders(list => list.map(f => (f.id === collectionId ? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f))) } catch { /* the row stays unticked, which is the honest signal */ } } /** * Make the folder and put the question in it, in one press. * * The panel used to say "make one in the question bank" and leave you to go * and do it β€” which means leaving the question you were reading, and coming * back to find your place. Naming a folder is the whole of making one. */ const createFolderWith = async (title, questionId) => { const name = title.trim() if (!name) return setFolderBusy(true) try { const made = await api.post('/collections/', { title: name }) await api.put(`/collections/${made.data.id}/questions/${questionId}`) setFolders(list => [...list, { ...made.data, question_ids: [questionId] }]) setFolderQuery('') } catch { /* nothing is added, and the field keeps what was typed */ } finally { setFolderBusy(false) } } /** * Choosing is answering. * * Study mode used to hold the choice as a draft and wait for "Submit * response" β€” a second press to confirm something you had already decided, * on every question. Clicking an option marks it: green if it was right, red * if it was not, with the explanation. Exam mode records it and moves on * when you do. * * A question whose answer is already on screen takes no more answers: after * the block is closed, and after Show answer, there is nothing left to * decide and anything recorded now would be a copy rather than a response. */ const chooseAnswer = value => { if (!current || attemptClosed) return if (isStudy && (answers[current.id] || revealed.has(current.id))) return setAnswer(current.id, value) } /** * Options ruled out. * * Striking one through is how anybody actually works a five-option question: * eliminate, then choose among what is left. It is working-out rather than * an answer, so it lives in the page and is not saved β€” it should not follow * you into another sitting of the same question. */ const [ruledOut, setRuledOut] = useState({}) const toggleRuledOut = (index) => setRuledOut(prev => { const forQuestion = new Set(prev[current.id] || []) if (forQuestion.has(index)) forQuestion.delete(index) else forQuestion.add(index) return { ...prev, [current.id]: [...forQuestion] } }) const isRuledOut = (index) => (ruledOut[current?.id] || []).includes(index) // Free text is the exception: clicking an option is a decision, typing is // not, so a typed answer is held until Enter or leaving the field. const [typed, setTyped] = useState('') const commitTyped = () => { if (!current || !typed.trim() || attemptClosed) return if (isStudy && (answers[current.id] || revealed.has(current.id))) return setAnswer(current.id, typed.trim()) } useEffect(() => { let active = true setResponseStats(null); setStatsError('') // What everybody else picked is worth reading whether the answer was given // or asked for β€” it is the same page of feedback either way. if (isStudy && attemptId && current && (answers[current.id] || revealed.has(current.id))) { api.get(`/study-tools/attempts/${attemptId}/questions/${current.id}/responses`) .then(r => { if (active) setResponseStats(r.data) }) .catch(() => { if (active) setStatsError('Response statistics are unavailable.') }) } return () => { active = false } }, [isStudy, attemptId, current?.id, answers[current?.id], revealed.has(current?.id)]) const clearCurrentHighlights = () => { if (!current || !manualHighlights[current.id]) return setManualHighlights(prev => { const next = { ...prev } delete next[current.id] return next }) } const removeJoinedHighlight = (textId, offset) => { if (!current) return const [questionKey, fieldKey] = textId.split('::') if (questionKey !== String(current.id)) return const existing = manualHighlights[current.id]?.[fieldKey] || [] const joined = existing.find(range => offset >= range.start && offset < range.end) if (!joined) return setManualHighlights(prev => { const questionRanges = prev[current.id] || {} const nextRanges = (questionRanges[fieldKey] || []).filter(range => range.start !== joined.start || range.end !== joined.end) const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges } if (!nextRanges.length) delete nextQuestion[fieldKey] const next = { ...prev, [current.id]: nextQuestion } if (!Object.keys(nextQuestion).length) delete next[current.id] return next }) } const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || [] // Only before the answer is in: opening a tip while reading the explanation // is revision, and docking it would punish looking things up afterwards. const noteHint = useCallback(() => { const qid = current?.id if (!qid || answers[qid]) return setHints(prev => (prev.includes(qid) ? prev : [...prev, qid])) }, [current?.id, answers]) const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim()) const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => { if (!keepReadThrough) setReadThrough(false) setCurrentIdx(targetIdx) } const handleSubmit = useCallback(async (autoSubmit = false) => { if (!attemptId || submitting) return if (!autoSubmit && Object.keys(answers).length === 0) { showToast('No answers selected β€” submitting with 0 answered.') await new Promise(r => setTimeout(r, 1200)) } setSubmitting(true) setSubmitError('') try { const submission = { answers: Object.entries(answers).map(([qid, answer]) => ({ question_id: parseInt(qid), user_answer: answer, })), // The question still open has not been banked yet; without it the last // question of every session would report no time at all. timings: { ...questionTimes, ...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}), }, hints, } const res = await api.post(`/attempts/${attemptId}/submit`, submission) clearInterval(timerRef.current) // Handed in, so the block is closed and the player may show its answers. // Set here rather than when the clock hit zero because the server only // reveals a completed attempt, and it is not completed until this returns. setAttemptClosed(true) api.delete(`/attempts/progress/${attemptId}`).catch(() => {}) // A session ends on its analysis: score, timing and what to do next. The // answer-by-answer review is one link from there. const target = `/sessions/${attemptId}` // A block the clock ended stays where it is. It has just been marked, // the player is already showing the answers, and that is the review β€” // the same one a study session gives, on the questions still in front of // them. Throwing them onto the analysis page instead would take the // paper away at the moment it finally became readable; Exit goes there. if (timeUpRef.current) return navigate(target, { state: { result: res.data } }) } catch (err) { const detail = err.response?.data?.detail setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.') } finally { setSubmitting(false) } }, [attemptId, answers, hints, submitting, navigate, showToast]) /** * Leave. * * In study mode that means suspending: the answers are saved, the clock * pauses, and you pick it up where you left off. * * Exam mode suspends too. The clock only runs while the exam is on screen, * so leaving stops it rather than spending it β€” an exam you are not looking * at is not an exam you are sitting, and time you were not given the * questions for is not time you used. * * Either way it is one press. Nobody leaves by accident, and nothing is lost. */ const leaveNow = useCallback(async () => { if (attemptId && quizMode) { try { await api.post('/attempts/progress', { quiz_id: parseInt(id), attempt_id: attemptId, answers, hints, current_idx: currentIdx, mode: quizMode, voice: selectedVoice || null, time_left: timeLeft, started_at: startedAt, total_time: totalTime, suspended: true, }, { headers: { 'x-quiz-session': SESSION_ID } }) } catch { // Staying put is the safe failure: leaving now would lose the answers. setProgressError('Could not save before leaving. Keep this tab open and retry saving.') return } } navigate(exitTarget()) }, [attemptId, quizMode, isStudy, id, answers, currentIdx, selectedVoice, timeLeft, startedAt, totalTime, navigate, handleSubmit]) useEffect(() => { if (!quizMode || !current) return const keydown = event => { if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.('input, textarea, select, [contenteditable="true"]') || document.querySelector('dialog[open]')) return if (expandedImagePath) { if (event.key === 'Escape') { event.preventDefault(); setExpandedImagePath('') } return } if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return if (hasActiveTextSelection()) return if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1]) else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1)) else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1)) else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id) else if (event.key === ' ' && current.image_path) { setImageZoom(1); setExpandedImagePath(current.image_path) } else return event.preventDefault() } window.addEventListener('keydown', keydown) return () => window.removeEventListener('keydown', keydown) }, [quizMode, current, currentIdx, answers, favorites, expandedImagePath]) if (loading) return
Loading quiz...
if (!quiz) return null if (resumeError) return

{resumeError}

if (!quizMode) return (
{isModerator && (
✏️ Edit Questions
)} {starting ? (
Loading quiz…
) : showOverview ? (

{quiz.title}

{quiz.mode === 'timed' ? 'Exam mode' : 'Study mode'} {' Β· '}{quiz.questions_count || quiz.questions_per_attempt} questions {quiz.time_limit_minutes ? ` Β· ${quiz.time_limit_minutes} minutes` : ''}

{quiz.mode === 'timed' ? 'The clock runs only while the exam is on screen β€” leave it and it stops.' : 'Each answer is marked as you go, with the explanation.'}

Back to history
) : (
Starting…
)}
) const answeredCount = Object.keys(answers).length const totalCount = questions.length const isLast = currentIdx === totalCount - 1 // Reading the block back rather than sitting it. There is nothing left to // protect once it is closed, so a finished exam reads like study mode: the // rail says what each question was, and the answers are on the page. const reviewing = attemptClosed // Whether this question's answer is on screen β€” given, asked for with Show // answer, or open to everybody because the block is over. Category and // difficulty are hints, so they wait for it too; while an exam is still // being sat this is false for every question, which is the whole rule. const answerRevealed = reviewing || (isStudy && (!!answers[current?.id] || revealed.has(current?.id))) // The block chrome β€” item and block counters, the countdown, Pause and End // Block β€” belongs to a block being sat. Study mode never had any of it, and // a closed block has nothing left to pause or hand in. const examChrome = !isStudy && !reviewing /** * Ending the block. * * In an exam the dialog names how many items are still unanswered before * anything is handed in β€” the one warning worth giving. A study session with * everything answered has nothing left to warn about, so it finishes rather * than asking a question whose answer is already known. */ const endBlock = () => (isStudy && answeredCount >= totalCount ? handleSubmit(false) : setShowReview(true)) const quizNavigation = (position = 'bottom') => (
{/* The bar is three things: leave, back, on. The question count and the list behind it live in the rail and, on a narrow screen, behind the site's own menu button β€” repeating them here crowded the one row that has to stay legible at the foot of every question. */} {/* Skip and Next are not the same decision. Moving on from a question you have not answered is a choice, and the button says which one it is rather than calling both of them Next. In review everything is already marked, so there is nothing to skip β€” it is only a way through. On the last question Next carries on being Next. There is nowhere further to go, so where it goes is out: it ends the block, which is what "next" means at the end of a paper. Disabling it there left the hand that had pressed it four times with nothing under it. */} {/* And the end is still its own button, named for what it does. Two controls, one outcome, because the one you reach for at the end of a block is not always the one you have been pressing all the way through it. */} {isLast && !reviewing && ( )}
) const activeReadForCurrent = current && activeReadSegment?.questionId === current.id const questionSpeechRange = current ? getSpeechChunkRange( questionStem(current), QUESTION_HIGHLIGHT_WORDS, activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null, ) : null const toggleFavorite = async (questionId) => { const isFavorited = favorites.includes(questionId) try { if (isFavorited) { await api.delete(`/favorites/${questionId}`) setFavorites(prev => prev.filter(id => id !== questionId)) showToast('Removed from favorites') } else { await api.post('/favorites', { question_id: questionId }) setFavorites(prev => [...prev, questionId]) showToast('Added to favorites') } } catch (err) { showToast(err.response?.data?.detail || 'Failed to update favorite') } } const QuestionRailItem = ({ q, i }) => { const isActive = i === currentIdx const isDone = !!answers[q.id] const marked = favorites.includes(q.id) // Only questions the learner has reached show their text. Previewing one // they have not opened would give away the case before they read it. // // While an exam is being sat, none of them do: a real paper's status rail // is a column of numbers, and reading the stems still to come is not // something the exam being rehearsed would allow. Once it is handed in // there is nothing left to protect, so the review reads like study mode. const seen = (isStudy || reviewing) ? seenIndexes.has(i) : false const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : '' return ( ) } return (
{/* The floating global-notes tab is gone. A note taken while sitting a question is about that question, and there is a per-question note in the toolbar below; a second, unrelated notepad floating over the same screen only made it ambiguous which one you were writing in. The global note still lives on the dashboard. */} {tool && setTool(null)} />} {showReview && (() => { const missing = questions .map((question, index) => ({ question, index })) .filter(({ question }) => !answers[question.id]) return (

{missing.length ? 'Warning - This block is incomplete!' : 'End Block'}

{missing.length ? ( <>

Number of unanswered items in this block: {missing.length}

{/* Said because it is true, not to talk anybody out of it: the block can be resumed, and a real paper cannot. */}

You will be able to resume, however we do not recommend this as this deviates from your exam day experience.

) : (

All {totalCount} questions are answered.

)}
) })()} {submitError &&
{submitError}
} {progressError &&
{progressError}
} {fiveLeft && !timeUp && (

Block Time Warning

This block will end in 5 minutes.

)} {timeUp && (

Time's Up

You have run out of time to complete this question block.

{/* Closing is what hands it in. Until it is pressed the block is simply stopped: nothing has been marked, and a learner who comes back to a screen saying this has not already had an answer sheet taken from them while they were away. */}
)} {clockPaused && !isStudy && (

Exam Paused

{/* Nothing else. The clock is stopped and the questions are covered; a paragraph about how real exams work is somebody else's disclaimer. */}
)} {/* Only an exam asks, and it asks the right question. Leaving suspends: the answers are saved and the clock stops while the block is off screen, so this is worth one word rather than a warning β€” and it is not called "End Session", because nothing ends. Study mode is not asked at all; there is nothing there to lose. */} {leaving && (

Leave this block?

Your answers are saved and the clock stops while you are away. Pick it up where you left off.

)} {/* Still there? The clock is already stopped by the time this shows β€” it is not a threat, it is how the time stays honest. */} {(askingStillHere || away) && (

Still there?

{away ? 'The clock has been stopped since you stopped. Nothing is lost β€” pick up where you left off.' : 'Nothing has happened for a few minutes, so the clock is stopped. Time you were not at the desk for is not time you spent on the question.'}

)} {toast && (
{toast}
)} {/* Header */}

{quiz.title}

{isStudy ? 'πŸ“– Study' : '🎯 Exam'} {/* The Review badge lives on the rail now, beside the name of the session it belongs to. Two of them on one screen, saying the same word about the same block, was one too many. */} Q {currentIdx + 1} / {totalCount} {answeredCount} answered
{/* One clock. While a block is being sat the countdown lives in the bar at the foot of the screen, where a paper puts it; this is what is left for anything else that runs to a limit. */} {timeLeft !== null && !examChrome && } {/* Suspend, Restart and Edit were three buttons above a question nobody was looking away from to press them. Exit is in the bar at the bottom, where the session's own controls are; restarting and editing belong to the session list and the editor. */}
{/* ── The session, on a phone ──────────────────────────────────── The desktop keeps the rail permanently beside the question. A phone has no room for it, so it is a drawer holding the same list β€” with the site's own menu on the other tab, because the alternative is a second hamburger somewhere else for the same purpose. */} {!hasRail && navOpen && (
e.target === e.currentTarget && setNavOpen(false)}>
{/* The same burger that opened it closes it: on a phone this drawer is what that button does while a session is open. */}
{drawerTab === 'questions' ? ( <>
{/* The block is closed, so there is nothing left to sit; what you are doing now is reading it back. */} {reviewing && Review} {isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title} {answeredCount}/{totalCount}
{questions.map((q, i) => )}
{/* How long this is taking, where it is being read β€” the same two figures the desktop shows beside the explanation. */}
setClockPaused(v => !v)} />
) : ( )}
)} {/* The question, with the rail on one side and the labs on the other β€” both optional, and the question taking whatever they leave. */}
{/* Rendered whenever the rail is away, and hidden by the same breakpoint that hides the rail β€” the sidebar is a CSS decision, and its handle has to be made the same way or the two disagree. */} {!railOpen && ( )} {/* Main content */}
{examChrome ? ( <> {/* Where you are in the paper, in the paper's own terms. The block counter reads 1 of 1 today because a session is one block; it is here so that the day it is not, the learner is not left counting questions to work out where they are. */}

Item: {currentIdx + 1} of {totalCount} Block: 1 of 1

{/* Moving between items is the thing done most often, so it sits in the middle with the count between the two arrows rather than tucked in beside the tools. */}
{/* No chevron in the text: the round arrow above each label is the chevron, and printing a second one beside it read as two controls stuck together. */} {currentIdx + 1} / {totalCount}
) : ( /* Where you are, and nothing else. This used to be a button with a ☰ on it when there was no rail, which put a second door to the list of questions a few pixels below the one in the header β€” and the one in the header is the door that is always there, on every page, in the same place. */

Question{currentIdx + 1} of {totalCount}

)}
{/* The exam's own tools, and only the exam's: a study session has no clock to beat and no calculator, and its labs are on the question's own bar beside the case. The labs open into the column next to the question in both modes β€” the same panel, so reference ranges are read against the stem rather than over it. */} {!isStudy && ( <> )} {/* An exam moves between items from the middle of this bar, and ends the block from the bar at the foot of it. Repeating either here is the same action under a second name. */} {/* No arrows here. The bar at the foot of the player is sticky and carries Prev and Next already; a second pair above the question is the same control twice on one screen. The exam chrome keeps its own, because there the foot of the screen is the block bar rather than the navigation. */}
{current && (
{/* Difficulty is a hint, so it waits until the answer is in. The category trail is gone entirely: it named the answer's own topic and led out of a session you are part-way through. What to read next belongs in the explanation, which links to it. */}
{answerRevealed && current.difficulty && ( )} {/* Only when it is not the ordinary kind. A pill reading "Multiple choice" above five lettered options is a label for something already obvious; True/False and a blank to type in are worth saying because they change what you do. */} {current.question_type && current.question_type !== 'mcq' && ( {current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'} )}
{/* A stem carrying a lab table cannot live inside a heading β€” the table would be invalid markup there β€” so the heading is the labelled region and the prose sits inside it. */}
{/* Beside the tip rather than up in the toolbar: reference ranges are read against the case in front of you, and a control at the top of the screen is a different place from where the numbers are. Except in an exam, where the chrome across the top is that control and repeating it here is the same button twice. */} {!examChrome && ( )} {current.attending_tip && ( )} {/* Saving, sharing and reporting are occasional, so they fold away behind one control rather than each taking a slot in a bar that is read on every question. */} setQuiz(q => ({ ...q, share_token: token }))} />
βš‘ Give feedback
{voices.length > 0 && !examChrome && ( { if (readThrough) { if (currentIdx < totalCount - 1) { safeNavigate(currentIdx + 1, { keepReadThrough: true }) } else { setReadThrough(false) } return } }} onActiveChange={setTtsActive} onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })} /> )} {voices.length > 0 && !examChrome && ( )}
{/* The panels sit under the toolbar, where the eye already is, and only one opens at a time: two would push the options off screen. */} {panel === 'tip' && current.attending_tip && (
)} {panel === 'note' && (