From 3b3d432694c37d7afbd6767150f6c28ba374c8bb Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 11 Aug 2025 16:31:27 +0100 Subject: [PATCH] Refactor RotaBuilder export functionality and enhance display of run times with improved layout --- gen_cons.py | 4 +- output/timetable.css | 49 ++++-- output/timetable.js | 397 +++++++++++++++++++++++++++++++++++++++---- rota/shifts.py | 15 +- 4 files changed, 411 insertions(+), 54 deletions(-) diff --git a/gen_cons.py b/gen_cons.py index 41366e6..e527a8c 100644 --- a/gen_cons.py +++ b/gen_cons.py @@ -318,7 +318,7 @@ def main( rota_start_date, weeks_to_rota=weeks, balance_offset_modifier=bom, - name="cons rota", + name="cons rota test", ) # Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1) Rota.constraint_options["balance_weekends"] = True @@ -486,7 +486,7 @@ def main( # solver_options = {"seconds": time_to_run, "threads": 10} # start_time = time.time() - Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs") + Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs") # Rota.solve_shifts_by_block(solver_options, block_length=13) # Rota.solve_shifts_individually(solver_options) diff --git a/output/timetable.css b/output/timetable.css index 342c514..6ec6985 100644 --- a/output/timetable.css +++ b/output/timetable.css @@ -31,7 +31,7 @@ table { .table-div { /* overflow-x: auto; */ - width: 80%; + /* width: 80%; */ overflow-x: scroll; margin-left: 200px; overflow-y: visible; @@ -118,7 +118,8 @@ th.bank-holiday { color: #0074D9; } -.torquay, .torbay { +.torquay, +.torbay { color: #5FA8E8; } @@ -250,6 +251,7 @@ td.large-shift-diff-pos::before { content: "*"; color: red; } + td.large-shift-diff-neg::before { content: "*"; color: green; @@ -259,6 +261,7 @@ tr.large-shift-diff-pos td { border-bottom: solid 1px red; border-top: solid 1px red; } + tr.large-shift-diff-neg td { border-bottom: solid 1px green; border-top: solid 1px green; @@ -279,11 +282,9 @@ table.transposed td { border: 1px solid black; max-width: 100px; } */ -table.transposed { -} +table.transposed {} -table.transposed tr { -} +table.transposed tr {} table.transposed th, table.transposed td { @@ -327,12 +328,38 @@ table.transposed th.bank-holiday { line-height: 1; } -.force-assigned { - background-color: #ffe066 !important; - border: 2px solid #ff8800 !important; -} - button.selected { background: #0074d9; color: #fff; +} + +#main-table th.col-hover { + background: #ffe066 !important; + color: #222 !important; +} + +.highlight-day-btn.highlighted { + background: #ffe066 !important; + color: #222 !important; + border: 2px solid #bfa600 !important; +} + +.highlight-day-btn.highlighted { + background: #ffe066 !important; + color: #222 !important; + border: 2px solid #bfa600 !important; +} + +#main-table td.day-highlighted, +#main-table th.day-highlighted { + /* background is set inline for custom color */ +} + +#display-settings-modal { + transition: box-shadow 0.2s; +} + +#main-table td.force-assigned.force-assigned-highlight { + background-color: #ffe066 !important; + border: 2px solid #ff8800 !important; } \ No newline at end of file diff --git a/output/timetable.js b/output/timetable.js index 1c63f70..5045ec0 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -18,6 +18,8 @@ const mean = arr => arr.reduce((p, c) => p + c, 0) / arr.length; tables = $(".table-div"); +$("#extra-div").append("
"); + function generateExtra() { $(".auto-generated").remove(); @@ -158,7 +160,7 @@ function generateExtra() { bank_holidays_worked = bank_holidays.length; - nights_worked= $(tr).find(".night-shift").length; + nights_worked = $(tr).find(".night-shift").length; shift_diff = JSON.parse(worker_td.attr("data-shift-diff")); summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b); @@ -166,6 +168,8 @@ function generateExtra() { total_summed_shift_diffs = total_summed_shift_diffs + summed_shift_diff; total_summed_shift_diffs_abs = total_summed_shift_diffs_abs + Math.abs(summed_shift_diff); + + worker_td.get(0).dataset.shiftDiffSummed = summed_shift_diff.toPrecision(2); @@ -237,8 +241,10 @@ function generateExtra() { }); - $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs}
`)); - $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs_abs}
`)); + $("#shift-diffs").before($(`Total summed shift diff: ${total_summed_shift_diffs}
`)); + $("#shift-diffs").before($(`Total summed shift diff (abs): ${total_summed_shift_diffs_abs}
`)); + + $(table).before(summary_button); @@ -295,10 +301,8 @@ $(".table-div .worker-row .worker").each((n, tr) => { locum_shift_counts = jtr.data("locum-shift-counts") shift_targets = jtr.data("worker-targets") - console.log(shift_counts, locum_shift_counts, shift_targets) oshifts.forEach((s) => { - console.log(s, shift_counts, shift_targets, locum_shift_counts) if (s in shift_targets && shift_targets[s] > 0) { if (s in shift_counts) { c = shift_counts[s]; @@ -309,7 +313,7 @@ $(".table-div .worker-row .worker").each((n, tr) => { if (s in locum_shift_counts) { locum_shifts = `+${locum_shift_counts[s]}` } - row.append(`${c}${locum_shifts} (${shift_targets[s].toFixed(2)})`) + row.append(`${c}${locum_shifts} (${shift_targets[s].toFixed(2)})`) } else { row.append(`-`) @@ -373,34 +377,77 @@ oshifts.forEach((shift, i) => { shiftColors[shift] = `hsl(${(i * 360 / oshifts.length)}, 70%, 80%)`; }); -oshifts.forEach((shift) => { - let button = $(``); - // Set the button background to default (no color) initially - button.css({ - background: "", - border: "1px solid #888", - margin: "2px", - color: "#222" +// Add a button to toggle grid view +$("#shift-timetable-options").append( + `
` +); + +let gridViewEnabled = false; + +$("#toggle-grid-view").on("click", function () { + gridViewEnabled = !gridViewEnabled; + renderShiftTimetable(); +}); + + +const shiftColourControls = $(` +
Shift Timetable Colours +
+ Shift timetable colours: + +
+
+`); +$("#shift-timetable-options").append(shiftColourControls); + +// Add colour pickers for each shift +oshifts.forEach(shift => { + // Colour picker for the shift + let colorInput = $(``); + // Label for the shift + let label = $(``); + // On change, update the shiftColors map, button color, and re-render the timetable + colorInput.on("input", function () { + shiftColors[shift] = $(this).val(); + // Update the corresponding shift button color + $(`#shift-timetable-options button[data-shift='${shift}']`).css("background", selectedShifts.has(shift) ? shiftColors[shift] : ""); + renderShiftTimetable(); }); + $("#shift-colour-buttons").append(label.append(colorInput)); +}); - button.on("click", function (evt) { - let s = evt.target.dataset.shift; - if (selectedShifts.has(s)) { - selectedShifts.delete(s); - $(this).removeClass("selected"); - // Remove color when not selected - $(this).css("background", ""); - } else { - selectedShifts.add(s); - $(this).addClass("selected"); - // Set color when selected - $(this).css("background", shiftColors[shift]); +// Utility to convert hsl to hex for initial value +function rgb2hex(rgb) { + // Accepts hsl() or rgb() or hex + if (rgb.startsWith("#")) return rgb; + if (rgb.startsWith("hsl")) { + // Convert hsl to rgb + let hsl = rgb.match(/hsl\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%\)/); + if (!hsl) return "#ffe066"; + let h = Number(hsl[1]), s = Number(hsl[2]) / 100, l = Number(hsl[3]) / 100; + let a = s * Math.min(l, 1 - l); + function f(n) { + let k = (n + h / 30) % 12; + let color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color); } + return "#" + [f(0), f(8), f(4)].map(x => x.toString(16).padStart(2, "0")).join(""); + } + if (rgb.startsWith("rgb")) { + let rgbArr = rgb.match(/\d+/g).map(Number); + return "#" + rgbArr.map(x => x.toString(16).padStart(2, "0")).join(""); + } + return "#ffe066"; +} - // Update timetable - $("#shift-timetable-div").empty(); - if (selectedShifts.size === 0) return; +// Wrap timetable rendering in a function so we can call it for both views +function renderShiftTimetable() { + $("#shift-timetable-div").empty(); + if (selectedShifts.size === 0) return; + if (!gridViewEnabled) { + + // Original (list) view let table = $(""); $("table#main-table th.date").each((n, th) => { let row = $(""); @@ -430,11 +477,111 @@ oshifts.forEach((shift) => { table.append(row); }); $("#shift-timetable-div").append(table); + return; + } + + //// Get all weeks and days + let weeks = []; + let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + $("table#main-table td[data-week]").each(function () { + let week = $(this).attr("data-week"); + if (week && !weeks.includes(week)) weeks.push(week); + }); + weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b)); + + + // Get all workers who have at least one selected shift + let workerIds = []; + let workerIdToName = {}; + $("table#main-table td.worker").each(function () { + let workerId = $(this).attr("data-worker-id"); + if (workerId && !workerIds.includes(workerId)) { + workerIds.push(workerId); + let workerName = $(this).find("span.name").text().trim(); + workerIdToName[workerId] = workerName; + } + }); + + // workerId -> week -> day -> {shifts: [...], name: ...} + let cellMap = {}; + $("table#main-table td.worker").each(function () { + let workerId = $(this).attr("data-worker-id"); + let workerName = $(this).find("span.name").text().trim(); + if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, cells: {} }; + }); + $("table#main-table td[data-week][data-day]").each(function () { + let td = $(this); + let tr = td.closest("tr"); + let workerTd = tr.find("td.worker"); + let workerId = workerTd.attr("data-worker-id"); + let week = td.attr("data-week"); + let day = td.attr("data-day"); + let shifts = (td.attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean); + if (workerId && week && day) { + if (!cellMap[workerId].cells[week]) cellMap[workerId].cells[week] = {}; + cellMap[workerId].cells[week][day] = shifts; + } + }); + + let gridTable = $(`
Shift Grid
`); + let header = $("Week"); + daysOfWeek.forEach(day => header.append(`${day}`)); + gridTable.append(header); + + weeks.forEach(week => { + let row = $(``); + row.append(`${week}`); + daysOfWeek.forEach(day => { + let cellContent = ""; + for (const workerId of workerIds) { + let worker = cellMap[workerId]; + let shifts = (worker.cells[week] && worker.cells[week][day]) || []; + let matchedShifts = Array.from(selectedShifts).filter(s => shifts.includes(s)); + if (matchedShifts.length > 0) { + let color = "#fff"; + if (matchedShifts.length === 1) { + color = shiftColors[matchedShifts[0]]; + } else if (matchedShifts.length > 1) { + color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`; + } + cellContent += `
${worker.name}
`; + } + } + row.append(`${cellContent}`); + }); + gridTable.append(row); + }); + $("#shift-timetable-div").append(gridTable); + + +} + +// Patch the timetable button logic to use renderShiftTimetable +oshifts.forEach((shift) => { + let button = $(``); + button.css({ + background: "", + border: "1px solid #888", + margin: "2px", + color: "#222" + }); + + button.on("click", function (evt) { + let s = evt.target.dataset.shift; + if (selectedShifts.has(s)) { + selectedShifts.delete(s); + $(this).removeClass("selected"); + $(this).css("background", ""); + } else { + selectedShifts.add(s); + $(this).addClass("selected"); + $(this).css("background", shiftColors[shift]); + } + renderShiftTimetable(); }); $("#shift-timetable-options").append(button); }); - //$("table.summary") // $("body").prepend("
Settings:
Customise training days lost per shift (you need to press recalculate for changes to take effect)
") @@ -486,19 +633,19 @@ $("td").hover((e) => { } // Find the span under the mouse, if any - let targetSpan = $(e.target).closest("span.multi-shift-shift").get(0) || - ($(e.target).hasClass("multi-shift-shift") ? e.target : null); + let targetSpan = $(e.target).closest("span.multi-shift-shift").get(0) || + ($(e.target).hasClass("multi-shift-shift") ? e.target : null); if (targetSpan) { // Only highlight spans with the same text let shiftDisplay = $(targetSpan).text().trim(); - $("span.multi-shift-shift").filter(function() { + $("span.multi-shift-shift").filter(function () { return $(this).text().trim() === shiftDisplay; }).addClass("shift-highlight"); } else if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) { // Fallback: highlight all cells containing ANY of the hovered shifts (legacy) let hovered_shifts = e.target.dataset.shift.split(",").map(s => s.trim()); - $("td").filter(function() { + $("td").filter(function () { let shifts = $(this).attr("data-shift"); if (!shifts) return false; let cell_shifts = shifts.split(",").map(s => s.trim()); @@ -630,7 +777,7 @@ function hsv2rgb(h, s, v) { function syntaxHighlight(json) { if (typeof json != 'string') { - json = JSON.stringify(json, undefined, 2); + json = JSON.stringify(json, undefined, 2); } json = json.replace(/&/g, '&').replace(//g, '>'); return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { @@ -648,4 +795,182 @@ function syntaxHighlight(json) { } return '' + match + ''; }); -} \ No newline at end of file +} + +(function() { + const mainTable = document.getElementById("main-table"); + if (!mainTable) return; + + // Helper to remove highlight from all headers + function clearColHover() { + const ths = mainTable.querySelectorAll("th.col-hover"); + ths.forEach(th => th.classList.remove("col-hover")); + } + + // Mouseover handler for all cells + mainTable.addEventListener("mouseover", function(e) { + let cell = e.target.closest("td,th"); + if (!cell) return; + // Find the column index + let colIdx = cell.cellIndex; + if (colIdx === undefined) return; + clearColHover(); + // Highlight the header in this column (first row) + let headerRow = mainTable.tHead ? mainTable.tHead.rows[0] : mainTable.rows[0]; + if (headerRow && headerRow.cells[colIdx]) { + headerRow.cells[colIdx].classList.add("col-hover"); + } + }); + + // Mouseout handler to clear highlight + mainTable.addEventListener("mouseout", function(e) { + clearColHover(); + }); + + +})(); + +// --- Add a button to show the Display Settings modal --- +$("#main-table").before( + `` +); + +// --- Modal show/hide logic --- +$("#show-display-settings").on("click", function() { + $("#display-settings-modal").show(); +}); +// Attach the event handler after the modal is added to the DOM +$(document).on("click", "#close-display-settings", function() { + $("#display-settings-modal").hide(); +}); + +// --- Modal HTML (replace the highlight-days-controls div with this) --- +const displaySettingsModal = $(` + +`); +$("body").append(displaySettingsModal); + +// --- Highlight day buttons in modal with color pickers --- +const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +let highlightedDays = new Set(); +let highlightDayColors = {}; // day -> color + +// Default highlight color +const defaultHighlightColor = "#ffe066"; + +// --- Add force assigned highlight controls to the Display Settings modal --- +const forceAssignedControls = $(` +
+ +
+`); +$("#display-settings-modal #highlight-days-controls").after(forceAssignedControls); + +// --- Highlighting logic --- +function updateDayHighlights() { + // Remove previous highlights and reset borders/backgrounds + $("#main-table td, #main-table th").removeClass("day-highlighted") + .css("border-left", "").css("border-right", "").css("background", ""); + + // For each selected day, highlight all cells in that column + highlightedDays.forEach(day => { + let color = highlightDayColors[day] || defaultHighlightColor; + // Highlight th: only change border color, not background + $(`#main-table th[data-day='${day}']`) + .addClass("day-highlighted") + .css("border-left", `3px solid ${color}`) + .css("border-right", `3px solid ${color}`) + .css("background", ""); + // Highlight td: only change border color, not background + $(`#main-table td[data-day='${day}']`) + .addClass("day-highlighted") + .css("border-left", `3px solid ${color}`) + .css("border-right", `3px solid ${color}`) + .css("background", ""); + }); + // Update modal button states + $("#highlight-days-buttons .highlight-day-btn").each(function() { + let day = $(this).data("day"); + if (highlightedDays.has(day)) { + $(this).addClass("highlighted"); + } else { + $(this).removeClass("highlighted"); + } + }); +} + +// --- Highlighting logic for force assigned shifts --- +function updateForceAssignedHighlights() { + if ($("#highlight-force-assigned").prop("checked")) { + // Add highlight to all force assigned cells + $("#main-table td.force-assigned").addClass("force-assigned-highlight"); + } else { + // Remove highlight + $("#main-table td.force-assigned").removeClass("force-assigned-highlight"); + } +} + +// --- Update both day and force assigned highlights together --- +function updateAllHighlights() { + updateDayHighlights(); + updateForceAssignedHighlights(); +} + +// --- Update force assigned highlights when checkbox changes --- +$("#display-settings-modal").on("change", "#highlight-force-assigned", function() { + updateForceAssignedHighlights(); +}); + + + + +// --- Also call updateAllHighlights on modal open --- +$("#show-display-settings").on("click", function() { + $("#display-settings-modal").show(); + updateAllHighlights(); +}); + +// --- Highlight day buttons in modal with color pickers --- +daysOfWeek.forEach(day => { + // Button to toggle highlight + let btn = $(``); + // Color picker + let colorInput = $(``); + highlightDayColors[day] = defaultHighlightColor; + + btn.on("click", function () { + if (highlightedDays.has(day)) { + highlightedDays.delete(day); + $(this).removeClass("highlighted"); + } else { + highlightedDays.add(day); + $(this).addClass("highlighted"); + } + updateAllHighlights(); + }); + + colorInput.on("input", function () { + highlightDayColors[day] = $(this).val(); + updateAllHighlights(); + }); + + $("#highlight-days-buttons").append(btn).append(colorInput); +}); + + +// --- Initial highlight on page load --- +$(function() { + updateAllHighlights(); +}); \ No newline at end of file diff --git a/rota/shifts.py b/rota/shifts.py index 8930e31..1b61033 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -609,6 +609,7 @@ class RotaBuilder(object): solve=True, debug_if_fail: bool = False, export=False, + export_with_timestamp=False, solver="appsi_highs", ): self.run_start_time = datetime.datetime.now() @@ -628,7 +629,7 @@ class RotaBuilder(object): self.run_end_time = datetime.datetime.now() if export: - self.export_rota_to_html(self.name) + self.export_rota_to_html(self.name, timestamp_filename=export_with_timestamp) def build_model(self): # Initialize model @@ -4234,9 +4235,11 @@ class RotaBuilder(object): if self.run_start_time is not None and self.run_end_time is not None: timetable.append( - f"
Start time: {self.run_start_time:%Y-%m-%d %H:%M:%S%z}
" - f"
End time: {self.run_end_time:%Y-%m-%d %H:%M:%S%z}
" - f"
Elapsed: {self.run_end_time - self.run_start_time}
" + "
" + f"
Start time: {self.run_start_time:%Y-%m-%d %H:%M:%S%z}
" + f"
End time: {self.run_end_time:%Y-%m-%d %H:%M:%S%z}
" + f"
Elapsed: {self.run_end_time - self.run_start_time}
" + "
" ) timetable.append( @@ -4621,10 +4624,12 @@ class RotaBuilder(object): {"TEST"} {self.locum_availability_map}
-

Force assigned shifts

+
+ Force assigned shifts
         {json.dumps(force_assigned_info, indent=4)}
         
+

Hard Constrain Shift Min/Max