feat: add notes functionality to exam state and update UI for note-taking

This commit is contained in:
LaurensBartelds committed 2026-04-22 22:48:24 +02:00
1 parent d0b1ac0145
commit c4c1e1a45a
5 files changed
+90 -31

No files matched your search

+13 -2
View File
@@ -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),
)
+28 -19
View File
@@ -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<AppScreen>('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 = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value }))
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (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() {
<span className="brand-name">BrainWave</span>
</div>
</header>
<EmailVerify onDone={handleVerifyDone} />
<EmailVerify onDone={handleAuthFlowDone} />
</div>
)
}
@@ -209,7 +218,7 @@ function AppInner() {
<span className="brand-name">BrainWave</span>
</div>
</header>
<ResetPassword onDone={handleResetDone} />
<ResetPassword onDone={handleAuthFlowDone} />
</div>
)
}
@@ -274,7 +283,7 @@ function AppInner() {
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
)}
{screen === 'exam' && exam && examState && (
<ExamRunner exam={exam} state={examState} setState={setExamState as React.Dispatch<React.SetStateAction<ExamState>>} onSubmit={submit} onExit={exit} />
<ExamRunner exam={exam} state={examState} setState={setExamStateStrict} onSubmit={submit} onExit={exit} />
)}
{(screen === 'results' || screen === 'review') && exam && examState && (
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
+31 -10
View File
@@ -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<Props>) {
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 (
<div className="exam-shell">
@@ -145,11 +158,7 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
<p>
<strong>Answer:</strong>{' '}
<Tex>
{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))}
</Tex>
<Tex>{answerText}</Tex>
</p>
)}
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
@@ -201,6 +210,18 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
</div>
</div>
<div className="meta-block notes-block">
<div className="meta-label">Notes</div>
<textarea
className="textarea notes-pad"
value={notes}
onChange={(e) => updateNotes(e.target.value)}
placeholder="Add short notes while you work..."
maxLength={1000}
aria-label="Exam notes"
/>
<div className="notes-count mono">{notes.length}/1000</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>
+17
View File
@@ -390,6 +390,7 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
background: var(--paper);
overflow-y: auto;
position: sticky; top: 57px;
height: calc(100vh - 57px);
max-height: calc(100vh - 57px);
}
.sidebar-title {
@@ -472,6 +473,7 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
padding: 24px 20px;
background: var(--paper);
position: sticky; top: 57px;
height: calc(100vh - 57px);
max-height: calc(100vh - 57px);
overflow-y: auto;
}
@@ -493,6 +495,21 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
}
.meta-value.mono { font-family: var(--font-mono); font-size: 18px; font-weight: 500; }
.notes-block { margin-top: 2px; }
.notes-pad {
height: clamp(100px, 20vh, 160px);
font-size: 13px;
line-height: 1.45;
resize: none;
overflow-y: auto;
}
.notes-count {
margin-top: 6px;
font-size: 11px;
color: var(--ink-4);
text-align: right;
}
.progress-bar {
height: 3px;
background: var(--line);
+1
View File
@@ -84,6 +84,7 @@ export interface ExamState {
currentIdx: number
answers: Record<string, unknown>
flags: Record<string, boolean>
notes: string
revealed?: Record<string, boolean>
startedAt: number
completedAt?: number