GT7 TT Statistics

Displays a popup with additional statistics for GT7 time trials

// ==UserScript==
// @name         GT7 TT Statistics
// @namespace    http://www.romeyke.de
// @version      1.4
// @description  Displays a popup with additional statistics for GT7 time trials
// @match        https://www.gran-turismo.com/*/gt7/sportmode/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  // Create the Stats link element
  const statsLink = document.createElement("a");
  statsLink.innerHTML = "Stats";
  statsLink.style.color = "red";
  statsLink.style.cursor = "pointer";
  statsLink.className = "mainNavLink";
  statsLink.addEventListener("click", function (e) {
    e.preventDefault();
    // Extract event number from the URL
    const urlSegments = window.location.pathname.split("/");
    let eventNumber = null;
    for (let i = 0; i < urlSegments.length; i++) {
      if (urlSegments[i] === "event" && i < urlSegments.length - 1) {
        eventNumber = urlSegments[i + 1];
        break;
      }
    }
    if (eventNumber) {
      showStatsPopup(eventNumber);
    }
  });

  // Find the parent div with class "mainNavRoot" and append the Stats link
  const mainNavRootDiv = document.querySelector(".mainNavRoot");
  if (mainNavRootDiv) {
    mainNavRootDiv.appendChild(statsLink);
  }

  // Function to show the stats popup
  async function showStatsPopup(eventNumber) {
    // Create the popup element
    const popup = document.createElement("div");
    popup.style.position = "fixed";
    popup.style.top = "50%";
    popup.style.left = "50%";
    popup.style.transform = "translate(-50%, -50%)";
    popup.style.backgroundColor = "black";
    popup.style.color = "white";
    popup.style.padding = "20px";
    popup.style.border = "1px solid black";
    popup.style.zIndex = "9999";
    popup.style.width = "800px";
    popup.style.height = "600px";
    popup.style.overflow = "auto";

    // Create the close button
    const closeButton = document.createElement("button");
    closeButton.innerHTML = "Close";
    closeButton.style.position = "absolute";
    closeButton.style.top = "10px";
    closeButton.style.right = "10px";
    closeButton.style.padding = "5px";
    closeButton.style.cursor = "pointer";
    closeButton.addEventListener("click", function () {
      document.body.removeChild(popup);
    });
    // Append the popup to the document body
    document.body.appendChild(popup);
    popup.appendChild(closeButton);

    // Create the headline
    const headline = document.createElement("h2");
    headline.innerHTML = "Please wait, loading data ...";
    popup.appendChild(headline);

    const { gold, silver, bronze, worldRecord, totalPlayers } =
      await retrieveData(eventNumber);

    headline.innerHTML = `World Record: ${formatTime(
      worldRecord
    )} (of ${totalPlayers.toLocaleString()} players)`;

    // Create the table
    const table = document.createElement("table");
    table.style.width = "100%";
    table.style.borderCollapse = "collapse";

    const tableHead = document.createElement("thead");
    tableHead.style.backgroundColor = "grey";
    const tableBody = document.createElement("tbody");

    // Create the table headers
    const headers = ["Reward", "Time", "Players", "%"];
    const headerRow = document.createElement("tr");
    for (let header of headers) {
      const headerCell = document.createElement("th");
      headerCell.innerHTML = header;
      headerCell.style.textAlign = "left";
      headerCell.style.border = "1px solid white";
      headerCell.style.padding = "10px";
      headerRow.appendChild(headerCell);
    }
    tableHead.appendChild(headerRow);
    tableBody.appendChild(
      addRow("Gold", gold.time, gold.ranking, gold.percent, "#FFD700")
    );
    tableBody.appendChild(
      addRow("Silver", silver.time, silver.ranking, silver.percent, "#C0C0C0")
    );
    tableBody.appendChild(
      addRow("Bronze", bronze.time, bronze.ranking, bronze.percent, "#CD7F32")
    );

    // Append the close button, headline, and table to the popup
    table.appendChild(tableHead);
    table.appendChild(tableBody);
    popup.appendChild(table);
  }
})();

function addRow(reward, time, players, percentage, color) {
  const row = document.createElement("tr");
  row.appendChild(addCell(reward, color));
  row.appendChild(addCell(time, color));
  row.appendChild(addCell(players.toLocaleString(), color));
  row.appendChild(addCell(percentage, color));
  return row;
}

function addCell(cellData, color) {
  const cell = document.createElement("td");
  cell.innerHTML = cellData;
  cell.style.border = "1px solid white";
  cell.style.backgroundColor = color;
  cell.style.padding = "10px";
  return cell;
}

async function retrieveData(eventNumber) {
  const params = await fetch(
    `https://static.gt7.game.gran-turismo.com/event/params/${eventNumber}.json`
  ).then((response) => response.json());
  const board_id = params.result.online.ranking_id;
  const pages = [];
  const firstPage = await getPage(board_id, 0, pages);
  const lastPageNumber = firstPage.result.total - 1;
  const lastPage = await getPage(board_id, lastPageNumber, pages);
  const highScore = firstPage.result.list[0].score;
  const totalPlayers =
    lastPage.result.list[lastPage.result.list.length - 1].display_rank;
  const bronzeMultiplier = eventNumber == '10131' ? 1.2 : 1.1;
  const bronzeScore = Math.floor(highScore * bronzeMultiplier);
  const bronzeRanking = await getRanking(
    board_id,
    bronzeScore,
    lastPageNumber,
    pages
  );
  const silverScore = Math.floor(highScore * 1.05);
  const silverRanking = await getRanking(
    board_id,
    silverScore,
    lastPageNumber,
    pages
  );
  const goldMultiplier = eventNumber == '9289' ? 1.01 : 1.03;
  const goldScore = Math.floor(highScore * goldMultiplier);
  const goldRanking = await getRanking(
    board_id,
    goldScore,
    lastPageNumber,
    pages
  );
  return {
    worldRecord: highScore,
    totalPlayers,
    gold: {
      time: formatTime(goldScore),
      ranking: goldRanking,
      percent: ((goldRanking / totalPlayers) * 100).toFixed(1),
    },
    silver: {
      time: formatTime(silverScore),
      ranking: silverRanking,
      percent: ((silverRanking / totalPlayers) * 100).toFixed(1),
    },
    bronze: {
      time: formatTime(bronzeScore),
      ranking: bronzeRanking,
      percent: ((bronzeRanking / totalPlayers) * 100).toFixed(1),
    },
  };
}

async function getRanking(board_id, targetScore, lastPageNumber, pages) {
  let lowerBound = 0;
  let upperBound = lastPageNumber;
  let ranking;
  while (ranking === undefined) {
    const pageNumber = Math.floor((lowerBound + upperBound) / 2);
    const response = await getPage(board_id, pageNumber, pages);
    const firstScore = response.result.list[0].score;
    const lastScore =
      response.result.list[response.result.list.length - 1].score;
    if (targetScore < firstScore) {
      upperBound = Math.max(pageNumber - 1, lowerBound);
    }
    if (targetScore > lastScore) {
      lowerBound = Math.min(pageNumber + 1, upperBound);
    }
    if (targetScore >= firstScore && targetScore <= lastScore) {
      ranking = response.result.list.find((item) => item.score >= targetScore);
    }
  }
  return ranking.display_rank;
}

async function getPage(board_id, page, existingPages) {
  if (existingPages[page]) {
    return existingPages[page];
  }
  const result = await fetch(
    "https://web-api.gt7.game.gran-turismo.com/ranking/get_list_by_page",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ board_id, page }),
    }
  ).then((res) => res.json());
  existingPages[page] = result;
  return result;
}

function formatTime(milliseconds) {
  const minutes = Math.floor(milliseconds / 60000);
  const seconds = Math.floor((milliseconds % 60000) / 1000);
  const millisecondsFormatted = (milliseconds % 1000)
    .toFixed(0)
    .padStart(3, "0");

  return `${minutes}:${
    seconds < 10 ? "0" : ""
  }${seconds}.${millisecondsFormatted}`;
}