"use client";
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { notFound } from 'next/navigation';
import type { TestSession, UserAnswer, Question, TestResult } from '@/lib/types';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
import { ChevronLeft, ChevronRight, Bookmark, Circle, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser, useFirestore } from '@/firebase';
import { doc, writeBatch } from 'firebase/firestore';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
const Palette = ({
session,
currentQuestionIndex,
onQuestionSelect,
}: {
session: TestSession;
currentQuestionIndex: number;
onQuestionSelect: (index: number) => void;
}) => {
return (
{session.questions.map((_, index) => {
const status = session.userAnswers[index].status;
let statusClass = '';
let isCurrent = index === currentQuestionIndex;
if (isCurrent) {
statusClass = 'bg-primary text-primary-foreground scale-110';
} else {
switch (status) {
case 'answered': statusClass = 'bg-green-500/20 border-green-700 text-green-400'; break;
case 'not-answered': statusClass = 'bg-red-500/20 border-red-700 text-red-400'; break;
case 'marked-for-review': statusClass = 'bg-purple-500/20 border-purple-700 text-purple-400'; break;
case 'answered-and-marked-for-review': statusClass = 'bg-yellow-500/20 border-yellow-700 text-yellow-400'; break;
default: statusClass = 'bg-muted/40 hover:bg-muted/80 border-white/20';
}
}
return (
onQuestionSelect(index)}
className={cn(
'h-12 w-12 text-lg font-semibold transition-all flex-shrink-0 border',
statusClass,
isCurrent ? 'ring-2 ring-primary-foreground ring-offset-2 ring-offset-background' : 'hover:scale-105'
)}
>
{index + 1}
);
})}
Answered
Not Answered
Marked
Answered & Marked
Not Visited
)
}
export function TestInterface({ testId }: { testId: string }) {
const router = useRouter();
const { toast } = useToast();
const [session, setSession] = useState(null);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [timeLeft, setTimeLeft] = useState(null);
const [isMounted, setIsMounted] = useState(false);
const { user } = useUser();
const firestore = useFirestore();
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const sessionData = localStorage.getItem(`test_session_${testId}`);
if (sessionData) {
const parsedSession: TestSession = JSON.parse(sessionData);
setSession(parsedSession);
if (parsedSession.config.timeLimit) {
const elapsedTime = Math.floor((Date.now() - parsedSession.startTime) / 1000);
setTimeLeft(parsedSession.config.timeLimit * 60 - elapsedTime);
}
}
setIsMounted(true);
}, [testId]);
const updateSessionState = (newSession: TestSession) => {
setSession(newSession);
localStorage.setItem(`test_session_${testId}`, JSON.stringify(newSession));
}
useEffect(() => {
if(!session || session.userAnswers[currentQuestionIndex].status !== 'not-visited') {
return;
}
const newAnswers = [...session.userAnswers];
newAnswers[currentQuestionIndex].status = 'not-answered';
updateSessionState({ ...session, userAnswers: newAnswers });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentQuestionIndex, session]);
const submitTest = useCallback(async () => {
if (!session || !user || !firestore) {
if (!user) {
toast({ title: 'Not Signed In', description: 'Please sign in to save your results.', variant: 'destructive'});
}
return;
};
setIsSubmitting(true);
try {
const endTime = Date.now();
let correctAnswers = 0;
let incorrectAnswers = 0;
let skipped = 0;
const performanceByCategory: { [key: string]: { correct: number; incorrect: number; skipped: number; total: number; type: 'subject' | 'topic' } } = {};
session.questions.forEach((q, i) => {
const categoryName = q.topic || q.subject;
const categoryType = q.topic ? 'topic' as const : 'subject' as const;
if (!performanceByCategory[categoryName]) {
performanceByCategory[categoryName] = { correct: 0, incorrect: 0, skipped: 0, total: 0, type: categoryType };
}
performanceByCategory[categoryName].total++;
const userAnswer = session.userAnswers[i];
if (userAnswer.status === 'not-visited' || userAnswer.status === 'not-answered' || userAnswer.status === 'marked-for-review') {
skipped++;
performanceByCategory[categoryName].skipped++;
} else if (userAnswer.selectedOption === q.correctAnswerIndex) {
correctAnswers++;
performanceByCategory[categoryName].correct++;
} else {
incorrectAnswers++;
performanceByCategory[categoryName].incorrect++;
}
});
const attempted = correctAnswers + incorrectAnswers;
const accuracy = attempted > 0 ? (correctAnswers / attempted) * 100 : 0;
let score = correctAnswers;
if(session.config.negativeMarking && session.config.negativeMarkingRatio) {
score -= incorrectAnswers * session.config.negativeMarkingRatio;
}
const performanceBreakdown: import('@/lib/types').PerformanceBreakdown[] = Object.entries(performanceByCategory).map(([category, stats]) => {
const attemptedInCategory = stats.correct + stats.incorrect;
return {
category: category,
categoryType: stats.type,
accuracy: attemptedInCategory > 0 ? (stats.correct / attemptedInCategory) * 100 : 0,
correctCount: stats.correct,
incorrectCount: stats.incorrect,
skippedCount: stats.skipped,
totalCount: stats.total,
};
});
const result: TestResult = {
id: session.id,
userId: user.uid,
config: session.config,
score,
accuracy,
totalQuestions: session.questions.length,
correctAnswers,
incorrectAnswers,
skipped,
takenAt: new Date(session.startTime).toISOString(),
timeTaken: Math.floor((endTime - session.startTime) / 1000),
performanceBreakdown,
};
const finalSession: TestSession = { ...session, userId: user.uid, endTime: endTime, userAnswers: session.userAnswers.map(ua => ({...ua, selectedOption: ua.selectedOption === undefined ? null : ua.selectedOption})) };
const batch = writeBatch(firestore);
const resultRef = doc(firestore, 'users', user.uid, 'testResults', session.id);
batch.set(resultRef, result);
const sessionRef = doc(firestore, 'users', user.uid, 'userTests', session.id);
batch.set(sessionRef, finalSession);
await batch.commit();
localStorage.removeItem(`test_session_${session.id}`);
toast({ title: 'Test Submitted! 🎉', description: 'Your results have been saved. Redirecting...' });
router.replace(`/results/${session.id}`);
} catch (error) {
console.error("Failed to submit test:", error);
toast({
title: "Submission Failed",
description: "Could not save your test results. Please check your connection and try again.",
variant: "destructive",
});
setIsSubmitting(false);
}
}, [session, router, user, firestore, toast]);
useEffect(() => {
if (timeLeft === null) return;
if (timeLeft <= 0) {
submitTest();
return;
}
const timer = setInterval(() => setTimeLeft(t => (t ?? 0) - 1), 1000);
return () => clearInterval(timer);
}, [timeLeft, submitTest]);
const updateAnswer = (questionIndex: number, selectedOption?: number, statusUpdate?: Partial) => {
if (!session) return;
const newAnswers = [...session.userAnswers];
const currentAnswer = newAnswers[questionIndex];
let newSession: TestSession;
if (selectedOption !== undefined) {
const newStatus = currentAnswer.status === 'marked-for-review' || currentAnswer.status === 'answered-and-marked-for-review' ? 'answered-and-marked-for-review' : 'answered';
newAnswers[questionIndex] = { ...currentAnswer, selectedOption, status: newStatus };
newSession = { ...session, userAnswers: newAnswers };
} else if (statusUpdate) {
newAnswers[questionIndex] = { ...currentAnswer, ...statusUpdate };
newSession = { ...session, userAnswers: newAnswers };
} else { // Clear response
const newStatus = currentAnswer.status === 'answered-and-marked-for-review' ? 'marked-for-review' : 'not-answered';
newAnswers[questionIndex] = { ...currentAnswer, selectedOption: null, status: newStatus };
newSession = { ...session, userAnswers: newAnswers };
}
updateSessionState(newSession);
};
const handleOptionChange = (value: string) => {
updateAnswer(currentQuestionIndex, parseInt(value));
};
const handleMarkForReview = () => {
const currentStatus = session?.userAnswers[currentQuestionIndex].status;
let newStatus: UserAnswer['status'];
if (currentStatus === 'answered') {
newStatus = 'answered-and-marked-for-review';
} else if (currentStatus === 'answered-and-marked-for-review') {
newStatus = 'answered';
} else if (currentStatus === 'marked-for-review') {
newStatus = 'not-answered';
} else { // not-answered or not-visited
newStatus = 'marked-for-review';
}
updateAnswer(currentQuestionIndex, undefined, { status: newStatus });
};
const clearResponse = () => {
updateAnswer(currentQuestionIndex, undefined, { selectedOption: null });
};
const handleQuestionSelect = (index: number) => {
setCurrentQuestionIndex(index);
};
const currentQuestion: Question | undefined = session?.questions[currentQuestionIndex];
const currentUserAnswer: UserAnswer | undefined = session?.userAnswers[currentQuestionIndex];
if (!isMounted) return
;
if (!session || !currentQuestion) {
useEffect(() => notFound(), []);
return null;
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
Question {currentQuestionIndex + 1} of {session.questions.length}
{session.config.exam}
{timeLeft !== null && (
Time Left
{formatTime(timeLeft)}
)}
{currentQuestion.questionText}
{currentQuestion.options.map((option, index) => (
{option}
))}
Mark for Review
Clear Response
setCurrentQuestionIndex(p => Math.max(0, p - 1))} disabled={currentQuestionIndex === 0}> Previous
{currentQuestionIndex < session.questions.length - 1 ? (
setCurrentQuestionIndex(p => Math.min(session.questions.length - 1, p + 1))}>Save & Next
) : (
Submit Test
Ready to finish?
This will end the test and calculate your score. You cannot go back after submitting.
Cancel
{isSubmitting ? (
<>
Submitting...
>
) : (
'Submit'
)}
)}
);
}