Skip to content

Commit 8585ccb

Browse files
committed
feat(quiz): timer fix, answer verification retry, new quiz seeds
- Fix timer restart on language switch (use startedAt prop) - Fix progress bar initial state calculation - Add answer verification retry with toast notifications - Hide score display for incomplete quizzes - Fix guest message condition for incomplete results - Add Button accent variant (prepared for future use) - Reorder categories: Next.js before Angular - Add seed quizzes: Angular, Vue, Node.js (40 questions each)
1 parent 32eedcc commit 8585ccb

39 files changed

Lines changed: 11220 additions & 53 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,4 @@ frontend/.env.bak
7272
frontend/.env*.bak
7373
frontend/.env.bak
7474

75+
tmpclaude-*

frontend/components/quiz/CountdownTimer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ interface CountdownTimerProps {
88
timeLimitSeconds: number;
99
onTimeUp: () => void;
1010
isActive: boolean;
11+
startedAt: Date;
1112
}
1213

1314
export function CountdownTimer({
1415
timeLimitSeconds,
1516
onTimeUp,
1617
isActive,
18+
startedAt,
1719
}: CountdownTimerProps) {
1820
const t = useTranslations('quiz.timer');
19-
const [endTime] = useState(() => Date.now() + timeLimitSeconds * 1000);
20-
const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);
21-
21+
const endTime = startedAt.getTime() + timeLimitSeconds * 1000;
22+
const [remainingSeconds, setRemainingSeconds] = useState(() =>
23+
Math.max(0, Math.floor((endTime - Date.now()) / 1000))
24+
);
25+
2226
useEffect(() => {
2327
if (!isActive) return;
2428

frontend/components/quiz/QuizContainer.tsx

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@
22

33
import { useLocale, useTranslations } from 'next-intl';
44
import { useRouter, useSearchParams } from 'next/navigation';
5-
import { useEffect } from 'react';
6-
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
7-
import { useReducer, useTransition, useState } from 'react';
5+
import { useReducer, useTransition, useState, useEffect, useCallback } from 'react';
6+
import { toast } from 'sonner';
87
import { useAntiCheat } from '@/hooks/useAntiCheat';
98
import { useQuizSession } from '@/hooks/useQuizSession';
109
import { useQuizGuards } from '@/hooks/useQuizGuards';
1110
import { QuizProgress } from './QuizProgress';
1211
import { QuizQuestion } from './QuizQuestion';
1312
import { QuizResult } from './QuizResult';
1413
import { CountdownTimer } from './CountdownTimer';
15-
import { Button } from '@/components/ui/button';
1614
import { submitQuizAttempt } from '@/actions/quiz';
1715
import { clearQuizSession, type QuizSessionData } from '@/lib/quiz/quiz-session';
16+
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
1817
import type { QuizQuestionClient } from '@/db/queries/quiz';
1918
import { ConfirmModal } from '@/components/ui/confirm-modal';
19+
import { Button } from '@/components/ui/button';
2020

2121
interface Answer {
2222
questionId: string;
@@ -58,12 +58,15 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState {
5858
};
5959

6060
case 'ANSWER_SELECTED':
61+
const answersWithoutThisQuestion = state.answers.filter(
62+
a => a.questionId !== action.payload.questionId
63+
);
6164
return {
6265
...state,
6366
selectedAnswerId: action.payload.answerId,
6467
questionStatus: 'revealed',
6568
answers: [
66-
...state.answers,
69+
...answersWithoutThisQuestion,
6770
{
6871
questionId: action.payload.questionId,
6972
selectedAnswerId: action.payload.answerId,
@@ -144,6 +147,7 @@ export function QuizContainer({
144147
}: QuizContainerProps) {
145148
const tRules = useTranslations('quiz.rules');
146149
const tExit = useTranslations('quiz.exitModal');
150+
const tQuestion = useTranslations('quiz.question');
147151
const [isPending, startTransition] = useTransition();
148152
const [state, dispatch] = useReducer(quizReducer, {
149153
status: 'rules',
@@ -156,7 +160,8 @@ export function QuizContainer({
156160
isIncomplete: false,
157161
});
158162
const [showExitModal, setShowExitModal] = useState(false);
159-
163+
const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false);
164+
160165
const locale = useLocale();
161166
const router = useRouter();
162167
const searchParams = useSearchParams();
@@ -169,10 +174,15 @@ export function QuizContainer({
169174
const currentQuestion = questions[state.currentIndex];
170175
const totalQuestions = questions.length;
171176

172-
useQuizSession({
177+
const handleRestoreSession = useCallback(
178+
(data: QuizSessionData) => dispatch({ type: 'RESTORE_SESSION', payload: data }),
179+
[]
180+
);
181+
182+
useQuizSession({
173183
quizId,
174184
state,
175-
onRestore: data => dispatch({ type: 'RESTORE_SESSION', payload: data }),
185+
onRestore: handleRestoreSession,
176186
});
177187

178188
const { markQuitting } = useQuizGuards({
@@ -200,7 +210,7 @@ const { markQuitting } = useQuizGuards({
200210
dispatch({ type: 'START_QUIZ' });
201211
};
202212

203-
const handleAnswer = async (answerId: string) => {
213+
const verifyAnswer = async (answerId: string) => {
204214
const response = await fetch('/api/quiz/verify-answer', {
205215
method: 'POST',
206216
headers: { 'Content-Type': 'application/json' },
@@ -210,19 +220,60 @@ const { markQuitting } = useQuizGuards({
210220
encryptedAnswers,
211221
}),
212222
});
213-
214-
const { isCorrect } = await response.json();
215223

216-
dispatch({
217-
type: 'ANSWER_SELECTED',
218-
payload: {
219-
answerId,
220-
isCorrect,
221-
questionId: currentQuestion.id,
222-
},
223-
});
224+
if (!response.ok) {
225+
throw new Error('Verify answer failed');
226+
}
227+
228+
const data = await response.json();
229+
230+
if (typeof data.isCorrect !== 'boolean') {
231+
throw new Error('Invalid verify response');
232+
}
233+
234+
return data.isCorrect;
224235
};
225236

237+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
238+
239+
240+
const handleAnswer = async (answerId: string) => {
241+
if (state.questionStatus !== 'answering') return;
242+
if (isVerifyingAnswer) return;
243+
244+
setIsVerifyingAnswer(true);
245+
246+
const maxRetries = 1;
247+
let attempt = 0;
248+
249+
try {
250+
while (true) {
251+
try {
252+
const isCorrect = await verifyAnswer(answerId);
253+
254+
dispatch({
255+
type: 'ANSWER_SELECTED',
256+
payload: {
257+
answerId,
258+
isCorrect,
259+
questionId: currentQuestion.id,
260+
},
261+
});
262+
return;
263+
} catch {
264+
if (attempt >= maxRetries) {
265+
toast.error(tQuestion('verifyFailed'));
266+
return;
267+
}
268+
attempt += 1;
269+
toast(tQuestion('verifyRetry'));
270+
await sleep(600);
271+
}
272+
}
273+
} finally {
274+
setIsVerifyingAnswer(false);
275+
}
276+
};
226277

227278
const handleNext = () => {
228279
if (state.currentIndex + 1 >= totalQuestions) {
@@ -373,7 +424,7 @@ const confirmQuit = () => {
373424
</div>
374425
</div>
375426

376-
<Button onClick={handleStart} className="w-full" size="lg">
427+
<Button onClick={handleStart} className="w-full" size="md">
377428
{tRules('startButton')}
378429
</Button>
379430
</div>
@@ -429,6 +480,7 @@ const confirmQuit = () => {
429480
timeLimitSeconds={calculatedTime}
430481
onTimeUp={handleTimeUp}
431482
isActive={state.status === 'in_progress'}
483+
startedAt={state.startedAt!}
432484
/>
433485
);
434486
})()}

frontend/components/quiz/QuizResult.tsx

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -90,27 +90,31 @@ export function QuizResult({
9090
return (
9191
<div className="max-w-2xl mx-auto space-y-8">
9292
<div className="text-center text-6xl">{motivation.emoji}</div>
93-
<div className="text-center space-y-2">
94-
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
95-
{score} / {total}
96-
</h2>
97-
<p className="text-xl text-gray-600 dark:text-gray-400">
98-
{percentage.toFixed(0)}% {t('correctAnswers')}
99-
</p>
100-
</div>
101-
<div className="space-y-2">
102-
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden">
103-
<div
104-
className={cn(
105-
'h-full transition-all duration-1000 ease-out',
106-
percentage < 50 && 'bg-red-500',
107-
percentage >= 50 && percentage < 80 && 'bg-orange-500',
108-
percentage >= 80 && 'bg-green-500'
109-
)}
110-
style={{ width: `${percentage}%` }}
111-
/>
112-
</div>
113-
</div>
93+
{!isIncomplete && (
94+
<>
95+
<div className="text-center space-y-2">
96+
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
97+
{score} / {total}
98+
</h2>
99+
<p className="text-xl text-gray-600 dark:text-gray-400">
100+
{percentage.toFixed(0)}% {t('correctAnswers')}
101+
</p>
102+
</div>
103+
<div className="space-y-2">
104+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden">
105+
<div
106+
className={cn(
107+
'h-full transition-all duration-1000 ease-out',
108+
percentage < 50 && 'bg-red-500',
109+
percentage >= 50 && percentage < 80 && 'bg-orange-500',
110+
percentage >= 80 && 'bg-green-500'
111+
)}
112+
style={{ width: `${percentage}%` }}
113+
/>
114+
</div>
115+
</div>
116+
</>
117+
)}
114118
<div className="text-center space-y-2">
115119
<h3 className={cn('text-xl font-semibold', motivation.color)}>
116120
{motivation.title}
@@ -141,7 +145,7 @@ export function QuizResult({
141145
</p>
142146
</div>
143147
)}
144-
{isGuest ? (
148+
{isGuest && !isIncomplete ? (
145149
<div className="space-y-4">
146150
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
147151
<p className="text-center text-blue-800 dark:text-blue-200 font-medium">
@@ -154,7 +158,7 @@ export function QuizResult({
154158
const url = `/${locale}/login?returnTo=/quiz/${quizSlug}`;
155159
window.location.href = url;
156160
}}
157-
variant="primary">
161+
>
158162
{t('loginButton')}
159163
</Button>
160164
<Button

frontend/components/ui/button.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
export interface ButtonProps
55
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
6-
variant?: 'primary' | 'secondary' | 'outline';
6+
variant?: 'primary' | 'secondary' | 'outline'| 'accent';
77
size?: 'sm' | 'md' | 'lg';
88
}
99

@@ -17,6 +17,8 @@
1717
'disabled:opacity-50 disabled:pointer-events-none',
1818

1919
// Variants
20+
variant === 'accent' &&
21+
'bg-accent text-accent-foreground hover:bg-accent/90',
2022
variant === 'primary' &&
2123
'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800',
2224
variant === 'secondary' &&

frontend/data/category.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export const categoryData = [
1515
createCategory('javascript', 'JavaScript', 3),
1616
createCategory('typescript', 'TypeScript', 4),
1717
createCategory('react', 'React', 5),
18-
createCategory('angular', 'Angular', 6),
18+
createCategory('next', 'Next.js', 6),
1919
createCategory('vue', 'Vue.js', 7),
20-
createCategory('next', 'Next.js', 8),
20+
createCategory('angular', 'Angular', 8),
2121
createCategory('node', 'Node.js', 9),
2222
];

0 commit comments

Comments
 (0)