Public Access
feat: add notes functionality to exam state and update UI for note-taking
This commit is contained in:
1 parent
d0b1ac0145
commit
c4c1e1a45a
5 files changed
+90
-31
No files matched your search
@@ -67,7 +67,7 @@ router.get('/:id', requireAuth, (req, res) => {
|
|||||||
// ─── Save Result ─────────────────────────────────────────
|
// ─── Save Result ─────────────────────────────────────────
|
||||||
router.post('/', requireAuth, (req, res) => {
|
router.post('/', requireAuth, (req, res) => {
|
||||||
try {
|
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 (
|
if (
|
||||||
!title || scorePoints == null || totalPoints == null ||
|
!title || scorePoints == null || totalPoints == null ||
|
||||||
@@ -77,6 +77,17 @@ router.post('/', requireAuth, (req, res) => {
|
|||||||
return
|
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 db = getDb()
|
||||||
const result = db.prepare(`
|
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)
|
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,
|
gradedCount,
|
||||||
passingScore ?? 60,
|
passingScore ?? 60,
|
||||||
elapsedMs ?? 0,
|
elapsedMs ?? 0,
|
||||||
JSON.stringify(state),
|
JSON.stringify(normalizedState),
|
||||||
JSON.stringify(exam),
|
JSON.stringify(exam),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+28
-19
@@ -14,6 +14,11 @@ import ResetPassword from './components/ResetPassword'
|
|||||||
|
|
||||||
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
||||||
|
|
||||||
|
const withNotes = (state: ExamState): ExamState => ({
|
||||||
|
...state,
|
||||||
|
notes: state.notes ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { user, loading: authLoading, logout } = useAuth()
|
const { user, loading: authLoading, logout } = useAuth()
|
||||||
const [screen, setScreen] = useState<AppScreen>('landing')
|
const [screen, setScreen] = useState<AppScreen>('landing')
|
||||||
@@ -27,8 +32,8 @@ function AppInner() {
|
|||||||
|
|
||||||
// Check URL for verify/reset-password routes on mount
|
// Check URL for verify/reset-password routes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = window.location.pathname
|
const path = globalThis.location.pathname
|
||||||
const search = window.location.search
|
const search = globalThis.location.search
|
||||||
if (path === '/verify' || search.includes('token=') && path === '/verify') {
|
if (path === '/verify' || search.includes('token=') && path === '/verify') {
|
||||||
setScreen('verify')
|
setScreen('verify')
|
||||||
} else if (path === '/reset-password') {
|
} else if (path === '/reset-password') {
|
||||||
@@ -56,7 +61,7 @@ function AppInner() {
|
|||||||
scorePoints: r.score_points,
|
scorePoints: r.score_points,
|
||||||
passingScore: r.passing_score,
|
passingScore: r.passing_score,
|
||||||
exam: { title: r.title, questions: [] } as unknown as Exam,
|
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,
|
_remoteId: r.id,
|
||||||
}))
|
}))
|
||||||
setHistory(apiHistory)
|
setHistory(apiHistory)
|
||||||
@@ -81,16 +86,25 @@ function AppInner() {
|
|||||||
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
||||||
setTweaks((t) => ({ ...t, [key]: value }))
|
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 loadExam = (data: Exam) => {
|
||||||
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
||||||
setExam(data)
|
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')
|
setScreen('exam')
|
||||||
}
|
}
|
||||||
|
|
||||||
const resume = () => {
|
const resume = () => {
|
||||||
const saved = loadState()
|
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(() => {
|
const submit = useCallback(() => {
|
||||||
@@ -124,6 +138,7 @@ function AppInner() {
|
|||||||
gradedCount: entry.gradedCount,
|
gradedCount: entry.gradedCount,
|
||||||
passingScore: entry.passingScore,
|
passingScore: entry.passingScore,
|
||||||
elapsedMs: entry.elapsedMs,
|
elapsedMs: entry.elapsedMs,
|
||||||
|
notes: entry.state.notes,
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
exam: entry.exam,
|
exam: entry.exam,
|
||||||
}),
|
}),
|
||||||
@@ -155,15 +170,15 @@ function AppInner() {
|
|||||||
}>(`/api/results/${remoteId}`)
|
}>(`/api/results/${remoteId}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setExam(data.exam)
|
setExam(data.exam)
|
||||||
setExamState(data.state)
|
setExamState(withNotes(data.state))
|
||||||
setScreen('review')
|
setScreen('review')
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback to local data
|
// Fallback to local data
|
||||||
setExam(entry.exam); setExamState(entry.state); setScreen('review')
|
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review')
|
||||||
})
|
})
|
||||||
} else {
|
} 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 = () => {
|
const handleAuthFlowDone = () => {
|
||||||
window.history.replaceState({}, '', '/')
|
globalThis.history.replaceState({}, '', '/')
|
||||||
setScreen('landing')
|
|
||||||
setAuthModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetDone = () => {
|
|
||||||
window.history.replaceState({}, '', '/')
|
|
||||||
setScreen('landing')
|
setScreen('landing')
|
||||||
setAuthModalOpen(true)
|
setAuthModalOpen(true)
|
||||||
}
|
}
|
||||||
@@ -195,7 +204,7 @@ function AppInner() {
|
|||||||
<span className="brand-name">BrainWave</span>
|
<span className="brand-name">BrainWave</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<EmailVerify onDone={handleVerifyDone} />
|
<EmailVerify onDone={handleAuthFlowDone} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -209,7 +218,7 @@ function AppInner() {
|
|||||||
<span className="brand-name">BrainWave</span>
|
<span className="brand-name">BrainWave</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ResetPassword onDone={handleResetDone} />
|
<ResetPassword onDone={handleAuthFlowDone} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,7 +283,7 @@ function AppInner() {
|
|||||||
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
||||||
)}
|
)}
|
||||||
{screen === 'exam' && exam && examState && (
|
{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 && (
|
{(screen === 'results' || screen === 'review') && exam && examState && (
|
||||||
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
|
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ interface Props {
|
|||||||
onExit: () => void
|
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 { currentIdx, answers, flags, startedAt, feedbackMode } = state
|
||||||
const q = exam.questions[currentIdx]
|
const q = exam.questions[currentIdx]
|
||||||
const total = exam.questions.length
|
const total = exam.questions.length
|
||||||
|
const notes = state.notes ?? ''
|
||||||
|
|
||||||
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id])
|
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id])
|
||||||
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed])
|
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(() =>
|
const toggleFlag = useCallback(() =>
|
||||||
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id])
|
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 = () => {
|
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)
|
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 === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1)
|
||||||
if (e.key === 'f') toggleFlag()
|
if (e.key === 'f') toggleFlag()
|
||||||
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) {
|
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
|
const choices = q.choices
|
||||||
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } }))
|
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
globalThis.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => globalThis.removeEventListener('keydown', handler)
|
||||||
}, [currentIdx, q, goTo, toggleFlag, setState])
|
}, [currentIdx, q, goTo, toggleFlag, setState])
|
||||||
|
|
||||||
const graded = feedbackMode === 'immediate' && revealed
|
const graded = feedbackMode === 'immediate' && revealed
|
||||||
@@ -90,6 +97,12 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
|
|||||||
}).length
|
}).length
|
||||||
|
|
||||||
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0)
|
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 (
|
return (
|
||||||
<div className="exam-shell">
|
<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' && (
|
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Answer:</strong>{' '}
|
<strong>Answer:</strong>{' '}
|
||||||
<Tex>
|
<Tex>{answerText}</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>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{q.explanation && <p><Tex>{q.explanation}</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'}
|
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{exam.author && (
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Author</div>
|
<div className="meta-label">Author</div>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
|
|||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: sticky; top: 57px;
|
position: sticky; top: 57px;
|
||||||
|
height: calc(100vh - 57px);
|
||||||
max-height: calc(100vh - 57px);
|
max-height: calc(100vh - 57px);
|
||||||
}
|
}
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
@@ -472,6 +473,7 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
|
|||||||
padding: 24px 20px;
|
padding: 24px 20px;
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
position: sticky; top: 57px;
|
position: sticky; top: 57px;
|
||||||
|
height: calc(100vh - 57px);
|
||||||
max-height: calc(100vh - 57px);
|
max-height: calc(100vh - 57px);
|
||||||
overflow-y: auto;
|
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; }
|
.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 {
|
.progress-bar {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface ExamState {
|
|||||||
currentIdx: number
|
currentIdx: number
|
||||||
answers: Record<string, unknown>
|
answers: Record<string, unknown>
|
||||||
flags: Record<string, boolean>
|
flags: Record<string, boolean>
|
||||||
|
notes: string
|
||||||
revealed?: Record<string, boolean>
|
revealed?: Record<string, boolean>
|
||||||
startedAt: number
|
startedAt: number
|
||||||
completedAt?: number
|
completedAt?: number
|
||||||
|
|||||||
Reference in new issue
Block a user