/* global React */
// ==============================================================
// screens.jsx — Landing, Exam, Results
// ==============================================================
var { useState, useEffect, useRef, useMemo, useCallback } = React;
// ---------- LANDING ----------
const Landing = ({ onLoadExam, history, onResume, onClearHistory }) => {
const [err, setErr] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef(null);
const handleFile = async (file) => {
setErr(null);
try {
const text = await file.text();
const data = JSON.parse(text);
const msg = window.validateExam(data);
if (msg) throw new Error(msg);
onLoadExam(data);
} catch (e) {
setErr(`Could not load: ${e.message}`);
}
};
const loadSample = async (path) => {
setErr(null);
try {
const r = await fetch(path);
const data = await r.json();
onLoadExam(data);
} catch (e) {
setErr(`Could not load sample: ${e.message}`);
}
};
const samples = [
{ path: 'samples/calculus-101.json', title: 'Calculus I — Midterm Practice', meta: '7 questions · 45 min · LaTeX math', mark: '∫' },
{ path: 'samples/spanish-a2.json', title: 'Spanish A2 — Vocabulary & Grammar', meta: '6 questions · 20 min · Translation', mark: 'ñ' },
{ path: 'samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
];
const resumable = window.loadState();
return (
BrainWave · practice exam platform
Upload an exam. Practice anything.
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) handleFile(f);
}}
>
Drop your exam JSON here
or click to browse · max 5 MB
e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{err &&
{err}
}
{resumable && (
Resume in-progress exam
{resumable.exam.title} · {Object.keys(resumable.answers || {}).length}/{resumable.exam.questions.length} answered
Resume →
)}
Supported types
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
{history.length > 0 && (
Past attempts
Clear history
{history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0;
const passed = pct >= (h.passingScore || 60);
return (
{h.title}
{window.formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
{window.formatTime(h.elapsedMs)}
{pct}%
window.__replayAttempt?.(h)}>Review
);
})}
)}
);
};
// ---------- EXAM (runner) ----------
const ExamRunner = ({ exam, state, setState, onSubmit, onExit }) => {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
const q = exam.questions[currentIdx];
const total = exam.questions.length;
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const setAnswer = (val) => {
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
};
const goTo = (i) => setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) }));
const toggleFlag = () => setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } }));
const checkAnswer = () => {
setState((s) => ({ ...s, revealed: { ...(s.revealed || {}), [q.id]: true } }));
setRevealed(true);
};
// Timer
const [now, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const elapsedMs = now - startedAt;
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null;
const timeUrgent = remainingMs != null && remainingMs < 60_000;
useEffect(() => {
if (remainingMs != null && remainingMs <= 0) onSubmit();
}, [remainingMs, onSubmit]);
// keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1);
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1);
if (e.key === 'f') toggleFlag();
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice') {
const idx = parseInt(e.key, 10) - 1;
if (q.choices[idx]) setAnswer(q.choices[idx].id);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [currentIdx, q]);
const graded = feedbackMode === 'immediate' && revealed;
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || answers[q.id].length > 0);
const result = graded ? window.gradeQuestion(q, answers[q.id]) : null;
// Navigator status
const navStatus = (qq) => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
const r = window.gradeQuestion(qq, answers[qq.id]);
if (r.status === 'correct') return 'correct';
if (r.status === 'wrong') return 'wrong';
}
if (flags[qq.id]) return 'flagged';
if (answers[qq.id] != null && (!Array.isArray(answers[qq.id]) || answers[qq.id].length > 0)) return 'answered';
return '';
};
const answeredCount = exam.questions.filter((qq) => navStatus(qq) === 'answered' || navStatus(qq) === 'correct' || navStatus(qq) === 'wrong').length;
return (
{/* Sidebar */}
{exam.title}
{exam.subtitle && {exam.subtitle}
}
Questions
{exam.questions.map((qq, i) => {
const st = navStatus(qq);
return (
goTo(i)}
>
{String(i + 1).padStart(2, '0')}
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')}
);
})}
Progress
{answeredCount} of {total} answered
Submit exam
Exit to home
{/* Main */}
Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
{q.category && {q.category} }
{q.points || 1} {q.points === 1 ? 'pt' : 'pts'}
{q.prompt}
{graded && result && q.type !== 'essay' && (
{result.correct ? 'Correct' : 'Not quite'}
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
Answer: {Array.isArray(q.answer) ? q.answer.map(id => q.choices?.find(c => c.id === id)?.text || id).join(', ') : (q.choices?.find(c => c.id === q.answer)?.text || q.answer)}
)}
{q.explanation &&
{q.explanation}
}
)}
goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && (
Check answer
)}
{currentIdx < total - 1 ? (
goTo(currentIdx + 1)}>Next →
) : (
Submit exam
)}
← → navigate
F flag
{q.type === 'multiple-choice' && 1 –{q.choices.length} select }
{/* Meta rail */}
Elapsed
{window.formatTime(elapsedMs)}
{remainingMs != null && (
Remaining
{window.formatTime(Math.max(0, remainingMs))}
)}
Points possible
{exam.questions.reduce((a, q) => a + (q.points || 1), 0)}
Mode
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
{exam.author && (
)}
);
};
// ---------- RESULTS ----------
const Results = ({ exam, state, onRestart, onExit, reviewOnly }) => {
const { answers, startedAt, completedAt } = state;
const elapsedMs = (completedAt || Date.now()) - startedAt;
const graded = exam.questions.map((q) => ({
q,
userAnswer: answers[q.id],
result: window.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;
// Categories breakdown
const byCat = {};
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
{window.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 (
);
})}
)}
Review
{graded.map((g, i) => (
setOpenIdx(openIdx === i ? null : i)}>
{String(i + 1).padStart(2, '0')}
{g.q.prompt.replace(/\$\$([^$]+)\$\$/g, '[math]').replace(/\$([^$]+)\$/g, '[math]').slice(0, 90)} {g.q.prompt.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 && Retake exam }
Back to home
);
};
Object.assign(window, { Landing, ExamRunner, Results });