feat: add redo functionality for failed exam questions and update translations
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s

This commit is contained in:
LaurensBartelds committed 2026-04-27 20:15:28 +02:00
1 parent acbcfde617
commit 028c732716
8 files changed
+67 -12

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ backend/db/brainwave.db-wal
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
brainwave.db-shm brainwave.db-shm
brainwave.db-wal brainwave.db-wal
node_modules/
+30 -1
View File
@@ -129,7 +129,7 @@ function AppInner() {
setExamState(withNotes(state)); setExamState(withNotes(state));
}; };
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null) => { const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null, isRedo = false) => {
if (saveToWorkspace && user && isAuthenticated()) { if (saveToWorkspace && user && isAuthenticated()) {
examApi.createExam(data) examApi.createExam(data)
.then((res) => { .then((res) => {
@@ -156,6 +156,7 @@ function AppInner() {
revealed: {}, revealed: {},
startedAt: Date.now(), startedAt: Date.now(),
feedbackMode: fm, feedbackMode: fm,
isRedo,
}); });
}); });
navigate(`/exam/${getExamUrlId(data)}/q1`); navigate(`/exam/${getExamUrlId(data)}/q1`);
@@ -195,6 +196,8 @@ function AppInner() {
state: { ...examState, completedAt }, state: { ...examState, completedAt },
}; };
// Skip saving to history / API when this is a redo session
if (!examState.isRedo) {
if (user && isAuthenticated()) { if (user && isAuthenticated()) {
void resultApi.submitResult({ void resultApi.submitResult({
exam_id: savedExamIdRef.current, exam_id: savedExamIdRef.current,
@@ -217,11 +220,36 @@ function AppInner() {
} }
setHistory((prev) => [entry, ...prev]); setHistory((prev) => [entry, ...prev]);
}
clearState(); clearState();
setExamState((s) => (s ? { ...s, completedAt } : s)); setExamState((s) => (s ? { ...s, completedAt } : s));
navigate("/results"); navigate("/results");
}, [exam, examState, user, navigate]); }, [exam, examState, user, navigate]);
const redoFailed = useCallback(() => {
if (!exam || !examState) return;
const graded = exam.questions.map((q) =>
gradeQuestion(q, examState.answers[q.id]),
);
const failedQuestions = exam.questions
.map((q, i) => ({ q, result: graded[i] }))
.filter(({ result }) => !result.correct)
.map(({ q }) => q);
if (failedQuestions.length === 0) return;
// Shuffle failed questions using Fisher-Yates
const shuffled = [...failedQuestions];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const redoExam: Exam = { ...exam, questions: shuffled };
loadExam(redoExam, false, savedExamIdRef.current, true);
}, [exam, examState]);
const exit = () => { const exit = () => {
setExam(null); setExam(null);
setExamState(null); setExamState(null);
@@ -461,6 +489,7 @@ function AppInner() {
state={examState} state={examState}
onRestart={restart} onRestart={restart}
onExit={exit} onExit={exit}
onRedoFailed={redoFailed}
reviewOnly={false} reviewOnly={false}
/> />
) : user && isAuthenticated() ? ( ) : user && isAuthenticated() ? (
+13 -2
View File
@@ -12,10 +12,11 @@ interface Props {
state: ExamState state: ExamState
onRestart: () => void onRestart: () => void
onExit: () => void onExit: () => void
onRedoFailed?: () => void
reviewOnly?: boolean reviewOnly?: boolean
} }
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) { export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { answers, startedAt, completedAt } = state const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt const elapsedMs = (completedAt ?? Date.now()) - startedAt
@@ -30,6 +31,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0) const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
const correctCount = graded.filter((g) => g.result.correct).length const correctCount = graded.filter((g) => g.result.correct).length
const failedCount = gradedCount - correctCount
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0 const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
const passing = exam.passingScore ?? 60 const passing = exam.passingScore ?? 60
const passed = pct >= passing const passed = pct >= passing
@@ -137,7 +139,16 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
})} })}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}> <div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>} {!reviewOnly && (
<>
<button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>
{onRedoFailed && failedCount > 0 && (
<button className="btn" onClick={onRedoFailed}>
{t('results.redoFailed')}
</button>
)}
</>
)}
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button> <button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
</div> </div>
</div> </div>
+1
View File
@@ -124,6 +124,7 @@
"explanation": "Explanation", "explanation": "Explanation",
"rubric": "Rubric (self-grade)", "rubric": "Rubric (self-grade)",
"retakeExam": "Retake exam", "retakeExam": "Retake exam",
"redoFailed": "Redo failed questions",
"cantRetake": "Exam no longer saved in workspace", "cantRetake": "Exam no longer saved in workspace",
"backToHome": "Back to home" "backToHome": "Back to home"
}, },
+1
View File
@@ -124,6 +124,7 @@
"explanation": "Uitleg", "explanation": "Uitleg",
"rubric": "Rubric (zelfbeoordeling)", "rubric": "Rubric (zelfbeoordeling)",
"retakeExam": "Examen opnieuw maken", "retakeExam": "Examen opnieuw maken",
"redoFailed": "Foute vragen opnieuw maken",
"cantRetake": "Examen niet meer opgeslagen in werkruimte", "cantRetake": "Examen niet meer opgeslagen in werkruimte",
"backToHome": "Terug naar home" "backToHome": "Terug naar home"
}, },
+1
View File
@@ -90,6 +90,7 @@ export interface ExamState {
startedAt: number; startedAt: number;
completedAt?: number; completedAt?: number;
feedbackMode: FeedbackMode; feedbackMode: FeedbackMode;
isRedo?: boolean;
} }
export interface HistoryEntry { export interface HistoryEntry {
+16 -8
View File
@@ -1,20 +1,28 @@
{ {
"name": "brainwave", "name": "brainwave",
"version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": { "name": "brainwave",
"helmet": "^8.1.0" "version": "0.0.1",
"devDependencies": {
"typescript": "^6.0.3"
} }
}, },
"node_modules/helmet": { "node_modules/typescript": {
"version": "8.1.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"license": "MIT", "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=14.17"
} }
} }
} }
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"name": "brainwave", "name": "brainwave",
"private": true, "private": true,
"version": "0.0.1" "version": "0.0.1",
"devDependencies": {
"typescript": "^6.0.3"
}
} }