CTU Extend

Extends the functionality of the CTU website

// ==UserScript==
// @name         CTU Extend
// @namespace    https://psushko.com
// @homepage     https://github.com/Pavel-Sushko/ctu-extend
// @website      https://psushko.com
// @source       https://github.com/Pavel-Sushko/ctu-extend
// @version      0.2.0
// @description  Extends the functionality of the CTU website
// @author       Pavel Sushko <[email protected]>
// @license      MIT
// @match        https://studentlogin.coloradotech.edu/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=coloradotech.edu
// @grant        GM_addElement
// ==/UserScript==

GM_addElement('script', {
	src: 'https://cdnjs.cloudflare.com/ajax/libs/mathjs/12.4.1/math.js',
	type: 'text/javascript',
});

const PAGES = {
	home: /^https?:\/\/studentlogin\.coloradotech\.edu\/\?.+#\/home\/active\/all$/i,
	gradebook: /^https?:\/\/studentlogin\.coloradotech\.edu\/\?.+#\/class\/\d+\/gradebook$/i,
	degreePlan: /^https?:\/\/studentlogin\.coloradotech\.edu\/\?.+#\/portal\/my-program\/degree-plan$/i,
};

// #region DOM Manipulation

/**
 * Get the first element that contains the lookup string
 *
 * @param {String} tag The tag to search for
 * @param {String} lookupString The string to search for
 * @returns {Element} The first element that contains the lookup string
 */
const getElement = (tag, lookupString) => {
	for (const element of document.querySelectorAll(tag))
		if (element.textContent.includes(lookupString)) return element;
};

// Function to create a tooltip
const createTooltip = (element, tooltipText) => {
	const tooltip = document.createElement('span');
	tooltip.className = 'tooltip-text';
	tooltip.innerText = tooltipText;

	element.style.position = 'relative';
	element.appendChild(tooltip);

	element.onmouseover = () => {
		tooltip.style.visibility = 'visible';
		tooltip.style.opacity = '1';
	};

	element.onmouseout = () => {
		tooltip.style.visibility = 'hidden';
		tooltip.style.opacity = '0';
	};
};

// Add CSS for tooltip
const addTooltipStyles = () => {
	const style = document.createElement('style');
	style.innerHTML = `
		.tooltip-text {
			visibility: hidden;
			width: 120px;
			background-color: black;
			color: #fff;
			text-align: center;
			border-radius: 6px;
			padding: 5px 0;
			position: absolute;
			z-index: 1;
			bottom: 110%; /* Position the tooltip above the text */
			left: 50%;
			margin-left: -60px;
			opacity: 0;
			transition: opacity 0.3s;
		}

		.tooltip-text::after {
			content: '';
			position: absolute;
			top: 100%; /* At the bottom of the tooltip */
			left: 50%;
			margin-left: -5px;
			border-width: 5px;
			border-style: solid;
			border-color: black transparent transparent transparent;
		}

		.tooltip:hover .tooltip-text {
			visibility: visible;
			opacity: 1;
		}
	`;
	document.head.appendChild(style);
};

const createId = (string) =>
	string
		.toLowerCase()
		.replace(/[^a-z0-9\s-]/g, '')
		.replace(/\s/g, '-')
		.replace(/-+/g, '-');

// #endregion

// #region Page Handlers

/**
 * Handles the home page
 */
const handleHome = async () => {
	addTooltipStyles();

	const SELECTORS = {
		earnedCredit: '.credit-item-title',
		requiredCredit: '.credits-required',
		donut: 'pec-donut-chart',
	};

	let creditsEarnedElement = document.querySelector(SELECTORS.earnedCredit);
	let creditsRequiredElement = document.querySelector(SELECTORS.requiredCredit);
	let donut = document.querySelector(SELECTORS.donut);

	while (!creditsEarnedElement || !creditsRequiredElement || !donut) {
		await new Promise((r) => setTimeout(r, 100));

		creditsEarnedElement = document.querySelector(SELECTORS.earnedCredit);
		creditsRequiredElement = document.querySelector(SELECTORS.requiredCredit);
		donut = document.querySelector(SELECTORS.donut);
	}

	const result = math.evaluate(creditsEarnedElement.innerText + creditsRequiredElement.innerText) * 100;
	const resultPercentage = `${result.toFixed(2)}%`;

	createTooltip(donut, resultPercentage);

	// TODO: Create honours tracking
};

/**
 * Handles the gradebook page
 */
const handleGradebook = async () => {
	const gradeThresholds = [
		{ grade: 'A', threshold: 94 },
		{ grade: 'A-', threshold: 90 },
		{ grade: 'B+', threshold: 86 },
		{ grade: 'B', threshold: 83 },
		{ grade: 'B-', threshold: 80 },
		{ grade: 'C+', threshold: 76 },
		{ grade: 'C', threshold: 73 },
		{ grade: 'C-', threshold: 70 },
		{ grade: 'D+', threshold: 65 },
		{ grade: 'D', threshold: 60 },
		{ grade: 'F', threshold: 0 },
	];

	let addedPoints = 0;

	// #region Grade Calculations

	/**
	 * Get the current absolute grade percentage
	 *
	 * @param {Number} earnedPoints
	 * @param {Number} maxPoints
	 * @returns {Number} The current absolute grade percentage
	 */
	const getPercentage = (earnedPoints, maxPoints) => (earnedPoints / maxPoints) * 100;

	/**
	 * Get the letter grade based on the percentage
	 *
	 * @param {Number} percentage
	 * @returns {String} The letter grade
	 */
	const getLetterGrade = (percentage) =>
		gradeThresholds.find(({ threshold }) => percentage >= threshold)?.grade || 'Grade not found';

	/**
	 * Get the points needed to get an A
	 *
	 * @param {Number} earnedPoints
	 * @param {Number} maxPoints
	 * @returns {Number} The points needed to get an A
	 */
	const getPointsToA = (earnedPoints, maxPoints) =>
		Math.max(0, maxPoints * (gradeThresholds[0].threshold / 100) - earnedPoints);

	// #endregion

	// #region Main

	/**
	 * Get the points from the lookup string
	 *
	 * @param {String} lookupString
	 * @param {Boolean} child
	 * @returns {Number} The points from the lookup string
	 */
	const getPoints = async (lookupString, child) => {
		let span = getElement('span', lookupString);

		while (!span) {
			await new Promise((r) => setTimeout(r, 100));
			span = getElement('span', lookupString);
		}

		let spanText = child ? span.querySelector('span').innerText : span.nextElementSibling.innerText;

		return Number(spanText.includes('N/A') ? '0' : spanText);
	};

	// Define earnedPoints and maxPoints first
	let earnedPoints = await getPoints('Points Earned to Date:', true);
	let maxPoints = await getPoints('Total Points Possible in Course:');

	/**
	 * Append the grade to the page
	 *
	 * @param {Number} earnedPoints
	 * @param {Number} maxPoints
	 */
	const appendGrade = () => {
		const gradeDiv = getElement('strong', 'Current Course Grade:').parentElement;
		const absoluteGradeDiv = gradeDiv.cloneNode(true);

		const percentage = getPercentage(earnedPoints, maxPoints);
		const grade = getLetterGrade(percentage);

		absoluteGradeDiv.querySelector('strong').innerText = 'Absolute Course Grade:';
		absoluteGradeDiv.querySelector('span').innerText = `${grade} (${getPointsToA(earnedPoints, maxPoints).toFixed(
			2
		)} points to A)`;

		const bottomDiv = absoluteGradeDiv.querySelector('div');
		bottomDiv.innerHTML = bottomDiv.innerHTML.replace(/\/\s[^<]+/, `/ ${maxPoints} `).replace(' to Date', '');

		gradeDiv.after(absoluteGradeDiv);
	};

	/**
	 * Update the grade display when points are added/removed
	 *
	 * @param {Number} addedPoints
	 * @param {Number} earnedPoints
	 * @param {Number} maxPoints
	 */
	const updateGrade = (addedPoints) => {
		const updatedPercentage = getPercentage(earnedPoints + addedPoints, maxPoints);
		const updatedGrade = getLetterGrade(updatedPercentage);

		const absoluteGradeDiv = getElement('strong', 'Absolute Course Grade:').parentElement;
		absoluteGradeDiv.querySelector('span').innerText = `${updatedGrade} (${getPointsToA(
			earnedPoints + addedPoints,
			maxPoints
		).toFixed(2)} points to A)`;
	};

	/**
	 * Add a checkbox to each row to allow adding/removing points to the grade
	 *
	 * @param {HTMLElement} row
	 */
	const addCheckbox = (row) => {
		const td = document.createElement('td');

		const checkbox = document.createElement('input');
		checkbox.type = 'checkbox';
		checkbox.id = createId(row.firstElementChild.innerText);
		checkbox.checked = false;

		checkbox.onclick = () => {
			let assignmentEarnedPoints = Number(row.children[3].innerText.replace(/[^0-9.]/g, ''));
			let possiblePoints = Number(row.children[4].innerText.replace(/[^0-9.]/g, ''));

			if (assignmentEarnedPoints !== 0) possiblePoints -= assignmentEarnedPoints;

			addedPoints += checkbox.checked ? possiblePoints : -possiblePoints;

			// Update the grade
			updateGrade(addedPoints);
		};

		td.appendChild(checkbox);
		row.prepend(td);
	};

	/**
	 * Add a new column to the grade table for checkboxes
	 *
	 * @param {HTMLElement} table
	 * @param {String} header
	 */
	const addColumn = (table, header) => {
		const th = document.createElement('th');
		th.innerText = header;

		table.querySelector('thead tr').prepend(th);

		for (const row of table.querySelectorAll('tbody tr')) {
			addCheckbox(row);
		}
	};

	appendGrade();
	addColumn(document.querySelector('table'), 'Add to Grade');
};

// #endregion

// #endregion

/**
 * Handles the degree plan page
 */
const handleDegreePlan = async () => {
	addTooltipStyles();

	const SELECTORS = {
		earnedCredit: '.credit-item-title',
		requiredCredit: '.credits-required',
		donut: 'div.hide-on-mobile pec-donut-chart',
	};

	let creditsEarnedElement = document.querySelector(SELECTORS.earnedCredit);
	let creditsRequiredElement = document.querySelector(SELECTORS.requiredCredit);
	let donut = document.querySelector(SELECTORS.donut);

	while (!creditsEarnedElement || !creditsRequiredElement) {
		await new Promise((r) => setTimeout(r, 100));

		creditsEarnedElement = document.querySelector(SELECTORS.earnedCredit);
		creditsRequiredElement = document.querySelector(SELECTORS.requiredCredit);
	}

	const result = math.evaluate(creditsEarnedElement.innerText + creditsRequiredElement.innerText) * 100;
	const resultPercentage = `${result.toFixed(2)}%`;

	let creditsSpan = getElement('span', 'Credits Earned');

	while (!creditsSpan) {
		await new Promise((r) => setTimeout(r, 100));

		creditsSpan = getElement('span', 'Credits Earned');
	}

	while (!donut) {
		await new Promise((r) => setTimeout(r, 100));

		donut = document.querySelector(SELECTORS.donut);
	}

	creditsSpan.innerHTML += ` (${resultPercentage})`;
	createTooltip(donut, resultPercentage);
};

/**
 * Handles the pages
 */
const handlePages = async () => {
	let prevPage = '';

	while (true) {
		if (prevPage !== window.location.href) {
			switch (true) {
				case PAGES.home.test(window.location.href):
					await handleHome();
					break;
				case PAGES.gradebook.test(window.location.href):
					await handleGradebook();
					break;
				case PAGES.degreePlan.test(window.location.href):
					await handleDegreePlan();
					break;
				default:
					break;
			}
		}

		prevPage = window.location.href;

		await new Promise((r) => setTimeout(r, 300));
	}
};

// #endregion

/**
 * Entry point
 */
(async function () {
	handlePages();
})();