feat(quiz): redesign quiz cards with categories and countdown timer#89
Conversation
Quiz Cards Redesign:
- Add grid layout (2 columns desktop, 1 mobile) for better visual hierarchy
- Implement category tabs with adaptive breakpoints (3/5/10 columns)
- Create Badge component for reusable status indicators
- Add QuizCard component with category badge and completion status
- Display user progress (best score, attempts, progress bar) for authenticated users
- Show conditional CTA ("Retake Quiz" vs "Start Quiz")
Countdown Timer:
- Create CountdownTimer component with MM:SS format display
- Implement color-coded warnings (blue/yellow/red based on remaining time)
- Add auto-submit functionality when timer expires
- Support dynamic time calculation (DB value or 30 sec/question fallback)
- Integrate pulse animation for critical time warnings (< 10%)
Database & Queries:
- Update getActiveQuizzes() to join categories and translations
- Add getUserQuizzesProgress() with Map-based O(1) lookup optimization
- Support timeLimitSeconds as nullable with fallback logic
✅ Deploy Preview for develop-devlovers ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughAdds per-user quiz progress data and category fields, new UI components (QuizCard, QuizzesSection, Badge, CountdownTimer), integrates a client-side countdown into QuizContainer with timeLimitSeconds passed consistently, and hardens guest-result and pending-result handling. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant QuizzesPage as /quizzes Page
participant Auth as getCurrentUser()
participant QDB as getActiveQuizzes() / getUserQuizzesProgress()
participant UI as QuizzesSection / QuizCard
User->>QuizzesPage: Request /quizzes
QuizzesPage->>Auth: getCurrentUser()
Auth-->>QuizzesPage: session|null
QuizzesPage->>QDB: getActiveQuizzes()
QDB-->>QuizzesPage: quizzes list
alt session present
QuizzesPage->>QDB: getUserQuizzesProgress(userId)
QDB-->>QuizzesPage: progress map
end
QuizzesPage->>UI: Render QuizzesSection(quizzes, userProgressMap)
UI-->>User: Tabbed quiz grid with QuizCards (progress badges)
sequenceDiagram
participant User
participant QuizPage as /quiz/[slug] Page
participant Container as QuizContainer
participant Timer as CountdownTimer
participant Submit as submit handler
User->>QuizPage: Load quiz page
QuizPage->>Container: Render with timeLimitSeconds
User->>Container: Start quiz
Container->>Timer: isActive = true, start interval
loop every 1s
Timer->>Timer: decrement remaining
Timer-->>User: update UI (progress, color)
end
Timer->>Container: onTimeUp()
Container->>Submit: handleSubmit()
Submit-->>User: submit result
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (9)
frontend/components/ui/badge.tsx (1)
15-24: The 'gray' and 'default' variants have identical styling.Lines 15-16 and 23-24 apply the same background and text colors in both light and dark modes. This duplication may cause confusion and makes the 'gray' variant redundant.
💡 Consider removing the duplicate variant or differentiating the styles
Option 1: Remove the redundant 'gray' variant
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> { - variant?: 'default' | 'success' | 'blue' | 'purple' | 'gray'; + variant?: 'default' | 'success' | 'blue' | 'purple'; }And remove the corresponding styling block:
variant === 'purple' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400', - variant === 'gray' && - 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400', classNameOption 2: Differentiate the styling
If both variants are needed, ensure they have distinct visual appearances.
frontend/db/queries/quiz.ts (1)
345-361: Consider validating totalQuestions consistency across attempts.The aggregation correctly tracks
bestScoreandattemptsCount, buttotalQuestionsis taken from the first attempt encountered (line 352) and never updated. If the quiz structure changed between attempts,totalQuestionscould theoretically differ across attempts for the same quiz.While this scenario is unlikely in practice, consider whether validation or consistency checks are needed.
💡 Optional: Add consistency validation
If
totalQuestionsshould be immutable per quiz, you could add a consistency check:} else { existing.attemptsCount += 1; + // Validate consistency (optional) + if (row.totalQuestions !== existing.totalQuestions) { + console.warn(`Quiz ${row.quizId}: totalQuestions mismatch`); + } if (row.score > existing.bestScore) { existing.bestScore = row.score; } }Alternatively, use the maximum
totalQuestionsseen:if (row.score > existing.bestScore) { existing.bestScore = row.score; + existing.totalQuestions = row.totalQuestions; }frontend/app/[locale]/quizzes/page.tsx (1)
15-20: Replaceanytype with proper UserQuizProgress type.Line 15 uses
Record<string, any>foruserProgressMap, which loses type safety. The actual structure isUserQuizProgressfrom the database queries.🔎 Proposed fix for type safety
Import the type and use it:
-import { getActiveQuizzes, getUserQuizzesProgress } from '@/db/queries/quiz'; +import { + getActiveQuizzes, + getUserQuizzesProgress, + type UserQuizProgress +} from '@/db/queries/quiz'; import { getCurrentUser } from '@/lib/auth'; import QuizzesSection from '@/components/quiz/QuizzesSection';Then update the type:
- let userProgressMap: Record<string, any> = {}; + let userProgressMap: Record<string, UserQuizProgress> = {};frontend/components/quiz/QuizzesSection.tsx (1)
9-24: Avoid duplicating type definitions; import from the source.Lines 9-18 duplicate the
Quizinterface fromfrontend/db/queries/quiz.ts, and lines 20-24 define aUserProgressinterface that closely resemblesUserQuizProgressbut omitslastAttemptAt. This duplication can lead to inconsistencies and maintenance issues.🔎 Proposed fix
Import the types from the source:
'use client'; import { useState } from 'react'; import { useParams } from 'next/navigation'; import { QuizCard } from './QuizCard'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { categoryData } from '@/data/category'; +import type { Quiz, UserQuizProgress } from '@/db/queries/quiz'; -interface Quiz { - id: string; - slug: string; - title: string | null; - description: string | null; - questionsCount: number; - timeLimitSeconds: number | null; - categorySlug: string | null; - categoryName: string | null; -} - -interface UserProgress { - bestScore: number; - totalQuestions: number; - attemptsCount: number; -} - interface QuizzesSectionProps { quizzes: Quiz[]; - userProgressMap: Record<string, UserProgress>; + userProgressMap: Record<string, UserQuizProgress>; }If
lastAttemptAtis not needed in the UI, you can create a derived type usingOmit:type UserProgress = Omit<UserQuizProgress, 'lastAttemptAt'>;frontend/components/quiz/CountdownTimer.tsx (2)
19-34: AddonTimeUpto useCallback dependencies or use a ref to prevent interval recreation.The
useEffectat line 34 includesonTimeUpin its dependency array. If the parent component doesn't memoize this callback withuseCallback, the effect will re-run on every render, potentially creating multiple intervals (though the cleanup prevents overlapping timers, it's still inefficient).💡 Recommended approaches
Option 1: Use a ref for the callback (recommended)
'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { cn } from '@/lib/utils';export function CountdownTimer({ timeLimitSeconds, onTimeUp, isActive, }: CountdownTimerProps) { const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds); + const onTimeUpRef = useRef(onTimeUp); + + useEffect(() => { + onTimeUpRef.current = onTimeUp; + }, [onTimeUp]); useEffect(() => { if (!isActive) return; const interval = setInterval(() => { setRemainingSeconds(prev => { if (prev <= 1) { clearInterval(interval); - onTimeUp(); + onTimeUpRef.current(); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(interval); - }, [isActive, onTimeUp]); + }, [isActive]);Option 2: Document that parent must memoize the callback
Add a comment noting that
onTimeUpshould be wrapped inuseCallbackin the parent component.
65-83: Consider internationalizing hard-coded Ukrainian text.Lines 65 ("Залишилось часу:"), 83 ("
⚠️ Час майже закінчився!", "⏰ Поспішайте!") contain hard-coded Ukrainian strings. Since the app supports multiple locales (as seen in other components), consider using i18n for consistency.frontend/components/quiz/QuizCard.tsx (2)
24-26: Add guard against division by zero in percentage calculation.If
userProgress.totalQuestionsis0, the division at line 25 will produceNaNorInfinity. While unlikely in production, this edge case should be handled defensively.🔎 Proposed fix
const percentage = userProgress - ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100) + ? userProgress.totalQuestions > 0 + ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100) + : 0 : 0;
67-67: Consider internationalizing the hard-coded English text.Line 67 uses hard-coded English strings
'attempt'and'attempts'. Since the app supports multiple locales, consider using i18n for consistency with the rest of the application.frontend/components/quiz/QuizContainer.tsx (1)
309-318: Simplify by computingcalculatedTimeoutside the JSX.Lines 309-318 use an immediately invoked function expression (IIFE) to compute
calculatedTimewithin the JSX. This adds unnecessary complexity and makes the code harder to read.🔎 Proposed refactor
Compute the value before the return statement:
+ const calculatedTime = timeLimitSeconds ?? (totalQuestions * 30); + return ( <div className="space-y-8 no-select"> <QuizProgress current={state.currentIndex} total={totalQuestions} answers={state.answers} /> - {(() => { - const calculatedTime = timeLimitSeconds ?? (totalQuestions * 30); - return ( - <CountdownTimer - timeLimitSeconds={calculatedTime} - onTimeUp={handleTimeUp} - isActive={state.status === 'in_progress'} - /> - ); - })()} + <CountdownTimer + timeLimitSeconds={calculatedTime} + onTimeUp={handleTimeUp} + isActive={state.status === 'in_progress'} + /> <QuizQuestion
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (8)
frontend/app/[locale]/quiz/[slug]/page.tsxfrontend/app/[locale]/quizzes/page.tsxfrontend/components/quiz/CountdownTimer.tsxfrontend/components/quiz/QuizCard.tsxfrontend/components/quiz/QuizContainer.tsxfrontend/components/quiz/QuizzesSection.tsxfrontend/components/ui/badge.tsxfrontend/db/queries/quiz.ts
🧰 Additional context used
🧬 Code graph analysis (5)
frontend/components/quiz/CountdownTimer.tsx (1)
frontend/lib/utils.ts (1)
cn(4-6)
frontend/app/[locale]/quizzes/page.tsx (4)
frontend/app/[locale]/layout.tsx (1)
dynamic(17-17)frontend/lib/auth.ts (1)
getCurrentUser(94-115)frontend/db/queries/quiz.ts (2)
getActiveQuizzes(106-141)getUserQuizzesProgress(329-365)frontend/components/quiz/QuizzesSection.tsx (1)
QuizzesSection(31-105)
frontend/components/quiz/QuizzesSection.tsx (4)
frontend/db/queries/quiz.ts (1)
Quiz(15-25)frontend/data/category.ts (1)
categoryData(11-22)frontend/components/ui/tabs.tsx (4)
Tabs(66-66)TabsList(66-66)TabsTrigger(66-66)TabsContent(66-66)frontend/components/quiz/QuizCard.tsx (1)
QuizCard(23-79)
frontend/components/ui/badge.tsx (1)
frontend/lib/utils.ts (1)
cn(4-6)
frontend/db/queries/quiz.ts (3)
frontend/db/schema/categories.ts (2)
categories(12-19)categoryTranslations(21-33)frontend/db/schema/quiz.ts (2)
quizzes(19-41)quizAttempts(128-162)frontend/db/index.ts (1)
db(17-17)
🔇 Additional comments (1)
frontend/db/queries/quiz.ts (1)
137-138: LGTM! Ordering includes category display order.The ordering by
categories.displayOrderfollowed byquizzes.displayOrderprovides a clear hierarchical sort. Sincequizzes.categoryIdis marked asnotNullin the schema (as shown in the relevant snippets), the LEFT JOIN to categories will always succeed, ensuringdisplayOrderis never NULL.
- Validate session userId matches request userId (security) - Check res.ok before clearing localStorage (data loss prevention) - Remove diff markers and fix formatting Security: Prevents attackers from saving quiz results to other users' accounts Bug fix: Guest quiz data no longer lost on server errors
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (6)
frontend/components/dashboard/QuizSavedBanner.tsx (1)
20-27: Good error handling for sessionStorage parsing.The try-catch block correctly prevents crashes from malformed JSON and ensures cleanup happens in both success and failure paths.
Consider adding runtime validation to ensure the parsed data matches the
SavedQuizInfointerface before callingsetInfo. This would prevent potential runtime errors if the stored data has an unexpected shape (e.g., missing fields).Optional: Add type guard for extra safety
+ function isSavedQuizInfo(data: any): data is SavedQuizInfo { + return ( + typeof data === 'object' && + data !== null && + typeof data.score === 'number' && + typeof data.total === 'number' && + typeof data.percentage === 'number' && + typeof data.pointsAwarded === 'number' && + typeof data.quizSlug === 'string' + ); + } + useEffect(() => { const saved = sessionStorage.getItem('quiz_just_saved'); if (saved) { try { - setInfo(JSON.parse(saved)); + const parsed = JSON.parse(saved); + if (isSavedQuizInfo(parsed)) { + setInfo(parsed); + } else { + console.error('Invalid quiz_just_saved data shape:', parsed); + } sessionStorage.removeItem('quiz_just_saved'); } catch (error) { console.error('Failed to parse quiz_just_saved from sessionStorage:', error); sessionStorage.removeItem('quiz_just_saved'); } } }, []);frontend/components/quiz/PendingResultHandler.tsx (2)
15-26: Remove redundant condition check.Line 13 already returns early if
pendingis falsy, making theif (pending)check on line 15 redundant. You can safely remove the outer condition.🔎 Proposed simplification
- if (pending) { - fetch("/api/quiz/guest-result", { + fetch("/api/quiz/guest-result", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, quizId: pending.quizId, answers: pending.answers, violations: pending.violations, timeSpentSeconds: pending.timeSpentSeconds, }), }) - .then(async (res) => { + .then(async (res) => { if (res.ok) { clearPendingQuizResult(); } else { console.error("Guest-result API error:", res.status); } }) - .catch(err => { + .catch(err => { console.error("Guest-result fetch error:", err); }); - }
27-33: Good defensive error handling; consider minor refinements.The
res.okcheck correctly prevents clearing pending results on API errors, which addresses the PR objective to prevent guest quiz data loss. This is a solid improvement.Optional refinements:
Remove unnecessary
asynckeyword (line 27): The handler doesn't useawait, so theasyncmodifier is redundant.Enhance error logging: Consider logging more context (e.g., response body) to aid debugging:
.then(async (res) => { if (res.ok) { clearPendingQuizResult(); } else { const errorText = await res.text(); console.error("Guest-result API error:", res.status, errorText); } })Broader consideration: Currently, if the API call fails, the pending result remains in localStorage indefinitely with no automatic retry. You might consider implementing a retry mechanism (e.g., exponential backoff on component mount) or a background sync strategy to ensure guest results are eventually persisted.
frontend/components/quiz/QuizzesSection.tsx (1)
9-18: Interface inconsistency: missingisActivefield.The local
Quizinterface omits theisActivefield present in the canonical type fromfrontend/db/queries/quiz.ts. While this works because TypeScript structural typing allows wider types to be passed to narrower interfaces, it creates maintenance risk ifisActiveis later needed here.🔎 Consider importing the canonical type
-interface Quiz { - id: string; - slug: string; - title: string | null; - description: string | null; - questionsCount: number; - timeLimitSeconds: number | null; - categorySlug: string | null; - categoryName: string | null; -} +import type { Quiz } from '@/db/queries/quiz';Or add the missing field:
interface Quiz { id: string; slug: string; title: string | null; description: string | null; questionsCount: number; timeLimitSeconds: number | null; + isActive: boolean; categorySlug: string | null; categoryName: string | null; }frontend/components/quiz/QuizCard.tsx (2)
31-31: Hardcoded 'Uncategorized' string may need internationalization.The fallback string 'Uncategorized' is not localized. If the app supports multiple languages (as evidenced by the locale handling in
QuizzesSection.tsx), this should use a translation key or the component should accept a locale prop.
71-76: Conditional CTA text could benefit from internationalization.The button text ("Retake Quiz" / "Start Quiz") is hardcoded. For consistency with the multilingual support evident in the codebase, consider using translation keys.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
frontend/app/[locale]/quiz/[slug]/page.tsxfrontend/app/api/quiz/guest-result/route.tsfrontend/components/dashboard/QuizSavedBanner.tsxfrontend/components/quiz/PendingResultHandler.tsxfrontend/components/quiz/QuizCard.tsxfrontend/components/quiz/QuizzesSection.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/app/[locale]/quiz/[slug]/page.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
frontend/app/api/quiz/guest-result/route.ts (1)
frontend/lib/auth.ts (1)
getCurrentUser(94-115)
frontend/components/quiz/QuizzesSection.tsx (2)
frontend/data/category.ts (1)
categoryData(11-22)frontend/components/quiz/QuizCard.tsx (1)
QuizCard(23-79)
frontend/components/quiz/QuizCard.tsx (2)
frontend/components/ui/badge.tsx (1)
Badge(36-36)frontend/db/seed-quiz-types.ts (1)
p(37-40)
frontend/components/quiz/PendingResultHandler.tsx (1)
frontend/lib/guest-quiz.ts (1)
clearPendingQuizResult(51-54)
🔇 Additional comments (5)
frontend/app/api/quiz/guest-result/route.ts (1)
30-36: LGTM: Authentication check prevents privilege escalation.The session validation correctly ensures users can only save quiz results for their own account, preventing a potential privilege escalation where a user could submit results under another user's ID.
frontend/components/quiz/QuizzesSection.tsx (2)
37-41: Locale validation logic is correct.The type guard ensures only valid locales are used with a safe fallback to 'en'.
63-97: Category filtering and rendering looks good.The component correctly filters quizzes per category and provides a friendly empty state. The fallback at line 82 (
quiz.categoryName ?? category.slug) appropriately handles null category names.frontend/components/quiz/QuizCard.tsx (2)
47-47: Time calculation fallback aligns with PR requirements.The fallback formula
quiz.questionsCount * 30(30 seconds per question) matches the PR description's "Dynamic time calculation: use DB value or fallback to 30 seconds per question."
50-69: Progress visualization is well-implemented.The progress section clearly displays best score, completion percentage, and attempt count. The progress bar provides good visual feedback.
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (3)
frontend/components/quiz/QuizCard.tsx (3)
1-1: Consider removing 'use client' directive if not needed.This component doesn't use any client-side hooks, state, or event handlers. It could potentially be a server component, which would improve performance. However, this directive might be intentional for future enhancements or required by parent component constraints.
60-65: Consider adding ARIA labels for accessibility.The progress bar would be more accessible with ARIA attributes to help screen readers communicate the completion status to users.
🔎 Suggested enhancement
- <div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden"> + <div + className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden" + role="progressbar" + aria-valuenow={percentage} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`Quiz completion: ${percentage}%`} + > <div className="h-full bg-blue-600 rounded-full transition-all" style={{ width: `${percentage}%` }} /> </div>
45-48: Optional: Replace emoji icons with SVG for better accessibility.While emoji icons (📝, ⏱️) are visually appealing, they may not be announced consistently by all screen readers. Consider using SVG icons with proper
aria-labelattributes or text alternatives for improved accessibility.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
frontend/components/quiz/QuizCard.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/components/quiz/QuizCard.tsx (2)
frontend/components/ui/badge.tsx (1)
Badge(36-36)frontend/db/seed-quiz-types.ts (1)
p(37-40)
🔇 Additional comments (3)
frontend/components/quiz/QuizCard.tsx (3)
6-21: Well-structured type definitions.The interface properly defines all props with correct nullable types. The
categoryName: string | nullon line 14 correctly matches the source type and the component handles the null case with a fallback (line 31). Excellent work addressing the previous review feedback.
23-26: Excellent defensive coding for percentage calculation.The calculation now properly guards against division by zero by checking
userProgress.totalQuestions > 0before performing the division. This addresses the previous review concern and ensures robust handling of edge cases.
28-78: Clean implementation with good defensive coding.The component properly handles all nullable fields, provides appropriate fallbacks, and includes conditional rendering for user progress. The time calculation (line 47) correctly uses a fallback of 30 seconds per question when
timeLimitSecondsis null, and the CTA text adapts based on whether the user has attempted the quiz.
feat(quiz): redesign quiz cards with categories and countdown timer
Quiz Cards Redesign:
Add grid layout (2 columns desktop, 1 mobile) for better visual hierarchy
Implement category tabs with adaptive breakpoints (3/5/10 columns)
Create Badge component for reusable status indicators
Add QuizCard component with category badge and completion status
Display user progress (best score, attempts, progress bar) for authenticated users
Show conditional CTA ("Retake Quiz" vs "Start Quiz")
Countdown Timer:
Create CountdownTimer component with MM:SS format display
Implement color-coded warnings (blue/yellow/red based on remaining time)
Add auto-submit functionality when timer expires
Support dynamic time calculation (DB value or 30 sec/question fallback)
Integrate pulse animation for critical time warnings (< 10%)
Database & Queries:
Update getActiveQuizzes() to join categories and translations
Add getUserQuizzesProgress() with Map-based O(1) lookup optimization
Support timeLimitSeconds as nullable with fallback logic
Summary by CodeRabbit
New Features
Bug Fixes
✏️ Tip: You can customize this high-level summary in your review settings.