import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession"; import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; import { cn } from "@/lib/utils"; function normalizeType(t: string) { return t.toLowerCase().replace(/\s+/g, "_"); } function countWords(s: string) { return s.trim() ? s.trim().split(/\s+/).length : 0; } function buildAnswerMap(sections: ExamSessionSection[]) { const map = new Map(); for (const sec of sections) { for (const q of sec.questions) { map.set(q.id, { question_id: q.id, answer: null, flagged: false, time_spent_ms: 0, }); } } return map; } export default function ExamSession() { const { examId: examIdParam } = useParams(); const examId = Number(examIdParam); const navigate = useNavigate(); const { data: session, isLoading, isError } = useExamSession(examId); const autoSave = useExamAutoSave(); const submitMut = useExamSubmit(); const [sectionIdx, setSectionIdx] = useState(0); const [questionIdx, setQuestionIdx] = useState(0); const [answers, setAnswers] = useState>(new Map()); const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null); const [reviewOpen, setReviewOpen] = useState(false); const [playing, setPlaying] = useState(false); const totalSecRef = useRef(0); const qStartRef = useRef(Date.now()); useEffect(() => { if (!session) return; setAnswers(buildAnswerMap(session.sections)); totalSecRef.current = session.total_time_min * 60; setTick(0); }, [session]); const section = session?.sections[sectionIdx]; const question = section?.questions[questionIdx]; const totalQuestions = useMemo( () => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0, [session], ); const [tick, setTick] = useState(0); useEffect(() => { const id = window.setInterval(() => setTick((t) => t + 1), 1000); return () => window.clearInterval(id); }, []); useEffect(() => { qStartRef.current = Date.now(); }, [question?.id]); const answeredCount = useMemo(() => { let n = 0; answers.forEach((a) => { if (a.answer === null || a.answer === "") return; if (Array.isArray(a.answer) && a.answer.length === 0) return; n += 1; }); return n; }, [answers]); const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0; const updateAnswer = useCallback( (questionId: number, patch: Partial) => { setAnswers((prev) => { const next = new Map(prev); const cur = next.get(questionId); if (!cur) return prev; const spent = Date.now() - qStartRef.current; next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent }); qStartRef.current = Date.now(); return next; }); }, [], ); const currentSectionAnswers = useCallback((): ExamAnswer[] => { if (!section) return []; return section.questions.map((q) => answers.get(q.id)!).filter(Boolean); }, [section, answers]); useEffect(() => { if (!session || !section) return; const id = window.setInterval(() => { autoSave.mutate({ examId, payload: { section_id: section.id, answers: currentSectionAnswers() }, }); }, 10000); return () => window.clearInterval(id); }, [session, section, examId, autoSave, currentSectionAnswers]); const handleFlag = () => { if (!question) return; const cur = answers.get(question.id); if (!cur) return; updateAnswer(question.id, { flagged: !cur.flagged }); }; const goNext = () => { if (!session || !section) return; if (questionIdx < section.questions.length - 1) { setQuestionIdx((i) => i + 1); return; } if (sectionIdx < session.sections.length - 1) { setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 }); return; } setReviewOpen(true); }; const goPrev = () => { if (questionIdx > 0) { setQuestionIdx((i) => i - 1); return; } if (sectionIdx > 0) { const prevSec = session!.sections[sectionIdx - 1]; setSectionIdx((s) => s - 1); setQuestionIdx(prevSec.questions.length - 1); } }; useEffect(() => { if (!sectionGate?.open) return; if (sectionGate.remaining <= 0) { setSectionIdx(sectionGate.sec); setQuestionIdx(0); setSectionGate(null); return; } const t = window.setTimeout( () => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)), 1000, ); return () => window.clearTimeout(t); }, [sectionGate]); const summary = useMemo(() => { let answered = 0; let unanswered = 0; let flagged = 0; session?.sections.forEach((sec) => { sec.questions.forEach((q) => { const a = answers.get(q.id); if (!a) { unanswered += 1; return; } if (a.flagged) flagged += 1; const empty = a.answer === null || a.answer === "" || (Array.isArray(a.answer) && a.answer.length === 0); if (empty) unanswered += 1; else answered += 1; }); }); return { answered, unanswered, flagged, total: totalQuestions }; }, [session, answers, totalQuestions]); const renderQuestion = (q: ExamQuestion) => { const a = answers.get(q.id); if (!a) return null; const nt = normalizeType(q.type); if (nt.includes("listen") || q.audio_url) { return (
0:00 / 3:42
{q.options?.length ? ( updateAnswer(q.id, { answer: v })} className="space-y-2" > {q.options.map((o) => (
))}
) : null}
); } if (nt.includes("multi") || nt === "mcq_multi") { const selected = Array.isArray(a.answer) ? a.answer : []; return (
{q.options?.map((o) => (
{ const on = ck === true; const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label); updateAnswer(q.id, { answer: next }); }} />
))}
); } if (nt.includes("gap")) { return ( updateAnswer(q.id, { answer: e.target.value })} placeholder="Your answer" className="max-w-md" /> ); } if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") { const opts = q.options?.length ? q.options : [ { label: "T", text: "True" }, { label: "F", text: "False" }, { label: "NG", text: "Not Given" }, ]; return ( updateAnswer(q.id, { answer: v })} className="space-y-2" > {opts.map((o) => (
))}
); } if (nt.includes("writing") || nt.includes("essay")) { const text = typeof a.answer === "string" ? a.answer : ""; const wc = countWords(text); const min = q.min_words ?? 150; return (