WorldGuessr Checker (Leaderboard + Individual Elo/XP) — Pro UI

Professional panel to check WorldGuessr leaderboards (elo/xp, all-time/past-day) and individual player stats with friendly formatting. Minimize to top-left.

// ==UserScript==
// @name         WorldGuessr Checker (Leaderboard + Individual Elo/XP) — Pro UI
// @namespace    wg-helper
// @version      1.1.0
// @description  Professional panel to check WorldGuessr leaderboards (elo/xp, all-time/past-day) and individual player stats with friendly formatting. Minimize to top-left.
// @author       you
// @match        https://worldguessr.com/*
// @match        https://www.worldguessr.com/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.worldguessr.com
// ==/UserScript==

(function () {
  'use strict';

  GM_addStyle(`
    #wg-helper {
      position: fixed;
      right: 16px;
      bottom: 16px;
      width: 520px;
      max-height: 80vh;
      background: #0f172a;
      color: #e2e8f0;
      border: 1px solid rgba(148,163,184,.3);
      border-radius: 16px;
      box-shadow: 0 20px 40px rgba(0,0,0,.35);
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
      overflow: hidden;
      z-index: 999999;
    }
    #wg-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 10px 12px; background: #111827;
      border-bottom: 1px solid rgba(148,163,184,.2);
      font-weight: 600; letter-spacing: .2px;
    }
    #wg-title { font-size: 14px; }
    #wg-actions { display:flex; gap:8px; }
    .wg-btn {
      cursor: pointer; border: 1px solid rgba(148,163,184,.3);
      background: #0b1220; color: #e2e8f0;
      padding: 6px 10px; border-radius: 10px; font-size: 12px;
    }
    .wg-btn:hover { background: #0f172a; }
    #wg-body { padding: 12px; display:flex; flex-direction: column; gap: 10px; }
    .wg-row { display:flex; gap: 8px; flex-wrap: wrap; align-items: center; }
    .wg-chip {
      padding: 6px 10px; border-radius: 999px; font-size: 12px;
      border: 1px solid rgba(148,163,184,.25); cursor: pointer;
      background:#0b1220;
    }
    .wg-chip.active { background: #1f2937; border-color: #60a5fa; }
    .wg-input {
      flex: 1;
      background: #0b1220; color: #e5e7eb; border: 1px solid rgba(148,163,184,.3);
      border-radius: 10px; padding: 8px 10px; font-size: 12px;
    }
    #wg-output {
      white-space: pre-wrap;
      background: #0b1220;
      border: 1px solid rgba(148,163,184,.25);
      border-radius: 12px;
      padding: 10px;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      font-size: 12px;
      color: #d1d5db;
      overflow: auto;
      max-height: 54vh;
    }
    .wg-section-title {
      font-size: 12px; font-weight: 700; color: #93c5fd;
      margin-top: 2px; margin-bottom: 4px;
    }
    .wg-muted { color: #9ca3af; font-size: 12px; }
    .wg-sep { height: 1px; background: rgba(148,163,184,.2); margin: 6px 0; }
    #wg-footer { padding: 8px 12px; display:flex; gap:8px; justify-content:flex-end; border-top:1px solid rgba(148,163,184,.2); }
    .wg-hidden { display:none !important; }
    .wg-mini { width: 320px; }
    #wg-restore {
      position: fixed;
      top: 10px; left: 10px;
      z-index: 1000000;
      background: #111827;
      color: #e2e8f0;
      border: 1px solid rgba(148,163,184,.3);
      padding: 6px 8px;
      font-size: 12px;
      border-radius: 10px;
      cursor: pointer;
      box-shadow: 0 8px 20px rgba(0,0,0,.35);
    }
  `);

  const panel = document.createElement('div');
  panel.id = 'wg-helper';
  panel.innerHTML = `
    <div id="wg-header">
      <div id="wg-title">WorldGuessr Checker</div>
      <div id="wg-actions">
        <button class="wg-btn" id="wg-copy">Copy</button>
        <button class="wg-btn" id="wg-clear">Clear</button>
        <button class="wg-btn" id="wg-size">Compact</button>
        <button class="wg-btn" id="wg-min">Minimize</button>
        <button class="wg-btn" id="wg-close">✕</button>
      </div>
    </div>
    <div id="wg-body">
      <div class="wg-row">
        <span class="wg-muted">View:</span>
        <span class="wg-chip active" data-view="leaderboard">Leaderboard</span>
        <span class="wg-chip" data-view="individual">Individual</span>
      </div>
      <div class="wg-row">
        <span class="wg-muted">Mode:</span>
        <span class="wg-chip active" data-mode="elo">Elo</span>
        <span class="wg-chip" data-mode="xp">XP</span>
        <span class="wg-sep" style="flex:1;background:transparent"></span>
        <span class="wg-muted">Range:</span>
        <span class="wg-chip active" data-range="all">All-time</span>
        <span class="wg-chip" data-range="day">Past-day</span>
      </div>
      <div class="wg-row" id="wg-username-row">
        <input id="wg-username" class="wg-input" placeholder="Enter username for Individual view…" />
      </div>
      <div class="wg-row">
        <button class="wg-btn" id="wg-run">Fetch</button>
      </div>
      <div>
        <div class="wg-section-title">Output</div>
        <div id="wg-output">(results will appear here)</div>
      </div>
    </div>
    <div id="wg-footer" class="wg-hidden">
      <span class="wg-muted">Tip: Leaderboard = top 100; Individual = single player. Processing does not show URLs.</span>
    </div>
  `;
  document.body.appendChild(panel);

  const restoreBtn = document.createElement('button');
  restoreBtn.id = 'wg-restore';
  restoreBtn.textContent = 'WG';
  restoreBtn.classList.add('wg-hidden');
  document.body.appendChild(restoreBtn);

  const state = {
    view: 'leaderboard',
    mode: 'elo',
    range: 'all',
    username: ''
  };

  const $ = (sel) => panel.querySelector(sel);
  const $all = (sel) => Array.from(panel.querySelectorAll(sel));
  const output = $('#wg-output');

  function setActive(groupSelector, attr, value) {
    $all(groupSelector).forEach(chip => {
      if (chip.getAttribute(attr) === value) chip.classList.add('active');
      else chip.classList.remove('active');
    });
  }

  function showUsernameRow(show) {
    $('#wg-username-row').classList.toggle('wg-hidden', !show);
  }

  function setOutput(text) {
    output.textContent = text;
  }

  function appendOutput(text) {
    output.textContent += text;
  }

  function gmGet(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers: { 'Accept': 'application/json' },
        onload: (res) => {
          try {
            if (res.status < 200 || res.status >= 300) {
              return reject(new Error(`HTTP ${res.status}`));
            }
            resolve(JSON.parse(res.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: () => reject(new Error(`Network error`))
      });
    });
  }

  function pick(valA, valB) {
    return (valA !== undefined && valA !== null) ? valA : valB;
  }

  function formatPlayerLine(p, mode) {
    const createdAt = pick(p.createdAt, p.joinDate);
    const lines = [
      `Username: ${p.username ?? '—'}`,
      `Total XP: ${p.totalXp ?? '—'}`,
      `createdAt: ${createdAt ?? '—'}`,
      `elo: ${p.elo ?? '—'}`,
      `rank: ${p.rank ?? '—'}`
    ];
    if (mode === 'elo' && p.eloToday != null) {
      lines.push(`Elo Gained Today: ${p.eloToday}`);
    } else if (mode === 'xp' && (p.xpToday != null || p.totalXpToday != null)) {
      const xpGain = p.xpToday ?? p.totalXpToday;
      lines.push(`XP Gained Today: ${xpGain}`);
    }
    return lines.join('\n');
  }

  function formatLeaderboard(list, mode) {
    if (!Array.isArray(list) || list.length === 0) {
      return '(no leaderboard data)';
    }
    return list.map(p => formatPlayerLine(p, mode)).join('\n\n');
  }

  function buildLeaderboardUrl(mode, range) {
    const base = `https://api.worldguessr.com/api/leaderboard`;
    const params = new URLSearchParams({ mode });
    if (range === 'day') params.set('pastDay', 'true');
    return `${base}?${params.toString()}`;
  }

  function buildIndividualUrls(username, mode, range) {
    const urls = {};
    if (mode === 'elo') {
      urls.elo = `https://api.worldguessr.com/api/eloRank?username=${encodeURIComponent(username)}`;
    } else if (mode === 'xp') {
      const base = `https://api.worldguessr.com/api/leaderboard`;
      const params = new URLSearchParams({ username, mode: 'xp' });
      if (range === 'day') params.set('pastDay', 'true');
      urls.xp = `${base}?${params.toString()}`;
    }
    return urls;
  }

  function formatIndividualElo(e, usernameFromInput) {
    const uname = usernameFromInput || e.username || '—';
    const leagueName = e?.league?.name || '—';
    const emoji = e?.league?.emoji || '';
    const winPct = (typeof e.win_rate === 'number')
      ? (e.win_rate * 100).toFixed(2) + '%'
      : (e.win_rate ?? '—');
    const lines = [
      `Username: ${uname}`,
      `Elo: ${e.elo ?? '—'}`,
      `Rank: ${e.rank ?? '—'}`,
      `Rank: ${leagueName} ${emoji}`.trim(),
      `Duel Wins: ${e.duels_wins ?? '—'}`,
      `Duel Losses: ${e.duels_losses ?? '—'}`,
      `Duels Tied: ${e.duels_tied ?? '—'}`,
      `Win Rate: ${winPct}`
    ];
    return lines.join('\n');
  }

  $all('.wg-chip[data-view]').forEach(chip => {
    chip.addEventListener('click', () => {
      state.view = chip.getAttribute('data-view');
      setActive('.wg-chip[data-view]', 'data-view', state.view);
      showUsernameRow(state.view === 'individual');
    });
  });

  $all('.wg-chip[data-mode]').forEach(chip => {
    chip.addEventListener('click', () => {
      state.mode = chip.getAttribute('data-mode');
      setActive('.wg-chip[data-mode]', 'data-mode', state.mode);
    });
  });

  $all('.wg-chip[data-range]').forEach(chip => {
    chip.addEventListener('click', () => {
      state.range = chip.getAttribute('data-range');
      setActive('.wg-chip[data-range]', 'data-range', state.range);
    });
  });

  $('#wg-username').addEventListener('input', (e) => {
    state.username = e.target.value.trim();
  });

  $('#wg-run').addEventListener('click', async () => {
    try {
      setOutput('Processing request...');
      if (state.view === 'leaderboard') {
        const url = buildLeaderboardUrl(state.mode, state.range);
        const data = await gmGet(url);
        const list = data?.leaderboard ?? [];
        const pretty = formatLeaderboard(list, state.mode);
        setOutput(pretty);
      } else {
        const username = state.username || prompt('Enter username:') || '';
        state.username = username.trim();
        if (!state.username) {
          setOutput('(No username provided)');
          return;
        }
        const urls = buildIndividualUrls(state.username, state.mode, state.range);
        const url = urls.elo || urls.xp;
        if (!url) {
          setOutput('(No URL for this combination)');
          return;
        }
        const res = await gmGet(url);
        if (state.mode === 'elo') {
          setOutput(formatIndividualElo(res, state.username));
        } else {
          let player = null;
          if (Array.isArray(res)) {
            player = res[0] || null;
          } else if (res && typeof res === 'object') {
            if (res.username) {
              player = res;
            } else if (res.leaderboard && Array.isArray(res.leaderboard)) {
              player = res.leaderboard.find(p => (p.username || '').toLowerCase() === state.username.toLowerCase()) || null;
            } else if (res.player) {
              player = res.player;
            } else {
              player = {
                username: state.username,
                totalXp: res.myXp ?? res.totalXp ?? undefined,
                elo: res.myElo ?? res.elo ?? undefined,
                rank: res.myRank ?? res.rank ?? undefined,
                createdAt: res.createdAt ?? res.joinDate ?? undefined,
                xpToday: res.xpToday ?? res.totalXpToday ?? undefined
              };
            }
          }
          if (!player) {
            setOutput('(No player data returned)');
            return;
          }
          setOutput(formatPlayerLine(player, 'xp'));
        }
      }
    } catch (err) {
      setOutput(`Error: ${err && err.message ? err.message : String(err)}`);
    }
  });

  $('#wg-copy').addEventListener('click', async () => {
    try {
      await navigator.clipboard.writeText(output.textContent || '');
      appendOutput(`\n\n(copied)`);
    } catch {
      appendOutput(`\n\n(copy failed)`);
    }
  });

  $('#wg-clear').addEventListener('click', () => setOutput(''));

  let compact = false;
  $('#wg-size').addEventListener('click', () => {
    compact = !compact;
    panel.classList.toggle('wg-mini', compact);
    $('#wg-size').textContent = compact ? 'Expand' : 'Compact';
  });

  $('#wg-close').addEventListener('click', () => {
    panel.remove();
    restoreBtn.remove();
  });

  $('#wg-min').addEventListener('click', () => {
    panel.classList.add('wg-hidden');
    restoreBtn.classList.remove('wg-hidden');
  });

  restoreBtn.addEventListener('click', () => {
    restoreBtn.classList.add('wg-hidden');
    panel.classList.remove('wg-hidden');
  });

  showUsernameRow(false);
})();