Skip to content

Commit c883084

Browse files
authored
(SP: 2) [Frontend] Quiz results dashboard, review cache fix, UX improvements (#317)
1 parent 7f20611 commit c883084

10 files changed

Lines changed: 225 additions & 82 deletions

File tree

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTranslations } from 'next-intl/server';
33
import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
44
import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard';
55
import { ProfileCard } from '@/components/dashboard/ProfileCard';
6+
import { QuizResultsSection } from '@/components/dashboard/QuizResultsSection';
67
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
78
import { StatsCard } from '@/components/dashboard/StatsCard';
89
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
@@ -116,6 +117,9 @@ export default async function DashboardPage({
116117
<div className="mt-8">
117118
<ExplainedTermsCard />
118119
</div>
120+
<div className="mt-8">
121+
<QuizResultsSection attempts={lastAttempts} locale={locale} />
122+
</div>
119123
</main>
120124
</DynamicGridBackground>
121125
</div>

frontend/app/[locale]/quiz/[slug]/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import { getTranslations } from 'next-intl/server';
55

66
import { QuizContainer } from '@/components/quiz/QuizContainer';
77
import { categoryTabStyles } from '@/data/categoryStyles';
8-
import { cn } from '@/lib/utils';
98
import { stripCorrectAnswers } from '@/db/queries/quiz';
109
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
1110
import { getCurrentUser } from '@/lib/auth';
11+
import { cn } from '@/lib/utils';
1212

1313
type MetadataProps = { params: Promise<{ locale: string; slug: string }> };
1414

@@ -52,9 +52,10 @@ export default async function QuizPage({
5252
notFound();
5353
}
5454

55-
const categoryStyle = quiz.categorySlug
56-
? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles]
57-
: null;
55+
const categoryStyle =
56+
quiz.categorySlug && quiz.categorySlug in categoryTabStyles
57+
? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles]
58+
: null;
5859

5960
const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN;
6061
const seed = Number.isFinite(parsedSeed)

frontend/components/quiz/QuizQuestion.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
2-
32
import { BookOpen, Check, Lightbulb, X } from 'lucide-react';
43
import { useTranslations } from 'next-intl';
4+
import { useEffect, useRef } from 'react';
55

66
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
77
import { QuizQuestionClient } from '@/db/queries/quiz';
@@ -36,6 +36,14 @@ export function QuizQuestion({
3636

3737
const isCorrectAnswer = isRevealed && isCorrect;
3838

39+
const nextButtonRef = useRef<HTMLButtonElement>(null);
40+
41+
useEffect(() => {
42+
if (isRevealed) {
43+
nextButtonRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
44+
}
45+
}, [isRevealed]);
46+
3947
return (
4048
<div className="flex flex-col gap-6">
4149
<div className="text-xl font-medium text-gray-900 dark:text-gray-100">
@@ -130,6 +138,7 @@ export function QuizQuestion({
130138
)}
131139
{isRevealed && (
132140
<button
141+
ref={nextButtonRef}
133142
onClick={onNext}
134143
disabled={isLoading}
135144
className="group animate-in fade-in slide-in-from-bottom-2 relative mt-2 w-full overflow-hidden rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300 disabled:opacity-50"

frontend/db/queries/quiz.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { cache } from 'react';
44

55
import { getOrCreateQuestionsCache } from '@/lib/quiz/quiz-answers-redis';
66
import type {
7-
AttemptReview,
87
AttemptQuestionDetail,
8+
AttemptReview,
99
QuizQuestionWithAnswers,
1010
UserLastAttempt,
1111
} from '@/types/quiz';
@@ -22,14 +22,7 @@ import {
2222
quizTranslations,
2323
quizzes,
2424
} from '../schema/quiz';
25-
export type {
26-
AttemptReview,
27-
AttemptQuestionDetail,
28-
QuizAnswer,
29-
QuizQuestion,
30-
QuizQuestionWithAnswers,
31-
UserLastAttempt,
32-
} from '@/types/quiz';
25+
export type { QuizAnswer, QuizQuestion, QuizQuestionWithAnswers } from '@/types/quiz';
3326

3427
export interface Quiz {
3528
id: string;
@@ -61,23 +54,25 @@ export interface QuizQuestionClient {
6154

6255
const attemptReviewCache = new Map<string, AttemptReview>();
6356

64-
function getAttemptReviewCacheKey(attemptId: string, locale: string) {
65-
return `${attemptId}:${locale}`;
57+
function getAttemptReviewCacheKey(attemptId: string, userId: string, locale: string) {
58+
return `${attemptId}:${userId}:${locale}`;
6659
}
6760

6861
async function getCachedAttemptReview(
6962
attemptId: string,
63+
userId: string,
7064
locale: string
7165
): Promise<AttemptReview | null> {
72-
return attemptReviewCache.get(getAttemptReviewCacheKey(attemptId, locale)) ?? null;
66+
return attemptReviewCache.get(getAttemptReviewCacheKey(attemptId, userId, locale)) ?? null;
7367
}
7468

7569
async function cacheAttemptReview(
7670
attemptId: string,
71+
userId: string,
7772
locale: string,
7873
review: AttemptReview
7974
): Promise<void> {
80-
attemptReviewCache.set(getAttemptReviewCacheKey(attemptId, locale), review);
75+
attemptReviewCache.set(getAttemptReviewCacheKey(attemptId, userId, locale), review);
8176
}
8277

8378
export function stripCorrectAnswers(
@@ -491,7 +486,7 @@ export async function getAttemptReviewDetails(
491486
userId: string,
492487
locale: string = 'uk'
493488
): Promise<AttemptReview | null> {
494-
const cached = await getCachedAttemptReview(attemptId, locale);
489+
const cached = await getCachedAttemptReview(attemptId, userId, locale);
495490
if (cached) return cached;
496491

497492
const attemptRow = await db
@@ -552,7 +547,7 @@ export async function getAttemptReviewDetails(
552547
completedAt: attempt.completedAt,
553548
incorrectQuestions: [],
554549
};
555-
await cacheAttemptReview(attemptId, locale, review);
550+
await cacheAttemptReview(attemptId, userId, locale, review);
556551
return review;
557552
}
558553

@@ -635,6 +630,6 @@ export async function getAttemptReviewDetails(
635630
incorrectQuestions,
636631
};
637632

638-
await cacheAttemptReview(attemptId, locale, review);
633+
await cacheAttemptReview(attemptId, userId, locale, review);
639634
return review;
640635
}

frontend/lib/quiz/quiz-answers-redis.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
quizQuestionContent,
88
quizQuestions} from '@/db/schema/quiz';
99
import { getRedisClient } from '@/lib/redis';
10-
import type { QuizQuestionWithAnswers } from '@/types/quiz';
10+
import type { QuizQuestionWithAnswers, AttemptReview} from '@/types/quiz';
1111

1212
interface QuizAnswersCache {
1313
quizId: string;
@@ -283,3 +283,43 @@ export async function clearVerifiedQuestions(
283283
console.warn('Failed to clear verified questions:', err);
284284
}
285285
}
286+
287+
const ATTEMPT_REVIEW_TTL = 48 * 60 * 60; // 48 hours
288+
289+
function getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale: string): string {
290+
return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
291+
}
292+
293+
export async function getCachedAttemptReview(
294+
attemptId: string,
295+
userId: string | undefined,
296+
locale: string
297+
): Promise<AttemptReview | null> {
298+
const redis = getRedisClient();
299+
if (!redis) return null;
300+
301+
try {
302+
return await redis.get<AttemptReview>(getAttemptReviewCacheKey(attemptId, userId, locale));
303+
} catch (err) {
304+
console.warn('Redis attempt review cache read failed:', err);
305+
return null;
306+
}
307+
}
308+
309+
export async function cacheAttemptReview(
310+
attemptId: string,
311+
userId: string | undefined,
312+
locale: string,
313+
data: AttemptReview
314+
): Promise<void> {
315+
const redis = getRedisClient();
316+
if (!redis) return;
317+
318+
try {
319+
await redis.set(getAttemptReviewCacheKey(attemptId, userId, locale), data, {
320+
ex: ATTEMPT_REVIEW_TTL,
321+
});
322+
} catch (err) {
323+
console.warn('Redis attempt review cache write failed:', err);
324+
}
325+
}

frontend/messages/en.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,37 @@
931931
"viewLeaderboard": "View leaderboard",
932932
"tryAgain": "Try again"
933933
},
934+
"quizResults": {
935+
"title": "Quiz Results",
936+
"noAttempts": "You haven't taken any quizzes yet",
937+
"startQuiz": "Try one",
938+
"score": "Score",
939+
"integrity": "Integrity",
940+
"points": "Points",
941+
"scoreHint": "Number of correct answers out of total questions",
942+
"integrityHint": "Fair play score (no violations detected)",
943+
"pointsHint": "Points earned for improving your previous result",
944+
"timeAgo": "{value} ago",
945+
"mastered": "Mastered",
946+
"needsReview": "Review",
947+
"study": "Study",
948+
"date": "Date",
949+
"status": "Status"
950+
},
951+
"quizReview": {
952+
"title": "Error Analysis",
953+
"subtitle": "{incorrect} of {total} — incorrect",
954+
"allCorrect": "All answers are correct!",
955+
"allCorrectHint": "You answered all questions correctly",
956+
"yourAnswer": "Your answer",
957+
"correctAnswer": "Correct answer",
958+
"explanation": "Explanation",
959+
"retakeQuiz": "Retake Quiz",
960+
"backToDashboard": "Back to Dashboard",
961+
"notFound": "Attempt not found",
962+
"expandAll": "Expand all",
963+
"collapseAll": "Collapse all"
964+
},
934965
"explainedTerms": {
935966
"title": "Learned Terms",
936967
"subtitle": "AI explanations you've saved",

frontend/messages/pl.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,37 @@
958958
"viewLeaderboard": "Zobacz ranking",
959959
"tryAgain": "Spróbuj ponownie"
960960
},
961+
"quizResults": {
962+
"title": "Wyniki quizów",
963+
"noAttempts": "Nie przeszedłeś jeszcze żadnego quizu",
964+
"startQuiz": "Spróbuj",
965+
"score": "Wynik",
966+
"integrity": "Czystość",
967+
"points": "Punkty",
968+
"scoreHint": "Liczba poprawnych odpowiedzi z ogólnej liczby",
969+
"integrityHint": "Wskaźnik uczciwego przejścia (bez naruszeń)",
970+
"pointsHint": "Punkty przyznane za poprawę wyniku",
971+
"timeAgo": "{value} temu",
972+
"mastered": "Opanowane",
973+
"needsReview": "Powtórka",
974+
"study": "Nauka",
975+
"date": "Data",
976+
"status": "Status"
977+
},
978+
"quizReview": {
979+
"title": "Analiza błędów",
980+
"subtitle": "{incorrect} z {total} — niepoprawnie",
981+
"allCorrect": "Wszystkie odpowiedzi poprawne!",
982+
"allCorrectHint": "Odpowiedziałeś poprawnie na wszystkie pytania",
983+
"yourAnswer": "Twoja odpowiedź",
984+
"correctAnswer": "Poprawna odpowiedź",
985+
"explanation": "Wyjaśnienie",
986+
"retakeQuiz": "Rozwiąż ponownie",
987+
"backToDashboard": "Wróć do panelu",
988+
"notFound": "Nie znaleziono próby",
989+
"expandAll": "Rozwiń wszystko",
990+
"collapseAll": "Zwiń wszystko"
991+
},
961992
"explainedTerms": {
962993
"title": "Nauczone Terminy",
963994
"subtitle": "Wyjaśnienia AI, które zapisałeś",

frontend/messages/uk.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,37 @@
934934
"viewLeaderboard": "Переглянути рейтинг",
935935
"tryAgain": "Пройти ще раз"
936936
},
937+
"quizResults": {
938+
"title": "Результати квізів",
939+
"noAttempts": "Ви ще не проходили жодного квізу",
940+
"startQuiz": "Спробувати",
941+
"score": "Результат",
942+
"integrity": "Чистота",
943+
"points": "Балів",
944+
"scoreHint": "Кількість правильних відповідей з загальної кількості",
945+
"integrityHint": "Показник чесного проходження (без порушень)",
946+
"pointsHint": "Бали, нараховані за покращення результату",
947+
"timeAgo": "{value} тому",
948+
"mastered": "Засвоєно",
949+
"needsReview": "Повторити",
950+
"study": "Вивчити",
951+
"date": "Дата",
952+
"status": "Статус"
953+
},
954+
"quizReview": {
955+
"title": "Аналіз помилок",
956+
"subtitle": "{incorrect} з {total} — неправильно",
957+
"allCorrect": "Всі відповіді правильні!",
958+
"allCorrectHint": "Ви відповіли на всі питання вірно",
959+
"yourAnswer": "Ваша відповідь",
960+
"correctAnswer": "Правильна відповідь",
961+
"explanation": "Пояснення",
962+
"retakeQuiz": "Пройти ще раз",
963+
"backToDashboard": "Назад до кабінету",
964+
"notFound": "Спроба не знайдена",
965+
"expandAll": "Розгорнути все",
966+
"collapseAll": "Згорнути все"
967+
},
937968
"explainedTerms": {
938969
"title": "Вивчені терміни",
939970
"subtitle": "AI-пояснення, які ви зберегли",

0 commit comments

Comments
 (0)