// ==UserScript==
// @name Daily Grid Closing Times
// @namespace https://aaiscloud.com/
// @version 2.0.1
// @description Add closing time to daily grid
// @author Nischal Tonthanahal
// @match https://www.aaiscloud.com/*/calendars/dailygridcalendar.aspx
// @match https://www.aaiscloud.com/*/Calendars/DailyGridCalendar.aspx
// @icon https://www.aais.com/hubfs/favicon.png
// @grant none
// @license GPLv3
// ==/UserScript==
window.closing_times = {};
window.rooms_closing_at = {};
window.all_rooms_done = false;
window.room_count = 0;
window.viewSwitchObserver = new MutationObserver(removeExistingVerticalLinesIfAny);
window.pageSwitchObserver = new MutationObserver(reload);
window.pageScrollObserver = new MutationObserver(updateClosingTimes);
/**
* A set of room names that should be ignored and marked as "Not ITS".
*/
window.ignored_room_names = new Set(["GFS LBY", "SGM LBY", "SOS B40", "KAP 160", "KAP 267", "OHE 122", "PED 205", "PED 206"])
/**
* Updates the closing times for each room on the daily grid.
* IMPORTANT: This function is called every time the content on the page changes eg: Due to a scroll event
*
* This is necessary since the website always fetches the schedules dynamically from the server as you scroll down the page.
* The website also destroys every schedule that is not currently in view and has to re-fetch it again every time you scroll up or down.
*
* As a consequence, we cannot extract all the closing times at one go, and this function has to be called every time
* the calendar view changes and then extract only the currently visible room schedules.
* We trigger functions on the changed elements using the MutationObserver() API.
*
* We accumulate the changes into window.closing_times object and then keep track of whether or not we have completed
* extraction by using the boolean flag window.all_rooms_done, which is set when the length of window.closing_times equals
* the total number of rooms available on the page as shown by the webpage on the bottom right.
*
* Once we have extracted closing times of all the rooms we need, we display the download CSV button to export it.
*/
function updateClosingTimes() {
try {
// Get the time header elements and extract the time information
// Get all elements having the class sch-simple-timeheader i.e. the ones that say 06:00 AM, 07:00 AM ... 10:00 PM
let time_header_elements = document.querySelectorAll('.sch-simple-timeheader');
// Create an array of all these elements so that we can use the map() function to process them
let time_headers = Array.from(time_header_elements);
// Process each time header element using the map() function
let times = time_headers.map(header => {
// Get the bounding rectangle of the time header element eg: 10:00 AM
let bound = header.getBoundingClientRect();
// Find out the end coordinate eg: in the block 10:00 AM this would represent the horizontal line of 11:00 AM
let end_coordinate = bound.right;
// Find out the middle coordinate eg: in the block 10:00 AM this would represent the horizontal line of 10:30 AM
let mid_coordinate = bound.left + (bound.right - bound.left) / 2;
// Get the text of the element eg: "10:00 AM"
let time_string = header.innerText;
// Parse the text using a RegEx to find the Hour, Minute, and whether it is AM or PM
let time_parsed = time_string.match(/(\d+):(\d+)\s+(AM|PM)/);
let hour = time_parsed[1]; // eg: "10"
let minute = time_parsed[2]; // eg: "00"
let am_pm = time_parsed[3]; // eg: "AM"
let time = { hour, minute, am_pm } // eg: {hour:"10", minute:"00", am_pm:"AM"}
// Store the structured `time` from above in `times` array
// along with the middle and end coordinates that
// represent the half-hour and end-of-hour respectively
return { time, mid_coordinate, end_coordinate }
})
// If the day view is selected, draw vertical lines at the closing times
// This is purely visual to help distinguish the event boundaries and has no other purpose
if (document.querySelector("#button-1032").classList.contains('x-btn-pressed')) {
drawVerticalLines(times);
} else {
removeExistingVerticalLinesIfAny();
}
// Get the room names and update the closing time for each room
let room_name_elements = document.querySelectorAll('[data-columnid="RoomDayGridId_BuildingRoomNumberRoomName"]')
let room_names = Array.from(room_name_elements);
room_names.forEach(room => {
let index = room.parentElement.parentElement.parentElement.getAttribute('data-recordindex')
if (room.querySelector(`[data-recordindex="${index}"].closing-tag`) != null) {
return false;
}
let room_name = room.innerText;
room_name = room_name.replace(/^\s+/, '');
room_name = room_name.replace(/\s+$/, '');
let room_schedule_row = document.querySelector(`table[data-boundview="RoomDayGridId-timelineview"][data-recordindex="${index}"]`)
let all_events = room_schedule_row.querySelectorAll('.sch-event');
let last_event;
if (all_events.length) {
last_event = all_events[all_events.length - 1];
} else {
last_event = null;
}
let closing_time_element = document.createElement("span");
if (window.ignored_room_names.has(room_name)) {
closing_time_element.innerHTML = `<div data-recordindex="${index}" class="closing-tag" style="display:inline-block; color: lightgray; background-color:white; font-weight:bold; margin-right: 2px; font-family: 'sans serif'; width:60px; text-align:center">Not ITS</span></div>`;
room.querySelector('div').insertBefore(closing_time_element, room.querySelector('span'));
if (window.closing_times[room_name] != null) {
return;
}
window.closing_times[room_name] = 'Ignored';
return;
}
if (last_event == null) {
closing_time_element.innerHTML = `<div data-recordindex="${index}" class="closing-tag" style="display:inline-block; color: white; background-color:red; font-weight:bold; margin-right: 2px; font-family: 'sans serif'; width:60px; text-align:center">No Class</span></div>`;
room.querySelector('div').insertBefore(closing_time_element, room.querySelector('span'));
if (window.closing_times[room_name] != null) {
return;
}
window.closing_times[room_name] = 'No Class';
return;
}
let bound = last_event.getBoundingClientRect();
let end_coordinate = bound.right;
let end_time;
for (let i = 0; i < times.length; i++) {
const cur_hour = times[i];
const next_hour = (i < times.length - 1) ? times[i + 1] : { time: { hour: '11', minute: '00', am_pm: 'PM' } }
if (cur_hour.end_coordinate < end_coordinate) {
continue;
}
if (cur_hour.mid_coordinate >= end_coordinate) {
end_time = { ...cur_hour.time };
end_time.minute = '30';
break;
}
end_time = next_hour.time;
break;
}
let end_time_string = `${end_time.hour}:${end_time.minute} ${end_time.am_pm}`
closing_time_element.innerHTML = `<div data-recordindex="${index}" class="closing-tag" style="display:inline-block; color: black; background-color:white; font-weight:bold; margin-right: 2px; font-family: 'sans serif'; width:60px; text-align:center">${end_time_string}</span></div>`
room.querySelector('div').insertBefore(closing_time_element, room.querySelector('span'));
if (window.closing_times[room_name] != null) {
return;
}
window.closing_times[room_name] = end_time_string;
if (window.rooms_closing_at[end_time_string] == null) {
window.rooms_closing_at[end_time_string] = [];
}
window.rooms_closing_at[end_time_string].push(room_name);
});
} catch (e) {
return;
} finally {
try {
let totalRooms = document.querySelector("#tbtext-1062");
window.room_count = parseInt(totalRooms.innerText.match(/Displaying \d+ - \d+ of (\d+)/)[1]);
} catch (e) { }
if (window.room_count > 0 && !window.all_rooms_done && Object.keys(window.closing_times).length == window.room_count) {
window.all_rooms_done = true;
printClosingTimes();
createDownloadButton();
}
}
}
/**
* Prints the closing times for all rooms to the console and creates a table.
*/
function printClosingTimes() {
console.clear();
let closing_times_array = Object.entries(window.rooms_closing_at);
closing_times_array.sort((a, b) => compareTimeStrings(a[0], b[0]));
let output = [];
for (let pair of closing_times_array) {
let time = pair[0];
let rooms = pair[1];
output.push(`${time}: ${rooms.join(', ')}`);
}
console.log(output.join('\n'));
console.table(window.closing_times);
}
/**
* Compares two time strings in the format "HH:MM AM/PM" and returns a negative value if the first time is earlier, a positive value if the first time is later, and 0 if the times are the same.
* @param {string} time1 - The first time string.
* @param {string} time2 - The second time string.
* @returns {number} - The result of the comparison.
*/
function compareTimeStrings(time1, time2) {
let [hour1, minute1, am_pm1] = time1.split(/[:\s]/);
let [hour2, minute2, am_pm2] = time2.split(/[:\s]/);
hour1 = Number(hour1);
hour2 = Number(hour2);
minute1 = Number(minute1);
minute2 = Number(minute2);
if (am_pm1 != am_pm2) {
return (am_pm1 == 'AM') ? -1 : 1;
}
if (hour1 != hour2) {
return hour1 - hour2;
}
return minute1 - minute2;
}
/**
* Converts an object to a CSV string.
* @param {object} obj - The object to be converted to CSV.
* @returns {string} - The CSV string.
*/
function convertToCSV(obj) {
var pairs = Object.entries(obj);
var csv = '';
for (var i = 0; i < pairs.length; i++) {
csv += pairs[i].join(',') + (i < pairs.length - 1 ? '\n' : '');
}
return csv;
}
/**
* Creates a download button for the closing times CSV file.
*/
function createDownloadButton() {
var csv = convertToCSV(window.closing_times);
var blob = new Blob([csv], { type: 'text/csv' });
var url = URL.createObjectURL(blob);
var button = document.createElement('a');
button.style = "position: absolute; left: 50%;margin: 8px; padding: 5px; color: black; border-radius: 3px; background: gold; display:inline-block; font-weight:bold; font-family: 'sans serif'; text-align:center";
button.textContent = 'Download CSV';
button.href = url;
button.download = 'closing_times.csv';
button.id = "download_csv";
document.body.append(button);
}
/**
* Removes the download button from the page.
*/
function removeDownloadButton() {
try {
document.querySelector("#download_csv").remove()
} catch { }
}
/**
* Reloads the page and resets the necessary variables.
*/
function reload() {
window.closing_times = {};
window.rooms_closing_at = {};
window.all_rooms_done = false;
window.room_count = 0;
removeDownloadButton();
window.pageScrollObserver.disconnect();
console.log("Reload");
setTimeout(() => {
console.log("Timer done");
window.pageScrollObserver.observe(document.querySelector('.x-grid-scroll-body.x-scroller'), { childList: true, subtree: true });
updateClosingTimes();
}, 4000);
}
/**
* Removes any existing vertical lines on the page.
*/
function removeExistingVerticalLinesIfAny() {
const existingLines = document.querySelectorAll('.vertical-line');
existingLines.forEach(line => line.remove());
}
/**
* Draws vertical lines on the page to indicate the closing times for each room.
* @param {object[]} times - An array of objects containing the time information.
*/
function drawVerticalLines(times) {
removeExistingVerticalLinesIfAny();
const referenceElement = document.querySelector('#RoomDayGridId-normal');
if (!referenceElement) {
console.error('Element with ID "RoomDayGridId-normal" not found');
return;
}
const referenceRect = referenceElement.getBoundingClientRect();
const colors = [
'lightpink',
'lightyellow',
'lightcoral',
'lightsalmon',
'lightseagreen',
'lightgoldenrodyellow',
];
times.forEach(({ end_coordinate }, index) => {
const line = document.createElement('div');
line.classList.add('vertical-line');
line.style.position = 'absolute';
line.style.left = `${end_coordinate}px`;
line.style.top = `${referenceRect.top}px`;
line.style.height = `${referenceRect.height}px`;
line.style.width = '1px';
line.style.backgroundColor = colors[index % colors.length];
line.style.zIndex = '1';
document.body.appendChild(line);
});
}
// Initialize the necessary variables and set up the event observers
window.addEventListener("load", () => {
setTimeout(() => {
reload()
console.log("Timer done");
window.viewSwitchObserver.observe(document.querySelector("#button-1032"), { childList: true, subtree: true });
window.pageSwitchObserver.observe(document.querySelector("#tbtext-1062"), { childList: true, subtree: true });
window.pageScrollObserver.observe(document.querySelector('.x-grid-scroll-body.x-scroller'), { childList: true, subtree: true });
updateClosingTimes();
}, 4000);
});