From b112a8fdac52bc496ea7a01926bd0fffae598eb4 Mon Sep 17 00:00:00 2001 From: shafin-r Date: Sun, 31 Aug 2025 02:20:55 +0600 Subject: [PATCH] fix(api): fix api logic for exam screen needs more work for the timercontext --- app/exam/[id]/page.tsx | 372 +++++++++++------------------------- app/exam/pretest/page.tsx | 14 +- components/Header.tsx | 57 ++---- components/QuestionItem.tsx | 183 ++++++------------ context/ExamContext.tsx | 279 +++++++-------------------- context/TimerContext.tsx | 41 ++-- types/exam.d.ts | 39 ---- 7 files changed, 301 insertions(+), 684 deletions(-) diff --git a/app/exam/[id]/page.tsx b/app/exam/[id]/page.tsx index 0abd813..5f86983 100644 --- a/app/exam/[id]/page.tsx +++ b/app/exam/[id]/page.tsx @@ -1,300 +1,156 @@ "use client"; -import React, { useEffect, useState, useCallback, useMemo } from "react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import React, { useEffect, useCallback, useMemo } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useTimer } from "@/context/TimerContext"; import { useExam } from "@/context/ExamContext"; -import { API_URL, getToken } from "@/lib/auth"; import Header from "@/components/Header"; import { useModal } from "@/context/ModalContext"; import Modal from "@/components/ExamModal"; -import { Question } from "@/types/exam"; import QuestionItem from "@/components/QuestionItem"; +import BackgroundWrapper from "@/components/BackgroundWrapper"; export default function ExamPage() { - // All hooks at the top - no conditional calls const router = useRouter(); - const { id } = useParams(); - const time = useSearchParams().get("time"); - const { isOpen, close } = useModal(); - const { setInitialTime, stopTimer } = useTimer(); - const { - currentAttempt, - setAnswer, - getAnswer, - submitExam: submitExamContext, - setApiResponse, - isExamStarted, - isExamCompleted, - isHydrated, - isInitialized, - currentExam, - } = useExam(); + const searchParams = useSearchParams(); + const test_id = searchParams.get("test_id") || ""; + const type = searchParams.get("type") || ""; - // State management - const [questions, setQuestions] = useState(null); - const [loading, setLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submissionLoading, setSubmissionLoading] = useState(false); - const [componentState, setComponentState] = useState< - "loading" | "redirecting" | "ready" - >("loading"); + const { isOpen, close, open } = useModal(); + const { timeRemaining, setInitialTime, stopTimer } = useTimer(); + const { test, answers, startExam, setAnswer, submitExam, cancelExam } = + useExam(); - // Combined initialization effect + // Start exam + timer useEffect(() => { - let mounted = true; - - const initializeComponent = async () => { - // Wait for hydration and initialization - if (!isHydrated || !isInitialized || isSubmitting) { - return; - } - - // Check exam state and handle redirects - if (!isExamStarted()) { - if (mounted) { - setComponentState("redirecting"); - setTimeout(() => { - if (mounted) router.push("/unit"); - }, 100); + if (type && test_id) { + startExam(type, test_id).then(() => { + if (test?.metadata.time_limit_minutes) { + setInitialTime(test.metadata.time_limit_minutes * 60); // convert to seconds } - return; - } - - if (isExamCompleted()) { - if (mounted) { - setComponentState("redirecting"); - setTimeout(() => { - if (mounted) router.push("/exam/results"); - }, 100); - } - return; - } - - // Component is ready to render - if (mounted) { - setComponentState("ready"); - } - }; - - initializeComponent(); - - return () => { - mounted = false; - }; - }, [ - isHydrated, - isInitialized, - isExamStarted, - isExamCompleted, - isSubmitting, - router, - ]); - - // Fetch questions effect - useEffect(() => { - if (componentState !== "ready") return; - - const fetchQuestions = async () => { - try { - const response = await fetch(`${API_URL}/mock/${id}`); - const data = await response.json(); - setQuestions(data.questions); - } catch (error) { - console.error("Error fetching questions:", error); - } finally { - setLoading(false); - } - }; - - fetchQuestions(); - if (time) setInitialTime(Number(time)); - }, [id, time, setInitialTime, componentState]); - - const handleSelect = useCallback( - (questionId: number, option: string) => { - setAnswer(questionId.toString(), option); - }, - [setAnswer] - ); - - const handleSubmit = async () => { - if (!currentAttempt) return console.error("No exam attempt found"); - - stopTimer(); - setSubmissionLoading(true); - setIsSubmitting(true); - - const answersForAPI = currentAttempt.answers.reduce( - (acc, { questionId, answer }) => { - acc[+questionId] = answer; - return acc; - }, - {} as Record - ); - - try { - const response = await fetch(`${API_URL}/submit`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${await getToken()}`, - }, - body: JSON.stringify({ mock_id: id, data: answersForAPI }), }); - - if (!response.ok) - throw new Error((await response.json()).message || "Submission failed"); - - const responseData = await response.json(); - submitExamContext(); - setApiResponse(responseData); - router.push("/exam/results"); - } catch (error) { - console.error("Error submitting answers:", error); - setIsSubmitting(false); - } finally { - setSubmissionLoading(false); } - }; + }, [type, test_id, startExam, setInitialTime]); const showExitDialog = useCallback(() => { if (window.confirm("Are you sure you want to quit the exam?")) { stopTimer(); + cancelExam(); router.push("/unit"); } - }, [stopTimer, router]); + }, [stopTimer, cancelExam, router]); - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = ""; - }; - - const handlePopState = (e: PopStateEvent) => { - e.preventDefault(); - showExitDialog(); - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - window.addEventListener("popstate", handlePopState); - - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - window.removeEventListener("popstate", handlePopState); - }; - }, [showExitDialog]); - - const answeredSet = useMemo(() => { - if (!currentAttempt) return new Set(); - return new Set(currentAttempt.answers.map((a) => String(a.questionId))); - }, [currentAttempt]); - - // Show loading/redirecting state - if (componentState === "loading" || componentState === "redirecting") { - const loadingText = - componentState === "redirecting" ? "Redirecting..." : "Loading..."; - - return ( -
-
-
-

{loadingText}

-
-
- ); - } - - // Show submission loading - if (submissionLoading) { + if (!test) { return (
-

Submitting...

+

Loading exam...

); } - // Render the main exam interface + // answered set for modal overview + // const answeredSet = useMemo( + // () => + // new Set( + // answers + // .map((a, idx) => + // a !== null && a !== undefined ? idx.toString() : null + // ) + // .filter(Boolean) as string[] + // ), + // [answers] + // ); + return (
-
- - {currentAttempt ? ( -
-
-

Questions: {currentExam?.questions.length}

-

- Answers:{" "} - - {currentAttempt?.answers.length} - -

-

- Skipped:{" "} - - {(currentExam?.questions?.length ?? 0) - - (currentAttempt?.answers?.length ?? 0)} - -

-
-
-
- {currentExam?.questions.map((q, idx) => { - const answered = answeredSet.has(String(q.id)); + {/* Header with live timer */} +
- return ( -
+
+
+

Questions: {test.questions.length}

+

+ Answered:{" "} + + {answeredSet.size} + +

+

+ Skipped:{" "} + + {test.questions.length - answeredSet.size} + +

+
+ +
+ +
+ {test.questions.map((q, idx) => { + const answered = answeredSet.has(String(idx)); + return ( +
- {idx + 1} -
- ); - })} -
-
- ) : ( -

No attempt data.

- )} - -
- {loading ? ( -
-
-
- ) : ( -
- {questions?.map((q) => ( - - ))} -
- )} - -
- + ? "bg-blue-900 text-white" + : "bg-gray-200 text-gray-900" + } + hover:opacity-80 transition`} + onClick={() => { + // optional: scroll to question + const el = document.getElementById(`question-${idx}`); + if (el) { + el.scrollIntoView({ behavior: "smooth" }); + close(); + } + }} + > + {idx + 1} +
+ ); + })} +
-
+ */} + + {/* Questions */} + +
+ {test.questions.map((q, idx) => ( +
+ setAnswer(idx, answer)} + /> +
+ ))} + + {/* Bottom submit bar */} +
+ + +
+
+
); } diff --git a/app/exam/pretest/page.tsx b/app/exam/pretest/page.tsx index 6e7d599..3a96e7f 100644 --- a/app/exam/pretest/page.tsx +++ b/app/exam/pretest/page.tsx @@ -14,14 +14,14 @@ import DestructibleAlert from "@/components/DestructibleAlert"; import BackgroundWrapper from "@/components/BackgroundWrapper"; import { API_URL, getToken } from "@/lib/auth"; import { useExam } from "@/context/ExamContext"; -import { Question } from "@/types/exam"; +import { Test } from "@/types/exam"; import { Metadata } from "@/types/exam"; function PretestPageContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { startExam, setCurrentExam } = useExam(); - const [examData, setExamData] = useState(); + const { startExam } = useExam(); + const [examData, setExamData] = useState(); // Get params from URL search params const id = searchParams.get("test_id") || ""; @@ -55,7 +55,7 @@ function PretestPageContent() { const data = await questionResponse.json(); const fetchedMetadata: Metadata = data.metadata; - const fetchedQuestions: Question[] = data.questions; + const fetchedQuestions: Test = data.questions; setMetadata(fetchedMetadata); setExamData(fetchedQuestions); @@ -123,9 +123,9 @@ function PretestPageContent() { function handleStartExam() { if (!examData) return; - setCurrentExam(examData); - startExam(examData); - router.push(`/exam/${id}?test_id=${metadata?.test_id}`); + router.push( + `/exam/${id}?type=${type}&test_id=${metadata?.test_id}&attempt_id=${metadata?.attempt_id}` + ); } return ( diff --git a/components/Header.tsx b/components/Header.tsx index e2a5523..4574c7b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,4 +1,6 @@ -import React, { useState, useEffect } from "react"; +"use client"; + +import React from "react"; import { useRouter } from "next/navigation"; import { ChevronLeft, Layers } from "lucide-react"; import { useTimer } from "@/context/TimerContext"; @@ -12,53 +14,25 @@ interface HeaderProps { displayUser?: boolean; displaySubject?: string; displayTabTitle?: string; - examDuration?: string | null; } const Header = ({ displayUser, displaySubject, displayTabTitle, - examDuration, }: HeaderProps) => { const router = useRouter(); const { open } = useModal(); - const { clearExam } = useExam(); - const [totalSeconds, setTotalSeconds] = useState( - examDuration ? parseInt(examDuration) * 60 : 0 - ); - const { stopTimer } = useTimer(); - const { user, isLoading } = useAuth(); - - useEffect(() => { - if (!examDuration) return; - - const timer = setInterval(() => { - setTotalSeconds((prev) => { - if (prev <= 0) { - clearInterval(timer); - return 0; - } - return prev - 1; - }); - }, 1000); - - return () => clearInterval(timer); - }, [examDuration]); - - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; + const { cancelExam } = useExam(); + const { stopTimer, timeRemaining } = useTimer(); + const { user } = useAuth(); const showExitDialog = () => { const confirmed = window.confirm("Are you sure you want to quit the exam?"); - if (confirmed) { - if (stopTimer) { - stopTimer(); - } - clearExam(); - router.push("/unit"); + stopTimer(); + cancelExam(); + router.push("/categories"); } }; @@ -66,6 +40,11 @@ const Header = ({ router.back(); }; + // format time from context + const hours = Math.floor(timeRemaining / 3600); + const minutes = Math.floor((timeRemaining % 3600) / 60); + const seconds = timeRemaining % 60; + return (
{displayUser && ( @@ -96,7 +75,8 @@ const Header = ({ )} - {examDuration && ( + {/* Exam timer header */} + {timeRemaining > 0 && (
- diff --git a/components/QuestionItem.tsx b/components/QuestionItem.tsx index 64557a3..121a413 100644 --- a/components/QuestionItem.tsx +++ b/components/QuestionItem.tsx @@ -1,136 +1,77 @@ -import { Question } from "@/types/exam"; -import { BookmarkCheck, Bookmark } from "lucide-react"; -import React, { useState } from "react"; -import { Badge } from "./ui/badge"; +"use client"; -interface ResultItemProps { - mode: "result"; +import React from "react"; +import { Question, Answer } from "@/types/exam"; +import { Bookmark } from "lucide-react"; + +interface QuestionItemProps { question: Question; - selectedAnswer: { answer: string } | undefined; + index: number; + selectedAnswer: Answer; + onSelect: (answer: Answer) => void; } -interface ExamItemProps { - mode: "exam"; - question: Question; - selectedAnswer?: string; - handleSelect: (questionId: number, option: string) => void; -} - -type QuestionItemProps = ResultItemProps | ExamItemProps; - -const QuestionItem = (props: QuestionItemProps) => { - const [bookmark, setBookmark] = useState(false); - - const { question } = props; - - const isExam = props.mode === "exam"; - - // Extract correct type-safe selectedAnswer - const selectedAnswer = isExam - ? props.selectedAnswer - : props.selectedAnswer?.answer; - - const handleOptionSelect = (key: string) => { - if (isExam && props.handleSelect) { - props.handleSelect(parseInt(question.id), key); - } - }; +const letters = ["A", "B", "C", "D"]; // extend if needed +const QuestionItem: React.FC = ({ + question, + index, + selectedAnswer, + onSelect, +}) => { return ( -
-

- {question.id}. {question.question} -

+
+

+ {index + 1}. {question.question} +

- {isExam && ( -
-
- -
- )} +
+
+ +
- {isExam ? ( -
- {Object.entries(question.options ?? {}).map(([key, value]) => { - const isSelected = selectedAnswer === key; +
+ {question.options.map((opt, optIdx) => { + const isSelected = + question.type === "Single" + ? selectedAnswer === optIdx + : Array.isArray(selectedAnswer) && + selectedAnswer.includes(optIdx); - return ( + return ( +
- ); - })} -
- ) : ( -
-
-
- - {!selectedAnswer ? ( - - Skipped - - ) : selectedAnswer === question.correctAnswer ? ( - - Correct - - ) : ( - - Incorrect - - )} -
- -
- {Object.entries(question.options ?? {}).map(([key, value]) => { - const isCorrect = key === question.correctAnswer; - const isSelected = key === selectedAnswer; - - let optionStyle = - "px-2 py-1 flex items-center rounded-full border font-medium text-sm"; - - if (isCorrect) { - optionStyle += " bg-green-600 text-white border-green-600"; - } else if (isSelected && !isCorrect) { - optionStyle += " bg-red-600 text-white border-red-600"; - } else { - optionStyle += " border-gray-300 text-gray-700"; - } - - return ( -
- {key.toUpperCase()} - {value} -
- ); - })} -
- -
- -
-

Solution:

-

{question.solution}

-
-
- )} + {opt} +
+ ); + })} +
); }; diff --git a/context/ExamContext.tsx b/context/ExamContext.tsx index d3228b6..448fbf6 100644 --- a/context/ExamContext.tsx +++ b/context/ExamContext.tsx @@ -1,231 +1,100 @@ "use client"; -import React, { - createContext, - useContext, - useState, - useEffect, - ReactNode, -} from "react"; -import { useRouter } from "next/navigation"; +import React, { createContext, useContext, useState } from "react"; +import { Test, Answer } from "@/types/exam"; +import { API_URL } from "@/lib/auth"; +import { getToken } from "@/lib/auth"; -import { Test, TestAttempt, TestContextType, Answer } from "@/types/exam"; -import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils"; -import { useAuth } from "./AuthContext"; +interface ExamContextType { + test: Test | null; + answers: Answer[]; + startExam: (testType: string, testId: string) => Promise; + setAnswer: (questionIndex: number, answer: Answer) => void; + submitExam: () => Promise; + cancelExam: () => void; +} -const ExamContext = createContext(undefined); +const ExamContext = createContext(undefined); -const STORAGE_KEYS = { - CURRENT_EXAM: "current-exam", - CURRENT_ATTEMPT: "current-attempt", -} as const; - -export const ExamProvider: React.FC<{ children: ReactNode }> = ({ +export const ExamProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const router = useRouter(); - const [currentExam, setCurrentExamState] = useState(null); - const [currentAttempt, setCurrentAttemptState] = useState( - null - ); - const [isHydrated, setIsHydrated] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const { user } = useAuth(); + const [test, setTest] = useState(null); + const [answers, setAnswers] = useState([]); - // Hydrate from storage - useEffect(() => { - const savedExam = getFromStorage(STORAGE_KEYS.CURRENT_EXAM); - const savedAttempt = getFromStorage( - STORAGE_KEYS.CURRENT_ATTEMPT - ); + // start exam + const startExam = async (testType: string, testId: string) => { + try { + const token = await getToken(); // if needed + const res = await fetch(`${API_URL}/tests/${testType}/${testId}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (savedExam) setCurrentExamState(savedExam); - if (savedAttempt) setCurrentAttemptState(savedAttempt); - - setIsHydrated(true); - }, []); - - // Persist exam - useEffect(() => { - if (!isHydrated) return; - if (currentExam) { - setToStorage(STORAGE_KEYS.CURRENT_EXAM, currentExam); - } else { - removeFromStorage(STORAGE_KEYS.CURRENT_EXAM); + if (!res.ok) throw new Error(`Failed to fetch test: ${res.status}`); + const data: Test = await res.json(); + setTest(data); + setAnswers(Array(data.questions.length).fill(null)); + } catch (err) { + console.error("startExam error:", err); } - }, [currentExam, isHydrated]); - - // Persist attempt - useEffect(() => { - if (!isHydrated) return; - if (currentAttempt) { - setToStorage(STORAGE_KEYS.CURRENT_ATTEMPT, currentAttempt); - } else { - removeFromStorage(STORAGE_KEYS.CURRENT_ATTEMPT); - } - }, [currentAttempt, isHydrated]); - - const setCurrentExam = (exam: Test) => { - setCurrentExamState(exam); - setCurrentAttemptState(null); }; - const startExam = (exam?: Test): void => { - const examToUse = exam || currentExam; - const user_id = user?.user_id; - - if (!examToUse) { - console.warn("No exam selected, redirecting to /unit"); - router.push("/unit"); - return; - } - - const attempt: TestAttempt = { - user_id: user_id, // TODO: inject from auth - test_id: examToUse.metadata.test_id, - subject_id: "temp-subject", // TODO: might delete - topic_id: "temp-topic", // TODO: might delete - test_type: "Mock", // or "Subject"/"Topic" depending on flow - attempt_id: crypto.randomUUID(), - start_time: new Date().toISOString(), - end_time: "", - user_questions: examToUse.questions, - user_answers: Array(examToUse.questions.length).fill(null), - correct_answers: [], - correct_answers_count: 0, - wrong_answers_count: 0, - skipped_questions_count: 0, - }; - - setCurrentAttemptState(attempt); - setIsInitialized(true); - }; - - const setAnswer = (questionId: string, answer: Answer) => { - if (!currentAttempt) { - console.warn("No exam attempt started, redirecting to /unit"); - router.push("/unit"); - return; - } - - const questionIndex = currentAttempt.user_questions.findIndex( - (q) => q.question_id === questionId - ); - if (questionIndex === -1) return; - - setCurrentAttemptState((prev) => { - if (!prev) return null; - const updatedAnswers = [...prev.user_answers]; - updatedAnswers[questionIndex] = answer; - return { ...prev, user_answers: updatedAnswers }; + // update answer + const setAnswer = (questionIndex: number, answer: Answer) => { + setAnswers((prev) => { + const updated = [...prev]; + updated[questionIndex] = answer; + return updated; }); }; - const setApiResponse = (response: any) => { - // If you want to store API response in attempt - setCurrentAttemptState((prev) => - prev ? { ...prev, apiResponse: response } : null - ); - }; + // submit exam + const submitExam = async () => { + if (!test) return; + const token = await getToken(); + try { + const { type, test_id, attempt_id } = test.metadata; + const res = await fetch( + `${API_URL}/tests/${type}/${test_id}/${attempt_id}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ answers }), + } + ); + if (!res.ok) throw new Error("Failed to submit exam"); - const submitExam = (): TestAttempt | null => { - if (!currentAttempt) { - console.warn("No exam attempt to submit, redirecting to /unit"); - router.push("/unit"); - return null; + // clear + setTest(null); + setAnswers([]); + } catch (err) { + console.error("Failed to submit exam. Reason:", err); } - - const totalQuestions = currentAttempt.user_questions.length; - - // Example evaluation (assumes correct answers in `correct_answers`) - const wrong = currentAttempt.user_answers.filter( - (ans, idx) => ans !== null && ans !== currentAttempt.correct_answers[idx] - ).length; - - const skipped = currentAttempt.user_answers.filter( - (ans) => ans === null - ).length; - const correct = totalQuestions - wrong - skipped; - - const completedAttempt: TestAttempt = { - ...currentAttempt, - end_time: new Date().toISOString(), - correct_answers_count: correct, - wrong_answers_count: wrong, - skipped_questions_count: skipped, - }; - - setCurrentAttemptState(completedAttempt); - return completedAttempt; }; - const clearExam = (): void => { - setCurrentExamState(null); - setCurrentAttemptState(null); - }; - - const getAnswer = (questionId: string): Answer => { - if (!currentAttempt) return null; - const index = currentAttempt.user_questions.findIndex( - (q) => q.question_id === questionId - ); - return index >= 0 ? currentAttempt.user_answers[index] : null; - }; - - const getApiResponse = (): any => { - return (currentAttempt as any)?.apiResponse; - }; - - const getProgress = (): number => { - if (!currentAttempt) return 0; - const totalQuestions = currentAttempt.user_questions.length; - const answered = currentAttempt.user_answers.filter( - (a) => a !== null - ).length; - return totalQuestions > 0 ? (answered / totalQuestions) * 100 : 0; - }; - - const isExamStarted = () => !!currentExam && !!currentAttempt; - - const isExamCompleted = (): boolean => { - if (!isHydrated) return false; - return !!currentAttempt?.end_time; - }; - - const contextValue: TestContextType = { - currentExam, - currentAttempt, - setCurrentExam, - startExam, - setAnswer, - submitExam, - clearExam, - setApiResponse, - getAnswer, - getProgress, - isExamStarted, - isExamCompleted, - getApiResponse, - isHydrated, - isInitialized, + // cancel exam + const cancelExam = () => { + setTest(null); + setAnswers([]); }; return ( - {children} + + {children} + ); }; -export const useExam = (): TestContextType => { - const context = useContext(ExamContext); - if (!context) { - throw new Error("useExam must be used within an ExamProvider"); - } - return context; -}; - -export const useExamResults = (): TestAttempt | null => { - const { currentAttempt, isExamCompleted, isHydrated } = useExam(); - if (!isHydrated) return null; - if (!isExamCompleted() || !currentAttempt) return null; - return currentAttempt; +export const useExam = (): ExamContextType => { + const ctx = useContext(ExamContext); + if (!ctx) throw new Error("useExam must be used inside ExamProvider"); + return ctx; }; diff --git a/context/TimerContext.tsx b/context/TimerContext.tsx index f68aaaa..b019c45 100644 --- a/context/TimerContext.tsx +++ b/context/TimerContext.tsx @@ -1,31 +1,36 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { + createContext, + useContext, + useState, + useEffect, + useRef, +} from "react"; -// Define the context type interface TimerContextType { timeRemaining: number; resetTimer: (duration: number) => void; stopTimer: () => void; - setInitialTime: (duration: number) => void; // New function to set the initial time + setInitialTime: (duration: number) => void; } -// Create the context with a default value of `undefined` const TimerContext = createContext(undefined); -// Provider Component export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const [timeRemaining, setTimeRemaining] = useState(0); // Default is 0 - let timer: NodeJS.Timeout; + const [timeRemaining, setTimeRemaining] = useState(0); + const timerRef = useRef(null); + // countdown effect useEffect(() => { - if (timeRemaining > 0) { - timer = setInterval(() => { + if (timeRemaining > 0 && !timerRef.current) { + timerRef.current = setInterval(() => { setTimeRemaining((prev) => { if (prev <= 1) { - clearInterval(timer); + clearInterval(timerRef.current!); + timerRef.current = null; return 0; } return prev - 1; @@ -34,20 +39,29 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ } return () => { - clearInterval(timer); // Cleanup timer on unmount + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } }; }, [timeRemaining]); const resetTimer = (duration: number) => { - clearInterval(timer); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = null; setTimeRemaining(duration); }; const stopTimer = () => { - clearInterval(timer); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } }; const setInitialTime = (duration: number) => { + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = null; setTimeRemaining(duration); }; @@ -60,7 +74,6 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ ); }; -// Hook to use the TimerContext export const useTimer = (): TimerContextType => { const context = useContext(TimerContext); if (!context) { diff --git a/types/exam.d.ts b/types/exam.d.ts index 5fa1606..1038b50 100644 --- a/types/exam.d.ts +++ b/types/exam.d.ts @@ -24,42 +24,3 @@ export interface Test { export type Answer = number | null; export type AnswersMap = Record; - -export interface TestAttempt { - user_id: string | undefined; - test_id: string; - subject_id: string; - topic_id: string; - test_type: "Subject" | "Topic" | "Mock" | "Past"; - attempt_id: string; - start_time: string; - end_time: string; - user_questions: Question[]; - user_answers: (number | null)[]; - correct_answers: number[]; - correct_answers_count: number; - wrong_answers_count: number; - skipped_questions_count: number; -} - -export interface TestContextType { - currentExam: Test | null; - currentAttempt: TestAttempt | null; - isHydrated: boolean; - isInitialized: boolean; - - // Actions - setCurrentExam: (exam: Test) => void; - startExam: (exam?: Test) => void; - setAnswer: (questionId: string, answer: Answer) => void; - submitExam: () => TestAttempt | null; // or Promise if API - clearExam: () => void; - setApiResponse: (response: any) => void; - - // Getters - getAnswer: (questionId: string) => Answer; - getProgress: () => number; - isExamStarted: () => boolean; - isExamCompleted: () => boolean; - getApiResponse: () => any; -}