// ==UserScript==
// @name Torn – Last Rehab & Last Swiss (PublicView CSV • row under header)
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Shows Last Swiss / Last rehab from a PublicView CSV on the Employees page.
// @match https://www.torn.com/companies.php*
// @run-at document-idle
// @license MIT
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LS_URL = 'tm_torn_public_csv_url_v1';
const LS_CACHE = 'tm_torn_public_cache_v1';
const CACHE_TTL_MS = 30 * 60 * 1000;
let cache = loadCache();
let fetchInFlight = null;
const EMP_LIST_SEL = '#employees form .employee-list-wrap ul.employee-list.t-blue-cont.h';
const isEmployeesRoute = () =>
location.pathname === '/companies.php' &&
/(^|#|&|\?)option=employees\b/i.test(location.href);
mountCsvAndRefreshButtons();
startOnce();
window.addEventListener('load', () => {
if (isEmployeesRoute()) renderAllEmployees(true);
});
window.addEventListener('hashchange', () => {
if (isEmployeesRoute()) startOnce();
});
async function startOnce() {
if (!isEmployeesRoute()) return;
await waitFor(() => document.querySelector(EMP_LIST_SEL), 10000, 250);
renderAllEmployees(true).catch(() => {});
}
async function renderAllEmployees(force = false) {
const url = getCsvUrl();
if (!url) return;
await ensureData(url, force);
document
.querySelectorAll(`${EMP_LIST_SEL} > li[data-user]`)
.forEach(injectLeftMeta);
}
function injectLeftMeta(liEl) {
const leftCell = liEl.querySelector(':scope > .acc-header .employee');
if (!leftCell) return;
leftCell.style.overflow = 'visible';
leftCell.style.height = 'auto';
leftCell.style.maxHeight = 'none';
leftCell.style.minHeight = '40px';
const header = liEl.querySelector(':scope > .acc-header');
if (header) { header.style.height = 'auto'; header.style.minHeight = '40px'; }
const id = liEl.getAttribute('data-user');
const name = leftCell.querySelector('.honor-text')?.textContent?.trim() || '';
const rec = lookup(id ? { id } : { id: null, name });
let meta = leftCell.querySelector(':scope > .tm-left-meta');
if (!meta) {
meta = document.createElement('div');
meta.className = 'tm-left-meta';
const anchor = leftCell.querySelector('a.user.name') || leftCell.lastElementChild;
anchor?.insertAdjacentElement('afterend', meta);
Object.assign(meta.style, {
marginTop: '3px',
fontSize: '12px',
fontFamily: 'Arial, sans-serif',
fontWeight: '400',
lineHeight: '16px',
color: 'rgb(221, 221, 221)',
whiteSpace: 'normal',
wordBreak: 'break-word',
pointerEvents: 'none',
});
}
const isUnknown = v => !v || /unknown/i.test(v);
const swissTxt = (rec && !isUnknown(rec.last_swiss)) ? tornRelative(rec.last_swiss) : 'Unknown';
const rehabTxt = (rec && !isUnknown(rec.last_rehab)) ? tornRelative(rec.last_rehab) : 'Unknown';
meta.innerHTML = `Swiss: ${swissTxt}<br>Rehab: ${rehabTxt}`;
}
async function ensureData(url, force = false) {
const now = Date.now();
if (!force && (now - cache.lastFetch < CACHE_TTL_MS)) return;
if (fetchInFlight) return fetchInFlight;
fetchInFlight = (async () => {
let reqUrl = url;
if (force) {
try { const u = new URL(url); u.searchParams.set('tmcb', String(Date.now())); reqUrl = u.toString(); }
catch { reqUrl = url + (url.includes('?') ? '&' : '?') + 'tmcb=' + Date.now(); }
}
const res = await fetch(reqUrl, { method: 'GET', cache: 'no-store', credentials: 'omit', redirect: 'follow' });
if (!res.ok) throw new Error('CSV fetch failed ' + res.status);
const csv = await res.text();
const rows = parseCsv(csv);
if (!rows.length) return;
const header = rows.shift().map(s => (s || '').trim().toLowerCase());
const ci = { id: header.indexOf('id'), name: header.indexOf('name'), swiss: header.indexOf('last_swiss'), rehab: header.indexOf('last_rehab') };
['id','name','swiss','rehab'].forEach(k => { if (ci[k] < 0) throw new Error('Missing column: ' + k); });
const byId = Object.create(null), byName = Object.create(null), byNameNorm = Object.create(null);
for (const r of rows) {
const id = ((r[ci.id] ?? '') + '').trim();
const name = ((r[ci.name] ?? '') + '').trim();
const last_swiss = ((r[ci.swiss] ?? '') + '').trim();
const last_rehab = ((r[ci.rehab] ?? '') + '').trim();
const obj = { id, name, last_swiss, last_rehab };
if (id) byId[id] = obj;
if (name) { byName[name.toLowerCase()] = obj; byNameNorm[normalizeName(name)] = obj; }
}
cache = { byId, byName, byNameNorm, lastFetch: now };
saveCache();
})().finally(() => { fetchInFlight = null; });
return fetchInFlight;
}
function lookup(ref) {
if (ref.id && cache.byId?.[ref.id]) return cache.byId[ref.id];
if (ref.name) {
const n = ref.name.toLowerCase();
if (cache.byName?.[n]) return cache.byName[n];
const nn = normalizeName(ref.name);
if (cache.byNameNorm?.[nn]) return cache.byNameNorm[nn];
}
return null;
}
function mountCsvAndRefreshButtons() {
const attach = () => {
const heading = document.querySelector('.title-black.top-round.m-top10[role="heading"][aria-level="5"]');
if (!heading) return;
let chip = heading.querySelector('.tm-csv-chip');
if (!chip) {
chip = document.createElement('button');
chip.className = 'tm-csv-chip';
chip.title = 'Paste your PublicView → CSV link';
Object.assign(chip.style, {
marginLeft: '8px', padding: '2px 8px', font: '12px/1 system-ui, Arial, sans-serif',
border: '0', borderRadius: '999px', cursor: 'pointer', verticalAlign: 'middle', color: '#fff',
});
heading.appendChild(chip);
chip.addEventListener('click', async () => {
await promptForUrl();
const ok = !!getCsvUrl();
chip.textContent = ok ? 'CSV: Set' : 'CSV: Missing';
chip.style.background = ok ? '#2e7d32' : '#c62828';
if (isEmployeesRoute()) renderAllEmployees(true);
document.dispatchEvent(new CustomEvent('tm:csv-updated'));
});
}
const ok = !!getCsvUrl();
chip.textContent = ok ? 'CSV: Set' : 'CSV: Missing';
chip.style.background = ok ? '#2e7d32' : '#c62828';
let refresh = heading.querySelector('.tm-csv-refresh');
if (!refresh) {
refresh = document.createElement('button');
refresh.className = 'tm-csv-refresh';
refresh.textContent = '↻ Refresh';
Object.assign(refresh.style, {
marginLeft: '6px', padding: '2px 8px', font: '12px/1 system-ui, Arial, sans-serif',
border: '0', borderRadius: '999px', cursor: 'pointer', verticalAlign: 'middle',
background: '#424a57', color: '#fff',
});
refresh.addEventListener('click', async () => {
const old = refresh.textContent; refresh.textContent = 'Refreshing…'; refresh.disabled = true;
try { await renderAllEmployees(true); } finally { refresh.textContent = old; refresh.disabled = false; }
});
heading.appendChild(refresh);
}
};
attach();
waitFor(() => document.querySelector('.title-black.top-round.m-top10[role="heading"][aria-level="5"]'), 8000, 250)
.then(attach).catch(() => {});
}
async function promptForUrl() {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:16px;';
wrap.innerHTML = `
<div style="background:#fff;border-radius:10px;max-width:520px;width:100%;padding:14px;font:14px/1.4 system-ui, Arial">
<div style="font-weight:700;margin-bottom:8px">Paste your <i>PublicView → CSV</i> link</div>
<div style="color:#555;margin-bottom:8px">From your sheet: <b>File → Share → Publish to web → Sheet: PublicView → Format: CSV</b></div>
<input id="tm_csv_inp" placeholder="https://docs.google.com/.../pub?gid=...&single=true&output=csv" inputmode="url"
style="width:100%;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:14px/1.4 system-ui, Arial;" />
<div style="margin-top:10px;display:flex;gap:8px;justify-content:flex-end">
<button id="tm_csv_cancel" style="padding:8px 10px;border:0;border-radius:8px;background:#eee;cursor:pointer">Cancel</button>
<button id="tm_csv_save" style="padding:8px 12px;border:0;border-radius:8px;background:#1976d2;color:#fff;cursor:pointer">Save</button>
</div>
</div>`;
document.body.appendChild(wrap);
const inp = wrap.querySelector('#tm_csv_inp');
inp.value = getCsvUrl() || '';
const close = () => wrap.remove();
wrap.querySelector('#tm_csv_cancel').addEventListener('click', close);
wrap.querySelector('#tm_csv_save').addEventListener('click', async () => {
const raw = (inp.value || '').trim();
if (!raw) return;
const url = canonCsvUrl(raw);
if (!url) return;
try { const head = await fetch(url, { method:'GET', cache:'no-store', credentials:'omit' }); if (!head.ok) throw 0; }
catch { return; }
localStorage.setItem(LS_URL, url);
close();
});
setTimeout(() => inp.focus(), 50);
}
// Helpers
function waitFor(pred, timeoutMs=5000, intervalMs=200){
return new Promise((resolve, reject)=>{
const t0 = Date.now();
(function tick(){
try { const v = pred(); if (v) return resolve(v); } catch {}
if (Date.now() - t0 >= timeoutMs) return reject(new Error('timeout'));
setTimeout(tick, intervalMs);
})();
});
}
function getCsvUrl(){ return localStorage.getItem(LS_URL) || ''; }
function canonCsvUrl(u){
try{
const url = new URL(u);
if (/docs\.google\.com\/spreadsheets\/d\/.+\/pub/.test(url.href) && url.searchParams.get('output')==='csv') return url.href;
if (/docs\.google\.com\/spreadsheets\/d\/.+\/gviz\/tq/.test(url.href)) { const tqx = url.searchParams.get('tqx') || ''; if (/out:csv/i.test(tqx)) return url.href; }
if (url.protocol==='https:' && /csv\b/i.test(url.search)) return url.href;
return '';
} catch { return ''; }
}
function loadCache(){ try{ return JSON.parse(localStorage.getItem(LS_CACHE)) || {byId:{},byName:{},byNameNorm:{},lastFetch:0}; } catch { return {byId:{},byName:{},byNameNorm:{},lastFetch:0}; } }
function saveCache(){ try{ localStorage.setItem(LS_CACHE, JSON.stringify(cache)); } catch {} }
function normalizeName(s){ if(!s)return''; let t=s.normalize('NFKC'); t=t.replace(/[\u{1F3FB}-\u{1F3FF}]/gu,''); t=t.replace(/\[[^\]]*\]|\([^\)]*\)/g,''); t=t.replace(/\s+/g,' ').trim(); return t.toLowerCase(); }
function parseCsv(text){ const rows=[]; let row=[],cur='',inQ=false; for(let i=0;i<text.length;i++){ const c=text[i]; if(inQ){ if(c==='"'){ if(text[i+1]==='"'){cur+='"'; i++;} else inQ=false; } else cur+=c; } else { if(c==='"') inQ=true; else if(c===','){ row.push(cur); cur=''; } else if(c==='\n'){ row.push(cur); rows.push(row); row=[]; cur=''; } else if(c!=='\r') cur+=c; } } if(cur.length||row.length){ row.push(cur); rows.push(row); } return rows; }
// Torn date helpers
function parseTornDate(str){
const m = String(str).match(/(\d{2}):(\d{2}):(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})/);
if (!m) return null;
const [, hh, mm, ss, dd, MM, yy] = m;
const year = 2000 + (+yy);
return new Date(year, (+MM)-1, (+dd), (+hh), (+mm), (+ss));
}
function tornRelative(str){
const d = parseTornDate(str);
if (!d) return String(str);
const diff = Date.now() - d.getTime();
if (diff < 0) return String(str);
const mins = Math.floor(diff/60000);
if (mins < 60) return `${str} (${mins}m ago)`;
const hours = Math.floor(mins/60);
if (hours < 48) return `${str} (${hours}h ago)`;
const days = Math.floor(hours/24);
return `${str} (${days}d ago)`;
}
})();