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, ExamOptionItem, 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, Mic, Square } from "lucide-react"; import { cn } from "@/lib/utils"; import { mediaService } from "@/services/media.service"; function normalizeType(t: string | null | undefined) { return (t ?? "").toLowerCase().replace(/\s+/g, "_"); } function normalizeOption(o: ExamOptionItem): { label: string; text: string } { if (typeof o === "string") return { label: o, text: o }; return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" }; } 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) { // The backend `/api/exam//session` endpoint returns a // `saved_answer` per question whenever a previous attempt is // resumed (browser refresh, network drop, accidental tab-close). // Seed the local map from that value so the student picks up // exactly where they left off — without this, autosaved answers // were silently dropped on every reload. const saved = (q as unknown as { saved_answer?: unknown }).saved_answer; const flagged = Boolean( (q as unknown as { flagged?: boolean }).flagged, ); map.set(q.id, { question_id: q.id, answer: saved === undefined || saved === null ? null : (saved as ExamAnswer["answer"]), flagged, time_spent_ms: 0, }); } } return map; } export default function ExamSession() { const { examId: examIdParam } = useParams(); const examId = Number(examIdParam); const navigate = useNavigate(); const { data: rawSession, isLoading, isError } = useExamSession(examId); const autoSave = useExamAutoSave(); const submitMut = useExamSubmit(); const session = useMemo(() => { if (!rawSession) return rawSession; const raw = rawSession as any; return { ...raw, title: raw.title || raw.exam_title || "", sections: (raw.sections || []).map((s: any) => ({ ...s, questions: (s.questions || []).map((q: any) => ({ ...q, type: q.type || q.question_type || q.skill || "", })), })), } as typeof rawSession; }, [rawSession]); 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 attemptId = (session as any)?.attempt_id; const id = window.setInterval(() => { autoSave.mutate({ examId, payload: { attempt_id: attemptId, 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); const opts = (q.options ?? []).map(normalizeOption); if (nt.includes("listen") || q.audio_url) { return (
{opts.length ? ( updateAnswer(q.id, { answer: v })} className="space-y-2" > {opts.map((o) => (
))}
) : null}
); } if (nt.includes("multi") || nt === "mcq_multi") { const selected = Array.isArray(a.answer) ? a.answer : []; return (
{opts.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") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) { 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 tfOpts = opts.length ? opts : [ { label: "TRUE", text: "TRUE" }, { label: "FALSE", text: "FALSE" }, { label: "NOT GIVEN", text: "NOT GIVEN" }, ]; return ( updateAnswer(q.id, { answer: v })} className="space-y-2" > {tfOpts.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 (