Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions frontend/app/[locale]/quiz/[slug]/loading.tsx

This file was deleted.

24 changes: 13 additions & 11 deletions frontend/components/quiz/CountdownTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,20 @@ export function CountdownTimer({
}: CountdownTimerProps) {
const t = useTranslations('quiz.timer');
const endTime = startedAt.getTime() + timeLimitSeconds * 1000;
const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);
// Timer needs current time on mount to show correct remaining (e.g. after language switch restore).
const [remainingSeconds, setRemainingSeconds] = useState(() =>
Math.max(0, Math.floor((endTime - Date.now()) / 1000))
);

const [isSynced, setIsSynced] = useState(false);
const [prevEndTime, setPrevEndTime] = useState(endTime);

if (endTime !== prevEndTime) {
setPrevEndTime(endTime);
setIsSynced(false);
setRemainingSeconds(timeLimitSeconds);
}

useEffect(() => {
if (!isActive) return;

let synced = false;

const interval = setInterval(() => {
const tick = () => {
const now = Date.now();
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));

Expand All @@ -48,12 +46,16 @@ export function CountdownTimer({
}

if (remaining === 0) {
clearInterval(interval);
clearInterval(intervalId);
queueMicrotask(onTimeUp);
}
}, 100);
};

return () => clearInterval(interval);
const intervalId = setInterval(tick, 1000);

return () => {
clearInterval(intervalId);
};
}, [isActive, onTimeUp, endTime]);

useEffect(() => {
Expand Down
59 changes: 43 additions & 16 deletions frontend/components/quiz/QuizContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import {
useCallback,
useEffect,
useReducer,
useState,
Expand All @@ -24,8 +23,13 @@ import { Link } from '@/i18n/routing';
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
import {
clearQuizSession,
loadQuizSession,
type QuizSessionData,
} from '@/lib/quiz/quiz-session';
import {
getQuizReloadKey,
QUIZ_ALLOW_RESTORE_KEY,
} from '@/lib/quiz/quiz-storage-keys';

import { CountdownTimer } from './CountdownTimer';
import { QuizProgress } from './QuizProgress';
Expand Down Expand Up @@ -180,16 +184,44 @@ export function QuizContainer({
: '#3B82F6';
const [isStarting, setIsStarting] = useState(false);
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
currentIndex: 0,
answers: [],
questionStatus: 'answering',
selectedAnswerId: null,
startedAt: null,
pointsAwarded: null,
attemptId: null,
isIncomplete: false,
const [state, dispatch] = useReducer(quizReducer, quizId, (id) => {
const defaultState: QuizState = {
status: 'rules',
currentIndex: 0,
answers: [],
questionStatus: 'answering',
selectedAnswerId: null,
startedAt: null,
pointsAwarded: null,
attemptId: null,
isIncomplete: false,
};

if (typeof window === 'undefined') return defaultState;

const allowRestore = sessionStorage.getItem(QUIZ_ALLOW_RESTORE_KEY);
const reloadKey = getQuizReloadKey(id);
const isReload = sessionStorage.getItem(reloadKey);

if (!allowRestore && !isReload) return defaultState;

const saved = loadQuizSession(id);
if (!saved) return defaultState;

return {
status: saved.status,
currentIndex: saved.currentIndex,
answers: saved.answers.map(a => ({
...a,
answeredAt: new Date(a.answeredAt),
})),
questionStatus: saved.questionStatus,
selectedAnswerId: saved.selectedAnswerId,
startedAt: saved.startedAt ? new Date(saved.startedAt) : null,
pointsAwarded: saved.pointsAwarded ?? null,
attemptId: saved.attemptId ?? null,
isIncomplete: saved.isIncomplete ?? false,
};
});
const [showExitModal, setShowExitModal] = useState(false);
const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false);
Expand All @@ -205,14 +237,9 @@ export function QuizContainer({
const currentQuestion = questions[state.currentIndex];
const totalQuestions = questions.length;

const handleRestoreSession = useCallback((data: QuizSessionData) => {
dispatch({ type: 'RESTORE_SESSION', payload: data });
}, []);

useQuizSession({
quizId,
state,
onRestore: handleRestoreSession,
});

const { markQuitting } = useQuizGuards({
Expand Down
52 changes: 36 additions & 16 deletions frontend/components/quiz/QuizzesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,42 @@ export default function QuizzesSection({
{categoryQuizzes.length > 0 ? (
<div className="max-w-5xl">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{categoryQuizzes.map(quiz => (
<QuizCard
key={quiz.id}
quiz={{
id: quiz.id,
slug: quiz.slug,
title: quiz.title,
description: quiz.description,
questionsCount: quiz.questionsCount,
timeLimitSeconds: quiz.timeLimitSeconds,
categoryName: quiz.categoryName ?? category.slug,
categorySlug: quiz.categorySlug ?? category.slug,
}}
userProgress={progressLoaded ? (progressMap[quiz.id] || null) : null}
/>
))}
{!progressLoaded
? categoryQuizzes.map(quiz => (
<div
key={quiz.id}
className="flex flex-col rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-neutral-900"
>
<div className="animate-pulse">
<div className="mb-3 flex gap-2">
<div className="h-5 w-16 rounded-full bg-gray-200 dark:bg-neutral-700" />
</div>
<div className="mb-2 h-6 w-3/4 rounded bg-gray-200 dark:bg-neutral-700" />
<div className="mb-3 h-4 w-full rounded bg-gray-200 dark:bg-neutral-700" />
<div className="mb-3 flex gap-3">
<div className="h-3.5 w-20 rounded bg-gray-200 dark:bg-neutral-700" />
<div className="h-3.5 w-16 rounded bg-gray-200 dark:bg-neutral-700" />
</div>
</div>
<div className="mt-auto h-10 rounded-xl bg-gray-200 dark:bg-neutral-700" />
</div>
))
: categoryQuizzes.map(quiz => (
<QuizCard
key={quiz.id}
quiz={{
id: quiz.id,
slug: quiz.slug,
title: quiz.title,
description: quiz.description,
questionsCount: quiz.questionsCount,
timeLimitSeconds: quiz.timeLimitSeconds,
categoryName: quiz.categoryName ?? category.slug,
categorySlug: quiz.categorySlug ?? category.slug,
}}
userProgress={progressMap[quiz.id] || null}
/>
))}
</div>
</div>
) : (
Expand Down
23 changes: 7 additions & 16 deletions frontend/hooks/useQuizSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,36 +33,27 @@ type QuizState = {
type UseQuizSessionParams = {
quizId: string;
state: QuizState;
onRestore: (data: QuizSessionData) => void;
};

export function useQuizSession({
quizId,
state,
onRestore,
}: UseQuizSessionParams): void {
const reloadKey = getQuizReloadKey(quizId);

useEffect(() => {
const isReload = sessionStorage.getItem(reloadKey);
if (isReload) {
sessionStorage.removeItem(reloadKey);
}
if (isReload) sessionStorage.removeItem(reloadKey);

const allowRestore = sessionStorage.getItem(QUIZ_ALLOW_RESTORE_KEY);
if (allowRestore) {
sessionStorage.removeItem(QUIZ_ALLOW_RESTORE_KEY);
}

const saved = loadQuizSession(quizId);
if (!saved) return;
if (allowRestore) sessionStorage.removeItem(QUIZ_ALLOW_RESTORE_KEY);

if (isReload || allowRestore) {
onRestore(saved);
} else {
clearQuizSession(quizId);
// Fresh visit (no restore flag) — clear any stale session
if (!isReload && !allowRestore) {
const saved = loadQuizSession(quizId);
if (saved) clearQuizSession(quizId);
}
}, [quizId, reloadKey, onRestore]);
}, [quizId, reloadKey]);

useEffect(() => {
if (state.status === 'rules') return;
Expand Down