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"); 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) => { shifts.push($(td).attr("data-shift")); }) 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; 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); worker_td.after(`
Total shifts: ${total_shifts}, Weekends: ${weekends_worked}, Bank holidays: ${bank_holidays_worked}, Summed shift diff: ${summed_shift_diff.toPrecision(2)},
`) 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(() => { $(table).find(".rota-day").toggleClass("hidden"); $(table).find(".worker-summary").toggle(); }); $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs}
`)); $(table).before($(`Total summed shift diff: ${total_summed_shift_diffs_abs}
`)); $(table).before(summary_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"); }); $("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}`); }); $(".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") 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; } row.append(`${c} (${shift_targets[s].toFixed(2)})`) } else { row.append(`-`) } }) $("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) }) }); oshifts.forEach((shift) => { button = $(``) button.on("click", (evt) => { console.log(evt.target.dataset.shift) $("#shift-timetable-div").empty(); table = $(""); $("table#main-table th.date").each((n, th) => { row = $("") row.append(``); $(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => { //row.append(``) row.append($(td).closest("tr").children("td:first").clone()) }); table.append(row) }); $("#shift-timetable-div").append(table); }) $("#shift-timetable-options").append(button) }); //$("table.summary") // $("body").prepend("
Settings:
Customise training days lost per shift (you need to press recalculate for changes to take effect)
") // 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") }) } if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) { $(`td[data-shift=${e.target.dataset.shift}]`).addClass("shift-highlight") $(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match") // if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) { // $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match") // } } }, (e) => { //end hover $("td.pair-match").removeClass("pair-match") $("td.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; let exactShiftsText = "(none)"; if (ds.exactShifts) { try { let parsed = JSON.parse(ds.exactShifts); if (Object.keys(parsed).length > 0) { exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", "); } } catch(e) {} } dlg = $(`
Site: ${ds.site}
FTE: ${ds.fte} (${ds.fte_adj})
Exact shifts: ${exactShiftsText}
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(''); };
${th.dataset.date}${}