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(`
${shift}: ${average.toFixed(2)}
`); // } // days_lost_avg = mean(sites_days_lost[site]); // $(table).find(`.site-${site}`).append(`
Training days lost: ${days_lost_avg.toFixed(2)}
`); // } summary_button = $("").click(() => { let $rotaDays = $(table).find(".rota-day"); let $workerSummaries = $(table).find(".worker-summary"); // determine current visibility of rota days let rotaVisible = $rotaDays.length ? !$rotaDays.first().hasClass('hidden') : true; if (rotaVisible) { $rotaDays.addClass('hidden'); $workerSummaries.show(); } else { $rotaDays.removeClass('hidden'); $workerSummaries.hide(); } }); $("#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); // Add per-table avoid-toggle button (near the summary button) avoid_button = $("").click((evt) => { // ensure style exists once if ($('#avoid-shift-style').length === 0) { $('head').append(` `); } let $btn = $(evt.target); let enabled = $btn.data('enabled') || false; enabled = !enabled; $btn.data('enabled', enabled); let $table = $(table); $table.find("td.rota-day.avoid-shift").each((i, el) => { let $el = $(el); if (enabled) { $el.addClass('avoid-highlight'); // preserve original title if (!$el.data('orig-title')) $el.data('orig-title', $el.attr('title') || ''); let avoid_shifts = $el.attr('data-avoid-shifts') || ''; let avoid_reason = $el.attr('data-avoid-reason') || ''; let suffix = []; // make the constraint type explicit in the tooltip suffix.push(`constraint: avoid_shifts_by_worker_dates`); if (avoid_shifts) suffix.push(`shifts: ${avoid_shifts}`); if (avoid_reason) suffix.push(`reason: ${avoid_reason}`); if (suffix.length) { let newTitle = $el.data('orig-title') + ' [' + suffix.join('; ') + ']'; $el.attr('title', newTitle); } } else { $el.removeClass('avoid-highlight'); // restore original title if ($el.data('orig-title') !== undefined) $el.attr('title', $el.data('orig-title')); } }); $btn.text(enabled ? 'Hide Avoids' : 'Toggle Avoids'); }); $(table).before(avoid_button); }); return global_shifts } shifts = generateExtra(); if (shifts.size < 1) { shifts = $("#shifts-container").data("shifts"); } oshifts = Array.from(shifts).sort() $("body").append("
") $("#show-colour-diff").on("click", (evt) => { $("table.tsummary").find(`td.target-assigned`).toggleClass("no-colour"); }); $("#toggle-prior-adjust").on("click", (evt) => { $("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle(); }); // Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra(). $("table.tsummary").append(""); h = $("table.tsummary tr.header"); h.append(`Worker`); h.append(`Site`); h.append(`FTE`); h.append(`FTE (adj)`); oshifts.forEach((s) => { h.append(`${s}`); }); // Render formatted Shifts settings and Worker details with filtering function renderShiftSettings() { const $container = $("#shifts-container"); if (!$container.length) return; let shiftsJson = $container.data("shifts-json"); try { if (typeof shiftsJson === 'string') shiftsJson = JSON.parse(shiftsJson); } catch (e) { // fallback: no structured data shiftsJson = null; } const $table = $("
"); $table.append(`NameSitesDaysBalance offset`); if (shiftsJson && Array.isArray(shiftsJson)) { shiftsJson.forEach(s => { const sites = (s.sites || []).join(", "); const days = (s.days || []).join(", "); $table.append(`${s.name}${sites}${days}${s.balance_offset ?? ''}`); }); } else { // fallback to existing content const html = $("#shifts-table").html(); $("#shifts-table").empty().append(html); } $("#shifts-table").empty().append($table); // filter input $("#shift-filter").on("input", (e) => { const v = $(e.target).val().toLowerCase(); $(".shift-row").each((i, el) => { const name = $(el).data('shift-name') + ''; $(el).toggle(name.toLowerCase().indexOf(v) !== -1); }); // update shift select in worker panel if present renderWorkerFilterOptions(); }); } function renderWorkerFilterOptions() { const $wd = $("#worker_details"); if (!$wd.length) return; let workers = $wd.data("workers"); try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; } // populate shift select with shifts present in current shifts table const shiftNames = []; $(".shift-row:visible").each((i, el) => shiftNames.push($(el).data('shift-name'))); const $select = $("#worker-shift-select"); if ($select.length === 0) { $("#worker_details").prepend('
'); } const $sel = $("#worker-shift-select"); $sel.empty().append(''); shiftNames.forEach(s => $sel.append(``)); // attach events $("#worker-filter").off('input').on('input', () => renderWorkerList()); $sel.off('change').on('change', () => renderWorkerList()); } function renderWorkerList() { const $wd = $("#worker_details"); if (!$wd.length) return; let workers = $wd.data("workers"); try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; } const filter = ($("#worker-filter").val() || '').toLowerCase(); const shiftFilter = ($("#worker-shift-select").val() || ''); let $list = $("#worker-list"); if ($list.length === 0) { $list = $("
"); $("#worker_details").append($list); } $list.empty(); if (!workers) return; workers.forEach(w => { const name = (w.name || '') + ''; const site = (w.site || '') + ''; if (filter && name.toLowerCase().indexOf(filter) === -1 && site.toLowerCase().indexOf(filter) === -1) return; // if shiftFilter, ensure worker has target or assigned for that shift // attempt to read per-worker targets and counts from DOM if not present in JSON let targets = w.shift_target_number || {}; let counts = w.shift_counts || {}; const domRow = $(`td.worker[data-worker='${name}']`).first(); if (domRow && domRow.length) { try { const domTargets = domRow.data('worker-targets'); if (domTargets && Object.keys(targets).length === 0) targets = domTargets; } catch (e) {} try { const domCounts = domRow.data('shift-counts'); if (domCounts && Object.keys(counts).length === 0) counts = domCounts; } catch (e) {} } if (shiftFilter) { const assigned = (counts && counts[shiftFilter]) ? counts[shiftFilter] : 0; const hasTarget = Object.keys(targets || {}).indexOf(shiftFilter) !== -1 && (targets[shiftFilter] || 0) > 0; const hasAssigned = assigned > 0; if (!hasTarget && !hasAssigned) return; } const fte = w.fte || ''; const card = $(`
${name} ${site}
FTE: ${fte}
`); // show shift targets summary if (w.shift_target_number) { const tbl = $("
"); Object.keys(w.shift_target_number).forEach(s => { const t = w.shift_target_number[s]; const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0; tbl.append(`${s}${c} assignedtarget ${parseFloat(t).toFixed(2)}`); }); card.append(tbl); } $list.append(card); }); } // 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") 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(`${c}${locum_shifts} (${shift_targets[s].toFixed(2)})`) } else { 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 = ``; 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(""); 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(``); row.append(``); row.append(``); 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(``); }); 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 = $(`
DateWeekDayWorker(s)
${date}${week}${day}${workerName}${gradeTextLinear}${shiftLabel}
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:
`); //$("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($("")); } newrows[colToRowIndex].append($(this)); }); }); $this.find("tr").remove(); $.each(newrows, function () { $this.append(this); }); $this.find("td.rota-day").each((n, el) => { $(el).text(el.dataset.shift) }); }); // ICS Export functionality let customShiftTimes = {}; // Will store user-defined times function getShiftTimes() { // Merge default with custom const defaultTimes = { 'night_weekday': { start: '20:00', end: '08:15', nextDay: true }, 'night_weekend': { start: '20:00', end: '08:15', nextDay: true }, 'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true }, 'truro_twilight': { start: '14:00', end: '02:30', nextDay: true }, 'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true }, 'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true }, 'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false }, 'weekend_truro': { start: '08:00', end: '20:30', nextDay: false }, 'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false }, 'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false }, 'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false }, 'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false }, }; return { ...defaultTimes, ...customShiftTimes }; } function generateICS(shiftType, shiftData) { let icsContent = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Rota Generator//EN `; const shiftTimes = getShiftTimes(); const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; shiftData.forEach(entry => { const date = entry.date; const workerName = entry.worker; const startDate = new Date(date + 'T' + time.start + ':00'); let endDate = new Date(date + 'T' + time.end + ':00'); if (time.nextDay) { endDate.setDate(endDate.getDate() + 1); } const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle; const description = workerName ? `Shift: ${shiftType}\nWorker: ${workerName}` : `Shift: ${shiftType}`; icsContent += `BEGIN:VEVENT UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota DTSTAMP:${formatDate(new Date())} DTSTART:${formatDate(startDate)} DTEND:${formatDate(endDate)} SUMMARY:${summary} DESCRIPTION:${description} END:VEVENT `; }); icsContent += 'END:VCALENDAR'; return icsContent; } function downloadICS(filename, content) { const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); } // Collect shift dates and worker assignments const shiftDates = {}; const workerShifts = {}; $('#main-table tr.worker-row').each(function() { const workerName = $(this).find('td.worker').attr('data-worker'); if (workerName) { workerShifts[workerName] = {}; $(this).find('td.rota-day').each(function() { const shift = $(this).attr('data-shift'); const date = $(this).attr('data-date'); if (shift && shift !== '' && date) { const shifts = shift.split(',').map(s => s.trim()).filter(s => s); shifts.forEach(s => { // Collect by shift type with worker info if (!shiftDates[s]) { shiftDates[s] = []; } // Check if we already have this date for this shift type const existingEntry = shiftDates[s].find(entry => entry.date === date); if (!existingEntry) { shiftDates[s].push({ date: date, worker: workerName }); } // Collect by worker if (!workerShifts[workerName][s]) { workerShifts[workerName][s] = []; } if (!workerShifts[workerName][s].includes(date)) { workerShifts[workerName][s].push(date); } }); } }); } }); // Add ICS export buttons const icsButtonsDiv = $('

Export ICS Files

'); const setTimesButton = $(``); icsButtonsDiv.append(setTimesButton); // Shift type exports (with worker info) const shiftExportsDiv = $('

Export by Shift Type

'); Object.keys(shiftDates).sort().forEach(shiftType => { const button = $(``); button.on('click', () => { const ics = generateICS(shiftType, shiftDates[shiftType]); downloadICS(`${shiftType}.ics`, ics); }); shiftExportsDiv.append(button); }); icsButtonsDiv.append(shiftExportsDiv); // Worker exports const workerExportsDiv = $('

Export by Worker

'); Object.keys(workerShifts).sort().forEach(workerName => { const workerShiftTypes = Object.keys(workerShifts[workerName]); if (workerShiftTypes.length > 0) { const button = $(``); button.on('click', () => { showWorkerShiftSelectionModal(workerName, workerShifts[workerName]); }); workerExportsDiv.append(button); } }); icsButtonsDiv.append(workerExportsDiv); $('#export-div').append(icsButtonsDiv); // Function to show worker shift selection modal function showWorkerShiftSelectionModal(workerName, workerShiftData) { const checkboxesDiv = $('#worker-shift-checkboxes'); checkboxesDiv.empty(); $('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`); // Create checkboxes for each shift type Object.keys(workerShiftData).sort().forEach(shiftType => { const dates = workerShiftData[shiftType]; const checkboxId = `shift-${shiftType}`; const checkbox = $(`
`); checkboxesDiv.append(checkbox); }); // Store worker data for export $('#worker-shift-export').data('workerName', workerName); $('#worker-shift-export').data('workerShiftData', workerShiftData); // Show modal workerShiftModal.show(); } // Modal for worker shift selection const workerShiftModal = $(` `); $('body').append(workerShiftModal); // Modal for setting shift times const shiftTimesModal = $(` `); $("body").append(shiftTimesModal); $('#set-shift-times').on('click', function() { $('#shift-times-modal').show(); populateShiftTimesModal(); }); $('#close-shift-times').on('click', function() { $('#shift-times-modal').hide(); }); $('#save-shift-times').on('click', function() { saveShiftTimes(); $('#shift-times-modal').hide(); }); // Worker shift modal event handlers $('#worker-shift-cancel').on('click', function() { workerShiftModal.hide(); }); $('#worker-shift-export').on('click', function() { const workerName = $(this).data('workerName'); const workerShiftData = $(this).data('workerShiftData'); // Get selected shift types const selectedShifts = []; $('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() { const shiftType = $(this).attr('id').replace('shift-', ''); selectedShifts.push(shiftType); }); if (selectedShifts.length === 0) { alert('Please select at least one shift type to export.'); return; } // Generate ICS for selected shifts let icsContent = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Rota Generator//EN `; selectedShifts.forEach(shiftType => { const dates = workerShiftData[shiftType]; const shiftTimes = getShiftTimes(); const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; dates.forEach(date => { const startDate = new Date(date + 'T' + time.start + ':00'); let endDate = new Date(date + 'T' + time.end + ':00'); if (time.nextDay) { endDate.setDate(endDate.getDate() + 1); } const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); icsContent += `BEGIN:VEVENT UID:${workerName}-${shiftType}-${date}@rota DTSTAMP:${formatDate(new Date())} DTSTART:${formatDate(startDate)} DTEND:${formatDate(endDate)} SUMMARY:${shiftTitle} - ${workerName} DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName} END:VEVENT `; }); }); icsContent += 'END:VCALENDAR'; downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent); workerShiftModal.hide(); }); function populateShiftTimesModal() { const controls = $('#shift-times-controls'); controls.empty(); const shiftTimes = getShiftTimes(); Object.keys(shiftDates).sort().forEach(shiftType => { const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; const div = $(`

`); 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 = $(` `); $("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'); }); });