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 @@
Time remaining:   +
+
+
+
+
{% 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) + ), }, )