let autoRefreshIntervalId = null; const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red const UNDESIRED_COLOR = '#f57900' // Tango Orange const DESIRED_COLOR = '#73d216' // Tango Chameleon let demoDataId = null; let scheduleId = null; let loadedSchedule = null; const byEmployeePanel = document.getElementById("byEmployeePanel"); const byEmployeeTimelineOptions = { timeAxis: {scale: "hour", step: 6}, orientation: {axis: "top"}, stack: false, xss: {disabled: true}, // Items are XSS safe through JQuery zoomMin: zoomMin, zoomMax: zoomMax, }; let byEmployeeGroupDataSet = new vis.DataSet(); let byEmployeeItemDataSet = new vis.DataSet(); let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions); const byLocationPanel = document.getElementById("byLocationPanel"); const byLocationTimelineOptions = { timeAxis: {scale: "hour", step: 6}, orientation: {axis: "top"}, xss: {disabled: true}, // Items are XSS safe through JQuery zoomMin: zoomMin, zoomMax: zoomMax, }; let byLocationGroupDataSet = new vis.DataSet(); let byLocationItemDataSet = new vis.DataSet(); let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions); let windowStart = JSJoda.LocalDate.now().toString(); let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString(); $(document).ready(function () { let initialized = false; function safeInitialize() { if (!initialized) { initialized = true; initializeApp(); } } // Ensure all resources are loaded before initializing $(window).on('load', safeInitialize); // Fallback if window load event doesn't fire setTimeout(safeInitialize, 100); }); function initializeApp() { replaceQuickstartSolverForgeAutoHeaderFooter(); $("#solveButton").click(function () { solve(); }); $("#stopSolvingButton").click(function () { stopSolving(); }); $("#analyzeButton").click(function () { analyze(); }); // HACK to allow vis-timeline to work within Bootstrap tabs $("#byEmployeeTab").on('shown.bs.tab', function (event) { byEmployeeTimeline.redraw(); }) $("#byLocationTab").on('shown.bs.tab', function (event) { byLocationTimeline.redraw(); }) setupAjax(); fetchDemoData(); } function setupAjax() { $.ajaxSetup({ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job } }); // Extend jQuery to support $.put() and $.delete() jQuery.each(["put", "delete"], function (i, method) { jQuery[method] = function (url, data, callback, type) { if (jQuery.isFunction(data)) { type = type || callback; callback = data; data = undefined; } return jQuery.ajax({ url: url, type: method, dataType: type, data: data, success: callback }); }; }); } function fetchDemoData() { $.get("/demo-data", function (data) { data.forEach(item => { $("#testDataButton").append($('' + item + '')); $("#" + item + "TestData").click(function () { switchDataDropDownItemActive(item); scheduleId = null; demoDataId = item; refreshSchedule(); }); }); demoDataId = data[0]; switchDataDropDownItemActive(demoDataId); refreshSchedule(); }).fail(function (xhr, ajaxOptions, thrownError) { // disable this page as there is no data let $demo = $("#demo"); $demo.empty(); $demo.html("

No test data available

") }); } function switchDataDropDownItemActive(newItem) { activeCssClass = "active"; $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass); $("#" + newItem + "TestData").addClass(activeCssClass); } function getShiftColor(shift, employee) { const shiftStart = JSJoda.LocalDateTime.parse(shift.start); const shiftStartDateString = shiftStart.toLocalDate().toString(); const shiftEnd = JSJoda.LocalDateTime.parse(shift.end); const shiftEndDateString = shiftEnd.toLocalDate().toString(); if (employee.unavailableDates.includes(shiftStartDateString) || // The contains() check is ignored for a shift end at midnight (00:00:00). (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && employee.unavailableDates.includes(shiftEndDateString))) { return UNAVAILABLE_COLOR } else if (employee.undesiredDates.includes(shiftStartDateString) || // The contains() check is ignored for a shift end at midnight (00:00:00). (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && employee.undesiredDates.includes(shiftEndDateString))) { return UNDESIRED_COLOR } else if (employee.desiredDates.includes(shiftStartDateString) || // The contains() check is ignored for a shift end at midnight (00:00:00). (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) && employee.desiredDates.includes(shiftEndDateString))) { return DESIRED_COLOR } else { return " #729fcf"; // Tango Sky Blue } } function refreshSchedule() { let path = "/schedules/" + scheduleId; if (scheduleId === null) { if (demoDataId === null) { alert("Please select a test data set."); return; } path = "/demo-data/" + demoDataId; } $.getJSON(path, function (schedule) { loadedSchedule = schedule; renderSchedule(schedule); }) .fail(function (xhr, ajaxOptions, thrownError) { showError("Getting the schedule has failed.", xhr); refreshSolvingButtons(false); }); } function renderSchedule(schedule) { console.log('Rendering schedule:', schedule); if (!schedule) { console.error('No schedule data provided to renderSchedule'); return; } refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); const unassignedShifts = $("#unassignedShifts"); const groups = []; // Check if schedule.shifts exists and is an array if (!schedule.shifts || !Array.isArray(schedule.shifts) || schedule.shifts.length === 0) { console.warn('No shifts data available in schedule'); return; } // Show only first 7 days of draft const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString(); const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString(); windowStart = scheduleStart; windowEnd = scheduleEnd; unassignedShifts.children().remove(); let unassignedShiftsCount = 0; byEmployeeGroupDataSet.clear(); byLocationGroupDataSet.clear(); byEmployeeItemDataSet.clear(); byLocationItemDataSet.clear(); // Check if schedule.employees exists and is an array if (!schedule.employees || !Array.isArray(schedule.employees)) { console.warn('No employees data available in schedule'); return; } schedule.employees.forEach((employee, index) => { const employeeGroupElement = $('
') .append($(`
)`) .append(employee.name)) .append($('
') .append($(employee.skills.map(skill => `${skill}`).join('')))); byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()}); employee.unavailableDates.forEach((rawDate, dateIndex) => { const date = JSJoda.LocalDate.parse(rawDate) const start = date.atStartOfDay().toString(); const end = date.plusDays(1).atStartOfDay().toString(); const byEmployeeShiftElement = $(`
`) .append($(`
`).text("Unavailable")); byEmployeeItemDataSet.add({ id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name, content: byEmployeeShiftElement.html(), start: start, end: end, type: "background", style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR, }); }); employee.undesiredDates.forEach((rawDate, dateIndex) => { const date = JSJoda.LocalDate.parse(rawDate) const start = date.atStartOfDay().toString(); const end = date.plusDays(1).atStartOfDay().toString(); const byEmployeeShiftElement = $(`
`) .append($(`
`).text("Undesired")); byEmployeeItemDataSet.add({ id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name, content: byEmployeeShiftElement.html(), start: start, end: end, type: "background", style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR, }); }); employee.desiredDates.forEach((rawDate, dateIndex) => { const date = JSJoda.LocalDate.parse(rawDate) const start = date.atStartOfDay().toString(); const end = date.plusDays(1).atStartOfDay().toString(); const byEmployeeShiftElement = $(`
`) .append($(`
`).text("Desired")); byEmployeeItemDataSet.add({ id: "employee-" + index + "-desired-" + dateIndex, group: employee.name, content: byEmployeeShiftElement.html(), start: start, end: end, type: "background", style: "opacity: 0.5; background-color: " + DESIRED_COLOR, }); }); }); schedule.shifts.forEach((shift, index) => { if (groups.indexOf(shift.location) === -1) { groups.push(shift.location); byLocationGroupDataSet.add({ id: shift.location, content: shift.location, }); } if (shift.employee == null) { unassignedShiftsCount++; const byLocationShiftElement = $('
') .append($(`
)`) .append("Unassigned")) .append($('
') .append($(`${shift.requiredSkill}`))); byLocationItemDataSet.add({ id: 'shift-' + index, group: shift.location, content: byLocationShiftElement.html(), start: shift.start, end: shift.end, style: "background-color: #EF292999" }); } else { const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234'); const byEmployeeShiftElement = $('
') .append($(`
)`) .append(shift.location)) .append($('
') .append($(`${shift.requiredSkill}`))); const byLocationShiftElement = $('
') .append($(`
)`) .append(shift.employee.name)) .append($('
') .append($(`${shift.requiredSkill}`))); const shiftColor = getShiftColor(shift, shift.employee); byEmployeeItemDataSet.add({ id: 'shift-' + index, group: shift.employee.name, content: byEmployeeShiftElement.html(), start: shift.start, end: shift.end, style: "background-color: " + shiftColor }); byLocationItemDataSet.add({ id: 'shift-' + index, group: shift.location, content: byLocationShiftElement.html(), start: shift.start, end: shift.end, style: "background-color: " + shiftColor }); } }); if (unassignedShiftsCount === 0) { unassignedShifts.append($(`

`).text(`There are no unassigned shifts.`)); } else { unassignedShifts.append($(`

`).text(`There are ${unassignedShiftsCount} unassigned shifts.`)); } byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd); byLocationTimeline.setWindow(scheduleStart, scheduleEnd); } function solve() { if (!loadedSchedule) { showError("No schedule data loaded. Please wait for the data to load or refresh the page."); return; } console.log('Sending schedule data for solving:', loadedSchedule); $.post("/schedules", JSON.stringify(loadedSchedule), function (data) { scheduleId = data; refreshSolvingButtons(true); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Start solving failed.", xhr); refreshSolvingButtons(false); }, "text"); } function analyze() { new bootstrap.Modal("#scoreAnalysisModal").show() const scoreAnalysisModalContent = $("#scoreAnalysisModalContent"); scoreAnalysisModalContent.children().remove(); if (loadedSchedule.score == null) { scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button."); } else { $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`); $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) { let constraints = scoreAnalysis.constraints; constraints.sort((a, b) => { let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score); if (aComponents.hard < 0 && bComponents.hard > 0) return -1; if (aComponents.hard > 0 && bComponents.soft < 0) return 1; if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) { return -1; } else { if (aComponents.medium < 0 && bComponents.medium > 0) return -1; if (aComponents.medium > 0 && bComponents.medium < 0) return 1; if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) { return -1; } else { if (aComponents.soft < 0 && bComponents.soft > 0) return -1; if (aComponents.soft > 0 && bComponents.soft < 0) return 1; return Math.abs(bComponents.soft) - Math.abs(aComponents.soft); } } }); constraints.map((e) => { let components = getScoreComponents(e.weight); e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft'); e.weight = components[e.type]; let scores = getScoreComponents(e.score); e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft); }); scoreAnalysis.constraints = constraints; scoreAnalysisModalContent.children().remove(); scoreAnalysisModalContent.text(""); const analysisTable = $(``).css({textAlign: 'center'}); const analysisTHead = $(``).append($(``) .append($(``)) .append($(``).css({textAlign: 'left'})) .append($(``)) .append($(``)) .append($(``)) .append($(``)) .append($(``))); analysisTable.append(analysisTHead); const analysisTBody = $(``) $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => { let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '' : ''; if (!icon) icon = constraintAnalysis.matches.length == 0 ? '' : ''; let row = $(``); row.append($(`
ConstraintType# MatchesWeightScore
`).html(icon)) .append($(``).text(constraintAnalysis.name).css({textAlign: 'left'})) .append($(``).text(constraintAnalysis.type)) .append($(``).html(`${constraintAnalysis.matches.length}`)) .append($(``).text(constraintAnalysis.weight)) .append($(``).text(constraintAnalysis.implicitScore)); analysisTBody.append(row); row.append($(``)); }); analysisTable.append(analysisTBody); scoreAnalysisModalContent.append(analysisTable); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Analyze failed.", xhr); }, "text"); } } function getScoreComponents(score) { let components = {hard: 0, medium: 0, soft: 0}; $.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => { components[parts[3]] = parseFloat(parts[1], 10); }); return components; } function refreshSolvingButtons(solving) { if (solving) { $("#solveButton").hide(); $("#stopSolvingButton").show(); $("#solvingSpinner").addClass("active"); if (autoRefreshIntervalId == null) { autoRefreshIntervalId = setInterval(refreshSchedule, 2000); } } else { $("#solveButton").show(); $("#stopSolvingButton").hide(); $("#solvingSpinner").removeClass("active"); if (autoRefreshIntervalId != null) { clearInterval(autoRefreshIntervalId); autoRefreshIntervalId = null; } } } function stopSolving() { $.delete(`/schedules/${scheduleId}`, function () { refreshSolvingButtons(false); refreshSchedule(); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Stop solving failed.", xhr); }); } function replaceQuickstartSolverForgeAutoHeaderFooter() { const solverforgeHeader = $("header#solverforge-auto-header"); if (solverforgeHeader != null) { solverforgeHeader.css("background-color", "#ffffff"); solverforgeHeader.append( $(`
`)); } const solverforgeFooter = $("footer#solverforge-auto-footer"); if (solverforgeFooter != null) { solverforgeFooter.append( $(``)); } }