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
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
conn.execute(text("""

View file

@ -17,6 +17,7 @@ class QuizAttempt(Base):
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
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")
user = relationship("User", back_populates="attempts")

View file

@ -198,7 +198,10 @@ def list_attempts(
db: Session = Depends(get_db),
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:
query = query.filter(QuizAttempt.quiz_id == quiz_id)
attempts = query.order_by(QuizAttempt.started_at.desc()).all()
@ -224,9 +227,10 @@ class ProgressSave(BaseModel):
current_idx: int
mode: str
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)
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")
@ -260,6 +264,7 @@ def save_progress(
"time_left": data.time_left,
"started_at": data.started_at,
"total_time": data.total_time,
"suspended": data.suspended,
}))
except Exception:
logger.warning("Redis unavailable for progress save", exc_info=True)
@ -310,7 +315,19 @@ def get_progress(
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")
started_at_str = saved.get("started_at")
if total_time is not None and started_at_str:
@ -337,6 +354,7 @@ def get_progress(
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
attempt.expired = 1 # mark as timer-expired; exclude from history
db.commit()
r.delete(key)
return None # no progress to resume — already submitted
@ -455,7 +473,7 @@ def get_quiz_history(
db: Session = Depends(get_db),
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 = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
@ -463,6 +481,7 @@ def get_quiz_history(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.order_by(QuizAttempt.completed_at)
.all()
@ -497,7 +516,7 @@ def get_dashboard_stats(
current_user: User = Depends(get_current_user),
):
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 = (
db.query(QuizAttempt.quiz_id)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
@ -505,6 +524,7 @@ def get_dashboard_stats(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.distinct().count()
)
@ -516,6 +536,7 @@ def get_dashboard_stats(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.all()
)

View file

@ -451,16 +451,37 @@ const timerStarted = timeLeft !== null
Your progress is saved you can resume from where you left off.
{timeLeft !== null && (
<><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.
{returnTo && quiz?.max_attempts && (
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</>
)}
<strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
<span style={{ fontSize: '0.8rem' }}>
Note: closing the tab without suspending leaves the timer running, and it may auto-submit if time runs out.
</span>
</>
)}
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<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>