Personal Stat Average & Comparison

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();