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:
parent
1db3e7f368
commit
84c4917d91
4 changed files with 120 additions and 7 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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%)',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Reference in a new issue