Greasy Fork is available in English.
Breaks out individual bets on the Torn bookie page and adds a status bar icon
// ==UserScript==
// @name Torn Bookie Tracker
// @namespace https://systoned.cc/
// @version 2.1
// @description Breaks out individual bets on the Torn bookie page and adds a status bar icon
// @author Systoned
// @match https://www.torn.com/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'bookie-tracker-panel';
const ICON_ID = 'bookie-tracker-icon';
const STORAGE_KEY = 'bt_data_v2';
const POS_KEY = 'bt_panel_pos';
const SPORT_ICONS = {
Football: '⚽',
Basketball: '🏀',
Motorsports: '🏎️',
Tennis: '🎾',
'Horse Racing': '🐎',
Rugby: '🏉',
'Rugby League': '🏉',
Baseball: '⚾',
'American Football': '🏈',
'Counter-Strike': '🎮',
'Mixed Martial arts': '🤼',
'Australian Football':'🏉',
Cricket: '🏏',
Badminton: '🏸',
Boxing: '🥊',
'Dota 2': '🎮',
Handball: '🤾',
Hockey: '🏒',
'League of Legends': '🎮',
Overwatch: '🎮',
Snooker: '🎱',
'StarCraft 2': '🎮',
Volleyball: '🏐',
};
const SPORT_CLASS_MAP = {
football: 'Football', basketball: 'Basketball', motor: 'Motorsports',
tennis: 'Tennis', horseracing: 'Horse Racing', rugby: 'Rugby',
rugbyleague: 'Rugby League', baseball: 'Baseball',
americanfootball: 'American Football', counterstrike: 'Counter-Strike',
mmaufc: 'Mixed Martial arts', australianfootball: 'Australian Football',
cricket: 'Cricket', badminton: 'Badminton', boxing: 'Boxing',
dota2: 'Dota 2', handball: 'Handball', hockey: 'Hockey',
leagueoflegends: 'League of Legends', overwatch: 'Overwatch',
snooker: 'Snooker', starcraft2: 'StarCraft 2', volleyball: 'Volleyball',
};
// ── Styles ────────────────────────────────────────────────────────────────────
const CSS = `
#${ICON_ID} {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
cursor: pointer;
position: relative;
vertical-align: middle;
}
#${ICON_ID} svg {
width: 15px;
height: 15px;
fill: none;
stroke: #c8a84b;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke 0.15s;
}
#${ICON_ID}:hover svg { stroke: #f0cc6a; }
#${ICON_ID} .bt-badge {
position: absolute;
top: -3px; right: -4px;
background: #c0392b;
color: #fff;
font-size: 8px;
font-weight: 700;
min-width: 12px;
height: 12px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 2px;
font-family: sans-serif;
}
#${PANEL_ID} {
position: fixed;
top: 50px;
right: 8px;
left: 8px;
width: auto;
max-height: 80vh;
z-index: 99999;
background: #2d2d2d;
border: 1px solid #111;
border-radius: 5px;
box-shadow: 0 4px 24px rgba(0,0,0,0.7);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
color: #ddd;
display: none;
flex-direction: column;
overflow: hidden;
}
#${PANEL_ID}.visible { display: flex; }
#${PANEL_ID} * { box-sizing: border-box; }
#${PANEL_ID} { overflow-x: hidden; }
@media (min-width: 460px) {
#${PANEL_ID} { left: auto; width: 380px; }
}
.bt-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #1a1a1a;
padding: 7px 12px;
border-bottom: 1px solid #111;
flex-shrink: 0;
cursor: move;
user-select: none;
}
.bt-header-title {
font-size: 12px;
font-weight: 700;
color: #fff;
}
.bt-close {
cursor: pointer;
color: #888;
font-size: 16px;
line-height: 1;
}
.bt-close:hover { color: #e55; }
.bt-cog {
cursor: pointer;
color: #888;
font-size: 14px;
line-height: 1;
padding: 2px 4px;
transition: color 0.15s, transform 0.2s;
display: inline-flex;
align-items: center;
}
.bt-cog:hover { color: #c8a84b; }
.bt-cog.active { color: #c8a84b; transform: rotate(45deg); }
.bt-settings {
background: #252525;
border-bottom: 1px solid #111;
padding: 10px 12px;
display: none;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.bt-settings.open { display: flex; }
.bt-settings-explain {
font-size: 11px;
color: #999;
line-height: 1.5;
}
.bt-settings-hint {
font-size: 11px;
color: #c8a84b;
line-height: 1.5;
padding: 6px 8px;
background: #3a3000;
border-left: 2px solid #c8a84b;
border-radius: 2px;
}
.bt-footer {
padding: 6px 12px;
text-align: center;
font-size: 10px;
color: #555;
border-top: 1px solid #222;
background: #1a1a1a;
flex-shrink: 0;
}
.bt-footer a {
color: #888;
text-decoration: none;
}
.bt-footer a:hover { color: #c8a84b; }
.bt-reseed-notice {
background: #3a3000;
border-bottom: 1px solid #555200;
padding: 8px 12px;
font-size: 11px;
color: #c8a84b;
text-align: center;
line-height: 1.5;
flex-shrink: 0;
}
.bt-tabs {
display: flex;
background: #222;
border-bottom: 2px solid #111;
flex-shrink: 0;
}
.bt-tab {
flex: 1;
padding: 8px 4px;
text-align: center;
font-size: 11px;
font-weight: 600;
color: #888;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.bt-tab:hover { color: #ccc; }
.bt-tab.active {
color: #fff;
border-bottom-color: #c8a84b;
}
.bt-content {
overflow-y: auto;
flex: 1;
}
.bt-pane { display: none; }
.bt-pane.active { display: block; }
.bt-stats {
display: grid;
grid-template-columns: 1fr 1fr;
}
.bt-stat {
padding: 10px 14px;
border-bottom: 1px solid #222;
border-right: 1px solid #222;
}
.bt-stat:nth-child(even) { border-right: none; }
.bt-stat-label {
font-size: 10px;
color: #888;
margin-bottom: 3px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.bt-stat-value {
font-size: 16px;
font-weight: 700;
color: #fff;
}
.bt-stat-value.green { color: #75b855; }
.bt-stat-value.red { color: #e05c3a; }
.bt-stat-value.gold { color: #c8a84b; }
.bt-wl {
font-size: 16px;
font-weight: 700;
}
.bt-wl .w { color: #75b855; }
.bt-wl .sep { color: #555; margin: 0 2px; }
.bt-wl .l { color: #e05c3a; }
.bt-sports {
padding: 8px 12px 10px;
border-bottom: 1px solid #222;
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.bt-sports-label {
width: 100%;
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3px;
}
.bt-sport-chip {
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
padding: 2px 7px;
font-size: 11px;
color: #ccc;
}
.bt-event {
border-bottom: 1px solid #222;
}
.bt-event-header {
background: #383838;
padding: 5px 12px;
font-size: 11px;
font-weight: 700;
color: #fff;
display: flex;
align-items: flex-start;
gap: 6px;
word-break: break-word;
}
.bt-bet {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 0 6px;
align-items: center;
padding: 5px 10px;
background: #2d2d2d;
}
.bt-bet:nth-child(even) { background: #313131; }
.bt-bet-sel {
font-size: 11px;
color: #ccc;
word-break: break-word;
}
.bt-bet-market {
font-size: 10px;
color: #777;
}
.bt-bet-odds {
font-size: 11px;
color: #999;
white-space: nowrap;
}
.bt-bet-stake {
font-size: 11px;
color: #bbb;
white-space: nowrap;
text-align: right;
}
.bt-bet-result {
font-size: 11px;
font-weight: 700;
white-space: nowrap;
text-align: right;
}
.bt-bet-result.won { color: #75b855; }
.bt-bet-result.lost { color: #e05c3a; }
.bt-bet-result.pending { color: #c8a84b; }
.bt-bet-result.refunded { color: #888; }
.bt-event-total {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 4px 12px;
background: #272727;
font-size: 11px;
border-top: 1px solid #222;
}
.bt-event-total-label { color: #666; }
.bt-event-total-val { font-weight: 700; }
.bt-event-total-val.green { color: #75b855; }
.bt-event-total-val.red { color: #e05c3a; }
.bt-event-total-val.gold { color: #c8a84b; }
.bt-pane-summary {
display: flex;
gap: 0;
border-bottom: 2px solid #111;
flex-shrink: 0;
}
.bt-pane-stat {
flex: 1;
padding: 8px 12px;
background: #252525;
border-right: 1px solid #1a1a1a;
}
.bt-pane-stat:last-child { border-right: none; }
.bt-pane-stat-label {
display: block;
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 2px;
}
.bt-pane-stat-value {
display: block;
font-size: 14px;
font-weight: 700;
color: #fff;
}
.bt-pane-stat-value.green { color: #75b855; }
.bt-pane-stat-value.red { color: #e05c3a; }
.bt-pane-stat-value.gold { color: #c8a84b; }
.bt-empty {
padding: 20px;
text-align: center;
color: #666;
font-size: 12px;
line-height: 1.6;
}
.bt-update-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #222;
padding: 5px 12px;
border-bottom: 1px solid #111;
flex-shrink: 0;
}
.bt-saved-at {
font-size: 10px;
color: #666;
}
.bt-update-btn {
background: #3a3000;
border: 1px solid #c8a84b;
border-radius: 3px;
color: #c8a84b;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
cursor: pointer;
font-family: inherit;
}
.bt-update-btn:hover {
background: #4a3d00;
color: #f0cc6a;
}
.bt-update-btn:disabled {
background: #2a2a2a;
border-color: #444;
color: #555;
cursor: not-allowed;
}
.bt-update-btn:disabled:hover {
background: #2a2a2a;
color: #555;
}
.bt-sport-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 0 10px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #222;
background: #2d2d2d;
}
.bt-sport-row:nth-child(even) { background: #313131; }
.bt-sport-name {
font-size: 12px;
font-weight: 600;
color: #ddd;
display: flex;
align-items: center;
gap: 6px;
}
.bt-sport-wl {
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.bt-sport-wl .w { color: #75b855; }
.bt-sport-wl .sep { color: #555; margin: 0 2px; }
.bt-sport-wl .l { color: #e05c3a; }
.bt-sport-stake {
font-size: 11px;
color: #999;
white-space: nowrap;
text-align: right;
}
.bt-sport-pnl {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
text-align: right;
min-width: 70px;
}
.bt-sport-pnl.green { color: #75b855; }
.bt-sport-pnl.red { color: #e05c3a; }
.bt-sport-pnl.gold { color: #c8a84b; }
.bt-sport-row-header {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 0 10px;
padding: 6px 12px;
background: #1a1a1a;
border-bottom: 1px solid #111;
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.bt-sport-row-header > div:nth-child(2),
.bt-sport-row-header > div:nth-child(3),
.bt-sport-row-header > div:nth-child(4) {
text-align: right;
}
`;
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'b';
if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'm';
if (Math.abs(n) >= 1_000) return Math.round(n / 1_000) + 'k';
return Math.round(n).toLocaleString();
}
function fmtSigned(n) {
if (n === 0) return '$0';
return (n > 0 ? '+$' : '-$') + fmt(Math.abs(n));
}
function colorClass(n) {
if (n > 0) return 'green';
if (n < 0) return 'red';
return 'gold';
}
function parseBetTitle(titleStr, defaultStatus) {
const lines = titleStr.split(/<br\s*\/?>/i).map(s => s.trim()).filter(Boolean);
return lines.map(line => {
const statusMatch = line.match(/^(Won|Lost|Pending|Refunded)\s+/i);
const status = statusMatch ? statusMatch[1].toLowerCase() : defaultStatus;
line = line.replace(/^(Won|Lost|Pending|Refunded)\s+/i, '');
const dollarMatch = line.match(/^\$([\d,]+)/);
let firstDollar = 0;
if (dollarMatch) {
firstDollar = parseFloat(dollarMatch[1].replace(/,/g, ''));
line = line.slice(dollarMatch[0].length).trim();
}
const oddsMatch = line.match(/^\(x([\d.]+)\)/);
let odds = null;
if (oddsMatch) {
odds = parseFloat(oddsMatch[1]);
line = line.slice(oddsMatch[0].length).trim();
}
// For won bets: title is "Won $WINNINGS (xODDS) from a $STAKE bet on..."
// For everything else: title is "$STAKE (xODDS) bet on..."
let stake = firstDollar;
const fromAMatch = line.match(/^from\s+a\s+\$([\d,]+)\s+bet\s+/i);
if (fromAMatch) {
stake = parseFloat(fromAMatch[1].replace(/,/g, ''));
line = line.slice(fromAMatch[0].length).trim();
} else {
line = line.replace(/^bet on\s*/i, '');
}
const marketMatch = line.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
let selection = line;
let market = '';
if (marketMatch) {
selection = marketMatch[1].trim();
market = marketMatch[2].trim();
}
let pnl = 0;
if (status === 'won' && odds) pnl = Math.round(stake * (odds - 1));
if (status === 'lost') pnl = -stake;
return { status, stake, odds, selection, market, pnl };
});
}
// Stable key for a bet
function betKey(matchId, bet) {
return [matchId || 'nomatch', bet.selection, bet.market, bet.stake].join('|');
}
// ── Scraping ─────────────────────────────────────────────────────────────────
// Walk ul.pop-list. Section headers are <li> with class "title" plus one of
// "live", "upcomming" (sic), or "completed". Everything else between headers
// is a match row.
function scrapeBets() {
const bets = {};
let bucket = null;
document.querySelectorAll('ul.pop-list > li').forEach(li => {
if (li.classList.contains('title')) {
if (li.classList.contains('completed')) bucket = 'completed';
else if (li.classList.contains('upcomming') || li.classList.contains('live')) bucket = 'pending';
else bucket = null;
return;
}
if (!bucket) return;
const anchor = li.querySelector('a[href*="/your-bets/"]');
if (!anchor) return;
// i-data is stable per match (e.g. i_192_375_784_33). The href contains
// a bet ID which changes whenever you add another bet to the same match,
// so it can't be used as a match identifier.
const matchId = anchor.getAttribute('i-data') || null;
const matchNameEl = li.querySelector('[class*="matchName"] p');
const matchName = matchNameEl ? (matchNameEl.title || matchNameEl.textContent.trim()) : 'Unknown';
const sportEl = li.querySelector('[class*="game"] i');
let sport = 'Unknown';
if (sportEl) {
const m = (sportEl.className || '').match(/gm-([a-z]+)-icon/);
if (m) sport = SPORT_CLASS_MAP[m[1]] || m[1];
}
li.querySelectorAll('.stick').forEach(stick => {
const textEl = stick.querySelector('.text');
if (!textEl) return;
const title = textEl.getAttribute('title') || '';
if (!title) return;
let defaultStatus = 'pending';
if (stick.classList.contains('won')) defaultStatus = 'won';
else if (stick.classList.contains('lost')) defaultStatus = 'lost';
else if (stick.classList.contains('refunded')) defaultStatus = 'refunded';
parseBetTitle(title, defaultStatus).forEach(parsed => {
const key = betKey(matchId, parsed);
bets[key] = { ...parsed, matchId, matchName, sport };
});
});
});
return bets;
}
// ── Storage ───────────────────────────────────────────────────────────────────
function loadData() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { bets: {}, savedAt: null };
} catch (e) {
return { bets: {}, savedAt: null };
}
}
function saveData(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function hasSavedData() {
const d = loadData();
return d.bets && Object.keys(d.bets).length > 0;
}
function savePos(pos) {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch (e) {}
}
function loadPos() {
try { return JSON.parse(localStorage.getItem(POS_KEY)) || null; } catch (e) { return null; }
}
function getValidPos() {
const pos = loadPos();
if (!pos) return null;
if (pos.left < 0 || pos.top < 0) return null;
if (pos.left > window.innerWidth - 40 || pos.top > window.innerHeight - 40) return null;
return pos;
}
// ── Bucketing for render ──────────────────────────────────────────────────────
// Turn the flat bets object into pending/completed event groups for rendering.
// A bet is pending if its status is 'pending', otherwise it's completed.
// Groups by match name (the only stable per-match identifier on Torn) and
// dedupes bets within a match by selection+market+stake+odds, so identical
// bets that ended up stored under different keys only render once.
function groupByEvent(bets) {
const pending = new Map();
const completed = new Map();
Object.values(bets).forEach(b => {
const target = b.status === 'pending' ? pending : completed;
const key = b.matchName;
if (!target.has(key)) {
target.set(key, { matchName: b.matchName, sport: b.sport, bets: [], seen: new Set() });
}
const entry = target.get(key);
const betDedupKey = [b.selection, b.market, b.stake, b.odds].join('|');
if (entry.seen.has(betDedupKey)) return;
entry.seen.add(betDedupKey);
entry.bets.push(b);
});
// Strip the seen set before returning
const strip = list => list.map(({ seen, ...rest }) => rest);
return { pending: strip([...pending.values()]), completed: strip([...completed.values()]) };
}
// ── Render ────────────────────────────────────────────────────────────────────
function renderOverview(pending, completed) {
const pendingCount = pending.reduce((n, e) => n + e.bets.length, 0);
const pendingStake = pending.reduce((s, e) => s + e.bets.reduce((b, bet) => b + bet.stake, 0), 0);
const pendingSports = [...new Set(pending.map(e => e.sport))];
let wins = 0, losses = 0, pnl = 0;
completed.forEach(e => e.bets.forEach(b => {
if (b.status === 'won') wins++;
if (b.status === 'lost') losses++;
if (b.status !== 'refunded') pnl += b.pnl;
}));
const sportChips = pendingSports.map(s =>
`<span class="bt-sport-chip">${SPORT_ICONS[s] || '🎲'} ${s}</span>`
).join('');
return `
<div class="bt-stats">
<div class="bt-stat">
<div class="bt-stat-label">Pending bets</div>
<div class="bt-stat-value gold">${pendingCount}</div>
</div>
<div class="bt-stat">
<div class="bt-stat-label">At stake</div>
<div class="bt-stat-value gold">$${fmt(pendingStake)}</div>
</div>
<div class="bt-stat">
<div class="bt-stat-label">Win / Loss</div>
<div class="bt-wl"><span class="w">${wins}W</span><span class="sep">/</span><span class="l">${losses}L</span></div>
</div>
<div class="bt-stat">
<div class="bt-stat-label">Total P&L</div>
<div class="bt-stat-value ${colorClass(pnl)}">${fmtSigned(pnl)}</div>
</div>
</div>
${pendingSports.length ? `
<div class="bt-sports">
<div class="bt-sports-label">Sports in play</div>
${sportChips}
</div>` : ''}
`;
}
function renderEvents(events, isPending) {
if (!events.length) return `<div class="bt-empty">No bets found.<br>Scroll the bookie page to load them first.</div>`;
const allBets = events.flatMap(e => e.bets);
const totalStake = allBets.reduce((s, b) => s + b.stake, 0);
let summaryHtml;
if (isPending) {
const totalReturn = allBets.reduce((s, b) => s + (b.odds ? Math.round(b.stake * b.odds) : b.stake), 0);
summaryHtml = `
<div class="bt-pane-summary">
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Total staked</span><span class="bt-pane-stat-value">$${fmt(totalStake)}</span></div>
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Potential return</span><span class="bt-pane-stat-value gold">$${fmt(totalReturn)}</span></div>
</div>`;
} else {
const totalPnl = allBets.filter(b => b.status !== 'refunded').reduce((s, b) => s + b.pnl, 0);
summaryHtml = `
<div class="bt-pane-summary">
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Total staked</span><span class="bt-pane-stat-value">$${fmt(totalStake)}</span></div>
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Total P&L</span><span class="bt-pane-stat-value ${colorClass(totalPnl)}">${fmtSigned(totalPnl)}</span></div>
</div>`;
}
const eventsHtml = events.map(ev => {
const icon = SPORT_ICONS[ev.sport] || '🎲';
const eventPnl = ev.bets.reduce((s, b) => s + b.pnl, 0);
const showTotal = ev.bets.length > 1 && !isPending;
const betRows = ev.bets.map(b => {
let resultStr, resultClass;
if (b.status === 'won') { resultStr = '+$' + fmt(b.pnl); resultClass = 'won'; }
else if (b.status === 'lost') { resultStr = '-$' + fmt(b.stake); resultClass = 'lost'; }
else if (b.status === 'pending') { resultStr = 'Pending'; resultClass = 'pending'; }
else { resultStr = 'Refunded'; resultClass = 'refunded'; }
return `
<div class="bt-bet">
<div>
<div class="bt-bet-sel" title="${b.selection}">${b.selection}</div>
${b.market ? `<div class="bt-bet-market">${b.market}</div>` : ''}
</div>
<div class="bt-bet-odds">${b.odds ? 'x' + b.odds : ''}</div>
<div class="bt-bet-stake">$${fmt(b.stake)}</div>
<div class="bt-bet-result ${resultClass}">${resultStr}</div>
</div>`;
}).join('');
const totalRow = showTotal ? `
<div class="bt-event-total">
<span class="bt-event-total-label">Event total</span>
<span class="bt-event-total-val ${colorClass(eventPnl)}">${fmtSigned(eventPnl)}</span>
</div>` : '';
return `
<div class="bt-event">
<div class="bt-event-header">${icon} ${ev.matchName}</div>
${betRows}
${totalRow}
</div>`;
}).join('');
return summaryHtml + eventsHtml;
}
function renderBySport(completed) {
if (!completed.length) {
return `<div class="bt-empty">No completed bets yet.<br>Click update to load them.</div>`;
}
const bySport = new Map();
completed.forEach(ev => {
ev.bets.forEach(b => {
if (b.status === 'refunded') return;
const row = bySport.get(ev.sport) || { sport: ev.sport, wins: 0, losses: 0, stake: 0, pnl: 0 };
if (b.status === 'won') row.wins++;
if (b.status === 'lost') row.losses++;
row.stake += b.stake;
row.pnl += b.pnl;
bySport.set(ev.sport, row);
});
});
if (!bySport.size) {
return `<div class="bt-empty">No completed bets yet.</div>`;
}
const rows = [...bySport.values()].sort((a, b) => b.pnl - a.pnl);
const totalPnl = rows.reduce((s, r) => s + r.pnl, 0);
const totalStake = rows.reduce((s, r) => s + r.stake, 0);
const summary = `
<div class="bt-pane-summary">
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Sports tracked</span><span class="bt-pane-stat-value">${rows.length}</span></div>
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Total staked</span><span class="bt-pane-stat-value">$${fmt(totalStake)}</span></div>
<div class="bt-pane-stat"><span class="bt-pane-stat-label">Total P&L</span><span class="bt-pane-stat-value ${colorClass(totalPnl)}">${fmtSigned(totalPnl)}</span></div>
</div>`;
const header = `
<div class="bt-sport-row-header">
<div>Sport</div>
<div>W/L</div>
<div>Staked</div>
<div>P&L</div>
</div>`;
const rowsHtml = rows.map(r => `
<div class="bt-sport-row">
<div class="bt-sport-name">${SPORT_ICONS[r.sport] || '🎲'} ${r.sport}</div>
<div class="bt-sport-wl"><span class="w">${r.wins}W</span><span class="sep">/</span><span class="l">${r.losses}L</span></div>
<div class="bt-sport-stake">$${fmt(r.stake)}</div>
<div class="bt-sport-pnl ${colorClass(r.pnl)}">${fmtSigned(r.pnl)}</div>
</div>`).join('');
return summary + header + rowsHtml;
}
// ── Panel ─────────────────────────────────────────────────────────────────────
function isBookiePage() {
return location.href.includes('sid=bookie');
}
function buildPanel() {
let panel = document.getElementById(PANEL_ID);
if (!panel) {
panel = document.createElement('div');
panel.id = PANEL_ID;
document.body.appendChild(panel);
}
renderPanel(panel);
applyPos(panel);
return panel;
}
function applyPos(panel) {
const pos = getValidPos();
if (pos) {
panel.style.left = pos.left + 'px';
panel.style.top = pos.top + 'px';
panel.style.right = 'auto';
}
}
let dragState = null;
function ensureDocDragListeners() {
if (ensureDocDragListeners.bound) return;
ensureDocDragListeners.bound = true;
document.addEventListener('mousemove', (e) => {
if (!dragState) return;
const { panel, startX, startY, startLeft, startTop } = dragState;
let newLeft = startLeft + (e.clientX - startX);
let newTop = startTop + (e.clientY - startY);
const maxLeft = window.innerWidth - 40;
const maxTop = window.innerHeight - 40;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragState) return;
const { panel } = dragState;
dragState = null;
const rect = panel.getBoundingClientRect();
savePos({ left: rect.left, top: rect.top });
});
}
function attachDrag(panel) {
const header = panel.querySelector('.bt-header');
if (!header) return;
ensureDocDragListeners();
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.bt-close')) return;
const rect = panel.getBoundingClientRect();
dragState = {
panel,
startX: e.clientX,
startY: e.clientY,
startLeft: rect.left,
startTop: rect.top,
};
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
e.preventDefault();
});
}
function renderPanel(panel) {
const data = loadData();
const { pending, completed } = groupByEvent(data.bets);
const pendingCount = pending.reduce((n, e) => n + e.bets.length, 0);
const completedCount = completed.reduce((n, e) => n + e.bets.length, 0);
const savedStr = data.savedAt ? 'Updated ' + new Date(data.savedAt).toLocaleTimeString() : 'No data yet';
const onBookie = isBookiePage();
const empty = !hasSavedData();
const hint = empty
? `Scroll to the bottom of the bookie page first to load all your bets, then click update.`
: `If you feel you're missing data, scroll to the bottom of the bookie page and manually click update.`;
panel.innerHTML = `
<div class="bt-header">
<span class="bt-header-title">Bookie Tracker</span>
<span class="bt-close" id="bt-close-btn">✕</span>
</div>
<div class="bt-update-bar">
<span class="bt-saved-at">${savedStr}</span>
<div style="display:flex;align-items:center;gap:8px">
<button class="bt-update-btn" id="bt-update-btn" ${onBookie ? '' : 'disabled title="Go to the bookie page to update"'}>Update bets</button>
<span class="bt-cog" id="bt-cog-btn" title="Settings">⚙</span>
</div>
</div>
<div class="bt-settings" id="bt-settings">
<div class="bt-settings-explain">
Bookie Tracker stores your Torn bookie history locally in your browser. Click Update on the bookie page to refresh your data.
</div>
<div class="bt-settings-hint">${hint}</div>
</div>
${empty ? `
<div class="bt-reseed-notice">
Scroll to the bottom of the bookie page and click Update to collect your data.
</div>
` : ''}
<div class="bt-tabs">
<div class="bt-tab active" data-tab="overview">Overview</div>
<div class="bt-tab" data-tab="pending">Pending (${pendingCount})</div>
<div class="bt-tab" data-tab="completed">Completed (${completedCount})</div>
<div class="bt-tab" data-tab="bysport">By sport</div>
</div>
<div class="bt-content">
<div class="bt-pane active" data-pane="overview">${renderOverview(pending, completed)}</div>
<div class="bt-pane" data-pane="pending">${renderEvents(pending, true)}</div>
<div class="bt-pane" data-pane="completed">${renderEvents(completed, false)}</div>
<div class="bt-pane" data-pane="bysport">${renderBySport(completed)}</div>
</div>
<div class="bt-footer">
Made by <a href="https://www.torn.com/profiles.php?XID=3583736" target="_blank">Systoned</a>
</div>
`;
panel.querySelector('#bt-close-btn').addEventListener('click', () => {
panel.classList.remove('visible');
});
const cogBtn = panel.querySelector('#bt-cog-btn');
const settings = panel.querySelector('#bt-settings');
cogBtn.addEventListener('click', () => {
const opened = settings.classList.toggle('open');
cogBtn.classList.toggle('active', opened);
});
const updateBtn = panel.querySelector('#bt-update-btn');
if (updateBtn) {
updateBtn.addEventListener('click', () => {
runUpdate();
renderPanel(panel);
});
}
panel.querySelectorAll('.bt-tab').forEach(tab => {
tab.addEventListener('click', () => {
panel.querySelectorAll('.bt-tab').forEach(t => t.classList.remove('active'));
panel.querySelectorAll('.bt-pane').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
panel.querySelector(`[data-pane="${tab.dataset.tab}"]`).classList.add('active');
});
});
attachDrag(panel);
}
// Scrape the page. Pending in storage is replaced by pending on the page
// (stale pending entries are removed). Completed bets accumulate.
function runUpdate() {
if (!isBookiePage()) return false;
const fresh = scrapeBets();
const data = loadData();
// Remove stored pending bets that are no longer on the page.
Object.keys(data.bets).forEach(key => {
if (data.bets[key].status === 'pending' && !(key in fresh)) {
delete data.bets[key];
}
});
// Merge fresh scrape into storage (adds new bets, updates existing).
Object.assign(data.bets, fresh);
data.savedAt = Date.now();
saveData(data);
updateBadge();
return true;
}
function togglePanel() {
let panel = document.getElementById(PANEL_ID);
if (panel && panel.classList.contains('visible')) {
panel.classList.remove('visible');
return;
}
panel = buildPanel();
panel.classList.add('visible');
}
// ── Badge ─────────────────────────────────────────────────────────────────────
function updateBadge() {
const icon = document.getElementById(ICON_ID);
if (!icon) return;
const badge = icon.querySelector('.bt-badge');
if (!badge) return;
const data = loadData();
const { pending } = groupByEvent(data.bets);
const count = pending.reduce((n, e) => n + e.bets.length, 0);
badge.textContent = count;
badge.style.display = count > 0 ? 'flex' : 'none';
}
// ── Status bar icon ───────────────────────────────────────────────────────────
function injectIcon() {
if (document.getElementById(ICON_ID)) return;
const statusBar = document.querySelector('ul[class*="status-icons"]');
if (!statusBar) return;
const li = document.createElement('li');
li.id = ICON_ID;
li.title = 'Bookie Tracker';
li.innerHTML = `
<svg viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="12" height="12" rx="1.5"/>
<line x1="4" y1="5" x2="11" y2="5"/>
<line x1="4" y1="7.5" x2="11" y2="7.5"/>
<line x1="4" y1="10" x2="8" y2="10"/>
</svg>
<span class="bt-badge" style="display:none"></span>
`;
li.addEventListener('click', togglePanel);
statusBar.appendChild(li);
updateBadge();
}
// ── Init ──────────────────────────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById('bt-styles')) return;
const style = document.createElement('style');
style.id = 'bt-styles';
style.textContent = CSS;
document.head.appendChild(style);
}
function init() {
injectStyles();
if (document.querySelector('ul[class*="status-icons"]')) {
injectIcon();
} else {
const observer = new MutationObserver(() => {
if (document.querySelector('ul[class*="status-icons"]')) {
observer.disconnect();
injectIcon();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
}
if (document.readyState !== 'loading') {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();