diff --git a/backend/src/routes/results.ts b/backend/src/routes/results.ts index 8a6fa4a..d7ce616 100644 --- a/backend/src/routes/results.ts +++ b/backend/src/routes/results.ts @@ -67,7 +67,7 @@ router.get('/:id', requireAuth, (req, res) => { // ─── Save Result ───────────────────────────────────────── router.post('/', requireAuth, (req, res) => { try { - const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, state, exam, examId } = req.body + const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body if ( !title || scorePoints == null || totalPoints == null || @@ -77,6 +77,17 @@ router.post('/', requireAuth, (req, res) => { return } + let noteSource = '' + if (typeof notes === 'string') { + noteSource = notes + } else if (typeof state.notes === 'string') { + noteSource = state.notes + } + const normalizedState = { + ...state, + notes: noteSource.slice(0, 1000), + } + const db = getDb() const result = db.prepare(` INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json) @@ -91,7 +102,7 @@ router.post('/', requireAuth, (req, res) => { gradedCount, passingScore ?? 60, elapsedMs ?? 0, - JSON.stringify(state), + JSON.stringify(normalizedState), JSON.stringify(exam), ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79710f5..8be9a25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,11 @@ import ResetPassword from './components/ResetPassword' const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' } +const withNotes = (state: ExamState): ExamState => ({ + ...state, + notes: state.notes ?? '', +}) + function AppInner() { const { user, loading: authLoading, logout } = useAuth() const [screen, setScreen] = useState('landing') @@ -27,8 +32,8 @@ function AppInner() { // Check URL for verify/reset-password routes on mount useEffect(() => { - const path = window.location.pathname - const search = window.location.search + const path = globalThis.location.pathname + const search = globalThis.location.search if (path === '/verify' || search.includes('token=') && path === '/verify') { setScreen('verify') } else if (path === '/reset-password') { @@ -56,7 +61,7 @@ function AppInner() { scorePoints: r.score_points, passingScore: r.passing_score, exam: { title: r.title, questions: [] } as unknown as Exam, - state: { currentIdx: 0, answers: {}, flags: {}, startedAt: 0, feedbackMode: 'end' as const } as ExamState, + state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState, _remoteId: r.id, })) setHistory(apiHistory) @@ -81,16 +86,25 @@ function AppInner() { const updateTweak = (key: K, value: Tweaks[K]) => setTweaks((t) => ({ ...t, [key]: value })) + const setExamStateStrict: React.Dispatch> = (nextState) => { + setExamState((prev) => { + if (!prev) return prev + return typeof nextState === 'function' + ? (nextState as (prevState: ExamState) => ExamState)(prev) + : nextState + }) + } + const loadExam = (data: Exam) => { const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode setExam(data) - setExamState({ currentIdx: 0, answers: {}, flags: {}, revealed: {}, startedAt: Date.now(), feedbackMode: fm }) + setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm }) setScreen('exam') } const resume = () => { const saved = loadState() - if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(saved.state); setScreen('exam') } + if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') } } const submit = useCallback(() => { @@ -124,6 +138,7 @@ function AppInner() { gradedCount: entry.gradedCount, passingScore: entry.passingScore, elapsedMs: entry.elapsedMs, + notes: entry.state.notes, state: entry.state, exam: entry.exam, }), @@ -155,15 +170,15 @@ function AppInner() { }>(`/api/results/${remoteId}`) .then((data) => { setExam(data.exam) - setExamState(data.state) + setExamState(withNotes(data.state)) setScreen('review') }) .catch(() => { // Fallback to local data - setExam(entry.exam); setExamState(entry.state); setScreen('review') + setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') }) } else { - setExam(entry.exam); setExamState(entry.state); setScreen('review') + setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') } } @@ -174,14 +189,8 @@ function AppInner() { } } - const handleVerifyDone = () => { - window.history.replaceState({}, '', '/') - setScreen('landing') - setAuthModalOpen(true) - } - - const handleResetDone = () => { - window.history.replaceState({}, '', '/') + const handleAuthFlowDone = () => { + globalThis.history.replaceState({}, '', '/') setScreen('landing') setAuthModalOpen(true) } @@ -195,7 +204,7 @@ function AppInner() { BrainWave - + ) } @@ -209,7 +218,7 @@ function AppInner() { BrainWave - + ) } @@ -274,7 +283,7 @@ function AppInner() { )} {screen === 'exam' && exam && examState && ( - >} onSubmit={submit} onExit={exit} /> + )} {(screen === 'results' || screen === 'review') && exam && examState && ( diff --git a/frontend/src/components/ExamRunner.tsx b/frontend/src/components/ExamRunner.tsx index 9abd648..fcb44ae 100644 --- a/frontend/src/components/ExamRunner.tsx +++ b/frontend/src/components/ExamRunner.tsx @@ -14,10 +14,11 @@ interface Props { onExit: () => void } -export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Props) { +export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly) { const { currentIdx, answers, flags, startedAt, feedbackMode } = state const q = exam.questions[currentIdx] const total = exam.questions.length + const notes = state.notes ?? '' const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]) useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed]) @@ -31,8 +32,14 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: const toggleFlag = useCallback(() => setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id]) + const updateNotes = (value: string) => + setState((s) => ({ ...s, notes: value.slice(0, 1000) })) + const checkAnswer = () => { - setState((s) => ({ ...s, revealed: { ...(s.revealed ?? {}), [q.id]: true } })) + setState((s) => ({ + ...s, + revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true }, + })) setRevealed(true) } @@ -58,13 +65,13 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: 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 + const ci = Number.parseInt(e.key, 10) - 1 const choices = q.choices if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } })) } } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) + globalThis.addEventListener('keydown', handler) + return () => globalThis.removeEventListener('keydown', handler) }, [currentIdx, q, goTo, toggleFlag, setState]) const graded = feedbackMode === 'immediate' && revealed @@ -90,6 +97,12 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: }).length const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0) + let answerText = '' + if (!Array.isArray(q.answer) && typeof q.answer === 'string') { + answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer + } else if (Array.isArray(q.answer)) { + answerText = q.answer.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ') + } return (
@@ -145,11 +158,7 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: {!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))} - + {answerText}

)} {q.explanation &&

{q.explanation}

} @@ -201,6 +210,18 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: {feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
+
+
Notes
+