diff --git a/cms/server/admin/static/aws_form_utils.js b/cms/server/admin/static/aws_form_utils.js new file mode 100644 index 0000000000..3d73eb9ec8 --- /dev/null +++ b/cms/server/admin/static/aws_form_utils.js @@ -0,0 +1,402 @@ +/* Contest Management System + * Copyright © 2012-2014 Stefano Maggiolo + * Copyright © 2012-2014 Luca Wehrstedt + * + * Form utilities for AWS. + * Extracted from aws_utils.js for better code organization. + */ + +"use strict"; + +window.CMS = window.CMS || {}; +var CMS = window.CMS; +CMS.AWSFormUtils = CMS.AWSFormUtils || {}; + + +/** + * Initialize password strength indicator for a password field. + * Uses zxcvbn library to calculate password strength and displays + * a colored bar with text feedback. + * + * fieldSelector (string): jQuery selector for the password input field. + * barSelector (string): jQuery selector for the strength bar element. + * textSelector (string): jQuery selector for the strength text element. + */ +CMS.AWSFormUtils.initPasswordStrength = function(fieldSelector, barSelector, textSelector) { + var strengthMessages = ["Very weak", "Weak", "Fair", "Strong", "Very strong"]; + var strengthColors = ["#dc3545", "#dc3545", "#ffc107", "#28a745", "#28a745"]; + var strengthWidths = ["20%", "40%", "60%", "80%", "100%"]; + + var $field = $(fieldSelector); + if (!$field.length) { + return; + } + + var $bar = $(barSelector); + var $text = $(textSelector); + + $field.on("input", function() { + var pwd = $(this).val(); + + if (!pwd) { + $bar.hide(); + $text.text(""); + return; + } + + if (typeof zxcvbn === "function") { + var result = zxcvbn(pwd); + var score = result.score; + + $bar.css({ + "background-color": strengthColors[score], + "width": strengthWidths[score] + }).show(); + $text.text("Password strength: " + strengthMessages[score]); + $text.css("color", strengthColors[score]); + } + }); +}; + + +/** + * Validates that end time is after start time for datetime-local inputs. + * Attaches to a form's submit event and prevents submission if invalid. + * + * formSelector (string): jQuery selector for the form element. + * startSelector (string): jQuery selector for the start datetime-local input. + * stopSelector (string): jQuery selector for the stop/end datetime-local input. + */ +CMS.AWSFormUtils.initDateTimeValidation = function(formSelector, startSelector, stopSelector) { + var form = document.querySelector(formSelector); + if (!form) return; + + form.addEventListener('submit', function(e) { + // Use form-scoped selectors to avoid matching inputs in other forms + var startInput = form.querySelector(startSelector); + var stopInput = form.querySelector(stopSelector); + if (!startInput || !stopInput) return; + + // Use valueAsNumber for reliable datetime-local comparison + var startValue = startInput.valueAsNumber; + var stopValue = stopInput.valueAsNumber; + if (!isNaN(startValue) && !isNaN(stopValue) && stopValue <= startValue) { + alert('End time must be after start time'); + e.preventDefault(); + } + }); +}; + + +/** + * Initializes a remove page with task handling options. + * Handles the radio button selection, dropdown enable/disable, and form submission. + * + * config (object): Configuration object with the following properties: + * - removeUrl (string): The base URL for the DELETE request. + * - hasTaskOptions (boolean): Whether task handling options are shown. + * - targetSelectId (string): ID of the target dropdown (e.g., 'target_contest_select'). + * - targetParamName (string): Query param name for target (e.g., 'target_contest_id'). + * - targetLabel (string): Label for validation alert (e.g., 'contest'). + */ +CMS.AWSFormUtils.initRemovePage = function(config) { + if (config.hasTaskOptions) { + // Cache DOM elements and check they exist + var targetSelectEl = document.getElementById(config.targetSelectId); + var moveRadioEl = document.getElementById('action_move'); + if (!targetSelectEl || !moveRadioEl) return; + + // Enable/disable the target dropdown based on the selected action + function syncTargetState() { + targetSelectEl.disabled = !moveRadioEl.checked; + } + document.querySelectorAll('input[name="action"]').forEach(function(radio) { + radio.addEventListener('change', syncTargetState); + }); + + // Initialize the dropdown state based on current selection + syncTargetState(); + } + + // Attach the remove function to CMS.AWSFormUtils namespace + // Also attach to window for backward compatibility with onclick handlers + CMS.AWSFormUtils.cmsDoRemove = function () { + var url = config.removeUrl; + + if (config.hasTaskOptions) { + var actionRadios = document.querySelectorAll('input[name="action"]'); + var selectedAction = null; + for (var i = 0; i < actionRadios.length; i++) { + if (actionRadios[i].checked) { + selectedAction = actionRadios[i].value; + break; + } + } + + if (!selectedAction) { + alert('Please select an option for handling tasks.'); + return; + } + + url += '?action=' + encodeURIComponent(selectedAction); + + if (selectedAction === 'move') { + var targetSelect = document.getElementById(config.targetSelectId); + if (targetSelect && targetSelect.value) { + url += '&' + config.targetParamName + '=' + encodeURIComponent(targetSelect.value); + } else { + alert('Please select a ' + config.targetLabel + ' to move tasks to.'); + return; + } + } + } + + if (confirm('Are you sure you want to remove this?')) { + CMS.AWSUtils.ajax_delete(url); + } + }; + // Backward compatibility alias + window.cmsDoRemove = CMS.AWSFormUtils.cmsDoRemove; +}; + + +/** + * Initializes read-only Tagify display on input element(s). + * Used to display tags in a visually consistent way without editing capability. + * + * inputSelector (string): CSS selector for the input element(s). + */ +CMS.AWSFormUtils.initReadOnlyTagify = function(inputSelector) { + // Defensive check for Tagify library + if (typeof Tagify === 'undefined') { + return; + } + + document.querySelectorAll(inputSelector).forEach(function(input) { + if (!input.value.trim()) return; + + new Tagify(input, { + delimiters: ",", + readonly: true, + editTags: false, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { + return item.value; + }).join(', '); + } + }); + }); +}; + + +/** + * Initializes Tagify on input element(s) with confirmation dialogs and save-on-confirm. + * Provides a unified interface for tag inputs across the admin interface. + * + * All tag operations (add, edit, remove) require confirmation before saving. + * Automatic removals (like duplicate detection) do not require confirmation but still save. + * + * config (object): Configuration object with the following properties: + * - inputSelector (string): CSS selector for the input element(s). + * - whitelist (array): Array of existing tags for autocomplete suggestions. + * - getSaveUrl (function): Function that receives the input element and returns the save URL. + * - saveParamName (string): Parameter name for the save request (e.g., 'student_tags'). + * - xsrfSelector (string): CSS selector for the XSRF token input (default: 'input[name="_xsrf"]'). + * - placeholder (string): Placeholder text (default: 'Type tags'). + * - editable (boolean): Whether tags can be edited by double-clicking (default: false). + * - enforceWhitelist (boolean): Whether to only allow tags from whitelist (default: false). + * - pattern (RegExp): Pattern for tag validation (default: null). + * - invalidMessage (string): Message to show when pattern validation fails. + */ +CMS.AWSFormUtils.initTagify = function(config) { + var inputs = document.querySelectorAll(config.inputSelector); + if (!inputs.length) return; + + var xsrfSelector = config.xsrfSelector || 'input[name="_xsrf"]'; + + inputs.forEach(function(input) { + var tagifyOptions = { + delimiters: ",", + maxTags: 20, + placeholder: config.placeholder || "Type tags", + whitelist: config.whitelist || [], + dropdown: { + maxItems: 20, + classname: "tags-look", + enabled: 0, + closeOnSelect: true + }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { + return item.value; + }).join(', '); + } + }; + + tagifyOptions.editTags = config.editable ? { clicks: 2, keepInvalid: false } : false; + tagifyOptions.enforceWhitelist = !!config.enforceWhitelist; + if (config.pattern) tagifyOptions.pattern = config.pattern; + + // Flag to track if a save should happen on the next 'change' event + var pendingSave = false; + // Flag to track if we're rolling back a cancelled add (to skip confirmation) + var isRollback = false; + // Flag to prevent confirmations during initial page load + var armed = false; + + function saveTags(tagifyInstance) { + // Use tagify.value (canonical state) instead of input.value + // input.value may be stale if Tagify's debounced update() hasn't run yet + var tags = tagifyInstance.value.map(function(t) { return t.value; }).join(', '); + var formData = new FormData(); + formData.append(config.saveParamName, tags); + var xsrfInput = document.querySelector(xsrfSelector); + if (xsrfInput) { + formData.append('_xsrf', xsrfInput.value); + } + + var saveUrl = config.getSaveUrl(input); + fetch(saveUrl, { + method: 'POST', + body: formData + }).then(function(response) { + if (!response.ok) { + console.error('Failed to save tags'); + } + }).catch(function(error) { + console.error('Error saving tags:', error); + }); + } + + // Track user-initiated removals (X click or backspace) + var userRemovalTriggeredAt = 0; + + tagifyOptions.hooks = { + beforeRemoveTag: function(tags) { + return new Promise(function(resolve, reject) { + // If this is a rollback from cancelled add, skip confirmation + if (isRollback) { + resolve(); + return; + } + + var now = Date.now(); + var isUserInitiated = (now - userRemovalTriggeredAt) < 200; + userRemovalTriggeredAt = 0; + + // Auto-removals (duplicates, etc.) don't need confirmation + if (!isUserInitiated) { + pendingSave = true; + resolve(); + return; + } + + // User-initiated removal needs confirmation + var tagValue = tags[0].data.value; + if (confirm('Remove tag "' + tagValue + '"?')) { + pendingSave = true; + resolve(); + } else { + reject(); + } + }); + } + }; + + var tagify = new Tagify(input, tagifyOptions); + + // Detect X button clicks + tagify.DOM.scope.addEventListener('click', function(e) { + if (e.target.closest('.tagify__tag__removeBtn')) { + userRemovalTriggeredAt = Date.now(); + } + }, true); + + // Detect backspace/delete key presses + tagify.DOM.input.addEventListener('keydown', function(e) { + if (e.key === 'Backspace' || e.key === 'Delete') { + userRemovalTriggeredAt = Date.now(); + } + }, true); + + // Handle add confirmation + tagify.on('add', function(e) { + // Skip confirmation if not armed yet (initial page load) + if (!armed) return; + + var tagValue = e.detail.data.value; + if (confirm('Add tag "' + tagValue + '"?')) { + pendingSave = true; + } else { + // Roll back the add - use isRollback flag to skip beforeRemoveTag confirmation + // Use non-silent removal so Tagify properly updates its internal state + isRollback = true; + tagify.removeTags(e.detail.tag); + isRollback = false; + } + }); + + // Handle edit confirmation + if (config.editable) { + var editingTagValue = null; + + tagify.on('edit:start', function(e) { + editingTagValue = e.detail.data.value; + }); + + tagify.on('edit:beforeUpdate', function(e) { + var oldVal = editingTagValue; + var newVal = e.detail.data && e.detail.data.value; + + // No change, no confirmation needed + if (oldVal === newVal) { + return; + } + + if (confirm('Change tag "' + oldVal + '" to "' + newVal + '"?')) { + pendingSave = true; + } else { + // Revert to old value + e.detail.data.value = oldVal; + } + }); + } + + // Save on 'change' event - this fires AFTER Tagify updates its internal state + tagify.on('change', function() { + if (pendingSave) { + saveTags(tagify); + pendingSave = false; + } + }); + + if (config.pattern && config.invalidMessage) { + tagify.on('invalid', function(e) { + if (e.detail.message === 'pattern mismatch') { + alert(config.invalidMessage); + } + }); + } + + // Arm the confirmations after a short delay to skip initial load events + // The 100ms delay allows Tagify to finish processing pre-existing tags + // before we start showing confirmation dialogs for user-initiated changes + setTimeout(function() { + armed = true; + }, 100); + }); +}; + + +// Backward compatibility aliases on CMS.AWSUtils +// These will be set up after aws_utils.js loads +document.addEventListener('DOMContentLoaded', function () { + if (typeof CMS.AWSUtils !== 'undefined') { + // Alias the new functions to the old names for backward compatibility + CMS.AWSUtils.initPasswordStrength = CMS.AWSFormUtils.initPasswordStrength; + CMS.AWSUtils.initDateTimeValidation = CMS.AWSFormUtils.initDateTimeValidation; + CMS.AWSUtils.initRemovePage = CMS.AWSFormUtils.initRemovePage; + CMS.AWSUtils.initReadOnlyTagify = CMS.AWSFormUtils.initReadOnlyTagify; + CMS.AWSUtils.initTagify = CMS.AWSFormUtils.initTagify; + } +}); diff --git a/cms/server/admin/static/aws_table_utils.js b/cms/server/admin/static/aws_table_utils.js new file mode 100644 index 0000000000..06c3173f20 --- /dev/null +++ b/cms/server/admin/static/aws_table_utils.js @@ -0,0 +1,223 @@ +/* Contest Management System + * Copyright © 2012-2014 Stefano Maggiolo + * Copyright © 2012-2014 Luca Wehrstedt + * + * Table sorting and filtering utilities for AWS. + * Extracted from aws_utils.js for better code organization. + */ + +"use strict"; + +window.CMS = window.CMS || {}; +var CMS = window.CMS; +CMS.AWSTableUtils = CMS.AWSTableUtils || {}; + + +/** + * Provides table row comparator for specified column and order. + * + * column_idx (int): Index of the column to sort by. + * numeric (boolean): Whether to sort numerically. + * ascending (boolean): Whether to sort in ascending order. + * return (function): Comparator function for Array.sort(). + */ +CMS.AWSTableUtils.getRowComparator = function(column_idx, numeric, ascending) { + return function(a, b) { + var cellA = $(a).children("td").eq(column_idx); + var cellB = $(b).children("td").eq(column_idx); + + // Use data-value if present, otherwise fallback to text + var valA = cellA.attr("data-value"); + if (typeof valA === "undefined" || valA === "") valA = cellA.text().trim(); + + var valB = cellB.attr("data-value"); + if (typeof valB === "undefined" || valB === "") valB = cellB.text().trim(); + + var result; + if (numeric) { + var numA = parseFloat(valA); + var numB = parseFloat(valB); + + // Treat non-numeric/empty values so they always sink to bottom regardless of sort direction + if (isNaN(numA)) numA = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + if (isNaN(numB)) numB = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + + result = numA - numB; + return ascending ? result : -result; + } else { + result = valA.localeCompare(valB); + return ascending ? result : -result; + } + }; +}; + + +/** + * Sorts specified table by specified column in specified order. + * + * table (jQuery): The table element to sort. + * column_idx (int): Index of the column to sort by. + * ascending (boolean): Whether to sort in ascending order. + * header_element (Element): Optional header element for the column. + */ +CMS.AWSTableUtils.sortTable = function(table, column_idx, ascending, header_element) { + var initial_column_idx = table.data("initial_sort_column_idx"); + var ranks_column = table.data("ranks_column"); + var data_column_idx = column_idx + (ranks_column ? 1 : 0); + var table_rows = table + .children("tbody") + .children("tr"); + + // Use provided header element if available, otherwise find by index + var column_header; + if (header_element) { + column_header = $(header_element); + } else { + column_header = table + .children("thead") + .children("tr") + .children("th") + .eq(data_column_idx); + } + var settings = (column_header.attr("data-sort-settings") || "").split(" "); + + var numeric = settings.indexOf("numeric") >= 0; + + // If specified, flip column's natural order, e.g. due to meaning of values. + if (settings.indexOf("reversed") >= 0) { + ascending = !ascending; + } + + // Normalize column index for data access, converting negative to positive from the end. + if (data_column_idx < 0) { + // For negative indices, calculate from the number of columns in data rows + var first_data_row = table_rows.first(); + var num_cols = first_data_row.children("td,th").length; + data_column_idx = num_cols + data_column_idx; + } + + // Reassign arrows to headers + table.find(".column-sort").html("↕"); + column_header.find(".column-sort").html(ascending ? "↑" : "↓"); + + // Do the sorting, by initial column and then by selected column. + table_rows + .sort(CMS.AWSTableUtils.getRowComparator(initial_column_idx, numeric, ascending)) + .sort(CMS.AWSTableUtils.getRowComparator(data_column_idx, numeric, ascending)) + .each(function(idx, row) { + table.children("tbody").append(row); + }); + + if (ranks_column) { + table_rows.each(function(idx, row) { + $(row).children("td").first().text(idx + 1); + }); + } +}; + + +/** + * Makes table sortable, adding ranks column and sorting buttons in header. + * + * table (jQuery): The table element to make sortable. + * ranks_column (boolean): Whether to add a ranks column. + * initial_column_idx (int): Index of the column to initially sort by. + * initial_ascending (boolean): Whether to initially sort in ascending order. + */ +CMS.AWSTableUtils.initTableSort = function(table, ranks_column, initial_column_idx, initial_ascending) { + table.addClass("sortable"); + var table_column_headers = table + .children("thead") + .children("tr"); + var table_rows = table + .children("tbody") + .children("tr"); + + // Normalize column index, converting negative to positive from the end. + initial_column_idx = table_column_headers + .children("th") + .eq(initial_column_idx) + .index(); + + table.data("ranks_column", ranks_column); + table.data("initial_sort_column_idx", initial_column_idx); + + // Declaring sort settings. + var previous_column_idx = initial_column_idx; + var ascending = initial_ascending; + + // Add sorting indicators to column headers + // Skip headers with the "no-sort" class + // Use data-sort-column attribute if present for correct column index + table_column_headers + .children("th") + .not(".no-sort") + .each(function(idx, header) { + var $header = $(header); + // Use data-sort-column if specified, otherwise use the header's index + var sortColumn = $header.data("sort-column"); + if (sortColumn === undefined) { + sortColumn = $header.index(); + } + $("", { + href: "#", + class: "column-sort", + click: function(e) { + e.preventDefault(); + ascending = !ascending && previous_column_idx == sortColumn; + previous_column_idx = sortColumn; + CMS.AWSTableUtils.sortTable(table, sortColumn, ascending, header); + } + }).appendTo(header); + }); + + // Add ranks column + if (ranks_column) { + table_column_headers.prepend("#"); + table_rows.prepend(""); + } + + // Do initial sorting + CMS.AWSTableUtils.sortTable(table, initial_column_idx, initial_ascending); +}; + + +/** + * Filters table rows based on search text. + * + * table_id (string): The id of the table to filter. + * search_text (string): The text to search for in table rows. + */ +CMS.AWSTableUtils.filterTable = function(table_id, search_text) { + var table = document.getElementById(table_id); + if (!table) { + return; + } + var rows = table.querySelectorAll("tbody tr"); + var search_lower = search_text.toLowerCase().trim(); + + rows.forEach(function(row) { + if (search_lower === "") { + row.style.display = ""; + return; + } + var text = row.textContent.toLowerCase(); + if (text.indexOf(search_lower) !== -1) { + row.style.display = ""; + } else { + row.style.display = "none"; + } + }); +}; + + +// Backward compatibility aliases on CMS.AWSUtils +// These will be set up after aws_utils.js loads +document.addEventListener('DOMContentLoaded', function () { + if (typeof CMS.AWSUtils !== 'undefined') { + // Alias the new functions to the old names for backward compatibility + CMS.AWSUtils.sort_table = CMS.AWSTableUtils.sortTable; + CMS.AWSUtils.init_table_sort = CMS.AWSTableUtils.initTableSort; + CMS.AWSUtils.filter_table = CMS.AWSTableUtils.filterTable; + } +}); diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css index 74c0ddcec8..18fd2839e2 100644 --- a/cms/server/admin/static/aws_tp_styles.css +++ b/cms/server/admin/static/aws_tp_styles.css @@ -188,44 +188,102 @@ } /* ========================================================================== - Add Student Dropdown/Modal + Native Dialog Element Styles ========================================================================== */ -.add-student-dropdown { - position: relative; - display: inline-block; +.tp-dialog { + border: none; + border-radius: 12px; + padding: 0; + box-shadow: var(--tp-shadow-lg); + max-width: 400px; + width: 90%; + overflow: visible; } -.add-student-form { - display: none; - position: absolute; - top: 100%; - right: 0; - margin-top: 8px; - background: var(--tp-bg-white); - border: 1px solid var(--tp-border); - border-radius: 8px; - padding: 16px; - box-shadow: var(--tp-shadow-lg); - z-index: 100; - min-width: 300px; +.tp-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); } -.add-student-form.show { - display: block; +.tp-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--tp-border); +} + +.tp-dialog-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--tp-text-primary); +} + +.tp-dialog-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--tp-text-secondary); + cursor: pointer; + padding: 0; + line-height: 1; } -.add-student-form label { +.tp-dialog-close:hover { + color: var(--tp-text-primary); +} + +.tp-dialog-body { + padding: 20px; +} + +.tp-dialog-body label { display: block; font-size: 0.85rem; font-weight: 600; color: var(--tp-text-secondary); - margin-bottom: 6px; + margin-bottom: 8px; } -.add-student-form .searchable-select { - width: 100%; - margin-bottom: 12px; +.tp-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--tp-border); + background: var(--tp-bg-light); + border-radius: 0 0 12px 12px; +} + +/* ========================================================================== + Progress Link and Modern Progress Bar + ========================================================================== */ + +.progress-link { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +.progress-link.no-tasks { + color: #9ca3af; + font-size: 0.85rem; +} + +.progress-percentage { + font-weight: 600; + font-size: 0.9rem; + min-width: 45px; + /* Color based on percentage using CSS custom property */ + color: hsl(calc(var(--pct, 0) * 1.2), 70%, 35%); +} + +.progress-score { + font-size: 0.8rem; + color: #6b7280; + white-space: nowrap; } /* ========================================================================== @@ -317,51 +375,6 @@ font-size: 0.7em; } -/* ========================================================================== - Progress Bar (inline style) - ========================================================================== */ - -.progress-bar-inline { - display: flex; - align-items: center; - gap: 8px; -} - -.progress-bar-inline .percentage { - font-size: 0.9rem; - font-weight: 600; - color: #374151; - min-width: 40px; - text-align: right; -} - -.progress-bar-inline .bar-container { - flex: 1; - height: 6px; - background: var(--tp-border); - border-radius: 3px; - overflow: hidden; - width: 120px; -} - -.progress-bar-inline .bar-fill { - height: 100%; - border-radius: 3px; - transition: width 0.3s ease; -} - -.progress-bar-inline .bar-fill.color-green { - background: var(--tp-success); -} - -.progress-bar-inline .bar-fill.color-orange { - background: var(--tp-warning); -} - -.progress-bar-inline .bar-fill.color-red { - background: var(--tp-danger); -} - /* ========================================================================== Icon-only Button (for actions like delete) ========================================================================== */ @@ -739,6 +752,40 @@ background-image: linear-gradient(rgba(255,255,255,0.3), rgba(255,255,255,0.3)); } +/* Score cell coloring using CSS custom properties + Uses --score (0-100) to calculate hue: 0 (Red) -> 60 (Yellow) -> 120 (Green) + The hue is clamped between 0 and 120 using min/max */ +.cell-content.score-cell { + --hue: calc(var(--score, 0) * 1.2); + background-color: hsl(min(120, max(0, var(--hue))), 75%, 90%); + color: hsl(min(120, max(0, var(--hue))), 90%, 20%); + background-image: none; +} + +/* Modern progress bar using CSS custom properties + Uses --pct (0-100) to set width and color automatically */ +.progress-bar-modern { + height: 6px; + background: var(--tp-border); + border-radius: 3px; + position: relative; + overflow: hidden; + width: 120px; +} + +.progress-bar-modern::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: calc(var(--pct, 0) * 1%); + /* HSL color transitions from red (0) to green (120) based on percentage */ + background: hsl(calc(var(--pct, 0) * 1.2), 70%, 45%); + border-radius: 3px; + transition: width 0.3s ease; +} + /* Hover effects */ .ranking-table tbody tr:hover td { background-color: var(--tp-bg-hover); @@ -967,7 +1014,7 @@ .training-day-header { background-color: var(--tp-bg-light) !important; text-align: center; - border-bottom: 1px solid var(--tp-border-dark) !important; + border-bottom: none !important; pointer-events: none; } @@ -2234,45 +2281,6 @@ text-decoration: underline; } -/* Year Progress */ -.tp-card-progress { - margin-top: auto; - padding-top: 16px; - border-top: 1px solid var(--tp-border); -} - -.tp-progress-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.tp-progress-label { - font-size: 0.85rem; - color: var(--tp-text-muted); -} - -.tp-progress-value { - font-size: 0.85rem; - color: var(--tp-text-secondary); - font-weight: 500; -} - -.tp-progress-bar { - height: 6px; - background: var(--tp-border); - border-radius: 3px; - overflow: hidden; -} - -.tp-progress-fill { - height: 100%; - background: var(--tp-primary); - border-radius: 3px; - transition: width 0.3s ease; -} - /* Create New Program Card */ .tp-create-card { display: flex; diff --git a/cms/server/admin/static/aws_utils.js b/cms/server/admin/static/aws_utils.js index d655fd2337..b4b0eb0ac6 100644 --- a/cms/server/admin/static/aws_utils.js +++ b/cms/server/admin/static/aws_utils.js @@ -403,189 +403,8 @@ CMS.AWSUtils.prototype.close_notification = function(item) { }; -/** - * Provides table row comparator for specified column and order. - */ -function get_table_row_comparator(column_idx, numeric, ascending) { - return function(a, b) { - var cellA = $(a).children("td").eq(column_idx); - var cellB = $(b).children("td").eq(column_idx); - - // Use data-value if present, otherwise fallback to text - var valA = cellA.attr("data-value"); - if (typeof valA === "undefined" || valA === "") valA = cellA.text().trim(); - - var valB = cellB.attr("data-value"); - if (typeof valB === "undefined" || valB === "") valB = cellB.text().trim(); - - var result; - if (numeric) { - var numA = parseFloat(valA); - var numB = parseFloat(valB); - - // Treat non-numeric/empty values so they always sink to bottom regardless of sort direction - if (isNaN(numA)) numA = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; - if (isNaN(numB)) numB = ascending ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; - - result = numA - numB; - return ascending ? result : -result; - } else { - result = valA.localeCompare(valB); - return ascending ? result : -result; - } - } -} - - -/** - * Sorts specified table by specified column in specified order. - */ -CMS.AWSUtils.sort_table = function(table, column_idx, ascending, header_element) { - var initial_column_idx = table.data("initial_sort_column_idx"); - var ranks_column = table.data("ranks_column"); - var data_column_idx = column_idx + (ranks_column ? 1 : 0); - var table_rows = table - .children("tbody") - .children("tr"); - - // Use provided header element if available, otherwise find by index - var column_header; - if (header_element) { - column_header = $(header_element); - } else { - column_header = table - .children("thead") - .children("tr") - .children("th") - .eq(data_column_idx); - } - var settings = (column_header.attr("data-sort-settings") || "").split(" "); - - var numeric = settings.indexOf("numeric") >= 0; - - // If specified, flip column's natural order, e.g. due to meaning of values. - if (settings.indexOf("reversed") >= 0) { - ascending = !ascending; - } - - // Normalize column index for data access, converting negative to positive from the end. - if (data_column_idx < 0) { - // For negative indices, calculate from the number of columns in data rows - var first_data_row = table_rows.first(); - var num_cols = first_data_row.children("td,th").length; - data_column_idx = num_cols + data_column_idx; - } - - // Reassign arrows to headers - table.find(".column-sort").html("↕"); - column_header.find(".column-sort").html(ascending ? "↑" : "↓"); - - // Do the sorting, by initial column and then by selected column. - table_rows - .sort(get_table_row_comparator(initial_column_idx, numeric, ascending)) - .sort(get_table_row_comparator(data_column_idx, numeric, ascending)) - .each(function(idx, row) { - table.children("tbody").append(row) - }); - - if (ranks_column) { - table_rows.each(function(idx, row) { - $(row).children("td").first().text(idx + 1) - }); - } -}; - - -/** - * Makes table sortable, adding ranks column and sorting buttons in header. - */ -CMS.AWSUtils.init_table_sort = function(table, ranks_column, - initial_column_idx, - initial_ascending) { - table.addClass("sortable"); - var table_column_headers = table - .children("thead") - .children("tr"); - var table_rows = table - .children("tbody") - .children("tr"); - - // Normalize column index, converting negative to positive from the end. - initial_column_idx = table_column_headers - .children("th") - .eq(initial_column_idx) - .index(); - - table.data("ranks_column", ranks_column); - table.data("initial_sort_column_idx", initial_column_idx); - - // Declaring sort settings. - var previous_column_idx = initial_column_idx; - var ascending = initial_ascending; - - // Add sorting indicators to column headers - // Skip headers with the "no-sort" class - // Use data-sort-column attribute if present for correct column index - table_column_headers - .children("th") - .not(".no-sort") - .each(function(idx, header) { - var $header = $(header); - // Use data-sort-column if specified, otherwise use the header's index - var sortColumn = $header.data("sort-column"); - if (sortColumn === undefined) { - sortColumn = $header.index(); - } - $("", { - href: "#", - class: "column-sort", - click: function(e) { - e.preventDefault(); - ascending = !ascending && previous_column_idx == sortColumn; - previous_column_idx = sortColumn; - CMS.AWSUtils.sort_table(table, sortColumn, ascending, header); - } - }).appendTo(header); - }); - - // Add ranks column - if (ranks_column) { - table_column_headers.prepend("#"); - table_rows.prepend(""); - } - - // Do initial sorting - CMS.AWSUtils.sort_table(table, initial_column_idx, initial_ascending); -}; - - -/** - * Filters table rows based on search text. - * - * table_id (string): the id of the table to filter. - * search_text (string): the text to search for in table rows. - */ -CMS.AWSUtils.filter_table = function(table_id, search_text) { - var table = document.getElementById(table_id); - if (!table) { - return; - } - var rows = table.querySelectorAll("tbody tr"); - var search_lower = search_text.toLowerCase().trim(); - - rows.forEach(function(row) { - if (search_lower === "") { - row.style.display = ""; - return; - } - var text = row.textContent.toLowerCase(); - if (text.indexOf(search_lower) !== -1) { - row.style.display = ""; - } else { - row.style.display = "none"; - } - }); -}; +// Table utilities (get_table_row_comparator, sort_table, init_table_sort, filter_table) +// have been moved to aws_table_utils.js for better code organization. /** @@ -982,50 +801,7 @@ CMS.AWSUtils.ajax_post = function(url) { }; -/** - * Initialize password strength indicator for a password field. - * Uses zxcvbn library to calculate password strength and displays - * a colored bar with text feedback. - * - * fieldSelector (string): jQuery selector for the password input field. - * barSelector (string): jQuery selector for the strength bar element. - * textSelector (string): jQuery selector for the strength text element. - */ -CMS.AWSUtils.initPasswordStrength = function(fieldSelector, barSelector, textSelector) { - var strengthMessages = ["Very weak", "Weak", "Fair", "Strong", "Very strong"]; - var strengthColors = ["#dc3545", "#dc3545", "#ffc107", "#28a745", "#28a745"]; - var strengthWidths = ["20%", "40%", "60%", "80%", "100%"]; - - var $field = $(fieldSelector); - if (!$field.length) { - return; - } - - var $bar = $(barSelector); - var $text = $(textSelector); - - $field.on("input", function() { - var pwd = $(this).val(); - - if (!pwd) { - $bar.hide(); - $text.text(""); - return; - } - - if (typeof zxcvbn === "function") { - var result = zxcvbn(pwd); - var score = result.score; - - $bar.css({ - "background-color": strengthColors[score], - "width": strengthWidths[score] - }).show(); - $text.text("Password strength: " + strengthMessages[score]); - $text.css("color", strengthColors[score]); - } - }); -}; +// initPasswordStrength has been moved to aws_form_utils.js /** @@ -1397,331 +1173,6 @@ CMS.AWSUtils.initModelSolutionSubtasks = function(options) { }; -/** - * Validates that end time is after start time for datetime-local inputs. - * Attaches to a form's submit event and prevents submission if invalid. - * - * formSelector (string): jQuery selector for the form element. - * startSelector (string): jQuery selector for the start datetime-local input. - * stopSelector (string): jQuery selector for the stop/end datetime-local input. - */ -CMS.AWSUtils.initDateTimeValidation = function(formSelector, startSelector, stopSelector) { - var form = document.querySelector(formSelector); - if (!form) return; - - form.addEventListener('submit', function(e) { - // Use form-scoped selectors to avoid matching inputs in other forms - var startInput = form.querySelector(startSelector); - var stopInput = form.querySelector(stopSelector); - if (!startInput || !stopInput) return; - - // Use valueAsNumber for reliable datetime-local comparison - var startValue = startInput.valueAsNumber; - var stopValue = stopInput.valueAsNumber; - if (startValue && stopValue && stopValue <= startValue) { - alert('End time must be after start time'); - e.preventDefault(); - } - }); -}; - - -/** - * Initializes a remove page with task handling options. - * Handles the radio button selection, dropdown enable/disable, and form submission. - * - * config (object): Configuration object with the following properties: - * - removeUrl (string): The base URL for the DELETE request. - * - hasTaskOptions (boolean): Whether task handling options are shown. - * - targetSelectId (string): ID of the target dropdown (e.g., 'target_contest_select'). - * - targetParamName (string): Query param name for target (e.g., 'target_contest_id'). - * - targetLabel (string): Label for validation alert (e.g., 'contest'). - */ -CMS.AWSUtils.initRemovePage = function(config) { - if (config.hasTaskOptions) { - // Cache DOM elements and check they exist - var targetSelectEl = document.getElementById(config.targetSelectId); - var moveRadioEl = document.getElementById('action_move'); - if (!targetSelectEl || !moveRadioEl) return; - - // Enable/disable the target dropdown based on the selected action - document.querySelectorAll('input[name="action"]').forEach(function(radio) { - radio.addEventListener('change', function() { - if (moveRadioEl.checked) { - targetSelectEl.disabled = false; - } else { - targetSelectEl.disabled = true; - } - }); - }); - - // Initialize the dropdown state - targetSelectEl.disabled = true; - } - - // Attach the remove function to CMS.AWSUtils namespace to avoid global pollution - // Also attach to window for backward compatibility with onclick handlers - CMS.AWSUtils.cmsDoRemove = function () { - var url = config.removeUrl; - - if (config.hasTaskOptions) { - var actionRadios = document.querySelectorAll('input[name="action"]'); - var selectedAction = null; - for (var i = 0; i < actionRadios.length; i++) { - if (actionRadios[i].checked) { - selectedAction = actionRadios[i].value; - break; - } - } - - if (!selectedAction) { - alert('Please select an option for handling tasks.'); - return; - } - - url += '?action=' + encodeURIComponent(selectedAction); - - if (selectedAction === 'move') { - var targetSelect = document.getElementById(config.targetSelectId); - if (targetSelect && targetSelect.value) { - url += '&' + config.targetParamName + '=' + encodeURIComponent(targetSelect.value); - } else { - alert('Please select a ' + config.targetLabel + ' to move tasks to.'); - return; - } - } - } - - if (confirm('Are you sure you want to remove this?')) { - CMS.AWSUtils.ajax_delete(url); - } - }; - // Backward compatibility alias - window.cmsDoRemove = CMS.AWSUtils.cmsDoRemove; -}; - - -/** - * Initializes read-only Tagify display on input element(s). - * Used to display tags in a visually consistent way without editing capability. - * - * inputSelector (string): CSS selector for the input element(s). - */ -CMS.AWSUtils.initReadOnlyTagify = function(inputSelector) { - // Defensive check for Tagify library - if (typeof Tagify === 'undefined') { - return; - } - - document.querySelectorAll(inputSelector).forEach(function(input) { - if (!input.value.trim()) return; - - new Tagify(input, { - delimiters: ",", - readonly: true, - editTags: false, - originalInputValueFormat: function(valuesArr) { - return valuesArr.map(function(item) { - return item.value; - }).join(', '); - } - }); - }); -}; - - -/** - * Initializes Tagify on input element(s) with confirmation dialogs and save-on-confirm. - * Provides a unified interface for tag inputs across the admin interface. - * - * All tag operations (add, edit, remove) require confirmation before saving. - * Automatic removals (like duplicate detection) do not require confirmation but still save. - * - * config (object): Configuration object with the following properties: - * - inputSelector (string): CSS selector for the input element(s). - * - whitelist (array): Array of existing tags for autocomplete suggestions. - * - getSaveUrl (function): Function that receives the input element and returns the save URL. - * - saveParamName (string): Parameter name for the save request (e.g., 'student_tags'). - * - xsrfSelector (string): CSS selector for the XSRF token input (default: 'input[name="_xsrf"]'). - * - placeholder (string): Placeholder text (default: 'Type tags'). - * - editable (boolean): Whether tags can be edited by double-clicking (default: false). - * - enforceWhitelist (boolean): Whether to only allow tags from whitelist (default: false). - * - pattern (RegExp): Pattern for tag validation (default: null). - * - invalidMessage (string): Message to show when pattern validation fails. - */ -CMS.AWSUtils.initTagify = function(config) { - var inputs = document.querySelectorAll(config.inputSelector); - if (!inputs.length) return; - - var xsrfSelector = config.xsrfSelector || 'input[name="_xsrf"]'; - - inputs.forEach(function(input) { - var tagifyOptions = { - delimiters: ",", - maxTags: 20, - placeholder: config.placeholder || "Type tags", - whitelist: config.whitelist || [], - dropdown: { - maxItems: 20, - classname: "tags-look", - enabled: 0, - closeOnSelect: true - }, - originalInputValueFormat: function(valuesArr) { - return valuesArr.map(function(item) { - return item.value; - }).join(', '); - } - }; - - tagifyOptions.editTags = config.editable ? { clicks: 2, keepInvalid: false } : false; - tagifyOptions.enforceWhitelist = !!config.enforceWhitelist; - if (config.pattern) tagifyOptions.pattern = config.pattern; - - // Flag to track if a save should happen on the next 'change' event - var pendingSave = false; - // Flag to track if we're rolling back a cancelled add (to skip confirmation) - var isRollback = false; - // Flag to prevent confirmations during initial page load - var armed = false; - - function saveTags(tagifyInstance) { - // Use tagify.value (canonical state) instead of input.value - // input.value may be stale if Tagify's debounced update() hasn't run yet - var tags = tagifyInstance.value.map(function(t) { return t.value; }).join(', '); - var formData = new FormData(); - formData.append(config.saveParamName, tags); - var xsrfInput = document.querySelector(xsrfSelector); - if (xsrfInput) { - formData.append('_xsrf', xsrfInput.value); - } - - var saveUrl = config.getSaveUrl(input); - fetch(saveUrl, { - method: 'POST', - body: formData - }).then(function(response) { - if (!response.ok) { - console.error('Failed to save tags'); - } - }).catch(function(error) { - console.error('Error saving tags:', error); - }); - } - - // Track user-initiated removals (X click or backspace) - var userRemovalTriggeredAt = 0; - - tagifyOptions.hooks = { - beforeRemoveTag: function(tags) { - return new Promise(function(resolve, reject) { - // If this is a rollback from cancelled add, skip confirmation - if (isRollback) { - resolve(); - return; - } - - var now = Date.now(); - var isUserInitiated = (now - userRemovalTriggeredAt) < 200; - userRemovalTriggeredAt = 0; - - // Auto-removals (duplicates, etc.) don't need confirmation - if (!isUserInitiated) { - pendingSave = true; - resolve(); - return; - } - - // User-initiated removal needs confirmation - var tagValue = tags[0].data.value; - if (confirm('Remove tag "' + tagValue + '"?')) { - pendingSave = true; - resolve(); - } else { - reject(); - } - }); - } - }; - - var tagify = new Tagify(input, tagifyOptions); - - // Detect X button clicks - tagify.DOM.scope.addEventListener('click', function(e) { - if (e.target.closest('.tagify__tag__removeBtn')) { - userRemovalTriggeredAt = Date.now(); - } - }, true); - - // Detect backspace/delete key presses - tagify.DOM.input.addEventListener('keydown', function(e) { - if (e.key === 'Backspace' || e.key === 'Delete') { - userRemovalTriggeredAt = Date.now(); - } - }, true); - - // Handle add confirmation - tagify.on('add', function(e) { - // Skip confirmation if not armed yet (initial page load) - if (!armed) return; - - var tagValue = e.detail.data.value; - if (confirm('Add tag "' + tagValue + '"?')) { - pendingSave = true; - } else { - // Roll back the add - use isRollback flag to skip beforeRemoveTag confirmation - // Use non-silent removal so Tagify properly updates its internal state - isRollback = true; - tagify.removeTags(e.detail.tag); - isRollback = false; - } - }); - - // Handle edit confirmation - if (config.editable) { - var editingTagValue = null; - - tagify.on('edit:start', function(e) { - editingTagValue = e.detail.data.value; - }); - - tagify.on('edit:beforeUpdate', function(e) { - var oldVal = editingTagValue; - var newVal = e.detail.data && e.detail.data.value; - - // No change, no confirmation needed - if (oldVal === newVal) { - return; - } - - if (confirm('Change tag "' + oldVal + '" to "' + newVal + '"?')) { - pendingSave = true; - } else { - // Revert to old value - e.detail.data.value = oldVal; - } - }); - } - - // Save on 'change' event - this fires AFTER Tagify updates its internal state - tagify.on('change', function() { - if (pendingSave) { - saveTags(tagify); - pendingSave = false; - } - }); - - if (config.pattern && config.invalidMessage) { - tagify.on('invalid', function(e) { - if (e.detail.message === 'pattern mismatch') { - alert(config.invalidMessage); - } - }); - } - - // Arm the confirmations after a short delay to skip initial load events - setTimeout(function() { - armed = true; - }, 100); - }); -}; +// Form utilities (initDateTimeValidation, initRemovePage, initReadOnlyTagify, initTagify) +// have been moved to aws_form_utils.js for better code organization. +// Backward compatibility aliases are set up in aws_form_utils.js. diff --git a/cms/server/admin/static/training_program.js b/cms/server/admin/static/training_program.js new file mode 100644 index 0000000000..cb1bb4f3a7 --- /dev/null +++ b/cms/server/admin/static/training_program.js @@ -0,0 +1,456 @@ +/* Contest Management System + * Copyright © 2024 IOI-ISR + * + * Training Program JavaScript Utilities + * Centralized JS for training program pages (histogram modal, etc.) + */ + +"use strict"; + +var CMS = CMS || {}; + +/** + * Training Program utilities namespace. + * Provides histogram modal functionality and other training program specific features. + */ +CMS.TrainingProgram = CMS.TrainingProgram || {}; + +// Module state (stored on namespace for access by methods) +CMS.TrainingProgram._histogramModal = null; +CMS.TrainingProgram._histogramTagify = null; +CMS.TrainingProgram._currentHistogramData = null; + +// Configuration (set via init) +CMS.TrainingProgram._config = { + allStudentTags: [], + tagsPerTrainingDay: {}, + historicalStudentTags: {}, + studentData: {}, + trainingDayTasks: {}, + studentAccessibleTasks: {}, + taskMaxScores: {}, + taskMaxScoresByTrainingDay: {} +}; + + +/** + * Initialize the training program module with data from templates. + * + * options (object): Configuration options containing: + * - allStudentTags (array): List of all student tags + * - tagsPerTrainingDay (object): Tags available per training day + * - historicalStudentTags (object): Historical tags per training day per student + * - studentData (object): Student information keyed by student ID + * - trainingDayTasks (object): Tasks per training day + * - studentAccessibleTasks (object): Accessible tasks per student per training day + * - taskMaxScores (object): Max scores per task + * - taskMaxScoresByTrainingDay (object): Max scores per task per training day + */ +CMS.TrainingProgram.init = function(options) { + if (options) { + var config = CMS.TrainingProgram._config; + Object.keys(options).forEach(function(key) { + if (config.hasOwnProperty(key)) { + config[key] = options[key]; + } + }); + } +}; + + +/** + * Initialize the histogram modal. + * Sets up Tagify for filtering and event listeners for closing. + */ +CMS.TrainingProgram.initHistogramModal = function() { + var modal = document.getElementById('histogramModal'); + if (!modal) return; + + CMS.TrainingProgram._histogramModal = modal; + + var histogramTagsInput = document.getElementById('histogramTagsFilter'); + if (histogramTagsInput && typeof Tagify !== 'undefined') { + CMS.TrainingProgram._histogramTagify = new Tagify(histogramTagsInput, { + delimiters: ",", + whitelist: CMS.TrainingProgram._config.allStudentTags, + enforceWhitelist: true, + editTags: false, + dropdown: { enabled: 0, maxItems: 20, closeOnSelect: true }, + originalInputValueFormat: function(valuesArr) { + return valuesArr.map(function(item) { return item.value; }).join(', '); + } + }); + CMS.TrainingProgram._histogramTagify.on('change', function() { + var data = CMS.TrainingProgram._currentHistogramData; + if (data) { + CMS.TrainingProgram._renderHistogram(data.scores, data.title, data.type); + } + }); + } + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + CMS.TrainingProgram.closeHistogramModal(); + } + }); + + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && modal.style.display === 'flex') { + CMS.TrainingProgram.closeHistogramModal(); + } + }); +}; + + +/** + * Open the histogram modal with score data. + * + * scores (array): Array of {studentId, score} objects + * title (string): Title for the histogram + * type (string): Type of histogram ('task' or 'training_day') + * trainingDayId (number): ID of the training day + * maxPossibleScore (number): Maximum possible score + */ +CMS.TrainingProgram.openHistogramModal = function(scores, title, type, trainingDayId, maxPossibleScore) { + var modal = CMS.TrainingProgram._histogramModal; + if (!modal) return; + + CMS.TrainingProgram._currentHistogramData = { + scores: scores, + title: title, + type: type, + trainingDayId: trainingDayId, + maxPossibleScore: (maxPossibleScore === undefined || maxPossibleScore === null) ? 100 : maxPossibleScore + }; + + var titleEl = document.getElementById('histogramTitle'); + if (titleEl) { + titleEl.textContent = title + ' - Score Distribution'; + } + + var tagify = CMS.TrainingProgram._histogramTagify; + var config = CMS.TrainingProgram._config; + if (tagify && trainingDayId && config.tagsPerTrainingDay[trainingDayId]) { + tagify.settings.whitelist = config.tagsPerTrainingDay[trainingDayId]; + tagify.removeAllTags(); + } else if (tagify) { + tagify.settings.whitelist = config.allStudentTags; + tagify.removeAllTags(); + } + + modal.style.display = 'flex'; + CMS.TrainingProgram._renderHistogram(scores, title, type); +}; + + +/** + * Close the histogram modal. + */ +CMS.TrainingProgram.closeHistogramModal = function() { + var modal = CMS.TrainingProgram._histogramModal; + if (modal) { + modal.style.display = 'none'; + } + CMS.TrainingProgram._currentHistogramData = null; +}; + + +/** + * Copy histogram data to clipboard. + */ +CMS.TrainingProgram.copyHistogramData = function() { + var textArea = document.getElementById('histogramTextData'); + if (!textArea) return; + + var textToCopy = textArea.value; + var btn = document.querySelector('.copy-btn'); + var originalText = btn ? btn.textContent : 'Copy'; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(textToCopy).then(function() { + if (btn) { + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + } + }).catch(function() { + CMS.TrainingProgram._fallbackCopy(textArea, btn, originalText); + }); + } else { + CMS.TrainingProgram._fallbackCopy(textArea, btn, originalText); + } +}; + + +/** + * Fallback copy method using execCommand. + * @private + */ +CMS.TrainingProgram._fallbackCopy = function(textArea, btn, originalText) { + textArea.select(); + document.execCommand('copy'); + if (btn) { + btn.textContent = 'Copied!'; + setTimeout(function() { btn.textContent = originalText; }, 2000); + } +}; + + +/** + * Get filtered scores based on selected tags. + * @private + */ +CMS.TrainingProgram._getFilteredScores = function(scores) { + var tagify = CMS.TrainingProgram._histogramTagify; + var filterTags = []; + + if (tagify) { + var tagifyValue = tagify.value; + if (tagifyValue && tagifyValue.length > 0) { + filterTags = tagifyValue.map(function(t) { return t.value; }); + } + } + + if (filterTags.length === 0) { + return scores; + } + + var data = CMS.TrainingProgram._currentHistogramData; + var trainingDayId = data ? data.trainingDayId : null; + var config = CMS.TrainingProgram._config; + + return scores.filter(function(item) { + var studentTags = []; + + if (trainingDayId && config.historicalStudentTags[trainingDayId] && + config.historicalStudentTags[trainingDayId][item.studentId]) { + studentTags = config.historicalStudentTags[trainingDayId][item.studentId]; + } else { + var studentInfo = config.studentData[item.studentId]; + if (studentInfo) { + studentTags = studentInfo.tags; + } + } + + if (!studentTags || studentTags.length === 0) return false; + return filterTags.every(function(tag) { + return studentTags.indexOf(tag) !== -1; + }); + }); +}; + + +/** + * Calculate the maximum score for filtered students. + * @private + */ +CMS.TrainingProgram._calculateFilteredMaxScore = function(filteredScores, trainingDayId, type) { + var data = CMS.TrainingProgram._currentHistogramData; + var config = CMS.TrainingProgram._config; + + if (type === 'task') { + return data ? data.maxPossibleScore : 100; + } + + if (type === 'training_day' && trainingDayId && config.trainingDayTasks[trainingDayId]) { + var accessibleTasksSet = new Set(); + filteredScores.forEach(function(item) { + var studentTasks = config.studentAccessibleTasks[trainingDayId] && + config.studentAccessibleTasks[trainingDayId][item.studentId]; + if (studentTasks) { + studentTasks.forEach(function(taskId) { + accessibleTasksSet.add(taskId); + }); + } + }); + + var maxScore = 0; + accessibleTasksSet.forEach(function(taskId) { + var taskMaxScore = 0; + if (trainingDayId && config.taskMaxScoresByTrainingDay[trainingDayId]) { + taskMaxScore = config.taskMaxScoresByTrainingDay[trainingDayId][taskId] || 0; + } else { + taskMaxScore = config.taskMaxScores[taskId] || 0; + } + maxScore += taskMaxScore; + }); + + return maxScore > 0 ? maxScore : (data ? data.maxPossibleScore : 100); + } + + return data ? data.maxPossibleScore : 100; +}; + + +/** + * Render the histogram with the given scores. + * @private + */ +CMS.TrainingProgram._renderHistogram = function(scores, title, type) { + var filteredScores = CMS.TrainingProgram._getFilteredScores(scores); + var scoreValues = filteredScores.map(function(s) { return s.score; }); + + scoreValues.sort(function(a, b) { return b - a; }); + + var data = CMS.TrainingProgram._currentHistogramData; + var trainingDayId = data ? data.trainingDayId : null; + var maxPossibleScore = CMS.TrainingProgram._calculateFilteredMaxScore(filteredScores, trainingDayId, type); + + var buckets = {}; + var bucketLabels = {}; + var bucketOrder = []; + + if (maxPossibleScore === 0) { + maxPossibleScore = 1; + } + + if (maxPossibleScore <= 15) { + var maxInt = Math.ceil(maxPossibleScore); + + for (var i = 0; i <= maxInt; i++) { + var key = i.toString(); + buckets[key] = 0; + bucketLabels[key] = key; + bucketOrder.push(key); + } + + scoreValues.forEach(function(score) { + var rounded = Math.round(score); + if (rounded > maxInt) rounded = maxInt; + if (rounded < 0) rounded = 0; + buckets[rounded.toString()]++; + }); + } else { + var bucketSize = maxPossibleScore / 10; + var lastBucketThreshold = maxPossibleScore * 0.9; + + buckets['0'] = 0; + bucketLabels['0'] = '0'; + bucketOrder.push('0'); + + for (var j = 1; j <= 9; j++) { + var upperBound = Math.round(j * bucketSize); + var lowerBound = Math.round((j - 1) * bucketSize); + var bucketKey = upperBound.toString(); + buckets[bucketKey] = 0; + bucketLabels[bucketKey] = '(' + lowerBound + ',' + upperBound + ']'; + bucketOrder.push(bucketKey); + } + + var lastKey = Math.round(maxPossibleScore).toString(); + buckets[lastKey] = 0; + bucketLabels[lastKey] = '>' + Math.round(lastBucketThreshold); + bucketOrder.push(lastKey); + + scoreValues.forEach(function(score) { + if (score === 0) { + buckets['0']++; + } else if (score > lastBucketThreshold) { + buckets[lastKey]++; + } else { + var bucketIndex = Math.ceil(score / bucketSize); + if (bucketIndex < 1) bucketIndex = 1; + if (bucketIndex > 9) bucketIndex = 9; + var bKey = Math.round(bucketIndex * bucketSize).toString(); + buckets[bKey]++; + } + }); + } + + var histogramBars = document.getElementById('histogramBars'); + if (!histogramBars) return; + + var maxCount = Math.max.apply(null, Object.values(buckets)) || 1; + var totalStudents = scoreValues.length; + + var barsHtml = ''; + bucketOrder.forEach(function(bucketKey, index) { + var count = buckets[bucketKey] || 0; + var percentage = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + var barHeight = maxCount > 0 ? (count / maxCount) * 100 : 0; + var hue = bucketOrder.length > 1 ? (index / (bucketOrder.length - 1)) * 120 : 60; + + barsHtml += '
' + + '
' + + '
' + + '
' + + '
' + bucketLabels[bucketKey] + '
' + + '
' + count + '
' + + '
'; + }); + histogramBars.innerHTML = barsHtml; + + var median = 0; + if (scoreValues.length > 0) { + var sorted = scoreValues.slice().sort(function(a, b) { return a - b; }); + var mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + median = (sorted[mid - 1] + sorted[mid]) / 2; + } else { + median = sorted[mid]; + } + } + + var statsEl = document.getElementById('histogramStats'); + if (statsEl) { + statsEl.innerHTML = + 'Total students: ' + totalStudents + + ' | Max possible: ' + Math.round(maxPossibleScore) + + (scoreValues.length > 0 ? ' | Average: ' + (scoreValues.reduce(function(a, b) { return a + b; }, 0) / scoreValues.length).toFixed(1) + + ' | Median: ' + median.toFixed(1) + + ' | Max: ' + Math.max.apply(null, scoreValues).toFixed(1) + + ' | Min: ' + Math.min.apply(null, scoreValues).toFixed(1) : ''); + } + + var textData = title + ' - Score Distribution\n'; + textData += '================================\n\n'; + textData += 'Statistics:\n'; + textData += 'Total: ' + totalStudents + '\n'; + textData += 'Max possible score: ' + Math.round(maxPossibleScore) + '\n'; + if (scoreValues.length > 0) { + textData += 'Average: ' + (scoreValues.reduce(function(a, b) { return a + b; }, 0) / scoreValues.length).toFixed(1) + '\n'; + textData += 'Median: ' + median.toFixed(1) + '\n'; + textData += 'Max: ' + Math.max.apply(null, scoreValues).toFixed(1) + '\n'; + textData += 'Min: ' + Math.min.apply(null, scoreValues).toFixed(1) + '\n'; + } + textData += '\nScores (high to low):\n'; + + var scoreGroups = {}; + scoreValues.forEach(function(score) { + var roundedScore = score.toFixed(1); + scoreGroups[roundedScore] = (scoreGroups[roundedScore] || 0) + 1; + }); + + var sortedScoreKeys = Object.keys(scoreGroups).sort(function(a, b) { return parseFloat(b) - parseFloat(a); }); + sortedScoreKeys.forEach(function(score) { + var count = scoreGroups[score]; + var pct = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + textData += score + ': ' + count + ' student' + (count !== 1 ? 's' : '') + ' (' + pct + '%)\n'; + }); + + textData += '\nHistogram buckets:\n'; + var reverseBucketOrder = bucketOrder.slice().reverse(); + reverseBucketOrder.forEach(function(bucketKey) { + var count = buckets[bucketKey] || 0; + var pct = totalStudents > 0 ? ((count / totalStudents) * 100).toFixed(1) : 0; + textData += bucketLabels[bucketKey] + ': ' + count + ' (' + pct + '%)\n'; + }); + + var textDataEl = document.getElementById('histogramTextData'); + if (textDataEl) { + textDataEl.value = textData; + } +}; + + +// Expose functions globally for backwards compatibility with onclick handlers +window.openHistogramModal = function(scores, title, type, trainingDayId, maxPossibleScore) { + CMS.TrainingProgram.openHistogramModal(scores, title, type, trainingDayId, maxPossibleScore); +}; +window.closeHistogramModal = CMS.TrainingProgram.closeHistogramModal; +window.copyHistogramData = CMS.TrainingProgram.copyHistogramData; + + +// Auto-initialize histogram modal on DOM ready +$(document).ready(function() { + CMS.TrainingProgram.initHistogramModal(); +}); diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index 5349072f69..a1e937a0e3 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -55,6 +55,8 @@ + + @@ -68,6 +70,7 @@ + {% if contest is none %} Admin @@ -548,10 +551,6 @@