In-progress quizzes panel, navigation warning, attempt reuse, progress via Redis

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) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 05:16:14 +02:00
parent 1db3e7f368
commit 84c4917d91
4 changed files with 120 additions and 7 deletions

View file

@ -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),

View file

@ -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' },

View file

@ -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 (
<div className="quiz-bottom">
{/* In-app leave confirmation */}
{leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 380, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Leave quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
Your progress is saved resume from where you left off next time.
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
<button className="btn btn-danger" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Leave</button>
</div>
</div>
</div>
)}
{toast && (
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',

View file

@ -4,6 +4,52 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
function InProgressSection() {
const [inProgress, setInProgress] = useState([])
const navigate = useNavigate()
useEffect(() => {
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 (
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
In Progress ({inProgress.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inProgress.map(a => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
<ConfirmButton
label="Delete" confirmLabel="Yes, delete"
onConfirm={() => deleteAttempt(a.attempt_id)}
/>
</div>
</div>
))}
</div>
</div>
)
}
function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
@ -321,6 +367,9 @@ export default function QuizzesPage() {
))
)}
{/* In-progress quizzes */}
{!isSearching && <InProgressSection />}
{/* Normal quiz grid */}
{!isSearching && (
<>