SB100 / BTN Show Stats Change News Item

// ==UserScript==
// @name         BTN Show Stats Change News Item
// @namespace    https://openuserjs.org/users/SB100
// @description  Shows stats since your last visit as a news item
// @updateURL    https://openuserjs.org/meta/SB100/BTN_Show_Stats_Change_News_Item.meta.js
// @version      1.0.1
// @author       SB100
// @copyright    2023, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @match        https://broadcasthe.net/index.php
// ==/UserScript==

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

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

// 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 = false;

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

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

/**
 * 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();

  const desiredSizeIndex = 0;
  const currentSizeIndex = SIZE_TYPES.findIndex(
    (size) => size.toUpperCase() === type
  );
  // unknown type
  if (currentSizeIndex === -1) {
    return 0;
  }

  const size = parseFloat(s.replace(/[^\d.]+/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) / 1000 ** i;
    if (size < 1) {
      return `${isNegative ? '-' : ''}${(
        Math.abs(bytes) /
        1000 ** (i - 1)
      ).toFixed(2)} ${SIZE_TYPES[i - 1]}`;
    }
  }

  return '0.00';
}

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

  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 statsSpans = document.querySelectorAll('#userinfo_stats li');

  const bonusMatch = document
    .getElementById('pointsStats')
    .textContent.match(/[\d,]+/);
  const bonus = parseFloat(bonusMatch && bonusMatch[0].replaceAll(',', ''));

  const lumensMatch = document
    .getElementById('toplumens')
    .textContent.match(/[\d,]+/);
  const lumens = parseFloat(lumensMatch && lumensMatch[0].replaceAll(',', ''));

  return {
    up: parseStatFromString(statsSpans[0].querySelector('span').innerText),
    bonus: Number.isNaN(bonus) ? 0 : bonus,
    lumens: Number.isNaN(lumens) ? 0 : lumens,
    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 a new news item entry
 */
function displayDelta(change) {
  const timeDiff = new Date().getTime() - change.generated;

  const mainColumn = document.querySelector('.sidebar');
  const changeElem = document.createElement('div');
  changeElem.classList.add('box');
  changeElem.innerHTML = `
<div class="head colhead_dark">
  <strong>Stats Update</strong>
</div>
<ul class="stats nobullet">
  <li>⬆️ &nbsp; ${formatBytes(change.up)}</li>
  <li>🪙 &nbsp; ${change.bonus
    .toString()
    .replace(/\B(?=(\d{3})+(?!\d))/g, ',')} bonus points</li>
  <li>✨ &nbsp; ${change.lumens
    .toString()
    .replace(/\B(?=(\d{3})+(?!\d))/g, ',')} lumens</li>
</ul>
<div class="extrapad" style="color: #999;" ${
    timeDiff > 1000
      ? `title="Values have been cached for ${msToTime(timeDiff)}"`
      : ''
  }>
  <center>
    Compared to ${msToTime(change.time)}ago ${timeDiff > 1000 ? '*' : ''}
  </center>
</div>`;

  mainColumn.prepend(changeElem);
}

// script runner
(function main() {
  // calculate old and new stats
  const newStats = getStatsFromHtml();
  const oldStats = JsonParseWithDefault(
    window.localStorage.getItem('lastStats') || newStats,
    newStats
  );

  // calculate the current change as if there were no caching
  const change = {
    up: newStats.up - oldStats.up,
    bonus: newStats.bonus - oldStats.bonus,
    lumens: newStats.lumens - oldStats.lumens,
    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.bonus !== 0 || change.lumens !== 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.bonus === 0 &&
    stats.lumens === 0
  ) {
    return;
  }

  displayDelta(stats);
})();