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(`
`);
});
tbl.append(tbody);
card.append(tbl);
}
// Include the original full worker detail text block so no information is lost.
const fullDetails = detailsByName[name] || '';
if (fullDetails) {
card.append(`Full details
${escapeHtml(fullDetails)}
`);
}
$list.append(card);
});
}
// Add CSS for worker details styling and comprehensive dark mode support
if ($('#worker-details-style').length === 0) {
$('head').append(``);
}
// initialize panels
renderShiftSettings();
renderWorkerFilterOptions();
renderWorkerList();
// Column for prior-adjustment (hidden by default)
h.append(`
Prior Adjust
`);
// Ensure header styles for visibility and stickiness are present
if ($('#tsummary-style').length === 0) {
$('head').append(`
`);
}
$(".table-div .worker-row .worker").each((n, tr) => {
//worker_td = $(tr).find("td").first();
jtr = $(tr);
row = $("
");
row.append(`
${jtr.data("worker")}
`)
row.append(`
${jtr.data("site")}
`)
row.append(`
${jtr.data("fte")}
`)
row.append(`
${parseFloat(jtr.data("fte_adj")).toFixed(2)}
`)
if (jtr.data("shift-diff-summed") >= 1) {
row.addClass("large-shift-diff-pos")
} else if (jtr.data("shift-diff-summed") <= -1) {
row.addClass("large-shift-diff-neg")
}
shift_counts = jtr.data("shift-counts")
locum_shift_counts = jtr.data("locum-shift-counts")
shift_targets = jtr.data("worker-targets")
// Compute total shifts from shift_counts (sum of assigned counts)
let total_shifts = 0;
try {
if (shift_counts && typeof shift_counts === 'object') {
total_shifts = Object.values(shift_counts).reduce((a, b) => a + (parseFloat(b) || 0), 0);
}
} catch (e) {
total_shifts = 0;
}
oshifts.forEach((s) => {
if (s in shift_targets && shift_targets[s] > 0) {
if (s in shift_counts) {
c = shift_counts[s];
} else {
c = 0;
}
locum_shifts = ""
if (s in locum_shift_counts) {
locum_shifts = `+${locum_shift_counts[s]}`
}
row.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 = {};
let rotaExportViewEnabled = false;
let shiftDisplayOrder = [...oshifts];
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(
` `
);
// Add a button to toggle rota export view (spreadsheet-style day-by-day shifts)
$("#shift-timetable-options").append(
``
);
// Add optional control to show worker names in rota export view
$("#shift-timetable-options").append(`
`);
let gridViewEnabled = false;
$("#toggle-grid-view").on("click", function () {
gridViewEnabled = !gridViewEnabled;
renderShiftTimetable();
});
$("#toggle-rota-export-view").on("click", function () {
rotaExportViewEnabled = !rotaExportViewEnabled;
if (rotaExportViewEnabled) {
$(this).text("Show Linear/Grid View");
} else {
$(this).text("Show Rota Export View");
}
renderShiftTimetable();
});
$(document).on("change", "#rota-export-show-workers", function () {
if (rotaExportViewEnabled) {
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 (rotaExportViewEnabled) {
renderRotaExportTimetable();
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);
}
function formatDateForExport(dateStr) {
if (!dateStr) return "";
let d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return dateStr;
let dd = String(d.getDate()).padStart(2, "0");
let mm = String(d.getMonth() + 1).padStart(2, "0");
let yyyy = d.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}
function getShiftHeaderLabel(shift) {
let s = (shift || "").toLowerCase();
if (s.includes("twil")) return "TWIL";
if (s.includes("night") || s.includes("oncall")) return "NIGHT";
if (s.includes("weekend")) return "WEEKEND";
return (shift || "").toUpperCase();
}
function renderRotaExportTimetable() {
let weeks = [];
let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let weekStartDates = {};
let showWorkers = $("#rota-export-show-workers").is(":checked");
// Build week index and week start dates from existing table data
$("table#main-table td[data-week][data-date]").each(function () {
let week = $(this).attr("data-week");
let date = $(this).attr("data-date");
if (!week || !date) return;
if (!weeks.includes(week)) weeks.push(week);
if (!weekStartDates[week] || new Date(date) < new Date(weekStartDates[week])) {
weekStartDates[week] = date;
}
});
weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b));
const orderedSelectedShifts = shiftDisplayOrder.filter(s => selectedShifts.has(s));
const dayShiftOrder = {};
daysOfWeek.forEach(day => {
dayShiftOrder[day] = orderedSelectedShifts.filter(shift => {
let found = false;
$(`table#main-table td[data-day='${day}']`).each(function () {
let shifts = ($(this).attr("data-shift") || "").split(",").map(x => x.trim()).filter(Boolean);
if (shifts.includes(shift)) {
found = true;
return false;
}
});
return found;
});
});
let table = $(`
Rota Export (Shifts by Day)
`);
let headerTop = $("
");
headerTop.append("
Week Start
");
headerTop.append("
Week
");
daysOfWeek.forEach(day => {
let span = Math.max(1, dayShiftOrder[day].length);
headerTop.append(`
${day.toUpperCase()}
`);
});
table.append(headerTop);
let headerBottom = $("
");
headerBottom.append("
");
headerBottom.append("
No
");
daysOfWeek.forEach(day => {
if (dayShiftOrder[day].length === 0) {
headerBottom.append("
`);
return;
}
dayShifts.forEach(shift => {
let workersForShift = new Set();
$(`table#main-table td[data-week='${week}'][data-day='${day}']`).each(function () {
let shifts = ($(this).attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean);
if (!shifts.includes(shift)) return;
let workerName = $(this).closest("tr").find("td.worker span.name").text().trim();
if (workerName) workersForShift.add(workerName);
});
let workerList = Array.from(workersForShift).sort();
let text = "";
if (showWorkers) {
text = workerList.join("/");
} else {
text = workerList.length > 0 ? "X" : "";
}
row.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');
});
});