From 7217cd3a287d04b65995370a0fce25546bd3e506 Mon Sep 17 00:00:00 2001 From: LaurensBartelds Date: Tue, 21 Apr 2026 21:51:34 +0200 Subject: [PATCH] feat: add ExamRunner screen --- frontend/src/components/ExamRunner.tsx | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 frontend/src/components/ExamRunner.tsx diff --git a/frontend/src/components/ExamRunner.tsx b/frontend/src/components/ExamRunner.tsx new file mode 100644 index 0000000..63ca342 --- /dev/null +++ b/frontend/src/components/ExamRunner.tsx @@ -0,0 +1,211 @@ +import { useState, useEffect, useCallback } from 'react' +import type { Exam, ExamState, Question } from '../types' +import { gradeQuestion } from '../utils/grading' +import { formatTime } from '../utils/formatting' +import Tex from './Tex' +import QuestionMedia from './QuestionMedia' +import QuestionRenderer from './questions/QuestionRenderer' + +interface Props { + exam: Exam + state: ExamState + setState: React.Dispatch> + onSubmit: () => void + onExit: () => void +} + +export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Props) { + const { currentIdx, answers, flags, startedAt, feedbackMode } = state + const q = exam.questions[currentIdx] + const total = exam.questions.length + + const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]) + useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed]) + + const setAnswer = (val: unknown) => + setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } })) + + const goTo = useCallback((i: number) => + setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total]) + + const toggleFlag = useCallback(() => + setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id]) + + const checkAnswer = () => { + setState((s) => ({ ...s, revealed: { ...(s.revealed ?? {}), [q.id]: true } })) + setRevealed(true) + } + + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) + + const elapsedMs = now - startedAt + const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null + const timeUrgent = remainingMs != null && remainingMs < 60_000 + + useEffect(() => { + if (remainingMs != null && remainingMs <= 0) onSubmit() + }, [remainingMs, onSubmit]) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1) + if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1) + if (e.key === 'f') toggleFlag() + if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) { + const ci = parseInt(e.key, 10) - 1 + if (q.choices[ci]) setAnswer(q.choices[ci].id) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [currentIdx, q, goTo, toggleFlag]) // eslint-disable-line react-hooks/exhaustive-deps + + const graded = feedbackMode === 'immediate' && revealed + const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0) + const result = graded ? gradeQuestion(q, answers[q.id]) : null + + const navStatus = (qq: Question): string => { + if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) { + const r = gradeQuestion(qq, answers[qq.id]) + if (r.status === 'correct') return 'correct' + if (r.status === 'wrong') return 'wrong' + } + if (flags[qq.id]) return 'flagged' + const a = answers[qq.id] + if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered' + return '' + } + + const answeredCount = exam.questions.filter((qq) => { + const st = navStatus(qq) + return st === 'answered' || st === 'correct' || st === 'wrong' + }).length + + const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0) + + return ( +
+ + +
+
+ Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} + {q.category && {q.category}} + {q.points ?? 1} {(q.points ?? 1) === 1 ? 'pt' : 'pts'} +
+ + {q.prompt} + + + + {graded && result && q.type !== 'essay' && ( +
+
{result.correct ? 'Correct' : 'Not quite'}
+
+ {!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && ( +

+ Answer:{' '} + + {Array.isArray(q.answer) + ? (q.answer as string[]).map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ') + : (q.choices?.find((c) => c.id === q.answer)?.text ?? String(q.answer))} + +

+ )} + {q.explanation &&

{q.explanation}

} +
+
+ )} + +
+ + + + {feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && ( + + )} + {currentIdx < total - 1 + ? + : } +
+ +
+ ← → navigate + F flag + {q.type === 'multiple-choice' && q.choices && ( + 1–{q.choices.length} select + )} +
+
+ + +
+ ) +}