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.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        none

// ==/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`;
    };

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

    const getAvgFromJson = (statKey, userKey, startDateStr) => {
        const startISO = startDateStr
            ? new Date(startDateStr + ' 00:00:00Z').toISOString().slice(0, 10)
            : null;

        const series = personalStats.data[statKey]?.find(u => String(u.uid) === String(userKey));
        if (!series?.data?.length) return null;

        const points = series.data; // newest first

        const startIdx = startISO
            ? points.findIndex(p => new Date(p.time * 1000).toISOString().slice(0, 10) === startISO)
            : points.length - 1;

        if (startIdx < 1) return null;

        const daysDelta = (new Date(points[0].time * 1000) - new Date(points[startIdx].time * 1000)) / 86_400_000;
        if (daysDelta <= 0) return null;

        return (points[0].value - points[startIdx].value) / daysDelta;
    };

    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;

        // Get the currently selected time period label from the dropdown
        const timeButton = document.querySelector('.dropdown-time button[class^="toggler"]');
        const timePeriod = timeButton?.textContent?.trim() || null;

        // Map time period label to a start date
        const getStartDate = () => {
            if (!timePeriod) return null;
            const now = new Date();
            if (timePeriod === '1 month')  { now.setMonth(now.getMonth() - 1); return now.toISOString().slice(0, 10); }
            if (timePeriod === '3 months') { now.setMonth(now.getMonth() - 3); return now.toISOString().slice(0, 10); }
            if (timePeriod === '6 months') { now.setMonth(now.getMonth() - 6); return now.toISOString().slice(0, 10); }
            if (timePeriod === '1 year')   { now.setFullYear(now.getFullYear() - 1); return now.toISOString().slice(0, 10); }
            if (timePeriod === '5 years')  { now.setFullYear(now.getFullYear() - 5); return now.toISOString().slice(0, 10); }
            return null; // "All"
        };

        const startDate = getStartDate();

        const results = [];

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

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

            let dailyAvg = null;

            // Try JSON data first (accurate, full history)
            if (personalStats?.definitions && personalStats?.data) {
                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) {
                    dailyAvg = getAvgFromJson(statKey, userKey, startDate);
                }
            }

            // Fall back to table if JSON not available (desktop only)
            if (dailyAvg === null) {
                const colIndex = [...headers].indexOf(th);
                const rows = [...table.querySelectorAll('tbody tr')];
                if (rows.length >= 2) {
                    const values = rows.map(row => {
                        const cells = row.querySelectorAll('td');
                        return cells[colIndex + 1] ? parseFloat(cells[colIndex + 1].textContent.replace(/,/g, '')) || null : null;
                    }).filter(v => v !== null);
                    if (values.length >= 2) {
                        dailyAvg = (values[values.length - 1] - values[0]) / (values.length - 1);
                    }
                }
            }

            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;

        // Compare across users per stat
        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 timeButton = document.querySelector('.dropdown-time button[class^="toggler"]')?.textContent?.trim() || '';
        const key = headers + '||' + firstRow + '||' + lastRow + '||' + timeButton;

        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);
        }
    };

    // Intercept Torn's own fetch to capture the full stats JSON
    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;
            }).catch(() => {});
        }
        return response;
    };

    tryStart();

})();