Personal Stat Average & Comparison

Shows daily averages for different stats, users, and time periods

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Personal Stat Average & Comparison
// @namespace    ben_hagen.torn.personalstataverageandcomparison
// @version      1.4.3
// @author       Ben_Hagen [2966467] 
// @description  Shows daily averages for different stats, users, and time periods
// @license      GNU GPLv3
// @match        https://www.torn.com/personalstats.php*
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  let personalStats;

  const fmt = (x) => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");

  const calcAverages = (startDate) => {
    if (!personalStats) return [];
    startDate = startDate
      ? (new Date(startDate + " 00:00:00Z")).toISOString().substring(0, 10)
      : startDate;

    // Read user/stat pairs from table headers — full untruncated names, works on all platforms
    const table = document.querySelector('[class^="chartWrapper"] table');
    if (!table) return [];
    const headers = [...table.querySelectorAll('thead th')].slice(1);
    const results = [];

    for (const th of headers) {
      const m = th.textContent.trim().match(/^(\S+)\s+\((.+)\)$/);
      if (!m) continue;
      const userName = m[1];
      const statName = m[2];

      const data = personalStats.data[
        Object.keys(personalStats.definitions).filter(key => personalStats.definitions[key] == statName)[0]
      ].filter(
        user => user.uid == Object.keys(personalStats.definitions).filter(key => personalStats.definitions[key] == userName)[0]
      )[0].data;

      const startIndex = startDate
        ? data.findIndex(e => new Date(e.time * 1e3).toISOString().substring(0, 10) == startDate)
        : data.length - 1;

      const totalDays  = (new Date(data[0].time * 1e3) - new Date(data[startIndex].time * 1e3)) / (86400 * 1e3);
      const startCount = data[startIndex].value;
      const endCount   = data[0].value;
      const average    = ((endCount - startCount) / totalDays).toFixed(2);

      let headerText;
      switch (statName) {
        case "Time played":
        case "Time spent traveling":
          headerText = `${parseInt(average / 60)} min/day`;
          break;
        case "Total networth":
        case "Rehabilitation fees":
        case "Value of received bounties":
        case "Money rewarded":
        case "Spent on bounties":
        case "Money mugged":
          headerText = `$${fmt(parseInt(average))}/day`;
          break;
        default:
          headerText = `${fmt(average)}/day`;
          break;
      }

      results.push({ userName, statName, headerText, rawAvg: parseFloat(average) });
    }
    return results;
  };

  const injectCards = (startDate) => {
    const anchor = document.querySelector('[class^="dropDowns"]');
    if (!anchor) return;

    let entries;
    try { entries = calcAverages(startDate); } catch(e) { return; }
    if (!entries.length) return;

    const userMap = new Map();
    for (const { userName, statName, headerText, rawAvg } of entries) {
      if (!userMap.has(userName)) userMap.set(userName, []);
      userMap.get(userName).push({ statName, headerText, rawAvg });
    }

    const statRawValues = {};
    for (const stats of userMap.values())
      for (const { statName, rawAvg } of stats) {
        if (!statRawValues[statName]) statRawValues[statName] = [];
        statRawValues[statName].push(rawAvg);
      }
    const statMin = {}, statMax = {};
    for (const [s, vals] of Object.entries(statRawValues)) {
      if (vals.length < 2) continue;
      statMin[s] = Math.min(...vals);
      statMax[s] = Math.max(...vals);
    }

    document.getElementById('tspa-row')?.remove();

    const row = document.createElement('div');
    row.id = 'tspa-row';
    row.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;padding:10px 4px 6px;border-top:1px solid rgba(255,255,255,0.1);margin-top:8px;font-size:12px;line-height:1.6;';

    for (const [userName, stats] of userMap) {
      const card = document.createElement('div');
      card.style.cssText = 'background:#2a2a2a;border-radius:4px;padding:24px 10px 6px 10px;min-width:180px;position:relative;';

      const heading = document.createElement('div');
      heading.textContent = userName;
      heading.style.cssText = 'font-weight:bold;color:#e8c97a;border-bottom:1px solid rgba(255,255,255,0.15);padding-bottom:3px;margin-bottom:4px;';
      card.appendChild(heading);

      for (const { statName, headerText, rawAvg } of stats) {
        const line = document.createElement('div');
        line.style.cssText = 'display:flex;justify-content:space-between;gap:14px;color:#ddd;';

        const l = document.createElement('span');
        l.textContent = statName;
        l.style.color = 'rgba(255,255,255,0.6)';

        const v = document.createElement('span');
        v.textContent = headerText;
        v.style.cssText = 'font-weight:600;white-space:nowrap;';
        if (statMin[statName] !== undefined)
          v.style.color = rawAvg === statMax[statName] ? '#2eb85c' : rawAvg === statMin[statName] ? '#e55353' : '#fff';
        else
          v.style.color = '#fff';

        line.appendChild(l);
        line.appendChild(v);
        card.appendChild(line);
      }
      row.appendChild(card);
    }

    // Copy button — injected into top-right of last card after cards are built
    const copyBtn = document.createElement('button');
    copyBtn.textContent = '📋 copy all';
    copyBtn.style.cssText = 'position:absolute;top:6px;right:6px;padding:2px 7px;background:#3a3a3a;color:#ddd;border:1px solid rgba(255,255,255,0.2);border-radius:4px;cursor:pointer;font-size:10px;';
    copyBtn.addEventListener('click', () => {
      const allStats = [...new Set([...userMap.values()].flatMap(s => s.map(x => x.statName)))];
      const timePeriod = document.querySelector('.dropdown-time button[class^="toggler"]')?.textContent?.trim() || 'All';
      const header = ['User', ...allStats].join('\t');
      const lines = [...userMap.entries()].map(([userName, stats]) => {
        const cells = allStats.map(s => stats.find(x => x.statName === s)?.headerText ?? '-');
        return [userName, ...cells].join('\t');
      });
      const text = [`Period: ${timePeriod}`, header, ...lines, '', 'get this script: https://www.torn.com/forums.php#/p=threads&f=67&t=16564709'].join('\n');
      const tableRows = [
        `<tr><td colspan="99" style="padding:4px 10px;border:1px solid #ccc;background:#f0f0f0;font-style:italic;">Period: ${timePeriod}</td></tr>`,
        `<tr>${['User', ...allStats].map(h => `<th style="padding:4px 10px;border:1px solid #ccc;background:#f0f0f0;">${h}</th>`).join('')}</tr>`,
        ...[...userMap.entries()].map(([userName, stats]) => {
          const cells = allStats.map(s => stats.find(x => x.statName === s)?.headerText ?? '-');
          return `<tr>${[userName, ...cells].map(c => `<td style="padding:4px 10px;border:1px solid #ccc;">${c}</td>`).join('')}</tr>`;
        })
      ].join('');
      const html = `<table style="border-collapse:collapse;">${tableRows}<tr><td colspan="99" style="text-align:right;padding:4px 10px;border:1px solid #ccc;font-style:italic;"><a href="https://www.torn.com/forums.php#/p=threads&f=67&t=16564709" style="text-decoration:none;">get this script</a></td></tr></table>`;
      navigator.clipboard.write([new ClipboardItem({
        'text/html': new Blob([html], { type: 'text/html' }),
        'text/plain': new Blob([text], { type: 'text/plain' })
      })]).then(() => {
        copyBtn.textContent = '✓ copied!';
        setTimeout(() => { copyBtn.textContent = '📋 copy all'; }, 2000);
      });
    });

    // Place copy button in top-right corner of the last card
    const lastCard = row.lastElementChild;
    lastCard.appendChild(copyBtn);

    anchor.insertAdjacentElement('afterend', row);
  };

  function observerFunction(mutationRecord) {
    for (const mutationEntry of mutationRecord) {
      if (mutationEntry.addedNodes) {
        for (const addedNode of mutationEntry.addedNodes) {
          if (addedNode.querySelector) {
            if (addedNode.querySelector("div > table")) injectCards();
          }
        }
      }
    }
  }

  const docObserver = new MutationObserver((mutationRecord) => {
    for (const mutationEntry of mutationRecord) {
      if (mutationEntry.addedNodes) {
        for (const addedNode of mutationEntry.addedNodes) {
          if (document.querySelector("div[class^='chartWrapper'")) {
            const target = document.querySelector("div[class^='chartWrapper'").firstElementChild;
            new MutationObserver(observerFunction).observe(target, { childList: true, subtree: true });
            docObserver.disconnect();
            return;
          }
        }
      }
    }
  });
  docObserver.observe(document, { childList: true, subtree: true });

  let lastTimePeriod = '';
  setInterval(() => {
    const btn = document.querySelector('.dropdown-time button[class^="toggler"]');
    const label = btn?.textContent?.trim() ?? '';
    if (label !== lastTimePeriod) {
      lastTimePeriod = label;
      setTimeout(injectCards, 500);
    }
  }, 500);

  const oldFetch = unsafeWindow.fetch;
  unsafeWindow.fetch = async (url, init) => {
    if (!url.includes("personalstats.php")) return oldFetch(url, init);
    let response = await oldFetch(url, init);
    let clone = response.clone();
    clone.json().then((json) => {
      if (!json.definitions) return;
      personalStats = json;
      setTimeout(injectCards, 300);
    });
    return response;
  };

})();