Hearthstone Leaderboard Rating Navigator

Jump to the page containing a target rating (MMR) using bounded binary search. UI injected into LeaderboardsFilters; live status shown bottom-right.

// ==UserScript==
// @name         Hearthstone Leaderboard Rating Navigator
// @namespace    https://hearthstone.blizzard.com/
// @version      1.0.0
// @description  Jump to the page containing a target rating (MMR) using bounded binary search. UI injected into LeaderboardsFilters; live status shown bottom-right.
// @match        https://hearthstone.blizzard.com/community/leaderboards*
// @match        https://hearthstone.blizzard.com/*/community/leaderboards*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // ------- Configuration -------
  const UI_ID = 'hs-rating-navigator';
  const STATUS_ID = 'hs-rating-navigator-status';
  const STATE_KEY = 'hsRatingSearchState';
  const MAX_STEPS = 50; // safety cap
  const TABLE_SELECTOR = "[class*='LeaderboardsTable-Rendered']";
  const FILTERS_SELECTOR = ".LeaderboardsFilters";
  const ROW_SELECTOR = ".row";
  const COL_RANK_SELECTOR = ".col-rank";
  const COL_RATING_SELECTOR = ".col-rating";
  const PAGINATION_CONTAINER = ".PaginationContainer";
  const MIN_RATING = 8000;

  // ------- Utilities -------
  const sleep = (ms) => new Promise(res => setTimeout(res, ms));

  function getCurrentPage() {
    const url = new URL(location.href);
    const p = parseInt(url.searchParams.get('page') || '1', 10);
    return Number.isFinite(p) && p >= 1 ? p : 1;
  }

  function writeState(s) {
    sessionStorage.setItem(STATE_KEY, JSON.stringify(s));
  }

  function readState() {
    try {
      const raw = sessionStorage.getItem(STATE_KEY);
      return raw ? JSON.parse(raw) : null;
    } catch {
      return null;
    }
  }

  function clearState() {
    sessionStorage.removeItem(STATE_KEY);
  }

  function ensureFiltersContainer() {
    return document.querySelector(FILTERS_SELECTOR);
  }

  function ensureTableContainer() {
    return document.querySelector(TABLE_SELECTOR);
  }

  async function waitForTable(timeoutMs = 10000) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const table = ensureTableContainer();
      if (table) {
        const rows = table.querySelectorAll(ROW_SELECTOR);
        if (rows && rows.length > 0) return table;
      }
      await sleep(100);
    }
    return ensureTableContainer();
  }

  async function waitForPagination(timeoutMs = 8000) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const maxPage = getMaxPageFromUI();
      if (maxPage != null) return maxPage;
      await sleep(100);
    }
    return getMaxPageFromUI();
  }

  function getMaxPageFromUI() {
    const cont = document.querySelector(PAGINATION_CONTAINER);
    if (!cont) return null;
    const btns = Array.from(cont.querySelectorAll('button'));
    let maxNum = null;
    for (const b of btns) {
      const t = (b.textContent || '').trim();
      const n = parseInt(t, 10);
      if (Number.isFinite(n)) {
        if (maxNum == null || n > maxNum) maxNum = n;
      }
    }
    return maxNum; // could be null if no numeric buttons yet (single-page case)
  }

  function parsePage() {
    const table = ensureTableContainer();
    if (!table) return null;

    const rows = Array.from(table.querySelectorAll(ROW_SELECTOR));
    const parsed = [];
    for (const r of rows) {
      const rankEl = r.querySelector(COL_RANK_SELECTOR);
      const ratingEl = r.querySelector(COL_RATING_SELECTOR);
      if (!rankEl || !ratingEl) continue;

      const rank = parseInt((rankEl.textContent || '').trim().replace(/[, ]+/g, ''), 10);
      const rating = parseInt((ratingEl.textContent || '').trim().replace(/[, ]+/g, ''), 10);
      if (Number.isFinite(rank) && Number.isFinite(rating)) {
        parsed.push({ row: r, rank, rating });
      }
    }
    if (parsed.length === 0) return null;

    // ranks ascend down the page; ratings descend with rank
    parsed.sort((a, b) => a.rank - b.rank);
    const ratings = parsed.map(p => p.rating);
    const minRating = Math.min(...ratings);
    const maxRating = Math.max(...ratings);

    return {
      rows: parsed,
      minRating,
      maxRating,
      minRank: parsed[0].rank,
      maxRank: parsed[parsed.length - 1].rank,
      count: parsed.length
    };
  }

  function highlightByRating(targetRating) {
    const table = ensureTableContainer();
    if (!table) return false;

    const rows = Array.from(table.querySelectorAll(ROW_SELECTOR)).map(r => {
      const ratingEl = r.querySelector(COL_RATING_SELECTOR);
      const rankEl = r.querySelector(COL_RANK_SELECTOR);
      if (!ratingEl || !rankEl) return null;
      const rating = parseInt((ratingEl.textContent || '').trim().replace(/[, ]+/g, ''), 10);
      const rank = parseInt((rankEl.textContent || '').trim().replace(/[, ]+/g, ''), 10);
      if (!Number.isFinite(rating) || !Number.isFinite(rank)) return null;
      return { r, rating, rank };
    }).filter(Boolean);

    if (!rows.length) return false;

    // exact match preferred; otherwise nearest rating (if tie, prefer higher rating / better rank)
    let best = null;
    let bestDiff = Infinity;
    for (const x of rows) {
      const diff = Math.abs(x.rating - targetRating);
      if (diff < bestDiff || (diff === bestDiff && x.rating > (best?.rating ?? -Infinity))) {
        best = x;
        bestDiff = diff;
      }
      if (diff === 0) break;
    }
    if (!best) return false;

    const elem = best.r;
    elem.scrollIntoView({ behavior: 'smooth', block: 'center' });
    elem.style.outline = '3px solid gold';
    elem.style.borderRadius = '6px';
    elem.style.background = 'rgba(255, 215, 0, 0.12)';
    return true;
  }

  // ------- UI -------
  function ensureControlUI() {
    if (document.getElementById(UI_ID)) return;

    const filters = ensureFiltersContainer();
    if (!filters) return;

    const wrap = document.createElement('div');
    wrap.id = UI_ID;
    wrap.style.display = 'flex';
    wrap.style.alignItems = 'center';
    wrap.style.gap = '8px';
    wrap.style.marginLeft = '8px';
    wrap.style.marginRight = '-40px';
    wrap.style.padding = '8px';
    wrap.style.border = '2px solid rgba(255,255,255,0.15)';
    wrap.style.borderRadius = '6px';
    wrap.style.fontFamily = 'Belwe Bold,Open Sans,Helvetica,Arial,sans-serif';
    wrap.style.background = 'rgb(195, 177, 137)';
    wrap.style.boxShadow = 'inset 0px 0px 4px #0000007d';

    const label = document.createElement('label');
    label.textContent = 'Jump to Rating:';
    label.style.whiteSpace = 'nowrap';
    label.style.fontWeight = '600';
    label.style.color = 'rgb(97, 67, 38)';

    const input = document.createElement('input');
    input.type = 'number';
    input.min = String(MIN_RATING);
    input.step = '1';
    input.placeholder = 'e.g., 8200';
    input.style.padding = '4px 8px';
    input.style.width = '120px';
    input.setAttribute('aria-label', 'Target Rating');

    const btn = document.createElement('button');
    btn.textContent = 'Search';
    btn.style.padding = '4px 10px';
    btn.style.cursor = 'pointer';
    btn.style.border = '2px solid rgb(97, 67, 38)';
    btn.style.backgroundColor = 'rgb(240, 223, 182)';
    btn.style.borderRadius = '4px';
    btn.style.color = 'rgb(35, 58, 110)';

    //const stopBtn = document.createElement('button');
    //stopBtn.textContent = 'Stop';
    //stopBtn.style.padding = '4px 10px';
    //stopBtn.style.cursor = 'pointer';

    filters.appendChild(wrap); // append as last child per requirement
    wrap.appendChild(label);
    wrap.appendChild(input);
    wrap.appendChild(btn);
    //wrap.appendChild(stopBtn);

    const st = readState();
    if (st && st.targetRating) input.value = String(st.targetRating);

    btn.addEventListener('click', () => {
      const val = parseInt((input.value || '').trim(), 10);
      if (!Number.isFinite(val) || val < MIN_RATING) {
        setStatus(`Enter a rating ≥ ${MIN_RATING}.`);
        return;
      }
      startSearch(val);
    });

    //stopBtn.addEventListener('click', () => stopSearch('Stopped.'));

    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        const val = parseInt((input.value || '').trim(), 10);
        if (!Number.isFinite(val) || val < MIN_RATING) {
          setStatus(`Enter a rating ≥ ${MIN_RATING}.`);
          return;
        }
        startSearch(val);
      }
    });
  }

  function ensureStatusUI() {
    let box = document.getElementById(STATUS_ID);
    if (box) return box;

    box = document.createElement('div');
    box.id = STATUS_ID;
    box.style.position = 'fixed';
    box.style.right = '12px';
    box.style.bottom = '12px';
    box.style.zIndex = '2147483647';
    box.style.maxWidth = '360px';
    box.style.background = 'rgba(20,22,30,0.9)';
    box.style.backdropFilter = 'blur(4px)';
    box.style.color = '#fff';
    box.style.fontFamily = 'monospace';
    box.style.fontSize = '12px';
    box.style.lineHeight = '1.4';
    box.style.padding = '10px 12px';
    box.style.border = '1px solid rgba(255,255,255,0.2)';
    box.style.borderRadius = '8px';
    box.style.boxShadow = '0 4px 16px rgba(0,0,0,0.35)';
    box.innerHTML = `
      <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
        <strong>Rating Search</strong>
        <button id="${STATUS_ID}-close" style="cursor:pointer;font-family:inherit;font-size:11px;padding:2px 6px;">✕</button>
      </div>
      <div id="${STATUS_ID}-content" style="margin-top:6px;white-space:pre;"></div>
      <div style="margin-top:6px;display:flex;gap:8px;">
        <button id="${STATUS_ID}-step" style="cursor:pointer;font-size:11px;padding:2px 6px;">Step</button>
        <button id="${STATUS_ID}-stop" style="cursor:pointer;font-size:11px;padding:2px 6px;">Stop</button>
      </div>
    `;
    document.body.appendChild(box);

    document.getElementById(`${STATUS_ID}-close`)?.addEventListener('click', () => box.remove());
    document.getElementById(`${STATUS_ID}-stop`)?.addEventListener('click', () => stopSearch('Stopped.'));
    document.getElementById(`${STATUS_ID}-step`)?.addEventListener('click', () => continueSearchStep());

    return box;
  }

  function setStatus(text) {
    ensureStatusUI();
    const el = document.getElementById(`${STATUS_ID}-content`);
    if (el) el.textContent = text;
  }

  // ------- Search control flow (by rating) -------
  function startSearch(targetRating) {
    const currentPage = getCurrentPage();
    const st = {
      active: true,
      targetRating,
      lo: null,      // page known to be "too early" (ratings too high) → need later page
      hi: null,      // page known to be "too late"  (ratings too low)  → need earlier page
      maxPage: null, // discovered from pagination UI
      steps: 0,
      startedAt: Date.now(),
      last: null
    };
    writeState(st);
    setStatus(`Searching for rating ${targetRating}… starting at page ${currentPage}…`);
    continueSearchStep();
  }

  function stopSearch(msg = 'Search ended.') {
    const st = readState();
    if (st) {
      st.active = false;
      writeState(st);
    }
    setStatus(msg);
  }

  function navigate(nextPage, st) {
    // Clamp to discovered bounds
    const maxPage = st.maxPage || getMaxPageFromUI() || null;
    let target = Math.floor(nextPage);
    if (target < 1) target = 1;
    if (maxPage != null && target > maxPage) {
      st.hi = maxPage;
      writeState(st);
      setStatus(renderStatus(st, `Hit max page (${maxPage}).`));
      return finalizeIfConvergedOrStop(st);
    }
    const url = new URL(location.href);
    url.searchParams.set('page', String(target));
    location.assign(url.toString());
  }

  function renderStatus(st, prefix = '') {
    const last = st.last;
    const lines = [];
    if (prefix) lines.push(prefix);
    lines.push(`target rating: ${st.targetRating}`);
    lines.push(`page:          ${last ? last.page : getCurrentPage()}  steps: ${st.steps}`);
    if (st.lo != null || st.hi != null) {
      lines.push(`bounds (pages): [${st.lo ?? '?'} .. ${st.hi ?? '?'}]`);
    }
    if (last) {
      const rr = `ratings ${last.minRating}–${last.maxRating}`;
      const ranks = `ranks ${last.minRank}–${last.maxRank}`;
      lines.push(`range: ${rr} | ${ranks}`);
      if (st.maxPage) lines.push(`maxPage discovered: ${st.maxPage}`);
    }
    return lines.join('\n');
  }

  async function continueSearchStep() {
    const st = readState();
    if (!st || !st.active) return;

    ensureStatusUI();

    await waitForTable(15000);
    const discoveredMax = await waitForPagination(6000);
    if (discoveredMax != null && st.maxPage !== discoveredMax) {
      st.maxPage = discoveredMax;
      writeState(st);
      // Initialize global bounds if unknown
      if (st.lo == null) st.lo = 1;
      if (st.hi == null) st.hi = discoveredMax;
    }

    const currentPage = getCurrentPage();
    const page = parsePage();
    if (!page) {
      st.steps++;
      writeState(st);
      if (st.steps > MAX_STEPS) return stopSearch('No rows found. Aborting.');
      setStatus(renderStatus(st, `Page ${currentPage}: no rows found.`));
      return;
    }

    const { minRating, maxRating, minRank, maxRank, count } = page;
    st.last = { page: currentPage, minRating, maxRating, minRank, maxRank, rows: count };
    st.steps++;
    writeState(st);

    // Found target page? (ratings are descending with earlier pages)
    if (st.targetRating >= minRating && st.targetRating <= maxRating) {
      setStatus(renderStatus(st, `Found page ${currentPage}.`));
      highlightByRating(st.targetRating);
      st.active = false;
      writeState(st);
      return;
    }

    // Decide direction:
    // - If target > maxRating: need earlier page (smaller page index) → tighten hi
    // - If target < minRating: need later   page (larger page index) → tighten lo
    if (st.targetRating > maxRating) {
      st.hi = currentPage;
      writeState(st);
    } else if (st.targetRating < minRating) {
      st.lo = currentPage;
      writeState(st);
    }

    // Choose next by binary search if we know both bounds; otherwise, try to discover bounds from maxPage.
    let next;
    if (st.lo != null && st.hi != null) {
      next = Math.floor((st.lo + st.hi) / 2);
    } else if (st.maxPage != null) {
      // Fallback to midpoint of [1..maxPage]
      next = Math.floor((1 + st.maxPage) / 2);
    } else {
      // Without maxPage, expand conservatively
      next = st.targetRating > maxRating ? Math.max(1, currentPage - 1) : currentPage + 1;
    }

    setStatus(renderStatus(st, `Narrowing → page ${next}`));
    if (next === currentPage || st.steps > MAX_STEPS) return finalizeIfConvergedOrStop(st);
    return navigate(next, st);
  }

  function finalizeIfConvergedOrStop(st) {
    st.active = false;
    writeState(st);
    setStatus(renderStatus(st, 'Converged on bound. Try an adjacent page if needed.'));
  }

  // ------- Boot -------
  function init() {
    ensureControlUI();
    ensureStatusUI();

    const st = readState();
    if (st && st.active) {
      continueSearchStep();
    } else {
      // Show current page summary
      waitForTable(6000).then(() => {
        const p = getCurrentPage();
        const info = parsePage();
        if (info) {
          setStatus(`Idle. Page ${p} ratings ${info.minRating}–${info.maxRating} (${info.count} rows) | ranks ${info.minRank}–${info.maxRank}`);
        } else {
          setStatus('Idle. Waiting for table…');
        }
        waitForPagination(4000).then(mp => {
          if (mp != null) setStatus(`${document.getElementById(`${STATUS_ID}-content`).textContent}\nmaxPage discovered: ${mp}`);
        });
      });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  // Re-inject if site re-renders
  const mo = new MutationObserver(() => {
    if (!document.getElementById(UI_ID)) ensureControlUI();
    if (!document.getElementById(STATUS_ID)) ensureStatusUI();
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });
})();