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.0.5
// @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

// ==/UserScript==


(function () {
    'use strict';

    const fmt = (n, decimals = 2) => {
        const parts = n.toFixed(decimals).split('.');
        parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
        return parts.join('.');
    };

    const formatAvg = (statName, avg) => {
        if (['Time played', 'Time spent traveling'].includes(statName))
            return `${fmt(avg / 60)} min/day`;
        if (['Total networth','Rehabilitation fees','Value of received bounties',
             'Money rewarded','Spent on bounties','Money mugged'].includes(statName))
            return `$${fmt(avg)}/day`;
        return `${fmt(avg)}/day`;
    };

    const parseValue = (str) => parseFloat(str.replace(/,/g, '')) || 0;

    let debounceTimer = null;
    let lastPollKey = '';
    let personalStats = null;

    const getAvgFromJson = (statName, userName) => {
        if (!personalStats?.definitions || !personalStats?.data) return null;
        const statKey = Object.keys(personalStats.definitions).find(k => personalStats.definitions[k] === statName);
        const userKey = Object.keys(personalStats.definitions).find(k => personalStats.definitions[k] === userName);
        if (!statKey || !userKey) return null;
        const series = personalStats.data[statKey]?.find(u => String(u.uid) === String(userKey));
        if (!series?.data?.length || series.data.length < 2) return null;
        const points = series.data; // newest first
        const daysDelta = (new Date(points[0].time * 1000) - new Date(points[points.length - 1].time * 1000)) / 86_400_000;
        if (daysDelta <= 0) return null;
        return (points[0].value - points[points.length - 1].value) / daysDelta;
    };

    const getAvgFromTable = (rows, colIndex) => {
        const values = rows.map(row => {
            const cells = row.querySelectorAll('td');
            return cells[colIndex + 1] ? parseValue(cells[colIndex + 1].textContent) : null;
        }).filter(v => v !== null);
        if (values.length < 2) return null;
        return (values[values.length - 1] - values[0]) / (values.length - 1);
    };

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

        const table = document.querySelector('[class^="chartWrapper"] table');
        if (!table) return;

        const headers = [...table.querySelectorAll('thead th')].slice(1);
        if (!headers.length) return;

        const rows = [...table.querySelectorAll('tbody tr')];
        if (rows.length < 2) return;

        const results = [];

        headers.forEach((th, colIndex) => {
            const fullLabel = th.textContent.trim();
            const userMatch = fullLabel.match(/^(\S+)\s+\((.+)\)$/);
            if (!userMatch) return;

            const userName = userMatch[1];
            const statName = userMatch[2];

            // Always prefer JSON (correct on both platforms), fall back to table
            const dailyAvg = getAvgFromJson(statName, userName) ?? getAvgFromTable(rows, colIndex);
            if (dailyAvg === null) return;

            let user = results.find(r => r.userName === userName);
            if (!user) { user = { userName, stats: [] }; results.push(user); }
            user.stats.push({ statName, rawAvg: dailyAvg, display: formatAvg(statName, dailyAvg) });
        });

        if (!results.length) return;

        const statRawValues = {};
        for (const { stats } of results) {
            for (const { statName, rawAvg } of stats) {
                if (!statRawValues[statName]) statRawValues[statName] = [];
                statRawValues[statName].push(rawAvg);
            }
        }

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

        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 results) {
            const card = document.createElement('div');
            card.style.cssText = 'background:#2a2a2a;border-radius:4px;padding:6px 10px;min-width:180px;';

            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, rawAvg, display } 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 = display;
                v.style.cssText = 'font-weight:600;white-space:nowrap;';

                if (statMin[statName] !== undefined && statMax[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);
        }

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

    const pollForChanges = () => {
        const table = document.querySelector('[class^="chartWrapper"] table');
        const headers = [...(table?.querySelectorAll('thead th') || [])].map(th => th.textContent).join('|');
        const firstRow = table?.querySelector('tbody tr:first-child')?.textContent || '';
        const lastRow = table?.querySelector('tbody tr:last-child')?.textContent || '';
        const key = headers + '||' + firstRow + '||' + lastRow;

        if (key && key !== lastPollKey) {
            lastPollKey = key;
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(injectAverages, 500);
        }
    };

    const tryStart = (attempts = 0) => {
        const table = document.querySelector('[class^="chartWrapper"] table');
        const anchor = document.querySelector('[class^="dropDowns"]');

        if (table && anchor) {
            injectAverages();
            setInterval(pollForChanges, 1000);
        } else if (attempts < 40) {
            setTimeout(() => tryStart(attempts + 1), 250);
        }
    };

    // Capture JSON; re-inject once it arrives so both platforms get accurate data
    const originalFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async (resource, init) => {
        const url = typeof resource === 'string' ? resource : (resource?.url ?? '');
        const response = await originalFetch(resource, init);
        if (url.includes('personalstats.php')) {
            response.clone().json().then(json => {
                if (json?.definitions) {
                    personalStats = json;
                    clearTimeout(debounceTimer);
                    debounceTimer = setTimeout(injectAverages, 300);
                }
            }).catch(() => {});
        }
        return response;
    };

    tryStart();

})();