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 = $(`${shift} `);
- // 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(
+ `Toggle Linear/Grid View `
+);
+
+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 = $(`${shift} `);
+ // 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 = $(``);
+ 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 = $(`${shift} `);
+ 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(
+ `Display Settings `
+);
+
+// --- 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 = $(`
+
+
Display Settings
+
+ Highlight days of week:
+
+
+
+ Close
+
+
+`);
+$("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 = $(`
+
+
+
+ Highlight force assigned shifts
+
+
+`);
+$("#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 = $(`${day} `);
+ // 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