| let autoRefreshIntervalId = null; |
| const formatter = JSJoda.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); |
| const startTime = formatter.format(JSJoda.LocalDateTime.now().withHour(20).withMinute(0).withSecond(0)); |
| const endTime = formatter.format(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).withMinute(0).withSecond(0)); |
| const zoomMin = 1000 * 60 * 60 |
| const zoomMax = 4 * 1000 * 60 * 60 * 24 |
|
|
| const byTimelineOptions = { |
| timeAxis: {scale: "hour", step: 1}, |
| orientation: {axis: "top"}, |
| stack: false, |
| xss: {disabled: true}, |
| zoomMin: zoomMin, |
| zoomMax: zoomMax, |
| showCurrentTime: false, |
| hiddenDates: [ |
| { |
| start: startTime, |
| end: endTime, |
| repeat: 'daily' |
| } |
| ], |
| }; |
|
|
| const byRoomPanel = document.getElementById("byRoomPanel"); |
| let byRoomGroupData = new vis.DataSet(); |
| let byRoomItemData = new vis.DataSet(); |
| let byRoomTimeline = new vis.Timeline(byRoomPanel, byRoomItemData, byRoomGroupData, byTimelineOptions); |
|
|
| const byPersonPanel = document.getElementById("byPersonPanel"); |
| let byPersonGroupData = new vis.DataSet(); |
| let byPersonItemData = new vis.DataSet(); |
| let byPersonTimeline = new vis.Timeline(byPersonPanel, byPersonItemData, byPersonGroupData, byTimelineOptions); |
|
|
| let scheduleId = null; |
| let loadedSchedule = null; |
| let viewType = "R"; |
| let selectedDemoData = "MEDIUM"; |
| let analyzeCache = null; |
|
|
|
|
| let appInitialized = false; |
|
|
| $(document).ready(function () { |
| |
| $(window).on('load', function() { |
| if (!appInitialized) { |
| appInitialized = true; |
| initializeApp(); |
| } |
| }); |
|
|
| |
| setTimeout(function() { |
| if (!appInitialized) { |
| appInitialized = true; |
| initializeApp(); |
| } |
| }, 100); |
| }); |
|
|
| function initializeApp() { |
| replaceQuickstartSolverForgeAutoHeaderFooter(); |
|
|
| $("#solveButton").click(function () { |
| solve(); |
| }); |
| $("#stopSolvingButton").click(function () { |
| stopSolving(); |
| }); |
| $("#analyzeButton").click(function () { |
| analyze(); |
| }); |
| $("#byRoomTab").click(function () { |
| viewType = "R"; |
| byRoomTimeline.redraw(); |
| refreshSchedule(); |
| }); |
| $("#byPersonTab").click(function () { |
| viewType = "P"; |
| byPersonTimeline.redraw(); |
| refreshSchedule(); |
| }); |
| setupAjax(); |
| loadDemoDataDropdown(); |
| refreshSchedule(); |
| } |
|
|
|
|
| function loadDemoDataDropdown() { |
| $.getJSON("/demo-data", function (demoDataList) { |
| const dropdown = $("#testDataButton"); |
| dropdown.empty(); |
|
|
| demoDataList.forEach(function (name) { |
| const isSelected = name === selectedDemoData; |
| const item = $(`<a class="dropdown-item" href="#"></a>`) |
| .text(name) |
| .css("font-weight", isSelected ? "bold" : "normal") |
| .click(function (e) { |
| e.preventDefault(); |
| selectDemoData(name); |
| }); |
| dropdown.append(item); |
| }); |
| }); |
| } |
|
|
|
|
| function selectDemoData(name) { |
| selectedDemoData = name; |
| scheduleId = null; |
| loadDemoDataDropdown(); |
| refreshSchedule(); |
| } |
|
|
|
|
| function setupAjax() { |
| $.ajaxSetup({ |
| headers: { |
| 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', |
| } |
| }); |
|
|
| |
| 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 refreshSchedule() { |
| let path; |
| if (scheduleId === null) { |
| path = "/demo-data/" + selectedDemoData; |
| } else { |
| path = "/schedules/" + scheduleId; |
| } |
|
|
| $.getJSON(path, function (schedule) { |
| loadedSchedule = schedule; |
| $('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule)); |
| renderSchedule(schedule); |
| }) |
| .fail(function (xhr, ajaxOptions, thrownError) { |
| showError("Getting the schedule has failed.", xhr); |
| refreshSolvingButtons(false); |
| }); |
| } |
|
|
|
|
| function renderSchedule(schedule) { |
| refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); |
| $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); |
|
|
| |
| if (schedule.score) { |
| fetchConstraintAnalysis(schedule); |
| } else { |
| |
| analyzeCache = null; |
| renderViews(schedule); |
| } |
| } |
|
|
|
|
| function renderViews(schedule) { |
| if (viewType === "R") { |
| renderScheduleByRoom(schedule); |
| } |
| if (viewType === "P") { |
| renderScheduleByPerson(schedule); |
| } |
| } |
|
|
|
|
| function fetchConstraintAnalysis(schedule) { |
| $.ajax({ |
| url: "/schedules/analyze", |
| type: "PUT", |
| data: JSON.stringify(schedule), |
| contentType: "application/json", |
| success: function(response) { |
| |
| analyzeCache = new Map(); |
|
|
| for (const constraint of response.constraints) { |
| const type = getConstraintType(constraint.weight); |
|
|
| for (const match of constraint.matches) { |
| const assignmentIds = extractAssignmentIds(match.justification, loadedSchedule); |
|
|
| for (const id of assignmentIds) { |
| if (!analyzeCache.has(id)) { |
| analyzeCache.set(id, { hard: [], medium: [], soft: [] }); |
| } |
| analyzeCache.get(id)[type].push({ |
| constraint: constraint.name, |
| score: match.score |
| }); |
| } |
| } |
| } |
|
|
| |
| renderViews(loadedSchedule); |
| }, |
| error: function(xhr, status, error) { |
| console.warn("Failed to fetch constraint analysis:", error); |
| analyzeCache = null; |
| renderViews(schedule); |
| } |
| }); |
| } |
|
|
|
|
| function getConstraintType(weight) { |
| |
| |
| const hardMatch = weight.match(/(-?\d+)hard/); |
| const mediumMatch = weight.match(/(-?\d+)medium/); |
| const softMatch = weight.match(/(-?\d+)soft/); |
|
|
| if (hardMatch && parseInt(hardMatch[1], 10) !== 0) return 'hard'; |
| if (mediumMatch && parseInt(mediumMatch[1], 10) !== 0) return 'medium'; |
| if (softMatch && parseInt(softMatch[1], 10) !== 0) return 'soft'; |
|
|
| return 'soft'; |
| } |
|
|
|
|
| function extractAssignmentIds(justification, schedule) { |
| const ids = new Set(); |
| if (!justification?.facts) return [...ids]; |
|
|
| |
| const meetingToAssignment = new Map(); |
| if (schedule?.meetingAssignments) { |
| for (const a of schedule.meetingAssignments) { |
| const meetingId = typeof a.meeting === 'object' ? a.meeting.id : a.meeting; |
| meetingToAssignment.set(meetingId, a.id); |
| } |
| } |
|
|
| for (const fact of justification.facts) { |
| if (fact.type === 'assignment' && fact.id) { |
| ids.add(fact.id); |
| } else if (fact.type === 'attendance' && fact.meetingId) { |
| const assignmentId = meetingToAssignment.get(fact.meetingId); |
| if (assignmentId) ids.add(assignmentId); |
| } |
| } |
| return [...ids]; |
| } |
|
|
|
|
| function getConflictStatus(assignmentId) { |
| |
| if (analyzeCache && analyzeCache.has(assignmentId)) { |
| const violations = analyzeCache.get(assignmentId); |
|
|
| if (violations.hard.length > 0) { |
| return { |
| status: 'hard', |
| icon: '<span class="fas fa-exclamation-triangle text-danger me-1"></span>', |
| style: 'background-color: #fee2e2; border-left: 4px solid #dc3545;', |
| reason: violations.hard.map(v => v.constraint).join(', ') |
| }; |
| } |
|
|
| if (violations.medium.length > 0) { |
| return { |
| status: 'medium', |
| icon: '<span class="fas fa-exclamation-circle text-warning me-1"></span>', |
| style: 'background-color: #fef3c7; border-left: 4px solid #ffc107;', |
| reason: violations.medium.map(v => v.constraint).join(', ') |
| }; |
| } |
|
|
| |
| |
| |
| } |
|
|
| |
| return { |
| status: 'ok', |
| icon: '<span class="fas fa-check-circle text-success me-1"></span>', |
| style: 'background-color: #d1fae5; border-left: 4px solid #10b981;', |
| reason: '' |
| }; |
| } |
|
|
|
|
| function calculatePersonWorkload(schedule) { |
| const personMeetingCount = new Map(); |
|
|
| if (!schedule.meetingAssignments || !schedule.meetings) { |
| return personMeetingCount; |
| } |
|
|
| const meetingMap = new Map(); |
| schedule.meetings.forEach(m => meetingMap.set(m.id, m)); |
|
|
| |
| schedule.meetingAssignments.forEach(assignment => { |
| if (assignment.room == null || assignment.startingTimeGrain == null) return; |
|
|
| const meeting = typeof assignment.meeting === 'string' |
| ? meetingMap.get(assignment.meeting) |
| : assignment.meeting; |
| if (!meeting) return; |
|
|
| |
| (meeting.requiredAttendances || []).forEach(att => { |
| const personId = att.person?.id || att.person; |
| personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1); |
| }); |
|
|
| |
| (meeting.preferredAttendances || []).forEach(att => { |
| const personId = att.person?.id || att.person; |
| personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1); |
| }); |
| }); |
|
|
| return personMeetingCount; |
| } |
|
|
|
|
| function getWorkloadBadge(meetingCount) { |
| if (meetingCount === 0) { |
| return '<span class="badge bg-secondary ms-2" title="No meetings">0</span>'; |
| } else if (meetingCount <= 5) { |
| return `<span class="badge bg-primary ms-2" title="${meetingCount} meetings">${meetingCount}</span>`; |
| } else if (meetingCount <= 9) { |
| return `<span class="badge bg-info text-dark ms-2" title="${meetingCount} meetings">${meetingCount}</span>`; |
| } else { |
| return `<span class="badge bg-dark ms-2" title="${meetingCount} meetings - Heavy workload">${meetingCount}</span>`; |
| } |
| } |
|
|
|
|
| function analyzeUnassignedReason(meeting, schedule) { |
| const reasons = []; |
|
|
| const totalAttendees = (meeting.requiredAttendances?.length || 0) + (meeting.preferredAttendances?.length || 0); |
|
|
| |
| const largestRoomCapacity = Math.max(...schedule.rooms.map(r => r.capacity)); |
| if (totalAttendees > largestRoomCapacity) { |
| reasons.push(`Needs ${totalAttendees} capacity, largest room has ${largestRoomCapacity}`); |
| } |
|
|
| |
| const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60; |
| if (durationHours > 3) { |
| reasons.push(`Long meeting (${durationHours}h) - fewer available slots`); |
| } |
|
|
| |
| const meetingMap = new Map(); |
| schedule.meetings.forEach(m => meetingMap.set(m.id, m)); |
|
|
| const requiredAttendeeIds = new Set((meeting.requiredAttendances || []).map(a => a.person?.id || a.person)); |
| let busyAttendeesCount = 0; |
|
|
| schedule.meetingAssignments.forEach(assignment => { |
| if (assignment.room == null || assignment.startingTimeGrain == null) return; |
| if (assignment.meeting === meeting.id) return; |
|
|
| const otherMeeting = typeof assignment.meeting === 'string' |
| ? meetingMap.get(assignment.meeting) |
| : assignment.meeting; |
| if (!otherMeeting) return; |
|
|
| const otherRequiredIds = new Set((otherMeeting.requiredAttendances || []).map(a => a.person?.id || a.person)); |
| for (const id of requiredAttendeeIds) { |
| if (otherRequiredIds.has(id)) { |
| busyAttendeesCount++; |
| break; |
| } |
| } |
| }); |
|
|
| if (busyAttendeesCount > 0 && requiredAttendeeIds.size > 0) { |
| const percentBusy = Math.round((busyAttendeesCount / schedule.meetingAssignments.filter(a => a.room && a.startingTimeGrain).length) * 100); |
| if (percentBusy > 30) { |
| reasons.push(`Required attendees have many existing meetings`); |
| } |
| } |
|
|
| |
| if (reasons.length === 0) { |
| reasons.push(`Being optimized by solver`); |
| } |
|
|
| return reasons; |
| } |
|
|
|
|
| function renderScheduleByRoom(schedule) { |
| const unassigned = $("#unassigned"); |
| unassigned.children().remove(); |
| byRoomGroupData.clear(); |
| byRoomItemData.clear(); |
|
|
| |
| if (!schedule.rooms || !Array.isArray(schedule.rooms)) { |
| console.warn('schedule.rooms is not available or not an array:', schedule.rooms); |
| return; |
| } |
|
|
| $.each(schedule.rooms.sort((e1, e2) => e1.name.localeCompare(e2.name)), (_, room) => { |
| let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${room.name}</h5></div>`; |
| byRoomGroupData.add({ |
| id: room.id, |
| content: content, |
| }); |
| }); |
|
|
| const meetingMap = new Map(); |
| if (schedule.meetings && Array.isArray(schedule.meetings)) { |
| schedule.meetings.forEach(m => meetingMap.set(m.id, m)); |
| } |
| const timeGrainMap = new Map(); |
| if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) { |
| schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t)); |
| } |
| const roomMap = new Map(); |
| if (schedule.rooms && Array.isArray(schedule.rooms)) { |
| schedule.rooms.forEach(r => roomMap.set(r.id, r)); |
| } |
| |
| if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) { |
| console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments); |
| return; |
| } |
| |
| $.each(schedule.meetingAssignments, (_, assignment) => { |
| |
| const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting; |
| |
| const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room; |
| |
| const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain; |
| |
| |
| if (!meet) { |
| console.warn(`Meeting not found for assignment ${assignment.id}`); |
| return; |
| } |
| |
| if (room == null || timeGrain == null) { |
| const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60; |
| const requiredCount = meet.requiredAttendances?.length || 0; |
| const preferredCount = meet.preferredAttendances?.length || 0; |
| const totalAttendees = requiredCount + preferredCount; |
|
|
| |
| const reasons = analyzeUnassignedReason(meet, schedule); |
|
|
| const unassignedElement = $(`<div class="card-body"/>`) |
| .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)) |
| .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`)) |
| .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`)); |
|
|
| if (reasons.length > 0) { |
| const reasonsList = $(`<div class="mt-2 small"/>`); |
| reasonsList.append($(`<span class="text-muted">Possible issues:</span>`)); |
| reasons.forEach(reason => { |
| reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`)); |
| }); |
| unassignedElement.append(reasonsList); |
| } |
|
|
| unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement))); |
| } else { |
| const conflictStatus = getConflictStatus(assignment.id); |
| const byRoomElement = $("<div />") |
| .append($("<div class='d-flex justify-content-center align-items-center' />") |
| .append($(conflictStatus.icon)) |
| .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))); |
| const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year); |
| const startTime = JSJoda.LocalTime.of(0, 0, 0, 0) |
| .plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day)); |
| const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime); |
| const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15); |
| byRoomItemData.add({ |
| id: assignment.id, |
| group: typeof room === 'string' ? room : room.id, |
| content: byRoomElement.html(), |
| start: startDateTime.toString(), |
| end: endDateTime.toString(), |
| style: `min-height: 50px; ${conflictStatus.style}`, |
| title: conflictStatus.reason || undefined |
| }); |
| } |
| }); |
|
|
| byRoomTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(), |
| JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString()); |
| } |
|
|
|
|
| function renderScheduleByPerson(schedule) { |
| const unassigned = $("#unassigned"); |
| unassigned.children().remove(); |
| byPersonGroupData.clear(); |
| byPersonItemData.clear(); |
|
|
| |
| if (!schedule.people || !Array.isArray(schedule.people)) { |
| console.warn('schedule.people is not available or not an array:', schedule.people); |
| return; |
| } |
|
|
| |
| const personMeetingCount = calculatePersonWorkload(schedule); |
|
|
| $.each(schedule.people.sort((e1, e2) => e1.fullName.localeCompare(e2.fullName)), (_, person) => { |
| const meetingCount = personMeetingCount.get(person.id) || 0; |
| const workloadBadge = getWorkloadBadge(meetingCount); |
| let content = `<div class="d-flex flex-column"> |
| <div class="d-flex align-items-center"> |
| <h5 class="card-title mb-1">${person.fullName}</h5> |
| ${workloadBadge} |
| </div> |
| </div>`; |
| byPersonGroupData.add({ |
| id: person.id, |
| content: content, |
| }); |
| }); |
| const meetingMap = new Map(); |
| if (schedule.meetings && Array.isArray(schedule.meetings)) { |
| schedule.meetings.forEach(m => meetingMap.set(m.id, m)); |
| } |
| const timeGrainMap = new Map(); |
| if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) { |
| schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t)); |
| } |
| const roomMap = new Map(); |
| if (schedule.rooms && Array.isArray(schedule.rooms)) { |
| schedule.rooms.forEach(r => roomMap.set(r.id, r)); |
| } |
| |
| if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) { |
| console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments); |
| return; |
| } |
| |
| $.each(schedule.meetingAssignments, (_, assignment) => { |
| |
| const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting; |
| |
| const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room; |
| |
| const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain; |
| |
| |
| if (!meet) { |
| console.warn(`Meeting not found for assignment ${assignment.id}`); |
| return; |
| } |
| |
| if (room == null || timeGrain == null) { |
| const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60; |
| const requiredCount = meet.requiredAttendances?.length || 0; |
| const preferredCount = meet.preferredAttendances?.length || 0; |
| const totalAttendees = requiredCount + preferredCount; |
|
|
| |
| const reasons = analyzeUnassignedReason(meet, schedule); |
|
|
| const unassignedElement = $(`<div class="card-body"/>`) |
| .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)) |
| .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`)) |
| .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`)); |
|
|
| if (reasons.length > 0) { |
| const reasonsList = $(`<div class="mt-2 small"/>`); |
| reasonsList.append($(`<span class="text-muted">Possible issues:</span>`)); |
| reasons.forEach(reason => { |
| reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`)); |
| }); |
| unassignedElement.append(reasonsList); |
| } |
|
|
| unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement))); |
| } else { |
| const conflictStatus = getConflictStatus(assignment.id); |
| const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year); |
| const startTime = JSJoda.LocalTime.of(0, 0, 0, 0) |
| .plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day)); |
| const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime); |
| const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15); |
| meet.requiredAttendances.forEach(attendance => { |
| const byPersonElement = $("<div />") |
| .append($("<div class='d-flex justify-content-center align-items-center' />") |
| .append($(conflictStatus.icon)) |
| .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))); |
| byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-success m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Required"))); |
| if (meet.preferredAttendances.map(a => a.person).indexOf(attendance.person) >= 0) { |
| byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred"))); |
| } |
| byPersonItemData.add({ |
| id: `${assignment.id}-${attendance.person.id}`, |
| group: attendance.person.id, |
| content: byPersonElement.html(), |
| start: startDateTime.toString(), |
| end: endDateTime.toString(), |
| style: `min-height: 50px; ${conflictStatus.style}`, |
| title: conflictStatus.reason || undefined |
| }); |
| }); |
| meet.preferredAttendances.forEach(attendance => { |
| if (meet.requiredAttendances.map(a => a.person).indexOf(attendance.person) === -1) { |
| const byPersonElement = $("<div />") |
| .append($("<div class='d-flex justify-content-center align-items-center' />") |
| .append($(conflictStatus.icon)) |
| .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))); |
| byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred"))); |
| byPersonItemData.add({ |
| id: `${assignment.id}-${attendance.person.id}`, |
| group: attendance.person.id, |
| content: byPersonElement.html(), |
| start: startDateTime.toString(), |
| end: endDateTime.toString(), |
| style: `min-height: 50px; ${conflictStatus.style}`, |
| title: conflictStatus.reason || undefined |
| }); |
| } |
| }); |
| } |
| }); |
|
|
| byPersonTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(), |
| JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString()); |
| } |
|
|
|
|
| |
| byRoomTimeline.on('select', function (properties) { |
| if (properties.items.length > 0) { |
| showMeetingDetails(properties.items[0]); |
| } |
| }); |
|
|
| byPersonTimeline.on('select', function (properties) { |
| if (properties.items.length > 0) { |
| |
| const itemId = properties.items[0]; |
| const assignmentId = itemId.includes('-') ? itemId.split('-').slice(0, -1).join('-') : itemId; |
| showMeetingDetails(assignmentId); |
| } |
| }); |
|
|
|
|
| function showMeetingDetails(assignmentId) { |
| if (!loadedSchedule) return; |
|
|
| |
| const assignment = loadedSchedule.meetingAssignments.find(a => a.id === assignmentId); |
| if (!assignment) { |
| console.warn('Assignment not found:', assignmentId); |
| return; |
| } |
|
|
| |
| const meetingMap = new Map(); |
| loadedSchedule.meetings.forEach(m => meetingMap.set(m.id, m)); |
| const roomMap = new Map(); |
| loadedSchedule.rooms.forEach(r => roomMap.set(r.id, r)); |
| const personMap = new Map(); |
| loadedSchedule.people.forEach(p => personMap.set(p.id, p)); |
|
|
| |
| const meeting = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting; |
| const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room; |
| const timeGrain = typeof assignment.startingTimeGrain === 'string' |
| ? loadedSchedule.timeGrains.find(t => t.id === assignment.startingTimeGrain) |
| : assignment.startingTimeGrain; |
|
|
| if (!meeting) { |
| console.warn('Meeting not found for assignment:', assignmentId); |
| return; |
| } |
|
|
| |
| const conflictStatus = getConflictStatus(assignmentId); |
|
|
| |
| const content = $("#meetingDetailsModalContent"); |
| content.empty(); |
|
|
| |
| const statusBadge = conflictStatus.status === 'hard' |
| ? '<span class="badge bg-danger ms-2">Hard Conflict</span>' |
| : conflictStatus.status === 'medium' |
| ? '<span class="badge bg-warning ms-2">Medium Issue</span>' |
| : conflictStatus.status === 'soft' |
| ? '<span class="badge bg-info ms-2">Soft Issue</span>' |
| : '<span class="badge bg-success ms-2">OK</span>'; |
|
|
| content.append($('<h4/>').html(meeting.topic + statusBadge)); |
|
|
| |
| if (conflictStatus.reason) { |
| content.append($('<div class="alert alert-info py-2"/>').text(conflictStatus.reason)); |
| } |
|
|
| |
| const detailsTable = $('<table class="table table-sm"/>'); |
| const tbody = $('<tbody/>'); |
|
|
| |
| const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60; |
| tbody.append($('<tr/>') |
| .append($('<th scope="row" style="width: 150px"/>').text('Duration')) |
| .append($('<td/>').text(`${durationHours} hour(s) (${(meeting.durationInGrains ?? meeting.duration_in_grains)} time grains)`))); |
|
|
| |
| if (room) { |
| tbody.append($('<tr/>') |
| .append($('<th scope="row"/>').text('Room')) |
| .append($('<td/>').text(`${room.name} (capacity: ${room.capacity})`))); |
| } else { |
| tbody.append($('<tr/>') |
| .append($('<th scope="row"/>').text('Room')) |
| .append($('<td/>').html('<span class="text-danger">Not assigned</span>'))); |
| } |
|
|
| |
| if (timeGrain) { |
| const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year); |
| const startTime = JSJoda.LocalTime.of(0, 0, 0, 0).plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day)); |
| const endTime = startTime.plusMinutes((meeting.durationInGrains ?? meeting.duration_in_grains) * 15); |
| tbody.append($('<tr/>') |
| .append($('<th scope="row"/>').text('Time')) |
| .append($('<td/>').text(`${startDate.toString()} ${startTime.toString()} - ${endTime.toString()}`))); |
| } else { |
| tbody.append($('<tr/>') |
| .append($('<th scope="row"/>').text('Time')) |
| .append($('<td/>').html('<span class="text-danger">Not scheduled</span>'))); |
| } |
|
|
| detailsTable.append(tbody); |
| content.append(detailsTable); |
|
|
| |
| content.append($('<h5 class="mt-3"/>').text('Required Attendees')); |
| if (meeting.requiredAttendances && meeting.requiredAttendances.length > 0) { |
| const reqList = $('<ul class="list-group list-group-flush"/>'); |
| meeting.requiredAttendances.forEach(att => { |
| const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person; |
| reqList.append($('<li class="list-group-item py-1"/>') |
| .html(`<span class="fas fa-user me-2 text-success"></span>${person}`)); |
| }); |
| content.append(reqList); |
| } else { |
| content.append($('<p class="text-muted"/>').text('No required attendees')); |
| } |
|
|
| |
| content.append($('<h5 class="mt-3"/>').text('Preferred Attendees')); |
| if (meeting.preferredAttendances && meeting.preferredAttendances.length > 0) { |
| const prefList = $('<ul class="list-group list-group-flush"/>'); |
| meeting.preferredAttendances.forEach(att => { |
| const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person; |
| prefList.append($('<li class="list-group-item py-1"/>') |
| .html(`<span class="fas fa-user me-2 text-info"></span>${person}`)); |
| }); |
| content.append(prefList); |
| } else { |
| content.append($('<p class="text-muted"/>').text('No preferred attendees')); |
| } |
|
|
| |
| if (conflictStatus.status !== 'ok' && analyzeCache && analyzeCache.has(assignmentId)) { |
| content.append($('<h5 class="mt-3 text-danger"/>').text('Conflicts')); |
| const conflictList = $('<ul class="list-group list-group-flush"/>'); |
|
|
| const violations = analyzeCache.get(assignmentId); |
|
|
| |
| violations.hard.forEach(v => { |
| conflictList.append($('<li class="list-group-item py-1 text-danger"/>') |
| .html(`<span class="fas fa-exclamation-triangle me-2"></span>${v.constraint}`)); |
| }); |
|
|
| |
| violations.medium.forEach(v => { |
| conflictList.append($('<li class="list-group-item py-1 text-warning"/>') |
| .html(`<span class="fas fa-exclamation-circle me-2"></span>${v.constraint}`)); |
| }); |
|
|
| |
| violations.soft.forEach(v => { |
| conflictList.append($('<li class="list-group-item py-1 text-info"/>') |
| .html(`<span class="fas fa-info-circle me-2"></span>${v.constraint}`)); |
| }); |
|
|
| content.append(conflictList); |
| } |
|
|
| |
| $("#meetingDetailsModalLabel").text("Meeting Details: " + meeting.topic); |
|
|
| |
| new bootstrap.Modal("#meetingDetailsModal").show(); |
| } |
|
|
|
|
| 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(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => { |
| components[parts[2]] = parseInt(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 copyTextToClipboard(id) { |
| var text = $("#" + id).text().trim(); |
|
|
| var dummy = document.createElement("textarea"); |
| document.body.appendChild(dummy); |
| dummy.value = text; |
| dummy.select(); |
| document.execCommand("copy"); |
| document.body.removeChild(dummy); |
| } |
|
|
|
|
| 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>`)); |
| } |
| } |
|
|