From 5398342e3d886835530bb73fa960656e065b293f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 19 Apr 2026 19:27:46 +0200 Subject: [PATCH] Suspend pauses timer; hide timer-expired attempts from history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/main.py | 4 ++++ backend/app/models/attempt.py | 1 + backend/app/routers/attempts.py | 31 ++++++++++++++++++++++++++----- frontend/src/pages/QuizPage.jsx | 31 ++++++++++++++++++++++++++----- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 8b49822..1cd5bca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(""" diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 97caef2..3798ac4 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -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") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 23dc4a8..6652ad2 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -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() ) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index cc38a72..e9fc41a 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -451,16 +451,37 @@ const timerStarted = timeLeft !== null Your progress is saved — you can resume from where you left off. {timeLeft !== null && ( <>

- ⚠️ Timer is still running. 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' : ''}. - )} + Timer will pause while you are away and resume when you return.
+ + Note: closing the tab without suspending leaves the timer running, and it may auto-submit if time runs out. + )}

- +