blackopsrepl's picture
update
e7cf451
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($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
$("#" + 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("<h1><p align=\"center\">No test data available</p></h1>")
});
}
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 = $('<div class="card-body p-2"/>')
.append($(`<h5 class="card-title mb-2"/>)`)
.append(employee.name))
.append($('<div/>')
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).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 = $(`<div/>`)
.append($(`<h5 class="card-title mb-1"/>`).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 = $(`<div/>`)
.append($(`<h5 class="card-title mb-1"/>`).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 = $(`<div/>`)
.append($(`<h5 class="card-title mb-1"/>`).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 = $('<div class="card-body p-2"/>')
.append($(`<h5 class="card-title mb-2"/>)`)
.append("Unassigned"))
.append($('<div/>')
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
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 = $('<div class="card-body p-2"/>')
.append($(`<h5 class="card-title mb-2"/>)`)
.append(shift.location))
.append($('<div/>')
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
const byLocationShiftElement = $('<div class="card-body p-2"/>')
.append($(`<h5 class="card-title mb-2"/>)`)
.append(shift.employee.name))
.append($('<div/>')
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
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($(`<p/>`).text(`There are no unassigned shifts.`));
} else {
unassignedShifts.append($(`<p/>`).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 = $(`<table class="table"/>`).css({textAlign: 'center'});
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
.append($(`<th></th>`))
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
.append($(`<th>Type</th>`))
.append($(`<th># Matches</th>`))
.append($(`<th>Weight</th>`))
.append($(`<th>Score</th>`))
.append($(`<th></th>`)));
analysisTable.append(analysisTHead);
const analysisTBody = $(`<tbody/>`)
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
let row = $(`<tr/>`);
row.append($(`<td/>`).html(icon))
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
.append($(`<td/>`).text(constraintAnalysis.type))
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
.append($(`<td/>`).text(constraintAnalysis.weight))
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
analysisTBody.append(row);
row.append($(`<td/>`));
});
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(
$(`<div class="container-fluid">
<nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
<a class="navbar-brand" href="https://www.solverforge.org">
<img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="nav nav-pills">
<li class="nav-item active" id="navUIItem">
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
</li>
<li class="nav-item" id="navRestItem">
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
</li>
<li class="nav-item" id="navOpenApiItem">
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
</li>
</ul>
</div>
<div class="ms-auto">
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
Data
</button>
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
</div>
</div>
</nav>
</div>`));
}
const solverforgeFooter = $("footer#solverforge-auto-footer");
if (solverforgeFooter != null) {
solverforgeFooter.append(
$(`<footer class="bg-black text-white-50">
<div class="container">
<div class="hstack gap-3 p-4">
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
<div class="vr"></div>
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
<div class="vr"></div>
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
<div class="vr"></div>
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
</div>
</div>
</footer>`));
}
}