From cbe4e6058c8ac7cd1bb44f8ca3275324b5b6e82d Mon Sep 17 00:00:00 2001 From: LaurensBartelds Date: Tue, 21 Apr 2026 21:54:37 +0200 Subject: [PATCH] feat: add Results/Review screen --- frontend/src/components/Results.tsx | 143 ++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/components/Results.tsx diff --git a/frontend/src/components/Results.tsx b/frontend/src/components/Results.tsx new file mode 100644 index 0000000..fff0ec7 --- /dev/null +++ b/frontend/src/components/Results.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react' +import type { Exam, ExamState } 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 + onRestart: () => void + onExit: () => void + reviewOnly?: boolean +} + +export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) { + const { answers, startedAt, completedAt } = state + const elapsedMs = (completedAt ?? Date.now()) - startedAt + + const graded = exam.questions.map((q) => ({ + q, + userAnswer: answers[q.id], + result: gradeQuestion(q, answers[q.id]), + })) + + const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0) + const scorePoints = graded.reduce((a, g) => a + g.result.points, 0) + const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length + const correctCount = graded.filter((g) => g.result.correct).length + const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0 + const passing = exam.passingScore ?? 60 + const passed = pct >= passing + + const byCat: Record = {} + graded.forEach((g) => { + const cat = g.q.category ?? 'Uncategorized' + if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 } + byCat[cat].possible += g.q.points ?? 1 + byCat[cat].earned += g.result.points + byCat[cat].total += 1 + if (g.result.correct) byCat[cat].correct += 1 + }) + + const [openIdx, setOpenIdx] = useState(null) + + return ( +
+
+ {reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title} +
+

+ You scored {pct}%
+ + {passed + ? `Above the ${passing}% passing mark.` + : `Below the ${passing}% passing mark. Keep practicing.`} + +

+ +
+
+
Points
+
{scorePoints}/ {totalPoints}
+
+
+
Correct
+
{correctCount}/ {gradedCount}
+
+
+
Time
+
{formatTime(elapsedMs)}
+
+
+ + {Object.keys(byCat).length > 1 && ( +
+
+ By category +
+ {Object.entries(byCat).map(([cat, c]) => { + const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0 + return ( +
+
{cat}
+
+
{catPct}%
+
+ ) + })} +
+ )} + +

Review

+ {graded.map((g, i) => { + const promptText = g.q.prompt + .replace(/\$\$([^$]+)\$\$/g, '[math]') + .replace(/\$([^$]+)\$/g, '[math]') + return ( +
+
setOpenIdx(openIdx === i ? null : i)}> + {String(i + 1).padStart(2, '0')} + + {promptText.slice(0, 90)} + {promptText.length > 90 ? '…' : ''} + + {g.result.status} + {openIdx === i ? '−' : '+'} +
+ {openIdx === i && ( +
+
+ {g.q.prompt} +
+ + {}} locked graded /> + {g.q.explanation && ( +
+
Explanation
+
{g.q.explanation}
+
+ )} + {g.q.rubric && ( +
+
Rubric (self-grade)
+
    + {g.q.rubric.map((r, j) =>
  • {r}
  • )} +
+
+ )} +
+ )} +
+ ) + })} + +
+ {!reviewOnly && } + +
+
+ ) +}