Torn Cooldown Tracker

A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Advertisement:

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

Advertisement:

// ==UserScript==
// @name         Torn Cooldown Tracker
// @namespace    https://greasyfork.org/
// @version      1.2.3
// @description  A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.
// @author       Imtazking [2189762]
// @license      MIT
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  // ── Platform ───────────────────────────────────────────────────────────────
  const IS_PDA = typeof PDA_httpGet !== 'undefined';
  const STORAGE_KEY = 'tct_api_key';
  const WIDGET_ID = 'tct-widget';
  const REFRESH_SEC = 30;
  const POS_KEY_X = 'tct_pos_x';
  const POS_KEY_Y = 'tct_pos_y';

  let apiKey = IS_PDA ? '###PDA-APIKEY###' : (GM_getValue ? GM_getValue(STORAGE_KEY, '') : '');
  let userData = null;
  let tickTimer = null;
  let refreshTimer = null;
  let collapsed = GM_getValue ? (GM_getValue('tct_collapsed', false)) : false;

  // ── Utilities ──────────────────────────────────────────────────────────────
  const $ = id => document.getElementById(id);

  function saveKey(k) { if (!IS_PDA && GM_setValue) GM_setValue(STORAGE_KEY, k); }
  function saveCollapsed(v) { if (GM_setValue) GM_setValue('tct_collapsed', v); }
  function savePos(x, y) {
    if (GM_setValue) { GM_setValue(POS_KEY_X, x); GM_setValue(POS_KEY_Y, y); }
  }
  function loadPos() {
    const x = GM_getValue ? GM_getValue(POS_KEY_X, null) : null;
    const y = GM_getValue ? GM_getValue(POS_KEY_Y, null) : null;
    return (x !== null && y !== null) ? { x, y } : null;
  }

  function fmtCountdown(secs) {
    if (!secs || secs <= 0) return null;
    const h = Math.floor(secs / 3600);
    const m = Math.floor((secs % 3600) / 60);
    const s = secs % 60;
    if (h > 0) return `${h}h ${m.toString().padStart(2,'0')}m`;
    if (m > 0) return `${m}m ${s.toString().padStart(2,'0')}s`;
    return `${s}s`;
  }

  function readyAt(secs) {
    if (!secs || secs <= 0) return null;
    const d = new Date(Date.now() + secs * 1000);
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  }

  function ticksToFull(current, max, tickAmt, tickSecs) {
    if (!tickAmt || !tickSecs || current >= max) return 0;
    const needed = max - current;
    const ticks = Math.ceil(needed / tickAmt);
    return ticks * tickSecs;
  }

  function fmtToFull(secs) {
    if (secs <= 0) return 'Full';
    const h = Math.floor(secs / 3600);
    const m = Math.floor((secs % 3600) / 60);
    if (h > 0) return `${h}h ${m}m to full`;
    if (m > 0) return `${m}m to full`;
    return `<1m to full`;
  }

  // ── CSS ────────────────────────────────────────────────────────────────────
  const CSS = `
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');

  #${WIDGET_ID} {
    position: fixed;
    bottom: 80px;
    right: 24px;
    z-index: 99996;
    width: 220px;
    background: #0d0d0d;
    border: 1px solid #1e1e1e;
    border-radius: 10px;
    font-family: 'Inter', system-ui, sans-serif;
    font-size: 12px;
    color: #c0c0c0;
    overflow: hidden;
    box-shadow: 0 4px 24px rgba(0,0,0,0.5);
  }

  #tct-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 9px 12px;
    background: #0a0a0a;
    border-bottom: 1px solid #1a1a1a;
    cursor: grab;
    user-select: none;
  }
  #tct-header:active { cursor: grabbing; }
  #tct-widget { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
  #tct-widget.dragging { box-shadow: none; }
  #tct-header.clicking { cursor: pointer; }
  .tct-grip {
    display: flex; flex-direction: column; gap: 2px;
    flex-shrink: 0; margin-right: 7px; opacity: 0.25;
    transition: opacity 0.15s;
  }
  #tct-header:hover .tct-grip { opacity: 0.5; }
  .tct-grip span {
    display: block; width: 14px; height: 1.5px;
    background: #c8c8c8; border-radius: 1px;
  }
  #tct-header-left {
    display: flex;
    align-items: center;
    gap: 7px;
  }
  .tct-wordmark {
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: #c8963e;
  }
  .tct-player-name {
    font-size: 10px;
    color: #333;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 90px;
  }
  #tct-chevron {
    font-size: 9px;
    color: #333;
    transition: transform 0.2s;
    flex-shrink: 0;
  }
  #tct-chevron.up { transform: rotate(180deg); }

  #tct-body { padding: 10px 12px; }
  #tct-body.hidden { display: none; }

  /* ── Section labels ── */
  .tct-section-label {
    font-size: 9px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: #2a2a2a;
    margin: 10px 0 6px;
  }
  .tct-section-label:first-child { margin-top: 0; }

  /* ── Cooldown rows ── */
  .tct-cd-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 5px 0;
    border-bottom: 1px solid #111;
  }
  .tct-cd-row:last-child { border-bottom: none; }

  .tct-cd-left {
    display: flex;
    align-items: center;
    gap: 7px;
  }
  .tct-cd-icon {
    width: 22px;
    height: 22px;
    border-radius: 5px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
    flex-shrink: 0;
  }
  .tct-cd-icon.drug    { background: #1a1230; }
  .tct-cd-icon.booster { background: #1a1508; }
  .tct-cd-icon.medical { background: #0c1e18; }

  .tct-cd-name {
    font-size: 11px;
    color: #888;
    font-weight: 500;
  }
  .tct-cd-right { text-align: right; }
  .tct-cd-timer {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    font-weight: 500;
    line-height: 1.2;
  }
  .tct-cd-timer.active  { color: #c0622a; }
  .tct-cd-timer.ready   { color: #4d9e6e; animation: tct-pulse 2s ease-in-out infinite; }
  .tct-cd-timer.partial { color: #c8963e; }
  @keyframes tct-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
  }

  .tct-cd-sub {
    font-size: 9px;
    color: #2e2e2e;
    margin-top: 1px;
    font-family: 'JetBrains Mono', monospace;
  }

  /* ── Bar rows ── */
  .tct-bar-row {
    padding: 5px 0;
    border-bottom: 1px solid #111;
  }
  .tct-bar-row:last-child { border-bottom: none; }

  .tct-bar-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 5px;
  }
  .tct-bar-left {
    display: flex;
    align-items: center;
    gap: 5px;
  }
  .tct-bar-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .tct-bar-name {
    font-size: 11px;
    color: #666;
    font-weight: 500;
  }
  .tct-bar-val {
    font-family: 'JetBrains Mono', monospace;
    font-size: 11px;
    color: #999;
    font-weight: 500;
  }
  .tct-bar-val .tct-max { color: #333; font-size: 10px; }

  .tct-bar-track {
    height: 3px;
    background: #1a1a1a;
    border-radius: 2px;
    overflow: hidden;
  }
  .tct-bar-fill {
    height: 100%;
    border-radius: 2px;
    transition: width 0.6s ease;
  }
  .tct-bar-sub {
    font-size: 9px;
    color: #666;
    margin-top: 3px;
    font-family: 'JetBrains Mono', monospace;
  }

  /* ── Setup ── */
  #tct-setup {
    padding: 14px 12px;
    text-align: center;
  }
  #tct-setup p {
    font-size: 11px;
    color: #333;
    line-height: 1.5;
    margin: 0 0 10px;
  }
  #tct-key-input {
    width: 100%;
    background: #141414;
    border: 1px solid #222;
    border-radius: 6px;
    color: #c0c0c0;
    padding: 7px 10px;
    font-size: 11px;
    font-family: 'Inter', sans-serif;
    text-align: center;
    letter-spacing: 0.06em;
    box-sizing: border-box;
    margin-bottom: 8px;
  }
  #tct-key-input:focus { outline: none; border-color: #c8963e; }
  #tct-connect-btn {
    width: 100%;
    padding: 7px;
    border-radius: 6px;
    font-size: 11px;
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    cursor: pointer;
    background: #c8963e;
    color: #0a0a0a;
    border: none;
    letter-spacing: 0.04em;
  }
  #tct-connect-btn:hover { opacity: 0.88; }

  /* ── Footer ── */
  #tct-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 12px;
    border-top: 1px solid #141414;
    background: #0a0a0a;
  }

  .tct-reset-btn {
    font-size: 9px;
    color: #222;
    cursor: pointer;
    background: none;
    border: none;
    font-family: 'Inter', sans-serif;
    padding: 0;
    letter-spacing: 0.03em;
  }
  .tct-reset-btn:hover { color: #c8963e; }

  /* ── Loading / error ── */
  .tct-status-msg {
    padding: 14px 12px;
    font-size: 11px;
    color: #333;
    text-align: center;
    line-height: 1.5;
  }
  .tct-status-msg.error { color: #804040; }
  .tct-retry {
    display: inline-block;
    margin-top: 8px;
    font-size: 10px;
    color: #c8963e;
    cursor: pointer;
    background: none;
    border: none;
    font-family: 'Inter', sans-serif;
    padding: 0;
  }
  `;

  // ── API ────────────────────────────────────────────────────────────────────
  function apiCall(cb) {
    const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
    if (IS_PDA) {
      try {
        PDA_httpGet(url, r => {
          try {
            const d = JSON.parse(r);
            if (d.error) return cb(null, d.error.error || 'API error');
            cb(d, null);
          } catch(e) { cb(null, 'Parse error'); }
        });
      } catch(e) { cb(null, 'PDA_httpGet failed'); }
    } else {
      GM_xmlhttpRequest({
        method: 'GET', url,
        onload(r) {
          try {
            const d = JSON.parse(r.responseText);
            if (d.error) return cb(null, d.error.error || 'API error');
            cb(d, null);
          } catch(e) { cb(null, 'Parse error'); }
        },
        onerror() { cb(null, 'Network error'); }
      });
    }
  }

  // ── Tick: count down live without API calls ────────────────────────────────
  function startTick() {
    stopTick();
    let lastRender = Date.now();
    tickTimer = setInterval(() => {
      if (!userData) return;
      const elapsed = Math.round((Date.now() - lastRender) / 1000);
      lastRender = Date.now();

      // Decrement cooldowns
      const cd = userData.cooldowns;
      if (cd.drug    > 0) cd.drug    = Math.max(0, cd.drug    - elapsed);
      if (cd.booster > 0) cd.booster = Math.max(0, cd.booster - elapsed);
      if (cd.medical > 0) cd.medical = Math.max(0, cd.medical - elapsed);

      // Increment bars — bars are top-level on userData (not nested under .bars)
      ['energy','nerve','happy','life'].forEach(k => {
        const b = userData[k];
        if (b && b.current < b.maximum && b.increment && b.interval) {
          b.current = Math.min(b.maximum, b.current + (b.increment * elapsed / b.interval));
        }
      });

      renderBody();
    }, 1000);
  }

  function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }

  // ── Silent background refresh ──────────────────────────────────────────────
  function startRefreshCycle() {
    stopRefreshCycle();
    refreshTimer = setInterval(() => {
      // Silently fetch new data and update state without touching the DOM
      const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
      const doFetch = IS_PDA
        ? cb => { try { PDA_httpGet(url, r => { try { cb(JSON.parse(r)); } catch(e){} }); } catch(e){} }
        : cb => { GM_xmlhttpRequest({ method:'GET', url, onload(r){ try { cb(JSON.parse(r.responseText)); } catch(e){} } }); };
      doFetch(d => {
        if (!d || d.error) return;
        // Update cooldowns
        if (d.cooldowns) userData.cooldowns = d.cooldowns;
        // Update bars preserving the live-ticked current values only if API value differs significantly
        ['energy','nerve','happy','life'].forEach(k => {
          if (d[k] && userData[k]) {
            userData[k].maximum  = d[k].maximum;
            userData[k].increment = d[k].increment;
            userData[k].interval  = d[k].interval;
            userData[k].fulltime  = d[k].fulltime;
            // Only snap current if API differs by more than 1 tick (avoids jump from network delay)
            const diff = Math.abs(d[k].current - Math.floor(userData[k].current));
            if (diff > d[k].increment * 2) userData[k].current = d[k].current;
          } else if (d[k]) {
            userData[k] = d[k];
          }
        });
      });
    }, REFRESH_SEC * 1000);
  }
  function stopRefreshCycle() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }


  // ── Fetch ──────────────────────────────────────────────────────────────────
  function fetchData() {
    setBody(`<div class="tct-status-msg">Updating…</div>`);
    apiCall((d, err) => {
      if (err) {
        setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
        const rb = $('tct-retry-btn');
        if (rb) rb.onclick = fetchData;
        return;
      }
      userData = d;
      renderBody();
      startTick();
      startRefreshCycle();
    });
  }

  // ── Render ─────────────────────────────────────────────────────────────────
  function setBody(html) {
    const body = $('tct-body');
    if (body) body.innerHTML = html;
  }

  function cdRow(type, label, emoji, secs, maxSecs) {
    const active = secs > 0;
    const pct = maxSecs ? Math.min(100, Math.round((secs / maxSecs) * 100)) : 0;
    let timerText, timerClass, sub;

    if (!active) {
      timerText = 'Ready';
      timerClass = 'ready';
      sub = '';
    } else if (maxSecs && secs > maxSecs) {
      // Over cap — locked out
      timerText = fmtCountdown(secs - maxSecs) + ' locked';
      timerClass = 'active';
      sub = `cap in ${fmtCountdown(secs - maxSecs)}`;
    } else {
      timerText = fmtCountdown(secs);
      timerClass = secs > maxSecs * 0.5 ? 'active' : 'partial';
      sub = readyAt(secs) ? `ready at ${readyAt(secs)}` : '';
    }

    return `
      <div class="tct-cd-row">
        <div class="tct-cd-left">
          <div class="tct-cd-icon ${type}">${emoji}</div>
          <span class="tct-cd-name">${label}</span>
        </div>
        <div class="tct-cd-right">
          <div class="tct-cd-timer ${timerClass}">${timerText}</div>
          ${sub ? `<div class="tct-cd-sub">${sub}</div>` : ''}
        </div>
      </div>`;
  }

  function barRow(key, label, color, bar) {
    if (!bar) return '';
    const cur  = Math.floor(bar.current);
    const max  = bar.maximum;
    const pct  = max > 0 ? Math.min(100, Math.round((cur / max) * 100)) : 0;
    // API fields: increment = amount per tick, interval = seconds per tick, fulltime = secs to full
    const secsToFull = cur < max && bar.fulltime ? bar.fulltime : 0;
    const sub = cur >= max ? 'Full' : fmtToFull(secsToFull);

    return `
      <div class="tct-bar-row">
        <div class="tct-bar-header">
          <div class="tct-bar-left">
            <div class="tct-bar-dot" style="background:${color}"></div>
            <span class="tct-bar-name">${label}</span>
          </div>
          <span class="tct-bar-val">${cur}<span class="tct-max">/${max}</span></span>
        </div>
        <div class="tct-bar-track">
          <div class="tct-bar-fill" style="width:${pct}%;background:${color}"></div>
        </div>
        <div class="tct-bar-sub">${sub}</div>
      </div>`;
  }

  function renderBody() {
    if (!userData) return;
    const cd   = userData.cooldowns || {};
    // bars are top-level keys on userData

    // Drug cap is 0 (must hit 0 to use again), booster cap 24h, medical cap 6h
    const BOOSTER_CAP = 24 * 3600;
    const MEDICAL_CAP = 6  * 3600;

    const html = `
      <div class="tct-section-label">Cooldowns</div>
      ${cdRow('drug',    'Drug',    '💊', cd.drug    || 0, 0)}
      ${cdRow('booster', 'Booster', '⚡', cd.booster || 0, BOOSTER_CAP)}
      ${cdRow('medical', 'Medical', '🩹', cd.medical || 0, MEDICAL_CAP)}

      <div class="tct-section-label">Bars</div>
      ${barRow('energy', 'Energy', '#4d9e6e', userData.energy)}
      ${barRow('nerve',  'Nerve',  '#c0622a', userData.nerve)}
      ${barRow('happy',  'Happy',  '#c8963e', userData.happy)}
      ${barRow('life',   'Life',   '#7855be', userData.life)}
    `;

    setBody(html);
  }

  // ── Widget shell ───────────────────────────────────────────────────────────
  function buildWidget() {
    const w = document.createElement('div');
    w.id = WIDGET_ID;
    w.innerHTML = `
      <div id="tct-header">
        <div id="tct-header-left">
          <div class="tct-grip" aria-hidden="true">
            <span></span><span></span><span></span>
          </div>
          <span class="tct-wordmark">Cooldowns</span>
          <span class="tct-player-name" id="tct-player-name"></span>
        </div>
        <span id="tct-chevron">${collapsed ? '▲' : '▼'}</span>
      </div>
      <div id="tct-body" class="${collapsed ? 'hidden' : ''}">
        ${!apiKey || apiKey === '###PDA-APIKEY###' && !IS_PDA
          ? setupHTML()
          : '<div class="tct-status-msg">Loading…</div>'}
      </div>
      <div id="tct-footer">
        ${!IS_PDA ? `<button class="tct-reset-btn" id="tct-reset">Change key</button>` : ''}
      </div>
    `;
    document.body.appendChild(w);

    // Apply saved position
    const savedPos = loadPos();
    if (savedPos) {
      w.style.right = 'auto';
      w.style.bottom = 'auto';
      w.style.left = savedPos.x + 'px';
      w.style.top  = savedPos.y + 'px';
    }

    // Drag logic
    let isDragging = false;
    let dragStartX, dragStartY;
    let dragMoved = false;

    const header = $('tct-header');

    header.addEventListener('pointerdown', e => {
      if (e.button !== 0) return;
      isDragging = true;
      dragMoved = false;
      const rect = w.getBoundingClientRect();
      dragStartX = e.clientX - rect.left;
      dragStartY = e.clientY - rect.top;
      w.style.right  = 'auto';
      w.style.bottom = 'auto';
      w.style.left = rect.left + 'px';
      w.style.top  = rect.top  + 'px';
      header.setPointerCapture(e.pointerId);
      w.classList.add('dragging');
      header.style.cursor = 'grabbing';
      e.preventDefault();
    });

    header.addEventListener('pointermove', e => {
      if (!isDragging) return;
      dragMoved = true;
      const newX = Math.max(0, Math.min(window.innerWidth  - w.offsetWidth,  e.clientX - dragStartX));
      const newY = Math.max(0, Math.min(window.innerHeight - w.offsetHeight, e.clientY - dragStartY));
      w.style.left = newX + 'px';
      w.style.top  = newY + 'px';
    });

    header.addEventListener('pointerup', e => {
      if (!isDragging) return;
      isDragging = false;
      header.style.cursor = '';
      header.releasePointerCapture(e.pointerId);
      w.classList.remove('dragging');
      if (dragMoved) {
        const rect = w.getBoundingClientRect();
        savePos(Math.round(rect.left), Math.round(rect.top));
      }
    });

    // Header toggle — only fires if we didn't drag
    $('tct-header').onclick = () => {
      if (dragMoved) return;
      collapsed = !collapsed;
      saveCollapsed(collapsed);
      $('tct-body').classList.toggle('hidden', collapsed);
      $('tct-chevron').classList.toggle('up', !collapsed);
    };
    $('tct-chevron').classList.toggle('up', !collapsed);

    if (!IS_PDA) {
      const rb = $('tct-reset');
      if (rb) rb.onclick = e => {
        e.stopPropagation();
        apiKey = ''; saveKey('');
        userData = null; stopTick(); stopRefreshCycle();
        setBody(setupHTML());
        bindSetup();
      };
    }

    if (apiKey && !(apiKey === '###PDA-APIKEY###' && !IS_PDA)) {
      fetchData();
    } else if (!IS_PDA) {
      bindSetup();
    } else {
      // PDA with injected key
      fetchData();
    }
  }

  function setupHTML() {
    return `
      <div id="tct-setup">
        <p>Enter your Torn API key.<br>Requires Minimal Access.</p>
        <input id="tct-key-input" type="text" placeholder="API key" maxlength="16" autocomplete="off" spellcheck="false"/>
        <button id="tct-connect-btn">Connect</button>
      </div>`;
  }

  function bindSetup() {
    const input = $('tct-key-input');
    const btn   = $('tct-connect-btn');
    if (!input || !btn) return;
    btn.onclick = () => {
      const v = input.value.trim();
      if (v.length !== 16) { input.style.borderColor = '#c0622a'; return; }
      apiKey = v; saveKey(v);
      setBody('<div class="tct-status-msg">Connecting…</div>');
      fetchData();
    };
    input.onkeydown = e => { if (e.key === 'Enter') btn.click(); };
  }

  function updatePlayerName() {
    if (!userData) return;
    const el = $('tct-player-name');
    if (el && userData.name) el.textContent = userData.name;
  }

  // Patch fetchData to update player name after load
  const _fetchData = fetchData;

  // ── Init ───────────────────────────────────────────────────────────────────
  function injectStyles() {
    if ($('tct-styles')) return;
    const s = document.createElement('style');
    s.id = 'tct-styles';
    s.textContent = CSS;
    document.head.appendChild(s);
  }

  function init() {
    if ($(WIDGET_ID)) return;
    injectStyles();
    buildWidget();
  }

  // Patch apiCall success to update name
  const origApiCall = apiCall;
  function apiCallWithName(cb) {
    origApiCall((d, err) => {
      if (d && d.name) {
        const el = $('tct-player-name');
        if (el) el.textContent = d.name;
      }
      cb(d, err);
    });
  }

  // Override fetchData to use named version
  function fetchData() {
    setBody(`<div class="tct-status-msg">Updating…</div>`);
    apiCallWithName((d, err) => {
      if (err) {
        setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
        const rb = $('tct-retry-btn');
        if (rb) rb.onclick = fetchData;
        return;
      }
      userData = d;
      renderBody();
      startTick();
      startRefreshCycle();
    });
  }

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

})();