SB100 / GGn Show Stats Change News Item

// ==UserScript==
// @namespace    https://openuserjs.org/users/SB100
// @name         GGn Show Stats Change News Item
// @description  Shows stats since your last visit
// @updateURL    https://openuserjs.org/meta/SB100/GGn_Show_Stats_Change_News_Item.meta.js
// @version      1.1.2
// @author       SB100
// @copyright    2021, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @include      https://gazellegames.net/index.php
// ==/UserScript==

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

/* jshint esversion: 6 */

/**
 * BEGIN SETTINGS: You can edit these values
 */

// change this number if you want to change the cache time. Currently cached for 1 minute. Set to 0 to turn caching off
const CACHE_TIME = 1000 * 60 * 1;

// If there have been no stats changes, 'true' will hide the news item and 'false' will show a news item with all stats set to 0
const HIDE_ON_NO_CHANGE = true;

/**
 * END SETTINGS: Don't change below here
 */

const SIZE_TYPES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

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

/**
 * Turn a string such as 77.45 GB [111] to Bytes as a floating number
 */
function parseStatFromString(s, decimals = 2) {
  const typeMatch = s.match(/[\d,]+\s([\w]+)/);
  const type = typeMatch && typeMatch.length >= 2 && typeMatch[1].toUpperCase();

  // unknown type
  if (!SIZE_TYPES.includes(type)) {
    return 0;
  }

  const currentSizeIndex = SIZE_TYPES.findIndex(size => size === type);
  const desiredSizeIndex = 0;
  const size = parseFloat(s.replace('/,/g', ''));

  return parseFloat(
    (size * 1000 ** (currentSizeIndex - desiredSizeIndex)).toFixed(decimals)
  );
}

/**
 * Turns bytes into the best representation possible
 */
function formatBytes(bytes) {
  if (bytes === 0) {
    return '0.00';
  }

  const isNegative = bytes < 0;

  for (let i = 1; i < SIZE_TYPES.length - 1; i += 1) {
    const size = Math.abs(bytes) / 1024 ** i;
    if (size < 1) {
      return `${isNegative ? '-' : ''}${(Math.abs(bytes) / 1024 ** (i - 1)).toFixed(2)} ${SIZE_TYPES[i - 1]}`;
    }
  }

  return '0.00';
}

/**
 * Turns ms into a more readable time
 */
function msToTime(s) {
  const ms = s % 1000;
  s = (s - ms) / 1000;
  const secs = s % 60;
  s = (s - secs) / 60;
  const mins = s % 60;
  s = (s - mins) / 60;
  const hrs = s % 60;
  s = (s - hrs) / 24;
  const days = s % 24;

  return `${days > 0 ? `${days} days ` : ''}${hrs > 0 ? `${hrs} hours ` : ''}${mins > 0 ? `${mins} mins ` : ''}${secs > 0 ? `${secs} secs ` : ''}`;
}

/**
 * Parse HTML and find up, down and ratio amounts
 */
function getStatsFromHtml() {
  const spans = document.querySelectorAll('#userinfo_stats span');
  const ratio = parseFloat(spans[3].textContent);

  const goldMatch = document.querySelector('#stats_gold span').textContent.match(/[\d\,]+/);
  const gold = parseFloat(goldMatch && goldMatch[0].replaceAll(',', ''));

  return {
    up: parseStatFromString(spans[0].textContent),
    down: parseStatFromString(spans[1].textContent),
    ratio: isNaN(ratio) ? 0 : ratio,
    gold: isNaN(gold) ? 0 : gold,
    time: (new Date).getTime()
  }
}

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

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

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

    window.localStorage.removeItem(key);
  }

  return null;
}

/**
 * Display the stats change in the header bar next to the user's username
 */
function displayDelta(change) {
  const timeDiff = (new Date).getTime() - change.generated;

  const mainColumn = document.querySelector('.main_column');
  const changeElem = document.createElement('div');
  changeElem.classList.add('box');
  changeElem.innerHTML = `
      <div class='head'>
        Stats Update
        <div class='newsdate' ${timeDiff > 1000 ? `title="Values have been cached for ${msToTime(timeDiff)}"` : ''}>
          Compared to ${msToTime(change.time)}ago ${timeDiff > 1000 ? '*' : ''}
        </div>
      </div>
      <div class='pad'>
        ⬆️ ${formatBytes(change.up)} up &nbsp;
        ⬇️ ${formatBytes(change.down)} down &nbsp;
        *️⃣ ${formatBytes(change.up - change.down)} buffer &nbsp;
        🔄 ${(change.ratio).toFixed(2)} ratio &nbsp;
        ⏺ ${change.gold.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} gold
      </div>
    `;

  mainColumn.prepend(changeElem);
}

// script runner
(function () {
  'use strict';

  const newStats = getStatsFromHtml();
  const oldStats = JsonParseWithDefault(window.localStorage.getItem('lastStats') || newStats, newStats);

  const change = {
    up: newStats.up - oldStats.up,
    down: newStats.down - oldStats.down,
    ratio: newStats.ratio - oldStats.ratio,
    gold: newStats.gold - oldStats.gold,
    time: newStats.time - oldStats.time,
    generated: newStats.time
  }

  // check for caching - if there is an entry, and we are in the cache time, we should use that.
  // if there wasn't a cached value, we should cache the current one and use that as our latest stats
  const cached = getCacheChangeObj();
  if (!cached) {
    if (change.up != 0 || change.down != 0 || change.ratio != 0 || change.gold != 0) {
      window.localStorage.setItem(`cacheStats-${newStats.time}`, JSON.stringify(change));
    }

    // store the new stats in local storage - this will be our new baseline for next time we show results with no cache
    window.localStorage.setItem('lastStats', JSON.stringify(newStats));
  }

  const stats = cached || change;
  if (HIDE_ON_NO_CHANGE && stats.up == 0 && stats.down == 0 && stats.ratio == 0 && stats.gold == 0) {
    return;
  }

  displayDelta(stats);
})();