Suspend pauses timer; hide timer-expired attempts from history

Suspend now pauses the timer instead of letting it run out:
- 'Suspend & Leave' sends suspended=true with time_left to backend
- On resume, backend re-anchors started_at to now with held time_left
- Closing tab without suspending continues to run the timer (unchanged)

Timer-expired auto-submits are marked with expired=1 and excluded from:
- Attempt history (GET /attempts/history)
- Dashboard stats (quiz count, total attempts, average score)
- Attempt list (GET /attempts)
- DDL: ALTER TABLE quiz_attempts ADD COLUMN expired INTEGER DEFAULT 0

Course-quiz decoupling is preserved — these changes only touch
non-course quizzes (Quiz.course_id IS NULL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-19 19:27:46 +02:00
parent c44014c787
commit 5398342e3d
4 changed files with 57 additions and 10 deletions

View file

@ -218,6 +218,10 @@ def setup_pgvector():
ALTER TABLE quiz_attempts ALTER TABLE quiz_attempts
ADD COLUMN IF NOT EXISTS selected_question_ids JSON ADD COLUMN IF NOT EXISTS selected_question_ids JSON
""")) """))
conn.execute(text("""
ALTER TABLE quiz_attempts
ADD COLUMN IF NOT EXISTS expired INTEGER DEFAULT 0
"""))
# Question ownership # Question ownership
conn.execute(text(""" conn.execute(text("""

View file

@ -17,6 +17,7 @@ class QuizAttempt(Base):
started_at = Column(DateTime, default=datetime.utcnow) started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True)
selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses
expired = Column(Integer, default=0) # 1 = auto-submitted due to timer expiry (abandoned), excluded from history
quiz = relationship("Quiz", back_populates="attempts") quiz = relationship("Quiz", back_populates="attempts")
user = relationship("User", back_populates="attempts") user = relationship("User", back_populates="attempts")

View file

@ -198,7 +198,10 @@ def list_attempts(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
query = db.query(QuizAttempt).filter(QuizAttempt.user_id == current_user.id) query = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
if quiz_id: if quiz_id:
query = query.filter(QuizAttempt.quiz_id == quiz_id) query = query.filter(QuizAttempt.quiz_id == quiz_id)
attempts = query.order_by(QuizAttempt.started_at.desc()).all() attempts = query.order_by(QuizAttempt.started_at.desc()).all()
@ -224,9 +227,10 @@ class ProgressSave(BaseModel):
current_idx: int current_idx: int
mode: str mode: str
voice: str | None = None voice: str | None = None
time_left: int | None = None # remaining seconds for timed mode (informational, not authoritative) time_left: int | None = None # remaining seconds for timed mode (authoritative when suspended=True)
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation) started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
total_time: int | None = None # original time limit in seconds total_time: int | None = None # original time limit in seconds
suspended: bool = False # when True, timer is paused; time_left is preserved authoritatively
@router.post("/progress") @router.post("/progress")
@ -260,6 +264,7 @@ def save_progress(
"time_left": data.time_left, "time_left": data.time_left,
"started_at": data.started_at, "started_at": data.started_at,
"total_time": data.total_time, "total_time": data.total_time,
"suspended": data.suspended,
})) }))
except Exception: except Exception:
logger.warning("Redis unavailable for progress save", exc_info=True) logger.warning("Redis unavailable for progress save", exc_info=True)
@ -310,7 +315,19 @@ def get_progress(
saved = _json.loads(data) saved = _json.loads(data)
# Check if timer expired for timed quiz (auto-submit) # If the quiz was suspended, timer is paused — re-anchor on resume so
# the held time_left becomes the new total_time starting now.
if saved.get("suspended"):
time_left = saved.get("time_left")
if time_left is not None and time_left > 0:
saved["started_at"] = datetime.now(timezone.utc).isoformat()
saved["total_time"] = int(time_left)
saved["suspended"] = False
# Persist the re-anchored progress so timer continues correctly
r.setex(key, 7 * 24 * 3600, _json.dumps(saved))
return saved
# Check if timer expired for timed quiz (auto-submit) — only for unsuspended attempts
total_time = saved.get("total_time") total_time = saved.get("total_time")
started_at_str = saved.get("started_at") started_at_str = saved.get("started_at")
if total_time is not None and started_at_str: if total_time is not None and started_at_str:
@ -337,6 +354,7 @@ def get_progress(
)) ))
attempt.score = score attempt.score = score
attempt.completed_at = datetime.utcnow() attempt.completed_at = datetime.utcnow()
attempt.expired = 1 # mark as timer-expired; exclude from history
db.commit() db.commit()
r.delete(key) r.delete(key)
return None # no progress to resume — already submitted return None # no progress to resume — already submitted
@ -455,7 +473,7 @@ def get_quiz_history(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes).""" """Return per-quiz attempt history for the performance line graph (excludes course quizzes and expired attempts)."""
completed = ( completed = (
db.query(QuizAttempt) db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id) .join(Quiz, QuizAttempt.quiz_id == Quiz.id)
@ -463,6 +481,7 @@ def get_quiz_history(
QuizAttempt.user_id == current_user.id, QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None), QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None), Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
) )
.order_by(QuizAttempt.completed_at) .order_by(QuizAttempt.completed_at)
.all() .all()
@ -497,7 +516,7 @@ def get_dashboard_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count() total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
# Count distinct quizzes the user has attempted (excludes course quizzes) # Count distinct quizzes the user has attempted (excludes course quizzes and expired attempts)
total_quizzes = ( total_quizzes = (
db.query(QuizAttempt.quiz_id) db.query(QuizAttempt.quiz_id)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id) .join(Quiz, QuizAttempt.quiz_id == Quiz.id)
@ -505,6 +524,7 @@ def get_dashboard_stats(
QuizAttempt.user_id == current_user.id, QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None), QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None), Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
) )
.distinct().count() .distinct().count()
) )
@ -516,6 +536,7 @@ def get_dashboard_stats(
QuizAttempt.user_id == current_user.id, QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None), QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None), Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
) )
.all() .all()
) )

View file

@ -451,16 +451,37 @@ const timerStarted = timeLeft !== null
Your progress is saved you can resume from where you left off. Your progress is saved you can resume from where you left off.
{timeLeft !== null && ( {timeLeft !== null && (
<><br/><br/> <><br/><br/>
<strong style={{ color: '#f59e0b' }}> Timer is still running.</strong> If time expires before you return, the quiz will be auto-submitted with your current answers. <strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
{returnTo && quiz?.max_attempts && ( <span style={{ fontSize: '0.8rem' }}>
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</> Note: closing the tab without suspending leaves the timer running, and it may auto-submit if time runs out.
)} </span>
</> </>
)} )}
</p> </p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button> <button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
<button className="btn btn-primary" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Suspend &amp; Leave</button> <button className="btn btn-primary" onClick={async () => {
// Save with suspended=true so the timer pauses on the server side
if (attemptId && quizMode) {
try {
await api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
suspended: true,
}, { headers: { 'x-quiz-session': SESSION_ID } })
} catch { }
}
const target = leaveTarget
setLeaveTarget(null)
navigate(target)
}}>Suspend &amp; Leave</button>
</div> </div>
</div> </div>
</div> </div>