Shows daily averages for different stats, users, and time periods
// ==UserScript==
// @name Personal Stat Average & Comparison
// @namespace ben_hagen.torn.personalstataverageandcomparison
// @version 1.4.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 unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
let personalStats;
const fmt = (x) => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const calcAverages = (startDate) => {
if (!personalStats) return [];
startDate = startDate
? (new Date(startDate + " 00:00:00Z")).toISOString().substring(0, 10)
: startDate;
// Read user/stat pairs from table headers — full untruncated names, works on all platforms
const table = document.querySelector('[class^="chartWrapper"] table');
if (!table) return [];
const headers = [...table.querySelectorAll('thead th')].slice(1);
const results = [];
for (const th of headers) {
const m = th.textContent.trim().match(/^(\S+)\s+\((.+)\)$/);
if (!m) continue;
const userName = m[1];
const statName = m[2];
const data = personalStats.data[
Object.keys(personalStats.definitions).filter(key => personalStats.definitions[key] == statName)[0]
].filter(
user => user.uid == Object.keys(personalStats.definitions).filter(key => personalStats.definitions[key] == userName)[0]
)[0].data;
const startIndex = startDate
? data.findIndex(e => new Date(e.time * 1e3).toISOString().substring(0, 10) == startDate)
: data.length - 1;
const totalDays = (new Date(data[0].time * 1e3) - new Date(data[startIndex].time * 1e3)) / (86400 * 1e3);
const startCount = data[startIndex].value;
const endCount = data[0].value;
const average = ((endCount - startCount) / totalDays).toFixed(2);
let headerText;
switch (statName) {
case "Time played":
case "Time spent traveling":
headerText = `${parseInt(average / 60)} min/day`;
break;
case "Total networth":
case "Rehabilitation fees":
case "Value of received bounties":
case "Money rewarded":
case "Spent on bounties":
case "Money mugged":
headerText = `$${fmt(parseInt(average))}/day`;
break;
default:
headerText = `${fmt(average)}/day`;
break;
}
results.push({ userName, statName, headerText, rawAvg: parseFloat(average) });
}
return results;
};
const injectCards = (startDate) => {
const anchor = document.querySelector('[class^="dropDowns"]');
if (!anchor) return;
let entries;
try { entries = calcAverages(startDate); } catch(e) { return; }
if (!entries.length) return;
const userMap = new Map();
for (const { userName, statName, headerText, rawAvg } of entries) {
if (!userMap.has(userName)) userMap.set(userName, []);
userMap.get(userName).push({ statName, headerText, rawAvg });
}
const statRawValues = {};
for (const stats of userMap.values())
for (const { statName, rawAvg } of stats) {
if (!statRawValues[statName]) statRawValues[statName] = [];
statRawValues[statName].push(rawAvg);
}
const statMin = {}, statMax = {};
for (const [s, vals] of Object.entries(statRawValues)) {
if (vals.length < 2) continue;
statMin[s] = Math.min(...vals);
statMax[s] = Math.max(...vals);
}
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 userMap) {
const card = document.createElement('div');
card.style.cssText = 'background:#2a2a2a;border-radius:4px;padding:24px 10px 6px 10px;min-width:180px;position:relative;';
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, headerText, rawAvg } 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 = headerText;
v.style.cssText = 'font-weight:600;white-space:nowrap;';
if (statMin[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);
}
// Copy button — injected into top-right of last card after cards are built
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋 copy all';
copyBtn.style.cssText = 'position:absolute;top:6px;right:6px;padding:2px 7px;background:#3a3a3a;color:#ddd;border:1px solid rgba(255,255,255,0.2);border-radius:4px;cursor:pointer;font-size:10px;';
copyBtn.addEventListener('click', () => {
const allStats = [...new Set([...userMap.values()].flatMap(s => s.map(x => x.statName)))];
const timePeriod = document.querySelector('.dropdown-time button[class^="toggler"]')?.textContent?.trim() || 'All';
const header = ['User', ...allStats].join('\t');
const lines = [...userMap.entries()].map(([userName, stats]) => {
const cells = allStats.map(s => stats.find(x => x.statName === s)?.headerText ?? '-');
return [userName, ...cells].join('\t');
});
const text = [`Period: ${timePeriod}`, header, ...lines, '', 'get this script: https://www.torn.com/forums.php#/p=threads&f=67&t=16564709'].join('\n');
const tableRows = [
`<tr><td colspan="99" style="padding:4px 10px;border:1px solid #ccc;background:#f0f0f0;font-style:italic;">Period: ${timePeriod}</td></tr>`,
`<tr>${['User', ...allStats].map(h => `<th style="padding:4px 10px;border:1px solid #ccc;background:#f0f0f0;">${h}</th>`).join('')}</tr>`,
...[...userMap.entries()].map(([userName, stats]) => {
const cells = allStats.map(s => stats.find(x => x.statName === s)?.headerText ?? '-');
return `<tr>${[userName, ...cells].map(c => `<td style="padding:4px 10px;border:1px solid #ccc;">${c}</td>`).join('')}</tr>`;
})
].join('');
const html = `<table style="border-collapse:collapse;">${tableRows}<tr><td colspan="99" style="text-align:right;padding:4px 10px;border:1px solid #ccc;font-style:italic;"><a href="https://www.torn.com/forums.php#/p=threads&f=67&t=16564709" style="text-decoration:none;">get this script</a></td></tr></table>`;
navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' })
})]).then(() => {
copyBtn.textContent = '✓ copied!';
setTimeout(() => { copyBtn.textContent = '📋 copy all'; }, 2000);
});
});
// Place copy button in top-right corner of the last card
const lastCard = row.lastElementChild;
lastCard.appendChild(copyBtn);
anchor.insertAdjacentElement('afterend', row);
};
function observerFunction(mutationRecord) {
for (const mutationEntry of mutationRecord) {
if (mutationEntry.addedNodes) {
for (const addedNode of mutationEntry.addedNodes) {
if (addedNode.querySelector) {
if (addedNode.querySelector("div > table")) injectCards();
}
}
}
}
}
const docObserver = new MutationObserver((mutationRecord) => {
for (const mutationEntry of mutationRecord) {
if (mutationEntry.addedNodes) {
for (const addedNode of mutationEntry.addedNodes) {
if (document.querySelector("div[class^='chartWrapper'")) {
const target = document.querySelector("div[class^='chartWrapper'").firstElementChild;
new MutationObserver(observerFunction).observe(target, { childList: true, subtree: true });
docObserver.disconnect();
return;
}
}
}
}
});
docObserver.observe(document, { childList: true, subtree: true });
let lastTimePeriod = '';
setInterval(() => {
const btn = document.querySelector('.dropdown-time button[class^="toggler"]');
const label = btn?.textContent?.trim() ?? '';
if (label !== lastTimePeriod) {
lastTimePeriod = label;
setTimeout(injectCards, 500);
}
}, 500);
const oldFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async (url, init) => {
if (!url.includes("personalstats.php")) return oldFetch(url, init);
let response = await oldFetch(url, init);
let clone = response.clone();
clone.json().then((json) => {
if (!json.definitions) return;
personalStats = json;
setTimeout(injectCards, 300);
});
return response;
};
})();