Torn Bookie Tracker

Breaks out individual bets on the Torn bookie page and adds a status bar icon

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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&amp;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&amp;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&amp;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&amp;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">&#x2715;</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">&#9881;</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);
  }

})();