feat: add Results/Review screen

This commit is contained in:
LaurensBartelds committed 2026-04-21 21:54:37 +02:00
1 parent 62f714a4a4
commit cbe4e6058c
1 file changed
+143
+143
View File
@@ -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<string, { earned: number; possible: number; correct: number; total: number }> = {}
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<number | null>(null)
return (
<div className="results">
<div className="results-eyebrow">
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
</div>
<h1 className="results-title">
You scored <em>{pct}%</em><br />
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
{passed
? `Above the ${passing}% passing mark.`
: `Below the ${passing}% passing mark. Keep practicing.`}
</span>
</h1>
<div className="score-grid">
<div className="score-cell">
<div className="score-cell-label">Points</div>
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Correct</div>
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Time</div>
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div>
</div>
</div>
{Object.keys(byCat).length > 1 && (
<div className="category-breakdown">
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>
By category
</div>
{Object.entries(byCat).map(([cat, c]) => {
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0
return (
<div key={cat} className="category-row">
<div style={{ fontWeight: 500 }}>{cat}</div>
<div className="category-bar"><div style={{ width: `${catPct}%` }} /></div>
<div className="category-score">{catPct}%</div>
</div>
)
})}
</div>
)}
<h2 className="review-heading">Review</h2>
{graded.map((g, i) => {
const promptText = g.q.prompt
.replace(/\$\$([^$]+)\$\$/g, '[math]')
.replace(/\$([^$]+)\$/g, '[math]')
return (
<div key={g.q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
<span className="review-title">
<Tex>{promptText.slice(0, 90)}</Tex>
{promptText.length > 90 ? '…' : ''}
</span>
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
</div>
{openIdx === i && (
<div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<Tex>{g.q.prompt}</Tex>
</div>
<QuestionMedia q={g.q} />
<QuestionRenderer q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">Explanation</div>
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
</div>
)}
{g.q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
</ul>
</div>
)}
</div>
)}
</div>
)
})}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
<button className="btn" onClick={onExit}>Back to home</button>
</div>
</div>
)
}