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.
+
>
)}