Public Access
feat: add Results/Review screen
This commit is contained in:
1 parent
62f714a4a4
commit
cbe4e6058c
1 file changed
+143
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user