From 28633305b4c9cc359e12415153512eb01e1b7bbc Mon Sep 17 00:00:00 2001 From: Krish Date: Sat, 29 Aug 2026 11:42:43 +0530 Subject: [PATCH] fix(quiz): give each question its own countdown instead of one shared clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuizPage and PostJobPage rendered every question at once via questions.map, so all QuestionCards mounted in the same tick and their countdowns ran in parallel from the same start time. The question never changed, so the card's per-question effect never re-ran: by the time a candidate reached question 2 its clock had already been running for the whole of question 1. All timers also expired together, so the expired.size >= questions.length auto-submit fired once, ~limit seconds after load. Both rounds were really on a single 75s clock while the UI advertised "75s per question". Render only the active question and advance through them one at a time: - currentIndex selects the mounted question; moving on remounts the next card, which starts a fresh limit-second countdown. - advance() goes to the next question, or submits on the last one. - handleExpire() locks the answer as-is and moves straight on, so expiry auto-submit now fires per question rather than for the round as a whole. - The set-based auto-submit effect is gone; advance() owns the commit path. Advancing is one-way, so leftover time on a question is forfeited rather than bankable — the clock is only meaningful if it cannot be carried over. PostJobPage additionally passes questionNumber/totalQuestions, which it previously omitted; the position label matters once the cards are not stacked. QuestionCard itself was already correct and is unchanged. Verified by mounting both pages under jsdom with fetch stubbed: advancing shows the timer back at full, expiry mid-round advances with a fresh clock, and the last question expiring submits exactly once with per-question seconds_left recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014U4ypgHVqMaSPtaJvFeCko --- frontend/src/features/jobs/PostJobPage.jsx | 53 ++++++++++++++------ frontend/src/features/quiz/QuizPage.jsx | 56 +++++++++++++++------- 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/frontend/src/features/jobs/PostJobPage.jsx b/frontend/src/features/jobs/PostJobPage.jsx index 9acfd9f..c337eec 100644 --- a/frontend/src/features/jobs/PostJobPage.jsx +++ b/frontend/src/features/jobs/PostJobPage.jsx @@ -26,6 +26,9 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [expired, setExpired] = useState(() => new Set()); + // Index of the question currently on screen. Only this one is mounted, so only + // its countdown runs; advancing remounts the next card with a fresh clock. + const [currentIndex, setCurrentIndex] = useState(0); // Live countdown per question, kept in a ref so ticking never re-renders the page. const timeLeft = useRef({}); @@ -35,11 +38,31 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) { const sent = useRef({ answers: false, followup: false }); const limit = quiz?.time_limit_seconds ?? 75; + const questions = quiz?.questions ?? []; + const currentQuestion = questions[currentIndex] ?? null; + const isLastQuestion = currentIndex >= questions.length - 1; function markExpired(id) { setExpired((prev) => (prev.has(id) ? prev : new Set(prev).add(id))); } + // Moving on forfeits whatever time is left on the current question; the clock is + // per question, so there is no going back to spend it later. + function advance() { + if (isLastQuestion) { + handleSubmit(); + } else { + setCurrentIndex(currentIndex + 1); + } + } + + // Expiry locks the answer as-is and moves straight on, so the poster never sits + // on a dead card. Running out on the last question commits the round. + function handleExpire(id) { + markExpired(id); + advance(); + } + function reset() { setQuiz(null); setAnswers({}); @@ -48,6 +71,7 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) { setResult(null); setError(""); setExpired(new Set()); + setCurrentIndex(0); timeLeft.current = {}; inputSignal.current = {}; sent.current = { answers: false, followup: false }; @@ -122,13 +146,6 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) { } } - // When every question's clock has run out, submit whatever is there. - useEffect(() => { - if (quiz && !followup && !result && expired.size >= quiz.questions.length) { - handleSubmit(); - } - }, [expired, quiz, followup, result]); - const followupExpired = followup && expired.has(followup.followup.id); useEffect(() => { if (followupExpired && !result) handleFollowUp(); @@ -180,18 +197,20 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) {
- {quiz.questions.map((q) => ( + {currentQuestion && ( (timeLeft.current[id] = s)} - onExpire={markExpired} + onExpire={handleExpire} onInputSignal={(id, sig) => (inputSignal.current[id] = sig)} onAnswerChange={(id, val) => setAnswers((prev) => ({ ...prev, [id]: val }))} /> - ))} + )}
@@ -200,8 +219,12 @@ export default function PostJobPage({ onUnauthorized, onCancel, onViewJobs }) { Cancel )} -
diff --git a/frontend/src/features/quiz/QuizPage.jsx b/frontend/src/features/quiz/QuizPage.jsx index dae3a92..95a7ee3 100644 --- a/frontend/src/features/quiz/QuizPage.jsx +++ b/frontend/src/features/quiz/QuizPage.jsx @@ -27,6 +27,9 @@ export default function QuizPage({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [expired, setExpired] = useState(() => new Set()); + // Index of the question currently on screen. Only this one is mounted, so only + // its countdown runs; advancing remounts the next card with a fresh clock. + const [currentIndex, setCurrentIndex] = useState(0); // Live countdown per question, kept in a ref so ticking never re-renders the page. const timeLeft = useRef({}); @@ -36,11 +39,31 @@ export default function QuizPage({ const sent = useRef({ answers: false, followup: false }); const limit = quiz?.time_limit_seconds ?? 75; + const questions = quiz?.questions ?? []; + const currentQuestion = questions[currentIndex] ?? null; + const isLastQuestion = currentIndex >= questions.length - 1; function markExpired(id) { setExpired((prev) => (prev.has(id) ? prev : new Set(prev).add(id))); } + // Moving on forfeits whatever time is left on the current question; the clock is + // per question, so there is no going back to spend it later. + function advance() { + if (isLastQuestion) { + handleSubmit(); + } else { + setCurrentIndex(currentIndex + 1); + } + } + + // Expiry locks the answer as-is and moves straight on, so the candidate never + // sits on a dead card. Running out on the last question commits the round. + function handleExpire(id) { + markExpired(id); + advance(); + } + function handleReset() { setQuiz(null); setAnswers({}); @@ -49,6 +72,7 @@ export default function QuizPage({ setResult(null); setError(""); setExpired(new Set()); + setCurrentIndex(0); timeLeft.current = {}; inputSignal.current = {}; sent.current = { answers: false, followup: false }; @@ -63,6 +87,7 @@ export default function QuizPage({ setFollowup(null); setFollowupAnswer(""); setExpired(new Set()); + setCurrentIndex(0); timeLeft.current = {}; inputSignal.current = {}; sent.current = { answers: false, followup: false }; @@ -94,6 +119,7 @@ export default function QuizPage({ setFollowup(null); setFollowupAnswer(""); setExpired(new Set()); + setCurrentIndex(0); timeLeft.current = {}; inputSignal.current = {}; sent.current = { answers: false, followup: false }; @@ -155,12 +181,6 @@ export default function QuizPage({ } } - useEffect(() => { - if (quiz && !followup && !result && expired.size >= quiz.questions.length) { - handleSubmit(); - } - }, [expired, quiz, followup, result]); - const followupExpired = followup && expired.has(followup.followup.id); useEffect(() => { if (followupExpired && !result) handleFollowUp(); @@ -270,28 +290,32 @@ export default function QuizPage({ - {quiz.questions.map((q, idx) => ( + {currentQuestion && ( (timeLeft.current[id] = s)} - onExpire={markExpired} + onExpire={handleExpire} onInputSignal={(id, sig) => (inputSignal.current[id] = sig)} onAnswerChange={(id, val) => setAnswers((prev) => ({ ...prev, [id]: val }))} /> - ))} + )}