Object.defineProperties(Array.prototype, {
count: {
value: function (query) {
/*
Counts number of occurrences of query in array, an integer >= 0
Uses the javascript == notion of equality.
*/
var count = 0;
for (let i = 0; i < this.length; i++)
if (this[i] == query)
count++;
return count;
}
}
});
const mean = arr => arr.reduce((p, c) => p + c, 0) / arr.length;
tables = $(".table-div");
$("#extra-div").append("
");
function generateExtra() {
$(".auto-generated").remove();
global_shifts = new Set();
total_summed_shift_diffs = 0;
total_summed_shift_diffs_abs = 0;
tables.each((n, table) => {
rows = $(table).find(".worker-row");
workers = {};
rows.each((n, tr) => {
worker_td = $(tr).find("td:first");
worker = worker_td.attr("data-worker");
shift_tds = $(tr).find(".rota-day");
shifts = []
shift_tds.each((n, td) => {
let data_shift = $(td).attr("data-shift");
if (data_shift && data_shift !== "") {
// Split by comma for multiple shifts per day
let split_shifts = data_shift.split(",");
split_shifts.forEach(s => {
if (s && s.trim() !== "") {
shifts.push(s.trim());
}
});
}
})
shifts_unique = new Set(shifts);
shifts_unique.delete("");
global_shifts = new Set([...global_shifts, ...shifts_unique]);
weekends_worked = 0;
weeks = shift_tds.length / 7;
whole_weekends_worked = 0;
for (var i = 1; i <= weeks; i++) {
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" || $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
weekends_worked = weekends_worked + 1;
}
if ($(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift") != "" && $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift") != "") {
whole_weekends_worked = whole_weekends_worked + 1;
}
}
shift_counts = {};
//total_shifts = 0;
days_lost = 0;
shifts_unique.forEach(el => {
shift_counts[el] = shifts.count(el);
//total_shifts = total_shifts + shifts.count(el);
// why like this?
if (el == "night_weekend") {
whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
}
if ($("#global-settings").length == 1) {
n = $(`#${el}-tdl`).val();
if (!isNaN(n)) {
days_lost = days_lost + shifts.count(el) * n;
}
}
// // 5 days for weekday nights
// if(el == "night_weekday") { days_lost = days_lost + shifts.count(el) / 4 * 5 }
// // 4 days for weekend nights
// if(el == "night_weekend") {
// days_lost = days_lost + shifts.count(el) / 3 * 4
// //whole_weekends_worked = whole_weekends_worked - shifts.count(el) / 3
// }
// // 0.5 for twilights
// if(el.includes("twilight")) {
// twilight_days_lost = shifts.count(el) / 2
// days_lost = days_lost + twilight_days_lost
// }
});
// Also 1 day for a whole weekend
days_lost = days_lost + whole_weekends_worked
// Highlight non working days
nwds = JSON.parse(worker_td.attr("data-nwds"));
nwds.forEach(element => {
// Restrict to date ranges
start_date = new Date(Date.parse(element[1]))
end_date = new Date(Date.parse(element[2]))
$(shift_tds).filter(`.${element[0]}`).each(
(n, td_el) => {
td_date = new Date(Date.parse(td_el.dataset.date));
if (td_date >= start_date && td_date < end_date) {
$(td_el).addClass("nwd");
}
}
)
});
workers[worker] = {
"site": worker_td.attr("data-site"),
"nwds": worker_td.attr("data-nwds"),
"fte": worker_td.attr("data-fte_adj"),
"end_date": worker_td.attr("data-end_date"),
"shift_diff": worker_td.attr("data-shift-diff"),
"shifts": shifts,
"shift_types": shifts_unique,
"weekends_worked": weekends_worked,
"shift_counts": shift_counts,
"days_lost": days_lost,
//"shifts"
}
sc = JSON.stringify(shift_counts);
bank_holidays = shift_tds.filter(".bank-holiday").filter(function (i) {
return $(this).attr('data-shift') != "";
});
bank_holidays_worked = bank_holidays.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);
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);
// compute separate Sat / Sun counts for this worker
let sat_count = 0;
let sun_count = 0;
for (let i = 1; i <= weeks; i++) {
let sat = $(tr).find(`[data-week='${i}'][data-day='Sat']`).attr("data-shift");
let sun = $(tr).find(`[data-week='${i}'][data-day='Sun']`).attr("data-shift");
if (sat && sat !== "") sat_count += 1;
if (sun && sun !== "") sun_count += 1;
}
// worker-summary will be inserted after we compute prior-adjust badges (sat/sun)
if (summed_shift_diff >= 1) {
worker_td.addClass("large-shift-diff-pos")
$(tr).find(".shift-diff-span").addClass("large-shift-diff-pos")
} else if (summed_shift_diff <= -1) {
worker_td.addClass("large-shift-diff-neg")
$(tr).find(".shift-diff-span").addClass("large-shift-diff-neg")
}
})
sites = {};
sites_days_lost = {}
for (const worker in workers) {
w = workers[worker];
if (w.fte == 100) {
for (let shift of w.shift_types) {
if (!(w.site in sites)) {
sites[w.site] = {};
}
if (!(shift in sites[w.site])) {
sites[w.site][shift] = [w.shift_counts[shift]];
} else {
sites[w.site][shift].push(w.shift_counts[shift]);
}
}
if (!(w.site in sites_days_lost)) {
sites_days_lost[w.site] = [w.days_lost];
} else {
sites_days_lost[w.site].push(w.days_lost);
}
}
}
// Calculate site shift summaries
// $(table).append("
Average shifts for a 100% FTE trainee
");
// for(const site in sites) {
// $(table).find(".site-summary").append(`
${site}
`);
// for(const shift in sites[site]) {
// average = mean(sites[site][shift])
// $(table).find(`.site-${site}`).append(`
`)
}
})
// Build per-shift prior-adjust spans from data-previous-shifts
// Try both the raw attribute and jQuery-parsed data for robustness
let prev_attr = jtr.attr("data-previous-shifts");
if (!prev_attr) prev_attr = jtr.data("previous-shifts");
let partsHtml = "";
let sat_adj = null;
let sun_adj = null;
if (prev_attr) {
try {
let prev_map = (typeof prev_attr === 'object') ? prev_attr : JSON.parse(prev_attr);
let parts = [];
// Build parts for non-weekend shifts first; we'll always render weekend per-day below.
for (let k in prev_map) {
if (!prev_map.hasOwnProperty(k)) continue;
if (k === 'weekend') continue;
let entry = prev_map[k];
let adj = null;
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
if ('adjustment' in entry) adj = parseFloat(entry.adjustment);
else if ('allocated' in entry && 'worked' in entry) adj = parseFloat(entry.allocated) - parseFloat(entry.worked);
} else if (Array.isArray(entry) && entry.length >= 2) {
adj = parseFloat(entry[1]) - parseFloat(entry[0]);
}
if (adj && Math.abs(adj) > 0.0001) {
let s = (adj > 0 ? '+' : '') + Number(adj).toFixed(2);
// span includes data attributes for per-shift colouring later
partsHtml += `${k}:${s}`;
}
}
// compute per-day weekend adjustments if present under 'weekend'
if ('weekend' in prev_map) {
let w = prev_map['weekend'];
// Prefer explicit per-day entries when present
if (w && typeof w === 'object' && !Array.isArray(w)) {
if ('Sat' in w) {
let e = w['Sat'];
if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) sat_adj = parseFloat(e.allocated) - parseFloat(e.worked);
else if (Array.isArray(e) && e.length >= 2) sat_adj = parseFloat(e[1]) - parseFloat(e[0]);
}
if ('Sun' in w) {
let e = w['Sun'];
if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) sun_adj = parseFloat(e.allocated) - parseFloat(e.worked);
else if (Array.isArray(e) && e.length >= 2) sun_adj = parseFloat(e[1]) - parseFloat(e[0]);
}
// If no per-day entries but there is an overall weekend value, split it evenly
if (sat_adj === null && sun_adj === null && 'allocated' in w && 'worked' in w) {
try {
let total = parseFloat(w.allocated) - parseFloat(w.worked);
if (isFinite(total)) {
sat_adj = sun_adj = total / 2.0;
}
} catch (err) {
// leave as null on parse error
}
}
// Handle a '__all__' bucket (tuple or object) as fallback
if (sat_adj === null && sun_adj === null && '__all__' in w) {
let e = w['__all__'];
if (Array.isArray(e) && e.length >= 2) {
let total = parseFloat(e[1]) - parseFloat(e[0]);
sat_adj = sun_adj = total / 2.0;
} else if (e && typeof e === 'object' && 'allocated' in e && 'worked' in e) {
let total = parseFloat(e.allocated) - parseFloat(e.worked);
sat_adj = sun_adj = total / 2.0;
}
}
} else if (Array.isArray(w) && w.length >= 2) {
// tuple-style: split evenly
let total = parseFloat(w[1]) - parseFloat(w[0]);
sat_adj = sun_adj = total / 2.0;
}
// Always append per-day prior-adjust parts for weekend so the section is per-day
if (sat_adj !== null && Math.abs(sat_adj) > 0.0001) {
let s = (sat_adj > 0 ? '+' : '') + Number(sat_adj).toFixed(2);
partsHtml += `Sat:${s}`;
}
if (sun_adj !== null && Math.abs(sun_adj) > 0.0001) {
let s = (sun_adj > 0 ? '+' : '') + Number(sun_adj).toFixed(2);
partsHtml += `Sun:${s}`;
}
}
} catch (e) {
// ignore parse errors
}
}
// helper to render small coloured adjustment badge
function _adjBadge(adj) {
if (adj === null || typeof adj === 'undefined' || isNaN(adj)) return '';
let adjf = parseFloat(adj);
if (!isFinite(adjf)) return '';
let sign = (adjf > 0) ? '+' : '';
let text = `${sign}${adjf.toFixed(2)}`;
// scale alpha by magnitude but ensure a minimum so small adjustments remain visible
let mag = Math.min(1.0, Math.abs(adjf) / 3.0);
if (mag < 0.12) mag = 0.12;
let bg, color;
if (adjf > 0) { bg = `rgba(0,128,0,${mag.toFixed(2)})`; color = '#fff'; }
else if (adjf < 0) { bg = `rgba(196,0,0,${mag.toFixed(2)})`; color = '#fff'; }
else { bg = 'rgba(128,128,128,0.15)'; color = '#000'; }
return ` (${text})`;
}
let sat_adj_html = _adjBadge(sat_adj);
let sun_adj_html = _adjBadge(sun_adj);
// compute Sat / Sun counts for this worker row (ensure variables are defined)
let sat_count = 0;
let sun_count = 0;
try {
let $row = jtr.closest('tr');
$row.find("td[data-day='Sat']").each((i, td) => {
let s = $(td).attr('data-shift');
if (s && s !== '') sat_count += 1;
});
$row.find("td[data-day='Sun']").each((i, td) => {
let s = $(td).attr('data-shift');
if (s && s !== '') sun_count += 1;
});
} catch (e) {
// fallback: leave counts at 0
}
console.log("Computed weekend counts and adjustments for worker:", jtr.data("worker"));
console.log(`sat_count: ${sat_count}, sun_count: ${sun_count}, sat_adj: ${sat_adj}, sun_adj: ${sun_adj}`);
// Insert worker summary now that we have per-day adjustment badges
// Hidden by default so summaries only appear when rota days are hidden
let workerSummaryHtml = `
Total shifts: ${total_shifts}, Weekends: ${weekends_worked}, Sat: ${sat_count}${sat_adj_html}, Sun: ${sun_count}${sun_adj_html}, Nights: ${nights_worked}, Bank holidays: ${bank_holidays_worked}, Summed shift diff: ${summed_shift_diff.toPrecision(2)},
`;
jtr.after(workerSummaryHtml);
row.append(`
${partsHtml}
`)
$("table.tsummary").append(row)
})
shift_max_diff_map = {};
shift_min_diff_map = {};
oshifts.forEach((s) => {
shift_max_diff_map[s] = 0;
shift_min_diff_map[s] = 0;
$("table.tsummary").find(`td.target-assigned.${s}`).each((n, td) => {
shift_max_diff_map[s] = Math.max(shift_max_diff_map[s], td.dataset.diff)
shift_min_diff_map[s] = Math.min(shift_min_diff_map[s], td.dataset.diff)
})
});
oshifts.forEach((shift) => {
$("table.tsummary").find(`td.target-assigned.${shift}`).each((n, td) => {
diff = td.dataset.diff;
if (diff > 0) {
max_diff = shift_max_diff_map[shift];
var h = 120 - Math.floor((max_diff - diff) * 120 / max_diff);
var s = diff / max_diff;
//var s = 0.5;
var v = 1;
$(td).css({
backgroundColor: hsv2rgb(h, s, 1)
})
} else {
min_diff = shift_min_diff_map[shift]
var h = Math.floor((min_diff - diff) * 120 / min_diff);
var s = diff / min_diff;
//var s = 0.5;
var v = 1;
$(td).css({
backgroundColor: hsv2rgb(h, s, 1)
})
}
$(td).addClass("no-colour")
//s = Math.max(shift_max_diff_map[s], td.dataset.diff)
//shift_min_diff_map[s] = Math.min(shift_max_diff_map[s], td.dataset.diff)
})
});
// Colour each per-shift prior-adjust span independently.
// For each shift, find the maximum absolute adjustment across rows and scale saturation accordingly.
let priorShiftMax = {};
$("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) => {
let $span = $(span);
let shift = $span.attr('data-shift');
let adj = parseFloat($span.attr('data-adj')) || 0;
let absadj = Math.abs(adj);
if (!(shift in priorShiftMax)) priorShiftMax[shift] = 0;
priorShiftMax[shift] = Math.max(priorShiftMax[shift], absadj);
});
// Apply colouring to each span using hsv2rgb (green for positive, red for negative)
$("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) => {
let $span = $(span);
let shift = $span.attr('data-shift');
let adj = parseFloat($span.attr('data-adj')) || 0;
if (Math.abs(adj) < 1e-9) return;
let max = priorShiftMax[shift] || Math.abs(adj) || 1;
let sat = (max === 0) ? 1 : Math.min(1, Math.abs(adj) / max);
let hue = (adj > 0) ? 120 : 0; // green or red
$span.css({ backgroundColor: hsv2rgb(hue, sat, 1), color: '#000' });
$span.addClass('no-colour');
});
let selectedShifts = new Set();
let shiftColors = {};
oshifts.forEach((shift, i) => {
selectedShifts.add(shift);
// Assign a unique color for each shift using HSL
shiftColors[shift] = `hsl(${(i * 360 / oshifts.length)}, 70%, 80%)`;
});
// 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));
});
// Add the toggle button if not already present
if ($("#show-all-shifts").length === 0) {
$("#shift-timetable-options").append(
``
);
}
let allShiftsShown = false; // Start with all shifts shown by default
function updateShowAllShiftsButton() {
if (allShiftsShown) {
$("#show-all-shifts").addClass("active").text("Hide All Shifts");
} else {
$("#show-all-shifts").removeClass("active").text("Show All Shifts");
}
}
// Toggle logic
$("#show-all-shifts").off("click").on("click", function () {
allShiftsShown = !allShiftsShown;
if (allShiftsShown) {
// Select all shifts
oshifts.forEach((shift) => {
selectedShifts.add(shift);
$(`#shift-timetable-options button[data-shift='${shift}']`)
.addClass("selected")
.css("background", shiftColors[shift]);
});
} else {
// Deselect all shifts
oshifts.forEach((shift) => {
selectedShifts.delete(shift);
$(`#shift-timetable-options button[data-shift='${shift}']`)
.removeClass("selected")
.css("background", "");
});
}
updateShowAllShiftsButton();
renderShiftTimetable();
});
// Ensure the button state is correct on page load and after timetable render
updateShowAllShiftsButton();
// 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";
}
// --- Add worker checkboxes to the shift timetable section ---
function renderWorkerCheckboxes() {
// Get all unique worker names and IDs from the main table
let workerList = [];
$("table#main-table td.worker").each(function () {
let workerId = $(this).attr("data-worker-id");
let workerName = $(this).find("span.name").text().trim();
if (workerId && !workerList.some(w => w.id === workerId)) {
workerList.push({ id: workerId, name: workerName });
}
});
// Build checkboxes
let html = `Show workers:`;
// Add a "Toggle All" button
html += ``;
workerList.forEach(w => {
html += ``;
});
// Attach toggle logic after DOM insertion
setTimeout(() => {
$("#toggle-all-workers").off("click").on("click", function() {
const $checkboxes = $("input[name='worker-filter-checkbox']");
const allChecked = $checkboxes.length === $checkboxes.filter(":checked").length;
$checkboxes.prop("checked", !allChecked);
// Update the filter set and re-render once, instead of triggering "change" for each
workerFilterSet = new Set(
Array.from(document.querySelectorAll("input[name='worker-filter-checkbox']:checked"))
.map(cb => cb.value)
);
// Call your function here after the map has finished
renderShiftTimetable();
});
}, 0);
html += ``;
// Prepend to the shift timetable options
$("#shift-timetable-options").prepend(html);
}
// Store the current filter value (set of workerIds)
let workerFilterSet = new Set();
// Store the current setting
let showShiftNames = false;
// Controls for rendering
let showWeekStart = false;
let showGrades = false;
// Call this after the DOM is ready and after timetable is rendered
renderWorkerCheckboxes();
// On first render, select all workers by default
$("input[name='worker-filter-checkbox']").each(function() {
workerFilterSet.add($(this).val());
});
renderShiftTimetable();
// Update the filter set on checkbox change
$(document).on("change", "input[name='worker-filter-checkbox']", function() {
workerFilterSet = new Set(
$("input[name='worker-filter-checkbox']:checked").map(function() { return $(this).val(); }).get()
);
renderShiftTimetable();
});
// Add a checkbox to show/hide worker grades in the shift timetable
$("#shift-timetable-options").prepend(`
`);
$(document).on("change", "#show-grades-checkbox", function() {
showGrades = $(this).is(":checked");
renderShiftTimetable();
});
// Add a checkbox to show/hide shift names in the shift timetable section
$("#shift-timetable-options").prepend(`
`).prepend(`
`);
// Add a button to remove all shift timetable colours (set to white)
$("#shift-colour-controls").append(
``
);
$("#remove-shift-colours").on("click", function () {
// Set all shift colours to white
oshifts.forEach(shift => {
shiftColors[shift] = "#ffffff";
// Update the colour picker if present
$(`.shift-colour-picker[data-shift='${shift}']`).val("#ffffff");
// Update the corresponding shift button color if selected
$(`#shift-timetable-options button[data-shift='${shift}']`).css("background", selectedShifts.has(shift) ? "#ffffff" : "");
});
renderShiftTimetable();
});
$("#show-shift-names-checkbox").on("change", function() {
showShiftNames = $(this).is(":checked");
renderShiftTimetable();
});
$("#show-week-start-checkbox").on("change", function () {
showWeekStart = $(this).is(":checked");
renderShiftTimetable();
});
// Patch renderShiftTimetable to show shift names if enabled
function renderShiftTimetable() {
console.log("Render shift timetable");
console.log(workerFilterSet)
$("#shift-timetable-div").empty();
if (selectedShifts.size === 0) return;
if (workerFilterSet.size === 0) return;
// Helper: returns true if worker id matches filter set or set is empty (all)
function workerMatchesFilter(workerId) {
if (workerFilterSet.size === 0) return true;
return workerFilterSet.has(workerId);
}
if (!gridViewEnabled) {
// Linear view
let table = $("
");
let headerRow = $("
");
headerRow.append("
Date
Week
Day
Worker(s)
");
table.append(headerRow);
$("table#main-table th.date").each((n, th) => {
let date = th.dataset.date;
let week = "";
let day = "";
let firstTd = $(`table#main-table td[data-date='${date}']`).first();
if (firstTd.length) {
week = firstTd.attr("data-week") || "";
day = firstTd.attr("data-day") || "";
}
let row = $("
");
row.append(`
${date}
`);
row.append(`
${week}
`);
row.append(`
${day}
`);
let workerCells = [];
$(`table#main-table td[data-date='${date}']`).filter(function () {
let shifts = $(this).attr("data-shift");
if (!shifts) return false;
let cell_shifts = shifts.split(",").map(s => s.trim());
return Array.from(selectedShifts).some(s => cell_shifts.includes(s));
}).each((n, td) => {
let workerTd = $(td).closest("tr").children("td:first");
let workerName = workerTd.find("span.name").text().trim();
// grade may be provided as a data attribute; if not, try to parse from visible text
let workerGrade = workerTd.attr('data-grade') || '';
if (!workerGrade) {
// attempt to parse e.g. "Name (4) [100]" -> capture 4
let txt = workerTd.text();
let m = txt.match(/\(([^)]+)\)/);
if (m) workerGrade = m[1].trim();
}
let workerId = workerTd.attr("data-worker-id");
if (!workerMatchesFilter(workerId)) return;
let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim());
let matchedShifts = Array.from(selectedShifts).filter(s => cell_shifts.includes(s));
let color = "#fff";
let shiftLabel = "";
if (matchedShifts.length === 1) {
color = shiftColors[matchedShifts[0]];
if (showShiftNames) shiftLabel = ` (${matchedShifts[0]})`;
} else if (matchedShifts.length > 1) {
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
if (showShiftNames) shiftLabel = ` (${matchedShifts.join(", ")})`;
}
let gradeTextLinear = showGrades ? ` (${workerGrade})` : '';
workerCells.push(`
${workerName}${gradeTextLinear}${shiftLabel}
`);
});
if (workerCells.length === 0) {
row.append("
");
} else {
row.append(workerCells.join(""));
}
table.append(row);
});
$("#shift-timetable-div").append(table);
return;
}
// Grid view
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));
// Build week -> start date map (earliest date in that week)
let weekStartDates = {};
$("table#main-table td[data-week][data-date]").each(function () {
let wk = $(this).attr("data-week");
let d = $(this).attr("data-date");
if (!wk || !d) return;
if (!weekStartDates[wk] || new Date(d) < new Date(weekStartDates[wk])) {
weekStartDates[wk] = d;
}
});
let workerIds = [];
let workerIdToName = {};
$("table#main-table td.worker").each(function () {
let workerId = $(this).attr("data-worker-id");
let workerName = $(this).find("span.name").text().trim();
let workerGrade = $(this).attr('data-grade') || '';
if (workerId && !workerIds.includes(workerId)) {
if (workerMatchesFilter(workerId)) {
workerIds.push(workerId);
workerIdToName[workerId] = workerName;
workerIdToName[workerId + '_grade'] = workerGrade;
}
}
});
let cellMap = {};
$("table#main-table td.worker").each(function () {
let workerId = $(this).attr("data-worker-id");
let workerName = $(this).find("span.name").text().trim();
let workerGrade = $(this).attr('data-grade') || '';
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, grade: workerGrade, 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 = $(`
`);
// Show week number and (optionally) the week's start date
let weekLabel = [week];
if (showWeekStart && weekStartDates[week]) {
weekLabel += `
${weekStartDates[week]}
`;
}
row.append(`
${weekLabel}
`);
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";
let shiftLabel = "";
if (matchedShifts.length === 1) {
color = shiftColors[matchedShifts[0]];
if (showShiftNames) shiftLabel = ` (${matchedShifts[0]})`;
} else if (matchedShifts.length > 1) {
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
if (showShiftNames) shiftLabel = ` (${matchedShifts.join(", ")})`;
}
let gradeText = showGrades ? ` (${worker.grade||''})` : '';
cellContent.push(`
${worker.name}${gradeText}${shiftLabel}
`);
}
}
row.append(`
${cellContent.join(" / ")}
`);
});
gridTable.append(row);
});
$("#shift-timetable-div").append(gridTable);
}
// Re-render worker checkboxes after timetable is updated (in case workers change)
function rerenderWorkerCheckboxesIfNeeded() {
$("#shift-timetable-worker-filter").remove();
renderWorkerCheckboxes();
// Restore checked state
$("input[name='worker-filter-checkbox']").each(function() {
if (workerFilterSet.has($(this).val())) {
$(this).prop("checked", true);
}
});
}
// Call this after timetable is (re)rendered if needed
rerenderWorkerCheckboxesIfNeeded();
// 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);
});
// Add help text to the shift timetable section
$("#shift-timetable-options").prepend(`
Shift Timetables Help:
Select one or more shifts below to view which workers are assigned on each day.
Use the colour pickers to customise shift colours in the timetable and main table.
Toggle between linear and grid view using the "Toggle Linear/Grid View" button.
`);
//$("table.summary")
// $("body").prepend("
Settings: Customise training days lost per shift (you need to press recalculate for changes to take effect)
")
// shifts.forEach((s) => {
// disable = false;
// if(s.includes("twilight")) {
// value = 0.5;
// } else if(s == "night_weekend") {
// value = 4 / 3;
// } else if(s == "night_weekday") {
// value = 5 / 4;
// } else {
// disable = true;
// value = 0;
// }
// if(!disable) {
// $("#global-settings form").append(``);
// }
// })
// $("#global-settings").append($("").click(() => {
// generateExtra();
// }))
// generateExtra();
$(".table-div + pre").each((n, pre) => {
$(pre).before($("").click(() => {
$(pre).toggle();
}));
})
$("td").hover((e) => {
// start hover
worker_td = $(e.target).closest('tr').find('td:first')[0]
pair = worker_td.dataset.pair;
index = $(e.target).index() - 1;
if (pair > 0) {
$(`td[data-pair=${pair}]`).each((n, el) => {
$($(el).closest('tr').find('td').get(index)).addClass("pair-match")
})
}
// 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);
if (targetSpan) {
// Only highlight spans with the same text
let shiftDisplay = $(targetSpan).text().trim();
$("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 () {
let shifts = $(this).attr("data-shift");
if (!shifts) return false;
let cell_shifts = shifts.split(",").map(s => s.trim());
return hovered_shifts.some(s => cell_shifts.includes(s));
}).addClass("shift-highlight");
}
}, (e) => {
// end hover
$("td.pair-match").removeClass("pair-match")
$("td.shift-highlight").removeClass("shift-highlight")
$("span.shift-highlight").removeClass("shift-highlight")
$("td.remote-site-match").removeClass("remote-site-match")
});
$("#rota-table tr td:first-child").each((n, td) => {
$(td).click(() => {
viewWorker(td);
})
})
function viewWorker(worker) {
ds = worker.dataset;
dlg = $(`
Site: ${ds.site}
FTE: ${ds.fte} (${ds.fte_adj})
Start date: ${ds.start_date}
End date: ${ds.end_date}
Non working days: ${ds.nwds}
Remote site: ${ds.remoteSite}
Summed shift diff: ${ds.shiftDiffSummed}
`)
$("body").append(dlg)
shifts = JSON.parse(ds.shiftCounts)
shift_targets = JSON.parse(ds.workerTargets)
shift_diffs = JSON.parse(ds.shiftDiff)
let shift_count_table = document.createElement("table")
shift_count_table.classList.add("shift-table")
let header_row = shift_count_table.createTHead().insertRow();
header_row.insertCell().appendChild(document.createTextNode("Shift"))
header_row.insertCell().appendChild(document.createTextNode("Count"))
header_row.insertCell().appendChild(document.createTextNode("Target"))
header_row.insertCell().appendChild(document.createTextNode("Diffs"))
table_body = shift_count_table.createTBody()
for (const shift in shifts) {
let row = table_body.insertRow();
row.insertCell().appendChild(document.createTextNode(shift))
row.insertCell().appendChild(document.createTextNode(shifts[shift]))
row.insertCell().appendChild(document.createTextNode(shift_targets[shift].toPrecision(2)))
row.insertCell().appendChild(document.createTextNode(shift_diffs[shift].toPrecision(2)))
}
dlg.get(0).appendChild(shift_count_table)
dlg.dialog()
}
$("#export-div").append($("#main-table").clone().attr("id", "gen-table").addClass("transposed"))
$("#gen-table").each(function () {
var $this = $(this);
var newrows = [];
//$this.find("th.site-title").parent().remove();
$this.find("tr").each(function (rowToColIndex) {
$(this).find("td, th").each(function (colToRowIndex) {
if (newrows[colToRowIndex] === undefined) {
newrows[colToRowIndex] = $("
");
}
while (newrows[colToRowIndex].find("td, th").length < rowToColIndex) {
newrows[colToRowIndex].append($("
`);
controls.append(div);
});
}
function saveShiftTimes() {
customShiftTimes = {};
$('#shift-times-controls .shift-start').each(function() {
const shift = $(this).data('shift');
const start = $(this).val();
const end = $(`input.shift-end[data-shift="${shift}"]`).val();
const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked');
customShiftTimes[shift] = { start, end, nextDay };
});
}
function hsv2rgb(h, s, v) {
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
var rgb, i, data = [];
if (s === 0) {
rgb = [v, v, v];
} else {
h = h / 60;
i = Math.floor(h);
data = [v * (1 - s), v * (1 - s * (h - i)), v * (1 - s * (1 - (h - i)))];
switch (i) {
case 0:
rgb = [v, data[2], data[0]];
break;
case 1:
rgb = [data[1], v, data[0]];
break;
case 2:
rgb = [data[0], v, data[2]];
break;
case 3:
rgb = [data[0], data[1], v];
break;
case 4:
rgb = [data[2], data[0], v];
break;
default:
rgb = [v, data[0], data[1]];
break;
}
}
return '#' + rgb.map(function (x) {
return ("0" + Math.round(x * 255).toString(16)).slice(-2);
}).join('');
};
//$("#workers-json-target").append(syntaxHighlight(JSON.stringify(JSON.parse(($("#worker_details").data("workers"))))))
function syntaxHighlight(json) {
if (typeof json != 'string') {
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) {
var cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '' + match + '';
});
}
(function() {
const mainTable = document.getElementById("main-table");
if (!mainTable) return;
// Ensure the header row is inside a proper element.
// Some exports use a bare
as the first row; move it into a thead so CSS/sticky headers work.
if (!mainTable.tHead) {
const firstRow = mainTable.querySelector('tr');
if (firstRow) {
const thead = mainTable.createTHead();
thead.appendChild(firstRow);
}
}
// 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(
``
);
// Ensure main table header is sticky and compact
if ($('#main-table-style').length === 0) {
$('head').append(`
`);
}
// --- 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:
`);
$("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();
});
// --- Dark Mode Toggle ---
$(function() {
// Add dark mode toggle button to the page
const darkModeButton = $('');
$('body').append(darkModeButton);
// Check for saved theme preference or default to light mode
const currentTheme = localStorage.getItem('theme') || 'light';
if (currentTheme === 'dark') {
$('html').addClass('dark-mode');
darkModeButton.text('☀️ Light Mode');
}
// Toggle dark mode
darkModeButton.on('click', function() {
$('html').toggleClass('dark-mode');
const isDark = $('html').hasClass('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
$(this).text(isDark ? '☀️ Light Mode' : '🌙 Dark Mode');
});
});