feat: Enhance case collection and detail forms with timing overrides and custom screens

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ross
2026-04-30 20:31:16 +01:00
co-authored by Copilot
parent 74cdf14af3
commit 6575c50507
10 changed files with 453 additions and 48 deletions
@@ -36,26 +36,40 @@
</div>
</div>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
{% if not question_completed and effective_question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This case has a view time limit of {{ effective_question_time_limit }} seconds.">
<div class="d-flex align-items-center gap-3">
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div><strong>Case view:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div style="flex:1; max-width:320px;">
<div class="progress" style="height:10px;">
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
</div>
{% if effective_answer_entry_grace_period %}
<div id="answer-window-info" class="small text-muted mt-1 {% if not case_view_locked or answer_entry_closed %}d-none{% endif %}">
Answer window remaining: <span id="answer-window-timer">{{ effective_answer_entry_grace_period }}s</span>
</div>
{% endif %}
<div id="timer-htmx-target" style="display:none;"></div>
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
</div>
{% endif %}
<div id="case-view-locked-alert" class="alert alert-warning small {% if not case_view_locked %}d-none{% endif %}">
{% if answer_entry_closed %}
Case view is locked and the answer window has closed.
{% else %}
Case view is now locked. You can continue entering your answer until the answer window closes.
{% endif %}
</div>
{% comment %} <details>
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
</details> {% endcomment %}
<div id="case-view-content" {% if case_view_locked %}style="display:none;"{% endif %}>
{% if not question_completed %}
{% if resources %}
<h5 class="mt-3">Resources</h5>
@@ -175,6 +189,7 @@
</div>
</details>
{% endif %}
</div>
{% if question_completed %}
@@ -235,7 +250,7 @@
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form}}
</fieldset>
</div>
@@ -245,7 +260,7 @@
{% elif collection.collection_type == "REP" %}
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form | crispy}}
</fieldset>
</div>
@@ -440,6 +455,22 @@
// Question time limit countdown + auto-submit
$(function () {
function lockCaseView() {
var $view = $('#case-view-content');
if ($view.length) {
$view.hide();
}
$('#case-view-locked-alert').removeClass('d-none');
$('#answer-window-info').removeClass('d-none');
}
function closeAnswerWindow() {
$('#answer-window-info').addClass('d-none');
$('#case-view-locked-alert')
.removeClass('d-none')
.text('Case view is locked and the answer window has closed.');
}
function lockQuestion() {
// Only disable save buttons to prevent further saves
$form = $('form.post-form');
@@ -453,15 +484,17 @@
$('#question-timer').text('Locked');
$progressInner.css('background', '#dc3545');
$progressInner.css('width', '0%');
closeAnswerWindow();
$("#question-timer-progress-outer").hide();
}
try {
var timeLimit = {{ collection.question_time_limit|default:'null' }};
var timeLimit = {{ effective_question_time_limit|default:'null' }};
var answerGrace = {{ effective_answer_entry_grace_period|default:'0' }};
var questionCompleted = {{ question_completed|yesno:"true,false" }};
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
console.debug('Timer init:', {timeLimit: timeLimit, answerGrace: answerGrace, questionCompleted: questionCompleted});
if (!timeLimit || questionCompleted) {
return;
@@ -483,25 +516,32 @@
return;
}
// Compute remaining based on the canonical start time (if provided)
var remaining = parseInt(timeLimit, 10);
// Compute remaining values based on the canonical start time (if provided)
var remainingToLock = parseInt(timeLimit, 10);
var remainingToClose = parseInt(timeLimit, 10) + parseInt(answerGrace || 0, 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);
remainingToLock = Math.max(0, remainingToLock - elapsed);
remainingToClose = Math.max(0, remainingToClose - elapsed);
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remainingToLock:', remainingToLock, 'remainingToClose:', remainingToClose);
} catch (e) {
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
}
}
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
if (remaining <= 0) {
console.debug('Timer: time already expired on load, locking without autosubmit');
// If case-view time has elapsed on load, hide case content.
if (remainingToLock <= 0) {
lockCaseView();
}
// If answer-entry time has elapsed on load, fully lock.
if (remainingToClose <= 0) {
console.debug('Timer: answer-entry window already expired on load, locking without autosubmit');
lockQuestion();
$timer.text(formatTime(0));
$timer.text('Locked');
return;
}
@@ -512,10 +552,16 @@
}
var $progressInner = $('#question-timer-progress-inner');
$timer.text(formatTime(remaining));
$timer.text(remainingToLock > 0 ? formatTime(remainingToLock) : 'Locked');
if ($('#answer-window-timer').length && remainingToLock <= 0 && remainingToClose > 0) {
$('#answer-window-info').removeClass('d-none');
$('#answer-window-timer').text(formatTime(remainingToClose));
} else if ($('#answer-window-timer').length) {
$('#answer-window-info').addClass('d-none');
}
function updateProgress() {
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
var pct = Math.max(0, Math.min(100, Math.round((remainingToLock / timeLimit) * 100)));
var widthPct = pct;
$progressInner.css('width', widthPct + '%');
@@ -539,10 +585,31 @@
updateProgress();
var intervalId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remainingToLock -= 1;
remainingToClose -= 1;
if (remainingToLock <= 0) {
lockCaseView();
$timer.text('Locked');
$progressInner.css('width', '0%');
$progressInner.css('background', '#dc3545');
} else {
$timer.text(formatTime(remainingToLock));
updateProgress();
}
if ($('#answer-window-timer').length) {
if (remainingToLock <= 0 && remainingToClose > 0) {
$('#answer-window-info').removeClass('d-none');
$('#answer-window-timer').text(formatTime(Math.max(0, remainingToClose)));
} else {
$('#answer-window-info').addClass('d-none');
}
}
if (remainingToClose <= 0) {
clearInterval(intervalId);
$timer.text('0:00');
$timer.text('Locked');
$progressInner.css('width', '0%');
$progressInner.css('background', '#dc3545');
//var $next = $form.find('button[name="next"]');
@@ -629,9 +696,6 @@
target: "#timer-htmx-target",
});
})();
} else {
$timer.text(formatTime(remaining));
updateProgress();
}
}, 1000);
@@ -3,6 +3,25 @@
{% block content %}
<h2>Collection: {{exam}}</h2>
{% if collection.start_screen_content %}
<div class="card mb-3">
<div class="card-body">
{{ collection.start_screen_content|safe }}
</div>
</div>
{% endif %}
{% if collection.start_screen_resources.exists %}
<div class="card mb-3">
<div class="card-body">
<h6>Resources</h6>
{% for resource in collection.start_screen_resources.all %}
<div class="small mb-1">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
{% endif %}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
@@ -52,7 +52,7 @@
</div>
<div class="list-group mb-3">
{% for question, answer, self_review, review_stats in question_answer_tuples %}
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
@@ -68,6 +68,9 @@
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if timing_state.effective_time_limit is not None and timing_state.case_view_locked %}
<span class="badge bg-dark ms-1">Time expired</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
@@ -159,8 +162,29 @@
</div>
{% endif %}
{% if collection.end_overview_content %}
<div class="card mt-3">
<div class="card-body">
{{ collection.end_overview_content|safe }}
</div>
</div>
{% endif %}
{% if end_overview_resources %}
<div class="card mt-3">
<div class="card-body">
<h6 class="mb-2">Further Resources</h6>
<div class="d-grid gap-2">
{% for resource in end_overview_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if cid_user_exam.user_user_id %}
{% if collection.show_results_sharing_information and cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
@@ -44,6 +44,23 @@
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
<section class="take-start-card">
<dl class="take-start-meta mb-3">
@@ -75,7 +92,7 @@
</section>
{# ---- Who can see your results ---- #}
{% if sharing_summary %}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
@@ -3,25 +3,17 @@
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none small fw-semibold"
href="{{ item.case_url }}">
Case {{ item.idx|add:1 }}: {{ item.cd.case.title|default:item.cd.case.pk|truncatechars:45 }}
Case {{ item.idx|add:1 }}
</a>
<div class="d-flex gap-1">
{% if item.is_time_expired %}
<span class="badge bg-dark">Time expired</span>
{% endif %}
{% if item.has_self_review %}
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">
<a class="btn btn-outline-secondary btn-sm py-0 px-2" href="{{ item.answer_url }}">Answer</a>
{% if question_completed%}
{% if item.self_review_view_url %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_view_url }}">View review</a>
<a class="btn btn-outline-warning btn-sm py-0 px-2" href="{{ item.self_review_edit_url }}">Edit</a>
{% else %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_add_url }}">Add review</a>
{% endif %}
{% endif %}
</div>
</div>
{% empty %}
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>