SB100 / BTN Sticky Next Class Info

// ==UserScript==
// @name         BTN Sticky Next Class Info
// @namespace    https://openuserjs.org/users/SB100
// @description  Shows what you need to progress to the next class on every page
// @updateURL    https://openuserjs.org/meta/SB100/BTN_Sticky_Next_Class_Info.meta.js
// @version      1.0.1
// @author       SB100
// @copyright    2023, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @grant        GM.xmlHttpRequest
// @match        https://broadcasthe.net/*
// @connect      broadcasthe.net
// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/**
 * =============================
 * ADVANCED OPTIONS
 * =============================
 */

// change this number if you want to change the cache time. Default: cached for 30 minutes. Set to 0 to turn caching off
const CACHE_TIME = 1000 * 60 * 30;

/**
 * =============================
 * END ADVANCED OPTIONS
 * DO NOT MODIFY BELOW THIS LINE
 * =============================
 */

/**
 * Try parsing a string into JSON, otherwise fallback
 */
function JsonParseWithDefault(s, fallback = null) {
  try {
    return JSON.parse(s);
  }
  catch (e) {
    return fallback;
  }
}

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  const template = document.createElement('template');
  template.innerHTML = html.trim();
  return template.content;
}

/**
 * Query a url for its HTML
 */
function query(url) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  GM.xmlHttpRequest({
    method: 'get',
    url,
    timeout: 10000,
    onloadstart: () => {},
    onload: (result) => {
      if (result.status !== 200) {
        rejecter(
          new Error(
            `[BTN Next Class Info] Error received from remote call: ${url}`
          )
        );
        return;
      }

      if (typeof result.response === 'undefined') {
        rejecter(
          new Error(`[BTN Next Class Info] No result received from ${url}`)
        );
        return;
      }

      resolver(htmlToElement(result.response));
    },
    onerror: (result) => {
      rejecter(result);
    },
    ontimeout: (result) => {
      rejecter(result);
    },
  });

  return p;
}

/**
 * Finds a valid cache object, and cleans up all the old ones if they are outdated
 */
function getCacheObj() {
  const sortedKeys = Object.keys(window.localStorage)
    .filter((key) => key.startsWith('nextClass-'))
    .sort((a, b) => {
      const aTime = parseInt(a.replace('nextClass-', ''), 10);
      const bTime = parseInt(b.replace('nextClass-', ''), 10);
      return aTime - bTime;
    });

  for (
    let i = 0, sortedKeysLength = sortedKeys.length; i < sortedKeysLength; i += 1
  ) {
    const key = sortedKeys[i];
    const keyTime = parseInt(key.replace('nextClass-', ''), 10);

    // last entry and we're still in the cache period
    if (
      i === sortedKeysLength - 1 &&
      new Date().getTime() - keyTime < CACHE_TIME
    ) {
      const result = JsonParseWithDefault(window.localStorage.getItem(key));
      if (result === null || Object.keys(result).length === 0) {
        return null;
      }

      // add time this was generated
      result.generated = keyTime;
      return result;
    }

    window.localStorage.removeItem(key);
  }

  return null;
}

async function queryForNextClassInfo() {
  const sections = [{
      name: 'data',
      needRegex: /Need (.*)\./,
      haveRegex: /You have (.*)\./,
    },
    {
      name: 'bp',
      needRegex: /Need (.*) bonus points\./,
      haveRegex: /You have (.*)\./,
    },
    {
      name: 'snatches',
      needRegex: /Need (.*) snatches\./,
      haveRegex: /You have (.*)\./,
    },
    {
      name: 'time',
      needRegex: /Need to have been a member for (.*)\./,
      haveRegex: /You have been a member for (.*)\./,
    },
    {
      name: 'hnr',
      needRegex: /Need to have: (\d+)/,
      haveRegex: /You have: (\d+)/,
    },
  ];
  const html = await query(
    'https://broadcasthe.net/user.php?action=next_class'
  );

  const nextClass = html
    .querySelector('#content .thin h2')
    ?.innerText?.match(/Required stats to progress to (.*)\./)?.[1];

  const results = Array.from(
    html.querySelectorAll('#content .thin table tr:not(.colhead, .center)')
  ).reduce(
    (result, row, idx) => {
      const sectionData = sections[idx];

      const need = row
        .querySelector('td:nth-child(2)')
        ?.innerText?.match(sectionData.needRegex)?.[1];
      const have = row
        .querySelector('td:nth-child(3)')
        ?.innerText?.match(sectionData.haveRegex)?.[1];
      const reached =
        row.querySelector('td:nth-child(4) img')?.getAttribute('title') ===
        'Requirement reached';

      // eslint-disable-next-line no-param-reassign
      result[sectionData.name] = {
        need,
        have,
        reached,
      };

      return result;
    }, {
      nextClass
    }
  );

  // store in cache
  window.localStorage.setItem(
    `nextClass-${new Date().getTime()}`,
    JSON.stringify(results)
  );

  // add in when this was generated
  results.generated = 'current';

  return results;
}

function addStatsToDOM(stats) {
  console.log(stats);
  const container = document.createElement('div');
  container.id = 'btn-next-stats__container';
  container.innerHTML = `<span class="btn-next-stats__item">
    <a href="/rules.php?p=class">Next class</a>: <a href="/user.php?action=next_class">${
      stats.nextClass
    }</a></span> &bull;
  <span class="btn-next-stats__item btn-next-stats__item--${
    stats.data.reached ? 'reached' : 'unreached'
  }">Data: ${stats.data.have} / ${stats.data.need}</span> &bull;
  <span class="btn-next-stats__item btn-next-stats__item--${
    stats.bp.reached ? 'reached' : 'unreached'
  }">BP: ${stats.bp.have} / ${stats.bp.need}</span> &bull;
  <span class="btn-next-stats__item btn-next-stats__item--${
    stats.snatches.reached ? 'reached' : 'unreached'
  }">Snatches: ${stats.snatches.have} / ${stats.snatches.need}</span> &bull;
  <span class="btn-next-stats__item btn-next-stats__item--${
    stats.time.reached ? 'reached' : 'unreached'
  }">Time: ${stats.time.have} / ${stats.time.need}</span> &bull;
  <span class="btn-next-stats__item btn-next-stats__item--${
    stats.hnr.reached ? 'reached' : 'unreached'
  }">H&amp;Rs: ${stats.hnr.have} / ${stats.hnr.need}</span>
`;

  const content = document.getElementById('content');
  content.insertBefore(container, content.firstChild);
}

function getCssTheme() {
  const external = document.querySelector(
    'link[type="text/css"][title="External CSS"]'
  );

  if (external) {
    if (external.href.includes('btn-future.css')) {
      return 'btn-future';
    }
  }

  return 'smptev3';
}

function createStyleTag(cssTheme) {
  const css = `#btn-next-stats__container {
    width: 100%;
    padding: 5px 15px;
    position: sticky;
    top: 0px;
    ${cssTheme === 'btn-future' ? 'margin-top: -10px;' : ''}
    ${
      cssTheme === 'btn-future'
        ? 'background-color: rgba(47, 43, 76, 0.85);'
        : 'background-color: rgba(0, 0, 0, 0.15);'
    }
    border-radius: 5px;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    ${cssTheme !== 'btn-future' ? 'margin-bottom: 10px;' : ''}
    z-index: 1;
}

.btn-next-stats__item {
  display: inline-block;
  padding: 5px;
  border-radius: 3px;
}

.btn-next-stats__item--unreached {
  color: rgb(202, 49, 66);
  background-color: rgba(81, 20, 26, 0.3);
}

.btn-next-stats__item--reached {
  color: rgb(75, 137, 92);
  background-color: rgba(30, 55, 37, 0.3);
}
`;

  const style = document.createElement('style');
  style.appendChild(document.createTextNode(css));

  document.head.appendChild(style);
}

// script runner
(async function main() {
  // add style tags
  createStyleTag(getCssTheme());

  // get it
  const nextClassStats = getCacheObj() || (await queryForNextClassInfo());

  // insert into DOM to display
  addStatsToDOM(nextClassStats);
})();