Shows daily averages for different stats, users, and time periods
// ==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();
})();