Personal Stat Average & Comparison

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();