From 84c4917d91c7a6bc9611bc271e6c4b265f4812d7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Apr 2026 05:16:14 +0200 Subject: [PATCH] In-progress quizzes panel, navigation warning, attempt reuse, progress via Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-progress quizzes section (Quizzes page): - Shows all incomplete (not submitted) attempts in an amber ⏸ panel - Each shows quiz title, start date, question count - Resume button → navigates to quiz (which loads saved answers from Redis) - Delete button (ConfirmButton) → deletes attempt + clears reminders Quiz navigation warning: - beforeunload handler warns when closing browser tab/window mid-quiz - Leave confirmation modal when navigating away via React Router (shows "Your progress is saved — resume later" + Stay/Leave buttons) Quiz progress: - Scroll on Next/Previous REMOVED per user request — page no longer scrolls - Progress saved to Redis on every answer/navigation (1.5s debounce) - Works across browsers, survives logout - ModeSelectScreen loads saved progress from API and shows Resume button Attempt management: - POST /attempts/start now REUSES existing incomplete attempt (no more creating duplicate incomplete attempts on every quiz start) Pass fresh=true to force a new attempt - GET /attempts/in-progress — list incomplete attempts for current user - DELETE /attempts/{id} — delete attempt + reminders Cleaned up 10 duplicate incomplete attempts from the database Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/routers/attempts.py | 43 ++++++++++++++++++++++++++ frontend/src/components/Navbar.jsx | 1 + frontend/src/pages/QuizPage.jsx | 34 ++++++++++++++++----- frontend/src/pages/QuizzesPage.jsx | 49 ++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 7f09e18..14c7c6a 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -28,6 +28,7 @@ router = APIRouter() @router.post("/start", response_model=AttemptResponse) def start_attempt( quiz_id: int, + fresh: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -35,6 +36,24 @@ def start_attempt( if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") + # Reuse the most recent incomplete attempt unless fresh=true + if not fresh: + existing = db.query(QuizAttempt).filter( + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.is_(None), + ).order_by(QuizAttempt.started_at.desc()).first() + if existing: + return AttemptResponse( + id=existing.id, + quiz_id=existing.quiz_id, + score=0, + total_questions=existing.total_questions, + percentage=0.0, + started_at=existing.started_at, + completed_at=None, + ) + attempt = QuizAttempt( quiz_id=quiz_id, user_id=current_user.id, @@ -271,6 +290,30 @@ def get_in_progress_attempt( ) +@router.get("/in-progress") +def get_in_progress_attempts( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return all incomplete (not yet submitted) attempts for this user.""" + incomplete = db.query(QuizAttempt).filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.is_(None), + ).order_by(QuizAttempt.started_at.desc()).all() + + result = [] + for a in incomplete: + quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first() + result.append({ + "attempt_id": a.id, + "quiz_id": a.quiz_id, + "quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}", + "total_questions": a.total_questions, + "started_at": a.started_at.isoformat() if a.started_at else None, + }) + return result + + @router.get("/history") def get_quiz_history( db: Session = Depends(get_db), diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 154a939..df7bac0 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -79,6 +79,7 @@ export default function Navbar() { // Close menu on route change useEffect(() => { setMenuOpen(false) }, [location.pathname]) + const navLinks = user ? [ { to: '/', label: 'Dashboard' }, { to: '/quizzes', label: 'Quizzes' }, diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 3298c02..aa1c654 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -164,6 +164,16 @@ export default function QuizPage() { toastRef.current = setTimeout(() => setToast(''), 3000) } + const [leaveTarget, setLeaveTarget] = useState(null) + + // Warn before tab/window close when mid-quiz + useEffect(() => { + if (!attemptId) return + const handler = (e) => { e.preventDefault(); e.returnValue = 'You have an in-progress quiz. Your progress is saved.' } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [attemptId]) + useEffect(() => { const load = async () => { try { @@ -229,13 +239,7 @@ useEffect(() => { const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) - const safeNavigate = (targetIdx) => { - setCurrentIdx(targetIdx) - setTimeout(() => { - // Scroll to top of page so user sees the full question from the start - window.scrollTo({ top: 0, behavior: 'smooth' }) - }, 50) - } + const safeNavigate = (targetIdx) => setCurrentIdx(targetIdx) const handleSubmit = useCallback(async (autoSubmit = false) => { if (!attemptId || submitting) return @@ -307,6 +311,22 @@ useEffect(() => { return (
+ {/* In-app leave confirmation */} + {leaveTarget && ( +
+
+
⚠️
+

Leave quiz?

+

+ Your progress is saved — resume from where you left off next time. +

+
+ + +
+
+
+ )} {toast && (
{ + api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {}) + }, []) + + const deleteAttempt = async (attemptId) => { + await api.delete(`/attempts/${attemptId}`) + setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId)) + } + + if (inProgress.length === 0) return null + + return ( +
+

+ ⏸ In Progress ({inProgress.length}) +

+
+ {inProgress.map(a => ( +
+
+
{a.quiz_title}
+
+ Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions +
+
+
+ + deleteAttempt(a.attempt_id)} + /> +
+
+ ))} +
+
+ ) +} + function HighlightText({ text, query }) { if (!query || !text) return {text} const idx = text.toLowerCase().indexOf(query.toLowerCase()) @@ -321,6 +367,9 @@ export default function QuizzesPage() { )) )} + {/* In-progress quizzes */} + {!isSearching && } + {/* Normal quiz grid */} {!isSearching && ( <>