// ==UserScript==
// @name Nitro Type - Top Team Requirements Table
// @namespace https://github.com/rickstaa/nitro-type-top-team-requirements-table
// @version 1.1.1
// @description Display top team races and points requirements for weekly and season leaderboards on Nitro Type team pages.
// @author Rick Staa
// @match *://*.nitrotype.com/team/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @license MIT
// ==/UserScript==
/**
* Fetches team stats from the NitroType API or from local storage.
* @param {number} teamPageTag - The tag of the team to fetch stats for.
* @returns {Promise<Object>} - A promise that resolves to an object containing the
* team stats. Uses cached stats if they are less than 20 minutes old.
*/
const fetchTeamStats = async (teamPageTag) => {
try {
const teamStats = JSON.parse(localStorage.getItem("teamStats"));
if (teamStats && Date.now() - teamStats.timestamp < 20 * 60 * 1000) {
// Use the cached team stats if they are less than 20 minutes old.
return teamStats.data;
} else {
// Retrieve the team stats from the NitroType API.
const response = await fetch(
`https://www.nitrotype.com/api/v2/teams/${teamPageTag}`
);
// Throw an error if the response is not successful.
if (!response.ok) {
throw new Error(`Failed to fetch team stats: ${response.status}`);
}
// Parse the response body as JSON and store it in local storage.
const data = await response.json();
localStorage.setItem(
"teamStats",
JSON.stringify({ data, timestamp: Date.now() })
);
// Return the team stats.
return data;
}
} catch (error) {
console.error(`Error fetching team stats: ${error}`);
throw error;
}
};
/**
* Fetches the season leaderboard from the NitroType API.
* @returns {Promise<Array>} - A promise that resolves to an array of leaderboard
* scores. Uses cached scores if they are less than 20 minutes old.
*/
const getSeasonLeaderBoardInfoStats = async () => {
try {
const leaderboardInfo = JSON.parse(
localStorage.getItem("seasonLeaderboardInfo")
);
if (
leaderboardInfo &&
Date.now() - leaderboardInfo.timestamp < 20 * 60 * 1000
) {
// Use the cached leaderboard info if it is less than 20 minutes old.
return leaderboardInfo.seasonLeaderboardInfo;
} else {
// Retrieve the season leaderboard from the NitroType API.
const response = await fetch(
"https://www.nitrotype.com/api/v2/leaderboards?time=season"
);
// Throw an error if the response is not successful.
if (!response.ok) {
throw new Error(
`Failed to fetch season leaderboard: ${response.status}`
);
}
// Parse the response body as JSON and store it in local storage.
const { results } = await response.json();
localStorage.setItem(
"seasonLeaderboardInfo",
JSON.stringify({
seasonLeaderboardInfo: results.scores,
timestamp: Date.now(),
})
);
// Return the season leaderboard.
return results.scores;
}
} catch (error) {
console.error(`Error fetching season leaderboard: ${error}`);
throw error;
}
};
/**
* Fetches the weekly leaderboard from the NitroType API.
* @returns {Promise<Array>} - A promise that resolves to an array of leaderboard
* scores. Uses cached scores if they are less than 20 minutes old.
*/
const getWeeklyLeaderBoardInfoStats = async () => {
try {
const leaderboardInfo = JSON.parse(
localStorage.getItem("weeklyLeaderboardInfo")
);
if (
leaderboardInfo &&
Date.now() - leaderboardInfo.timestamp < 20 * 60 * 1000
) {
// Use the cached leaderboard info if it is less than 20 minutes old.
return leaderboardInfo.weeklyLeaderboardInfo;
} else {
// Retrieve the season leaderboard from the NitroType API.
const response = await fetch(
"https://www.nitrotype.com/api/v2/leaderboards?time=weekly"
);
// Throw an error if the response is not successful.
if (!response.ok) {
throw new Error(
`Failed to fetch weekly leaderboard: ${response.status}`
);
}
// Parse the response body as JSON and store it in local storage.
const { results } = await response.json();
localStorage.setItem(
"weeklyLeaderboardInfo",
JSON.stringify({
weeklyLeaderboardInfo: results.scores,
timestamp: Date.now(),
})
);
// Return the season leaderboard.
return results.scores;
}
} catch (error) {
console.error(`Error fetching season leaderboard: ${error}`);
throw error;
}
};
/**
* Wait for an element to be available in the DOM.
* @param {string} selector - The selector to wait for.
* @returns {Promise<Element>} - A promise that resolves to the element.
*/
const waitForElm = (selector) => {
return new Promise((resolve) => {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
};
/**
* Userscript entry point.
*/
(async function () {
"use strict";
// Get the team ID from the URL.
const teamPageTag = window.location.href.split("/")[4];
// Return if user is not on its own team page.
const userTeamTAG = JSON.parse(
JSON.parse(localStorage.getItem("persist:nt")).user
).tag;
if (userTeamTAG !== teamPageTag) {
return;
}
// Get the team stats from the NitroType API.
let memberCount;
let seasonRaces;
try {
const { results } = await fetchTeamStats(teamPageTag);
memberCount = results.info.members;
seasonRaces = results.stats.find((stat) => stat.board === "season")?.played;
} catch (error) {
console.error(`Error retrieving team stats: ${error}`);
}
// Retrieve current season information.
const seasonInfo = NTGLOBALS.ACTIVE_SEASONS.find((s) => {
const now = Date.now();
return now >= s.startStamp * 1e3 && now <= s.endStamp * 1e3;
});
const DAYS_LEFT_IN_SEASON =
Math.abs(seasonInfo.endStamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24);
// Retrieve the weekly and season leaderboards from the NitroType API.
let weeklyLeaderBoardInfo, seasonLeaderBoardInfo;
try {
weeklyLeaderBoardInfo = await getWeeklyLeaderBoardInfoStats();
weeklyLeaderBoardInfo = weeklyLeaderBoardInfo.map(({ played, points }) => ({
played,
points,
}));
seasonLeaderBoardInfo = await getSeasonLeaderBoardInfoStats();
seasonLeaderBoardInfo = seasonLeaderBoardInfo.map(({ played, points }) => ({
played,
points,
}));
} catch (error) {
console.error(`Error retrieving leaderboard info: ${error}`);
}
// Define the table data for the weekly and season views.
const weeklyTopInfo = {
top1: weeklyLeaderBoardInfo[0],
top3: weeklyLeaderBoardInfo[2],
top10: weeklyLeaderBoardInfo[9],
top50: weeklyLeaderBoardInfo[49],
top100: weeklyLeaderBoardInfo[99],
};
const seasonTopInfo = {
top1: seasonLeaderBoardInfo[0],
top3: seasonLeaderBoardInfo[2],
top10: seasonLeaderBoardInfo[9],
top50: seasonLeaderBoardInfo[49],
top100: seasonLeaderBoardInfo[99],
};
// Calculate required daily member races for the weekly leaderboard.
for (const [key, value] of Object.entries(weeklyTopInfo)) {
weeklyTopInfo[key].dailyMemberRaces = Math.ceil(
value.played / 7 / memberCount
);
}
// Calculate required daily member races for the season leaderboard.
for (const [key, value] of Object.entries(seasonTopInfo)) {
seasonTopInfo[key].dailyMemberRaces = Math.ceil(
(value.played - seasonRaces) / DAYS_LEFT_IN_SEASON / memberCount
);
}
/**
* Creates a `tbody` element containing the table rows for the top team requirements table.
* @param {Object} topInfo - The top team requirements data.
* @returns {HTMLTableSectionElement} The `tbody` element containing the table rows.
*/
const createTopTeamRequirementsTableBody = (topInfo) => {
// Create a `tbody` element to hold the table rows.
const topInfoTableBody = document.createElement("tbody");
topInfoTableBody.className =
"table-body table-body--leaderboard--requirements";
// Add all the top team requirements to the table body.
for (const [key, value] of Object.entries(topInfo)) {
// Create a table row (`tr`) element for each entry in the `topInfo` object.
const topInfoTableBodyRow = document.createElement("tr");
topInfoTableBodyRow.className = "table-row";
// Cad the top position keys to the table row.
const topColumn = document.createElement("td");
topColumn.className = "table-cell tac table-cell--place";
topColumn.setAttribute("colspan", "2");
const topTextDiv = document.createElement("div");
topTextDiv.className = "mhc";
const topTextSpan = document.createElement("span");
topTextSpan.className = "h4 tc-ts";
topTextSpan.innerText = key.replace("top", "");
topTextDiv.appendChild(topTextSpan);
topColumn.appendChild(topTextDiv);
topInfoTableBodyRow.appendChild(topColumn);
// Add the number of races played to the table row.
const racesColumn = document.createElement("td");
racesColumn.className = "table-cell table-cell-races";
racesColumn.setAttribute("colspan", "3");
racesColumn.innerText = value.played.toLocaleString();
topInfoTableBodyRow.appendChild(racesColumn);
// Add number of points earned to the table row.
const pointsColumn = document.createElement("td");
pointsColumn.className = "table-cell table-cell--points";
pointsColumn.setAttribute("colspan", "3");
pointsColumn.innerText = value.points.toLocaleString();
topInfoTableBodyRow.appendChild(pointsColumn);
// Add required daily member races to the table row.
const dailyMemberRacesColumn = document.createElement("td");
dailyMemberRacesColumn.className = "table-cell table-cell--points";
dailyMemberRacesColumn.setAttribute("colspan", "3");
dailyMemberRacesColumn.innerText =
value.dailyMemberRaces.toLocaleString();
topInfoTableBodyRow.appendChild(dailyMemberRacesColumn);
// Add the table row to the `tbody` element
topInfoTableBody.appendChild(topInfoTableBodyRow);
}
// Return the `tbody` element
return topInfoTableBody;
};
/**
* Creates a tooltip for the top team requirements table.
* @returns {HTMLDivElement} The `div` element containing the tooltip.
*/
const createTableTooltip = () => {
const tableTooltipDiv = document.createElement("div");
tableTooltipDiv.className = "split well well--b well--s";
const tableToolTipSplit = document.createElement("div");
tableToolTipSplit.className = "split-cell";
const tableToolTipUl = document.createElement("ul");
tableToolTipUl.className = "list list--inline";
const tableToolTipLiWeekly = document.createElement("li");
tableToolTipLiWeekly.className = "list-item";
// Create weekly button.
const tableToolTipButtonWeekly = document.createElement("button");
tableToolTipButtonWeekly.className = "link link--s link--i";
tableToolTipButtonWeekly.textContent = "Weekly";
tableToolTipLiWeekly.appendChild(tableToolTipButtonWeekly);
tableToolTipUl.appendChild(tableToolTipLiWeekly);
tableToolTipSplit.appendChild(tableToolTipUl);
// Add separator.
const tableToolTipSeparator = document.createElement("li");
tableToolTipSeparator.className = "list-item bor";
tableToolTipSeparator.textContent = "\u00A0";
tableToolTipUl.appendChild(tableToolTipSeparator);
tableToolTipUl.className = "list list--inline";
// Create season button.
const tableToolTipLiSeason = document.createElement("li");
tableToolTipLiSeason.className = "list-item";
const tableToolTipButtonSeason = document.createElement("button");
tableToolTipButtonSeason.className = "link link--s link--h";
tableToolTipButtonSeason.textContent = "Season";
tableToolTipLiSeason.appendChild(tableToolTipButtonSeason);
tableToolTipUl.appendChild(tableToolTipLiSeason);
// Add the buttons to the tooltip.
tableToolTipSplit.appendChild(tableToolTipUl);
tableTooltipDiv.appendChild(tableToolTipSplit);
// Attach event listeners to the season button.
tableToolTipButtonSeason.addEventListener("click", (event) => {
// Change button style to active.
event.target.classList.toggle("link--h");
event.target.classList.toggle("link--i");
tableToolTipButtonWeekly.classList.toggle("link--h");
tableToolTipButtonWeekly.classList.toggle("link--i");
// Retrieve the table body.
const tableBody = document.querySelector(
".table-body--leaderboard--requirements"
);
// Update the table body if it exists.
if (tableBody) {
tableBody.parentNode.replaceChild(
createTopTeamRequirementsTableBody(seasonTopInfo),
tableBody
);
}
// Retrieve table footer span.
const tableFooterSpan = document.querySelector(
".table-footer--leaderboard--requirements--span"
);
// Update the table footer span if it exists.
if (tableFooterSpan) {
tableFooterSpan.innerText = `* Estimation was calculated for ${memberCount} members and ${seasonRaces} season races.`;
}
});
// Attach event listeners to the season button.
tableToolTipButtonWeekly.addEventListener("click", (event) => {
// Change button style to active.
event.target.classList.toggle("link--h");
event.target.classList.toggle("link--i");
tableToolTipButtonSeason.classList.toggle("link--h");
tableToolTipButtonSeason.classList.toggle("link--i");
// Retrieve the table body.
const tableBody = document.querySelector(
".table-body--leaderboard--requirements"
);
// Update the table body if it exists.
if (tableBody) {
tableBody.parentNode.replaceChild(
createTopTeamRequirementsTableBody(weeklyTopInfo),
tableBody
);
}
// Retrieve table footer span.
const tableFooterSpan = document.querySelector(
".table-footer--leaderboard--requirements--span"
);
// Update the table footer span if it exists.
if (tableFooterSpan) {
tableFooterSpan.innerText = `* Estimation was calculated for ${memberCount} members.`;
}
});
return tableTooltipDiv;
};
/**
* Create a table to show the top team requirements for the given topInfo.
* @param {object} topInfo The top team requirements data to display.
* @returns {HTMLTableElement} The `table` element containing the top team requirements.
*/
const createTopTeamRequirementsTable = (topInfo) => {
const table = document.createElement("table");
table.className = "table table--l table--striped table--leaderboard";
// Create the table header element and its row.
const header = document.createElement("thead");
header.className = "table-head";
const headerRow = document.createElement("tr");
headerRow.className = "table-row";
// Create the top position header.
const topPositionHeader = document.createElement("th");
topPositionHeader.className = "table-cell table-cell--top";
topPositionHeader.innerText = "Top";
topPositionHeader.setAttribute("colspan", "2");
topPositionHeader.style.textAlign = "center";
// Create the races header.
const racesHeader = document.createElement("th");
racesHeader.className = "table-cell table-cell--races";
racesHeader.innerText = "Races";
racesHeader.setAttribute("colspan", "3");
// Create the points header .
const pointsHeader = document.createElement("th");
pointsHeader.className = "table-cell table-cell--points";
pointsHeader.innerText = "Points";
pointsHeader.setAttribute("colspan", "3");
// Create the daily member races header.
const dailyMemberRacesHeader = document.createElement("th");
dailyMemberRacesHeader.className = "table-cell table-cell--points";
dailyMemberRacesHeader.innerHTML = "Daily Member Races";
dailyMemberRacesHeader.setAttribute("colspan", "3");
// Add table elements to the table.
headerRow.appendChild(topPositionHeader);
headerRow.appendChild(racesHeader);
headerRow.appendChild(pointsHeader);
headerRow.appendChild(dailyMemberRacesHeader);
header.appendChild(headerRow);
table.appendChild(header);
// Create the table body element and add it to the table element.
const body = createTopTeamRequirementsTableBody(topInfo);
table.appendChild(body);
// Create the table footer element.
const footer = document.createElement("tfoot");
footer.className = "table-foot";
const footerRow = document.createElement("tr");
footerRow.className = "table-row";
const footerCell = document.createElement("td");
footerCell.className = "table-cell tar prm";
footerCell.setAttribute("colspan", "11");
const footerCellSpan = document.createElement("span");
footerCellSpan.className =
"tsxs tc-ts tsi table-footer--leaderboard--requirements--span";
footerCellSpan.innerText = `* Estimation was calculated for ${memberCount} members.`;
// Add the table footer to the table.
footerCell.appendChild(footerCellSpan);
footerRow.appendChild(footerCell);
footer.appendChild(footerRow);
table.appendChild(footer);
// Return the table element.
return table;
};
/**
* Create the top team requirements widget.
* @param {object} topInfo the top team requirements data.
* @returns {HTMLDivElement} The `div` element containing the top team requirements widget.
*/
const createTopTeamRequirementsWidget = (topInfo) => {
const topTeamRequirementsTable = createTopTeamRequirementsTable(topInfo);
const topTeamRequirementsWidget = document.createElement("div");
topTeamRequirementsWidget.className = "row row--o well well--b well--l";
const topInfoTitle = document.createElement("h3");
topInfoTitle.className = "mbs";
topInfoTitle.innerText = "Top Team Requirements";
topTeamRequirementsWidget.appendChild(topInfoTitle);
const topInfoTableTooltip = createTableTooltip();
topTeamRequirementsWidget.appendChild(topInfoTableTooltip);
topTeamRequirementsWidget.appendChild(topTeamRequirementsTable);
return topTeamRequirementsWidget;
};
// Get the container div for the tables and the leaderboard table div.
const tablesContainerDiv = await waitForElm(".well--p.well--l_p");
const leaderboardTableDiv = document.querySelector(
".table--leaderboard"
)?.parentElement;
// Insert the top team requirements table if the leaderboard container and table exist.
if (tablesContainerDiv && leaderboardTableDiv) {
tablesContainerDiv.insertBefore(
createTopTeamRequirementsWidget(weeklyTopInfo),
leaderboardTableDiv.nextSibling
);
} else {
console.error("Could not find the container div or leaderboard table div.");
}
})();