diff --git a/atlas/migrations/0078_cidreportanswer_started_at_and_more.py b/atlas/migrations/0078_cidreportanswer_started_at_and_more.py
new file mode 100644
index 00000000..860eced8
--- /dev/null
+++ b/atlas/migrations/0078_cidreportanswer_started_at_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.1.4 on 2025-10-13 08:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('atlas', '0077_alter_casecollection_question_time_limit'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cidreportanswer',
+ name='started_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='cidreportanswer',
+ name='submitted_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='userreportanswer',
+ name='started_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='userreportanswer',
+ name='submitted_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/atlas/models.py b/atlas/models.py
index f2319d0e..22f505fa 100644
--- a/atlas/models.py
+++ b/atlas/models.py
@@ -1376,6 +1376,10 @@ class BaseReportAnswer(models.Model):
)
completed = models.BooleanField(default=False)
+ # Timestamp when the user first loaded the question (started answering)
+ started_at = models.DateTimeField(null=True, blank=True)
+ # Timestamp when the answer was submitted/saved
+ submitted_at = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
self.clean()
diff --git a/atlas/templates/atlas/collection_case_view_take.html b/atlas/templates/atlas/collection_case_view_take.html
index 546a5fe0..53fc0023 100644
--- a/atlas/templates/atlas/collection_case_view_take.html
+++ b/atlas/templates/atlas/collection_case_view_take.html
@@ -20,6 +20,11 @@
{% endif %}
@@ -338,12 +343,12 @@
try {
var timeLimit = {{ collection.question_time_limit|default:'null' }};
var questionCompleted = {{ question_completed|yesno:"true,false" }};
+ var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
if (!timeLimit || questionCompleted) {
return;
}
-
var $timer = $('#question-timer');
var $form = $('form.post-form');
@@ -352,7 +357,19 @@
return;
}
+ // Compute remaining based on the canonical start time (if provided)
var remaining = parseInt(timeLimit, 10);
+ if (answerStartedAtIso) {
+ try {
+ var started = new Date(answerStartedAtIso);
+ var now = new Date();
+ var elapsed = Math.floor((now - started) / 1000);
+ remaining = Math.max(0, remaining - elapsed);
+ console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
+ } catch (e) {
+ console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
+ }
+ }
function formatTime(s) {
var mins = Math.floor(s / 60);
@@ -360,13 +377,40 @@
return mins + ':' + (secs < 10 ? '0' + secs : secs);
}
+ var $progressInner = $('#question-timer-progress-inner');
$timer.text(formatTime(remaining));
+ function updateProgress() {
+ var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
+ var widthPct = pct;
+ $progressInner.css('width', widthPct + '%');
+
+ // Smooth color transition: green (120) -> orange (30) -> red (0)
+ // We use a two-stage interpolation so the midpoint (~50%) is orange.
+ var hue = 0;
+ if (pct > 50) {
+ // interpolate from orange (30) to green (120)
+ var t = (pct - 50) / 50.0; // 0..1
+ hue = 30 + t * (120 - 30);
+ } else {
+ // interpolate from red (0) to orange (30)
+ var t = pct / 50.0; // 0..1
+ hue = 0 + t * (30 - 0);
+ }
+
+ var color = 'hsl(' + Math.round(hue) + ', 75%, 40%)';
+ $progressInner.css('background', color);
+ }
+
+ updateProgress();
+
var intervalId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
clearInterval(intervalId);
$timer.text('0:00');
+ $progressInner.css('width', '0%');
+ $progressInner.css('background', '#dc3545');
var $next = $form.find('button[name="next"]');
var $finish = $form.find('button[name="finish"]');
@@ -381,6 +425,7 @@
}
} else {
$timer.text(formatTime(remaining));
+ updateProgress();
}
}, 1000);
diff --git a/atlas/views.py b/atlas/views.py
index eea143c7..136f0fc5 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -2737,6 +2737,29 @@ def collection_case_view_take(
).first()
ReportAnswerForm = UserQuestionAnswerForm
+ # If we have an answer object but it doesn't have a started_at, set it now.
+ if answer and getattr(answer, 'started_at', None) is None:
+ try:
+ answer.started_at = timezone.now()
+ answer.save()
+ except Exception:
+ # Non-fatal: if saving fails, continue without blocking the user
+ logger.exception("Failed to set started_at on answer", exc_info=True)
+
+ # If no answer exists yet, create a placeholder so we have a started_at timestamp
+ if not answer and collection.collection_type in ("REP", "QUE"):
+ try:
+ if cid is not None:
+ answer = CidReportAnswer(question=case_detail, cid=cid)
+ else:
+ answer = UserReportAnswer(question=case_detail, user=request.user)
+ answer.started_at = timezone.now()
+ answer.save()
+ except Exception:
+ # If creation fails, leave answer as None; the normal flow will handle form creation
+ answer = None
+ logger.exception("Failed to create initial answer object", exc_info=True)
+
if request.method == "POST":
if collection.collection_type in ("REP", "QUE"):
if not collection.publish_results:
@@ -2754,6 +2777,8 @@ def collection_case_view_take(
answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = case_detail
+ # Record submission timestamp
+ answer.submitted_at = timezone.now()
# answer.published_date = timezone.now()
if "complete_case" in request.POST:
@@ -2872,6 +2897,11 @@ def collection_case_view_take(
"cid_user_exam": cid_user_exam,
"question_completed": question_completed,
"self_review": self_review,
+ # Provide a canonical start timestamp (prefer answer.started_at, then cid_user_exam.start_time)
+ "answer_started_at_iso": (
+ (answer.started_at.isoformat() if getattr(answer, 'started_at', None) else None)
+ or (cid_user_exam.start_time.isoformat() if getattr(cid_user_exam, 'start_time', None) else None)
+ ),
},
)