Previous h3/text-base was still too large; dropped to h5/text-sm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
454 lines
18 KiB
JavaScript
454 lines
18 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
CheckSquare, Loader, AlertCircle, Trophy, ArrowRight,
|
|
Clock, CheckCircle, XCircle
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Link } from 'react-router-dom';
|
|
import Card from '../components/ui/Card';
|
|
import Button from '../components/ui/Button';
|
|
import Tag from '../components/ui/Tag';
|
|
import { useApp } from '../store/AppContext';
|
|
import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
|
import { getCurriculumWeek, getCurriculumCycle } from '../lib/curriculumService';
|
|
import { storage } from '../lib/storage';
|
|
|
|
const TIMER_SECONDS = 300; // 5 minutes
|
|
|
|
function setQuizActive(userId, active) {
|
|
if (!userId) return;
|
|
if (active) storage.set(`quiz:active:${userId}`, true);
|
|
else storage.remove(`quiz:active:${userId}`);
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('respellion:quiz-state', { detail: { active } }));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// ─── Helper: format mm:ss ──────────────────────────────────
|
|
function formatTime(sec) {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
const Testen = () => {
|
|
const { state } = useApp();
|
|
const { currentUser, weekNumber } = state;
|
|
|
|
const [phase, setPhase] = useState('intro'); // intro | loading | quiz | review | results
|
|
const [quiz, setQuiz] = useState(null);
|
|
const [answers, setAnswers] = useState({}); // { questionId: selectedIndex }
|
|
const [currentQ, setCurrentQ] = useState(0);
|
|
const [showFeedback, setShowFeedback] = useState(false);
|
|
const [timeLeft, setTimeLeft] = useState(TIMER_SECONDS);
|
|
const [result, setResult] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const timerRef = useRef(null);
|
|
|
|
// ── Check for existing result ──
|
|
useEffect(() => {
|
|
if (currentUser) {
|
|
const check = async () => {
|
|
const existing = await getTestResult(currentUser.id, weekNumber);
|
|
if (existing) {
|
|
setResult(existing);
|
|
setPhase('results');
|
|
}
|
|
};
|
|
check();
|
|
}
|
|
}, [currentUser, weekNumber]);
|
|
|
|
// ── Timer ──
|
|
const finishQuizRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (phase === 'quiz') {
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => {
|
|
if (prev <= 1) {
|
|
clearInterval(timerRef.current);
|
|
finishQuizRef.current?.();
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
}
|
|
return () => clearInterval(timerRef.current);
|
|
}, [phase]);
|
|
|
|
// ── Clear quiz-active flag whenever we leave the quiz phase or unmount ──
|
|
useEffect(() => {
|
|
if (phase !== 'quiz' && currentUser) {
|
|
setQuizActive(currentUser.id, false);
|
|
}
|
|
}, [phase, currentUser]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (currentUser) setQuizActive(currentUser.id, false);
|
|
};
|
|
}, [currentUser]);
|
|
|
|
// ── Start quiz ──
|
|
const startQuiz = async () => {
|
|
setPhase('loading');
|
|
setError(null);
|
|
try {
|
|
const q = await generateWeeklyQuiz(currentUser.id, weekNumber);
|
|
if (!q?.questions?.length) {
|
|
throw new Error('No questions could be generated for this week. Please try again.');
|
|
}
|
|
setQuiz(q);
|
|
setCurrentQ(0);
|
|
setAnswers({});
|
|
setShowFeedback(false);
|
|
setTimeLeft(TIMER_SECONDS);
|
|
setQuizActive(currentUser.id, true);
|
|
setPhase('quiz');
|
|
} catch (e) {
|
|
setError(e.message);
|
|
setPhase('intro');
|
|
}
|
|
};
|
|
|
|
// ── Select answer ──
|
|
const selectAnswer = (questionId, optionIndex) => {
|
|
if (showFeedback) return; // locked
|
|
setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
|
|
setShowFeedback(true);
|
|
};
|
|
|
|
// ── Next question / finish ──
|
|
const nextQuestion = () => {
|
|
setShowFeedback(false);
|
|
if (currentQ < quiz.questions.length - 1) {
|
|
setCurrentQ(prev => prev + 1);
|
|
} else {
|
|
finishQuiz();
|
|
}
|
|
};
|
|
|
|
// ── Finish quiz & score ──
|
|
const finishQuiz = useCallback(async () => {
|
|
clearInterval(timerRef.current);
|
|
if (!quiz) return;
|
|
|
|
const questions = quiz.questions;
|
|
let score = 0;
|
|
const breakdown = questions.map(q => {
|
|
const selected = answers[q.id];
|
|
const correct = selected === q.correctIndex;
|
|
if (correct) score++;
|
|
return {
|
|
questionId: q.id,
|
|
question: q.question,
|
|
topicLabel: q.topicLabel,
|
|
selected,
|
|
correctIndex: q.correctIndex,
|
|
correct,
|
|
explanation: q.explanation,
|
|
options: q.options,
|
|
};
|
|
});
|
|
|
|
const testResult = {
|
|
score,
|
|
total: questions.length,
|
|
percentage: Math.round((score / questions.length) * 100),
|
|
timeUsed: TIMER_SECONDS - timeLeft,
|
|
completedAt: new Date().toISOString(),
|
|
breakdown,
|
|
};
|
|
|
|
const { pointsEarned } = await saveTestResult(currentUser.id, weekNumber, testResult);
|
|
testResult.pointsEarned = pointsEarned;
|
|
setResult(testResult);
|
|
setPhase('results');
|
|
}, [quiz, answers, timeLeft, currentUser, weekNumber]);
|
|
|
|
useEffect(() => {
|
|
finishQuizRef.current = finishQuiz;
|
|
}, [finishQuiz]);
|
|
|
|
// ─── Intro / Start screen ────────────────────────────────
|
|
if (phase === 'intro') {
|
|
return (
|
|
<div className="p-4 md:p-8 max-w-2xl mx-auto">
|
|
<div className="text-center py-12">
|
|
<CheckSquare size={64} className="mx-auto text-teal/30 mb-6" />
|
|
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Theme Test</h1>
|
|
<p className="text-fg-muted text-lg mb-2">Cycle {getCurriculumCycle(weekNumber)} · Week {getCurriculumWeek(weekNumber)} of 26</p>
|
|
<p className="text-fg-muted mb-8 max-w-md mx-auto">
|
|
Test your knowledge of this week's theme with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
|
</p>
|
|
|
|
{error && (
|
|
<Card className="border border-red-200 bg-red-50 text-red-900 p-4 mb-6 text-sm text-left">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
|
<p>{error}</p>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-center gap-6 text-sm text-fg-muted">
|
|
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 5 questions</span>
|
|
<span className="flex items-center gap-1.5"><Clock size={14} /> 5 minutes</span>
|
|
<span className="flex items-center gap-1.5"><Trophy size={14} /> 20 pts max</span>
|
|
</div>
|
|
<Button onClick={startQuiz} className="text-lg px-8 py-3">
|
|
Start Test <ArrowRight size={20} className="ml-2" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Loading ──────────────────────────────────────────────
|
|
if (phase === 'loading') {
|
|
return (
|
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
|
<p className="font-medium text-lg">AI is generating your test...</p>
|
|
<p className="text-sm text-fg-muted mt-2">Preparing 5 questions based on your learning topics.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Results ──────────────────────────────────────────────
|
|
if (phase === 'results' && result) {
|
|
const isPerfect = result.percentage === 100;
|
|
const isGood = result.percentage >= 70;
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
|
{/* Score Header */}
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="text-center mb-10"
|
|
>
|
|
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
|
|
}`}>
|
|
<span className="text-4xl font-bold">{result.percentage}%</span>
|
|
</div>
|
|
<h1 className="text-3xl font-bold mb-2">
|
|
{isPerfect ? 'Perfect score!' : isGood ? 'Well done!' : 'Keep learning!'}
|
|
</h1>
|
|
<p className="text-fg-muted">
|
|
You scored {result.score}/{result.total} in {formatTime(result.timeUsed)}.
|
|
{result.pointsEarned && <span className="text-teal font-semibold"> +{result.pointsEarned} pts</span>}
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
|
<Card className="border border-bg-warm text-center py-4">
|
|
<CheckCircle size={24} className="mx-auto text-teal mb-1" />
|
|
<div className="text-2xl font-bold text-teal">{result.score}</div>
|
|
<div className="text-xs text-fg-muted">Correct</div>
|
|
</Card>
|
|
<Card className="border border-bg-warm text-center py-4">
|
|
<XCircle size={24} className="mx-auto text-red-400 mb-1" />
|
|
<div className="text-2xl font-bold text-red-500">{result.total - result.score}</div>
|
|
<div className="text-xs text-fg-muted">Incorrect</div>
|
|
</Card>
|
|
<Card className="border border-bg-warm text-center py-4">
|
|
<Clock size={24} className="mx-auto text-fg-muted mb-1" />
|
|
<div className="text-2xl font-bold">{formatTime(result.timeUsed)}</div>
|
|
<div className="text-xs text-fg-muted">Time Used</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Review toggle */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-bold">Question Review</h2>
|
|
<Link to="/leaderboard">
|
|
<Button variant="outline">
|
|
<Trophy size={16} className="mr-2" /> Leaderboard
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Question breakdown */}
|
|
<div className="space-y-4">
|
|
{result.breakdown.map((item, i) => (
|
|
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
|
|
}`}>
|
|
{i + 1}
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="font-medium">{item.question}</p>
|
|
<p className="text-xs text-fg-muted mt-0.5">{item.topicLabel}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 ml-10 mb-3">
|
|
{item.options.map((opt, oi) => {
|
|
const isCorrect = oi === item.correctIndex;
|
|
const wasSelected = oi === item.selected;
|
|
return (
|
|
<div
|
|
key={oi}
|
|
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${isCorrect
|
|
? 'border-teal bg-teal/5 text-teal font-medium'
|
|
: wasSelected
|
|
? 'border-red-300 bg-red-50 text-red-700'
|
|
: 'border-bg-warm text-fg-muted'
|
|
}`}
|
|
>
|
|
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
|
|
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}
|
|
{opt}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<p className="text-sm text-fg-muted ml-10 italic">{item.explanation}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Active Quiz ──────────────────────────────────────────
|
|
if (phase === 'quiz' && quiz) {
|
|
const q = quiz.questions[currentQ];
|
|
|
|
// Safety guard — should never happen, but prevents a crash if questions array is empty
|
|
if (!q) {
|
|
return (
|
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
|
<p className="text-red-500 font-medium">Something went wrong loading this question.</p>
|
|
<Button className="mt-4" onClick={() => setPhase('intro')}>Back</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedAnswer = answers[q.id];
|
|
const isCorrect = selectedAnswer === q.correctIndex;
|
|
const progress = ((currentQ + (showFeedback ? 1 : 0)) / quiz.questions.length) * 100;
|
|
const timerDanger = timeLeft < 60;
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 max-w-2xl mx-auto pb-24 md:pb-8">
|
|
{/* Header bar */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="font-mono text-sm text-fg-muted">
|
|
Question {currentQ + 1} of {quiz.questions.length}
|
|
</span>
|
|
<span className={`font-mono text-sm font-medium flex items-center gap-1.5 ${timerDanger ? 'text-red-500 animate-pulse' : 'text-fg-muted'}`}>
|
|
<Clock size={14} /> {formatTime(timeLeft)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="h-1.5 bg-bg-warm rounded-full mb-8 overflow-hidden">
|
|
<motion.div
|
|
className="h-full bg-teal rounded-full"
|
|
initial={false}
|
|
animate={{ width: `${progress}%` }}
|
|
transition={{ duration: 0.3 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Question card */}
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={q.id}
|
|
initial={{ opacity: 0, x: 30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -30 }}
|
|
transition={{ duration: 0.25 }}
|
|
>
|
|
<Card className="border border-bg-warm mb-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Tag variant="accent" className="text-xs">{q.topicLabel}</Tag>
|
|
</div>
|
|
<h5 className="text-sm font-bold leading-snug">{q.question}</h5>
|
|
</Card>
|
|
|
|
{/* Options */}
|
|
<div className="space-y-3">
|
|
{q.options.map((option, oi) => {
|
|
let optionClass = 'border-bg-warm bg-paper hover:border-teal/50 hover:bg-teal/5 cursor-pointer';
|
|
|
|
if (showFeedback) {
|
|
if (oi === q.correctIndex) {
|
|
optionClass = 'border-teal bg-teal/10 text-teal';
|
|
} else if (oi === selectedAnswer && !isCorrect) {
|
|
optionClass = 'border-red-300 bg-red-50 text-red-700';
|
|
} else {
|
|
optionClass = 'border-bg-warm text-fg-muted opacity-50';
|
|
}
|
|
} else if (selectedAnswer === oi) {
|
|
optionClass = 'border-teal bg-teal/5';
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={oi}
|
|
onClick={() => selectAnswer(q.id, oi)}
|
|
disabled={showFeedback}
|
|
className={`w-full text-left p-4 rounded-[var(--r-sm)] border transition-all flex items-center gap-3 ${optionClass}`}
|
|
>
|
|
<span className="flex-shrink-0 w-8 h-8 rounded-full border border-current flex items-center justify-center font-mono font-bold text-sm">
|
|
{String.fromCharCode(65 + oi)}
|
|
</span>
|
|
<span className="text-sm font-medium">{option.replace(/^[A-D]\)\s*/, '')}</span>
|
|
{showFeedback && oi === q.correctIndex && <CheckCircle size={18} className="ml-auto text-teal" />}
|
|
{showFeedback && oi === selectedAnswer && !isCorrect && oi !== q.correctIndex && <XCircle size={18} className="ml-auto text-red-400" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Feedback */}
|
|
{showFeedback && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="mt-6"
|
|
>
|
|
<Card className={`border ${isCorrect ? 'border-teal/30 bg-teal/5' : 'border-red-200 bg-red-50'}`}>
|
|
<div className="flex items-start gap-3">
|
|
{isCorrect
|
|
? <CheckCircle size={20} className="text-teal flex-shrink-0 mt-0.5" />
|
|
: <XCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />}
|
|
<div>
|
|
<p className={`font-bold text-sm ${isCorrect ? 'text-teal' : 'text-red-700'}`}>
|
|
{isCorrect ? 'Correct!' : 'Incorrect'}
|
|
</p>
|
|
<p className="text-sm mt-1">{q.explanation}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="flex justify-end mt-4">
|
|
<Button onClick={nextQuestion}>
|
|
{currentQ < quiz.questions.length - 1 ? <>Next Question <ArrowRight size={18} className="ml-2" /></> : <>Finish Test <Trophy size={18} className="ml-2" /></>}
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export default Testen;
|