GymIQ

Live ratio panel for Torn gym stats. TornPDA users should set injection time to END.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         GymIQ
// @namespace    torn-ratio-helper
// @version      4.5.2
// @description  Live ratio panel for Torn gym stats. TornPDA users should set injection time to END.
// @author       ClasixTV
// @match        https://www.torn.com/gym.php*
// @grant        None
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // GM storage fallback
  // TornPDA doesn't support GM_getValue/GM_setValue so we fall back to
  // localStorage if they're not available
  const store = {
    get: (key, def) => {
      if (typeof GM_getValue === 'function') return GM_getValue(key, def);
      try {
        const v = localStorage.getItem('gymiq_' + key);
        return v !== null ? JSON.parse(v) : def;
      } catch(e) { return def; }
    },
    set: (key, val) => {
      if (typeof GM_setValue === 'function') return GM_setValue(key, val);
      try { localStorage.setItem('gymiq_' + key, JSON.stringify(val)); } catch(e) {}
    },
  };

  // Persistent state
  let selectedRatio  = store.get('trh_ratio', 'baldr');
  let highStat       = store.get('trh_high', 'str');
  let defensiveDump  = store.get('trh_def_dump', 'str');
  let customMults    = store.get('trh_custom', { high: 1.00, secondary: 0.80, tert1: 0.60, tert2: 0.60 });
  let history        = store.get('trh_history', []);
  let theme          = store.get('trh_theme', 'dark');
  let warMode        = store.get('trh_war', false);
  let activeTab      = 'overview';

  // Ratio definitions
  const RATIOS = {
    baldr: {
      label: "Baldr's Ratio",
      short: "Baldr's",
      desc:  "High : 80% : 60% : 60%",
      multipliers: { high: 1.00, secondary: 0.80, tert1: 0.60, tert2: 0.60 },
    },
    hank: {
      label: "Hank's Ratio",
      short: "Hank's",
      desc:  "High : 80% : 80% : ~0%",
      multipliers: { high: 1.00, secondary: 0.80, tert1: 0.80, tert2: 0.00 },
    },
    custom: {
      label: "Custom Ratio",
      short: "Custom",
      desc:  "Your own multipliers",
      get multipliers() { return customMults; },
    },
  };

  const STAT_LABELS  = { str: 'Strength', spd: 'Speed', def: 'Defense', dex: 'Dexterity' };
  const STAT_COLORS  = { str: '#f97316', spd: '#22d3ee', def: '#a78bfa', dex: '#4ade80' };

  // Helpers
  function parseStatValue(text) {
    if (!text) return null;
    const cleaned = text.replace(/,/g, '').match(/[\d.]+/);
    return cleaned ? parseFloat(cleaned[0]) : null;
  }

  function fmtNum(n) {
    if (n === null || n === undefined || isNaN(n)) return '???';
    return Math.round(n).toLocaleString();
  }

  function fmtDate(ts) {
    const d = new Date(ts);
    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  }

  function parseInputNumber(value, fallback) {
    const parsed = Number.parseFloat(value);
    return Number.isFinite(parsed) ? parsed : fallback;
  }

  function readNumberInput(id, fallback) {
    const input = document.getElementById(id);
    if (!input) return fallback;
    if (typeof input.valueAsNumber === 'number' && !Number.isNaN(input.valueAsNumber)) {
      return input.valueAsNumber;
    }
    return parseInputNumber(input.value, fallback);
  }

  function getRoles(highStatKey) {
    if (highStatKey === 'str') {
      return { high: 'str', secondary: 'spd', tert1: 'def', tert2: 'dex' };
    }
    if (highStatKey === 'spd') {
      return { high: 'spd', secondary: 'str', tert1: 'def', tert2: 'dex' };
    }
    if (highStatKey === 'def') {
      return defensiveDump === 'spd'
        ? { high: 'def', secondary: 'dex', tert1: 'str', tert2: 'spd' }
        : { high: 'def', secondary: 'dex', tert1: 'spd', tert2: 'str' };
    }
    return defensiveDump === 'spd'
      ? { high: 'dex', secondary: 'def', tert1: 'str', tert2: 'spd' }
      : { high: 'dex', secondary: 'def', tert1: 'spd', tert2: 'str' };
  }

  function escapeHtml(text) {
    return String(text)
      .replace(/&/g, '&')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  function readWhatIfStats(baseStats) {
    const parseWhatIf = (key) => {
      const raw = document.getElementById(`trh-wi-${key}`)?.value;
      if (raw === '' || raw === null || raw === undefined) return baseStats[key] || 0;
      const parsed = Number.parseInt(raw, 10);
      return Number.isFinite(parsed) ? parsed : (baseStats[key] || 0);
    };

    return {
      str: parseWhatIf('str'),
      spd: parseWhatIf('spd'),
      def: parseWhatIf('def'),
      dex: parseWhatIf('dex'),
    };
  }

  function readStatsFromPage() {
    const stats = { str: null, spd: null, def: null, dex: null };

    // Torn's gym page structure (class suffixes are randomised):
    // <div class="propertyTitle___XXXX">
    //   <h3 class="title___XXXX">Strength</h3>
    //   <span class="propertyValue___XXXX">2,049.27</span>
    // </div>
    const statNames = { str: 'strength', spd: 'speed', def: 'defense', dex: 'dexterity' };

    const titleBlocks = document.querySelectorAll('[class*="propertyTitle"]');
    for (const block of titleBlocks) {
      const h3 = block.querySelector('h3');
      if (!h3) continue;
      const label = h3.textContent.trim().toLowerCase();
      const valueEl = block.querySelector('[class*="propertyValue"]');
      if (!valueEl) continue;
      const v = parseStatValue(valueEl.textContent);
      if (v === null) continue;
      for (const [key, name] of Object.entries(statNames)) {
        if (label === name) { stats[key] = v; break; }
      }
    }

    // Fall back to scanning page text if any stats are still missing
    if (Object.values(stats).some(v => v === null)) {
      const patterns = {
        str: /\bstrength\b[\s\S]{0,40}?\b([\d,.]+)\b/i,
        spd: /\bspeed\b[\s\S]{0,40}?\b([\d,.]+)\b/i,
        def: /\bdefense\b[\s\S]{0,40}?\b([\d,.]+)\b/i,
        dex: /\bdexterity\b[\s\S]{0,40}?\b([\d,.]+)\b/i,
      };
      const gymArea = document.querySelector('.gym-content, #gym-root, [class*="gym"], [class*="stats"], main');
      const searchText = gymArea ? gymArea.innerText : document.body.innerText;

      for (const [key, rx] of Object.entries(patterns)) {
        if (stats[key] !== null) continue;
        const matches = [...searchText.matchAll(new RegExp(rx.source, 'gi'))];
        let best = null;
        for (const m of matches) {
          const v = parseStatValue(m[1]);
          if (v !== null && v >= 0 && (best === null || v > best)) best = v;
        }
        if (best !== null) stats[key] = best;
      }
    }

    // If one stat looks tiny compared to the others it's probably a bad read
    const validStats = Object.values(stats).filter(v => v !== null);
    if (validStats.length >= 2) {
      const maxStat = Math.max(...validStats);
      for (const key of Object.keys(stats)) {
        if (stats[key] !== null && maxStat > 10000 && stats[key] < maxStat * 0.001) {
          stats[key] = null;
        }
      }
    }

    return stats;
  }

  function computeTargets(stats, ratioKey, highStatKey) {
    const ratio   = RATIOS[ratioKey];
    const roles   = getRoles(highStatKey);
    const mults   = ratio.multipliers;
    const highVal = stats[highStatKey] || 0;
    const targets = {};
    for (const [role, statKey] of Object.entries(roles)) {
      targets[statKey] = highVal * mults[role];
    }
    return targets;
  }

  function getStatus(actual, target, isDump) {
    if (isDump) {
      if (actual < 1000)   return { color: '#4ade80', emoji: 'OK', grade: 'A', label: 'Great' };
      if (actual < 50000)  return { color: '#facc15', emoji: '!', grade: 'C', label: 'Okay' };
                           return { color: '#f87171', emoji: 'X', grade: 'F', label: 'Too high' };
    }
    if (!target) return { color: '#64748b', emoji: '-', grade: '-', label: 'N/A' };
    const pct = actual / target;
    if (pct >= 0.98 && pct <= 1.05) return { color: '#4ade80', emoji: 'OK', grade: 'A+', label: 'Perfect' };
    if (pct >= 0.95 && pct < 0.98)  return { color: '#86efac', emoji: 'OK', grade: 'A',  label: 'On target' };
    if (pct >= 0.90 && pct < 0.95)  return { color: '#bef264', emoji: '!', grade: 'B',  label: 'Close' };
    if (pct >= 0.85 && pct < 0.90)  return { color: '#facc15', emoji: '!', grade: 'C',  label: 'Low' };
    if (pct >= 0.70 && pct < 0.85)  return { color: '#fb923c', emoji: '~', grade: 'D',  label: 'Far off' };
    if (pct < 0.70)                  return { color: '#f87171', emoji: 'X', grade: 'F',  label: 'Very far off' };
    if (pct > 1.05 && pct <= 1.15)  return { color: '#fb923c', emoji: '~', grade: 'B',  label: 'Slightly over' };
                                     return { color: '#f87171', emoji: 'X', grade: 'D',  label: 'Way over' };
  }

  function overallGrade(stats, targets, roles, ratioKey) {
    let totalPct = 0; let count = 0;
    for (const [key] of Object.entries(STAT_LABELS)) {
      const isDump = ratioKey === 'hank' && roles.tert2 === key;
      if (isDump || !targets[key]) continue;
      const actual = stats[key] || 0;
      totalPct += Math.min(actual / targets[key], 1.0);
      count++;
    }
    if (!count) return { score: 0, letter: 'F' };
    const score = Math.round((totalPct / count) * 100);
    const letter = score >= 98 ? 'A+' : score >= 93 ? 'A' : score >= 88 ? 'B+' : score >= 83 ? 'B'
                 : score >= 78 ? 'C+' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
    return { score, letter };
  }

  function recordHistory(stats) {
    const allFound = Object.values(stats).every(v => v !== null);
    if (!allFound) return;
    const lastEntry = history[history.length - 1];
    if (lastEntry && ['str', 'spd', 'def', 'dex'].every(key => lastEntry[key] === stats[key])) {
      return;
    }
    const now = Date.now();
    if (history.length > 0 && now - history[history.length - 1].ts < 3600000) {
      history[history.length - 1] = { ts: now, ...stats };
    } else {
      history.push({ ts: now, ...stats });
      if (history.length > 30) history = history.slice(-30);
    }
    store.set('trh_history', history);
  }

  // CSS
  const CSS = `
    #trh-root * { box-sizing: border-box; }
    #trh-root {
      font-family: 'Courier New', 'Lucida Console', monospace;
      background: #080c14;
      border: 1px solid #1a2535;
      border-top: 3px solid #00c4ff;
      border-radius: 8px;
      padding: 0;
      margin: 14px 0;
      max-width: 600px;
      box-shadow: 0 0 30px rgba(0,196,255,0.08), 0 4px 20px rgba(0,0,0,0.6);
      color: #c8d8e8;
      overflow: visible;
    }
    #trh-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 10px 14px;
      background: #0b1220;
      border-bottom: 1px solid #1a2535;
      border-radius: 8px 8px 0 0;
      overflow: hidden;
    }
    #trh-title { font-size: 13px; font-weight: 700; color: #00c4ff; letter-spacing: 2px; text-transform: uppercase; }
    #trh-tabs { display: flex; gap: 1px; background: #0b1220; border-bottom: 1px solid #1a2535; padding-left: 10px; overflow-x: auto; scrollbar-width: none; }
    #trh-tabs::-webkit-scrollbar { display: none; }
    .trh-tab {
      flex: 0 0 auto; padding: 8px 14px; font-size: 11px; font-family: 'Courier New', monospace;
      text-align: center; cursor: pointer; color: #4a6080; background: #080c14;
      border: none; text-transform: uppercase; letter-spacing: 1px;
      transition: all 0.15s; border-bottom: 2px solid transparent; white-space: nowrap;
    }
    .trh-tab:hover { color: #8ab0d0; background: #0d1826; }
    .trh-tab.active { color: #00c4ff; border-bottom: 2px solid #00c4ff; background: #0a1520; }
    #trh-body { padding: 14px; }
    .trh-section { margin-bottom: 14px; }
    .trh-label { font-size: 10px; color: #2a5070; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px; }
    .trh-stat-row { margin-bottom: 10px; }
    .trh-stat-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
    .trh-stat-name { font-size: 12px; font-weight: 700; letter-spacing: 1px; }
    .trh-stat-nums { font-size: 11px; color: #4a6080; }
    .trh-stat-nums span { color: #8ab0d0; }
    .trh-bar-bg { height: 6px; background: #0d1826; border-radius: 3px; overflow: hidden; }
    .trh-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; }
    .trh-stat-status { font-size: 10px; margin-top: 3px; }
    .trh-grade { font-size: 28px; font-weight: 900; line-height: 1; font-family: 'Courier New', monospace; }
    .trh-score-row { display: flex; align-items: center; gap: 14px; }
    .trh-score-detail { font-size: 11px; color: #4a6080; }
    .trh-score-detail strong { color: #8ab0d0; }
    .trh-rec-box {
      background: #0a1828; border: 1px solid #1a3050; border-left: 3px solid #00c4ff;
      border-radius: 4px; padding: 10px 12px; font-size: 12px;
    }
    .trh-rec-box .trh-rec-stat { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
    .trh-ctrl-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
    .trh-btn {
      background: #0d1826; border: 1px solid #1a3050; color: #8ab0d0;
      border-radius: 4px; padding: 5px 10px; font-size: 11px; cursor: pointer;
      font-family: 'Courier New', monospace; letter-spacing: 1px;
      transition: all 0.15s; text-transform: uppercase; white-space: nowrap;
    }
    .trh-btn:hover { background: #1a2e48; border-color: #00c4ff; color: #00c4ff; }
    .trh-btn.active { background: #002a40; border-color: #00c4ff; color: #00c4ff; }
    @media (max-width: 480px) {
      .trh-ctrl-row { flex-direction: column; align-items: stretch; }
      .trh-btn { text-align: center; width: 100%; }
      .trh-whatif-grid, .trh-custom-grid { grid-template-columns: 1fr; }
    }
    .trh-select {
      background: #0d1826; border: 1px solid #1a3050; color: #8ab0d0;
      border-radius: 4px; padding: 5px 8px; font-size: 11px; cursor: pointer;
      font-family: 'Courier New', monospace;
    }
    .trh-whatif-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
    .trh-whatif-field { display: flex; flex-direction: column; gap: 3px; }
    .trh-whatif-field label { font-size: 10px; color: #2a5070; letter-spacing: 1px; text-transform: uppercase; }
    .trh-whatif-field input {
      background: #0d1826; border: 1px solid #1a3050; color: #c8d8e8;
      border-radius: 4px; padding: 6px 8px; font-size: 12px; width: 100%;
      font-family: 'Courier New', monospace;
    }
    .trh-whatif-field input:focus { outline: none; border-color: #00c4ff; }
    .trh-history-row { display: flex; gap: 6px; align-items: center; font-size: 11px; padding: 5px 0; border-bottom: 1px solid #0d1826; }
    .trh-history-date { color: #2a5070; width: 60px; flex-shrink: 0; }
    .trh-history-stat { flex: 1; text-align: right; }
    .trh-history-delta { font-size: 10px; }
    .trh-custom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
    .trh-custom-field { display: flex; flex-direction: column; gap: 3px; }
    .trh-custom-field label { font-size: 10px; color: #2a5070; letter-spacing: 1px; text-transform: uppercase; }
    .trh-custom-field input {
      background: #0d1826; border: 1px solid #1a3050; color: #c8d8e8;
      border-radius: 4px; padding: 6px 8px; font-size: 12px; width: 100%;
      font-family: 'Courier New', monospace;
    }
    .trh-custom-field input:focus { outline: none; border-color: #00c4ff; }
    .trh-tbs { font-size: 22px; font-weight: 700; color: #00c4ff; letter-spacing: -1px; }
    .trh-tbs-label { font-size: 10px; color: #2a5070; letter-spacing: 2px; text-transform: uppercase; margin-top: 2px; }
    .trh-divider { border: none; border-top: 1px solid #0d1826; margin: 12px 0; }
    .trh-warn { font-size: 11px; color: #facc15; padding: 6px 10px; background: #1a1400; border-radius: 4px; margin-bottom: 10px; }
    .trh-no-history { font-size: 12px; color: #2a5070; text-align: center; padding: 20px; }
    .trh-war-badge {
      font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
      padding: 2px 7px; border-radius: 3px; background: #4a0a0a; color: #f87171;
      border: 1px solid #7a1a1a; animation: trh-pulse 2s infinite;
    }
    @keyframes trh-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
    .trh-drift-alert {
      font-size: 11px; color: #fb923c; padding: 6px 10px;
      background: #1a0e00; border-radius: 4px; margin-bottom: 10px;
      border: 1px solid #4a2800;
    }
    .trh-export-box {
      background: #0a1828; border: 1px solid #1a3050; border-radius: 4px;
      padding: 10px; font-size: 11px; color: #4a6080; font-family: 'Courier New', monospace;
      white-space: pre; overflow-x: auto; margin-top: 8px; line-height: 1.6;
    }
    #trh-root.trh-light {
      background: #f8fafc; border-color: #e2e8f0; border-top-color: #0284c7;
      box-shadow: 0 2px 12px rgba(0,0,0,0.1); color: #1e3a5f;
    }
    #trh-root.trh-light #trh-header { background: #f1f5f9; border-bottom-color: #e2e8f0; }
    #trh-root.trh-light #trh-title { color: #0284c7; }
    #trh-root.trh-light #trh-tabs { background: #f1f5f9; border-bottom-color: #e2e8f0; }
    #trh-root.trh-light .trh-tab { color: #94a3b8; background: #f8fafc; }
    #trh-root.trh-light .trh-tab:hover { color: #0284c7; background: #e0f2fe; }
    #trh-root.trh-light .trh-tab.active { color: #0284c7; border-bottom-color: #0284c7; background: #e0f2fe; }
    #trh-root.trh-light .trh-label { color: #94a3b8; }
    #trh-root.trh-light .trh-divider { border-top-color: #e2e8f0; }
    #trh-root.trh-light .trh-btn { background: #f1f5f9; border-color: #e2e8f0; color: #334155; }
    #trh-root.trh-light .trh-btn:hover { background: #e0f2fe; border-color: #0284c7; color: #0284c7; }
    #trh-root.trh-light .trh-btn.active { background: #bae6fd; border-color: #0284c7; color: #0284c7; }
    #trh-root.trh-light .trh-bar-bg { background: #e2e8f0; }
    #trh-root.trh-light .trh-rec-box { background: #f0f9ff; border-color: #bae6fd; }
    #trh-root.trh-light .trh-score-detail { color: #64748b; }
    #trh-root.trh-light .trh-score-detail strong { color: #1e3a5f; }
    #trh-root.trh-light .trh-stat-nums { color: #64748b; }
    #trh-root.trh-light .trh-export-box { background: #f1f5f9; border-color: #e2e8f0; color: #64748b; }
    #trh-root.trh-light .trh-tbs { color: #0284c7; }
    #trh-root.trh-light .trh-no-history { color: #94a3b8; }
    #trh-root.trh-light .trh-warn { background: #fefce8; color: #854d0e; }
    #trh-root.trh-light .trh-drift-alert { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
    #trh-root.trh-light .trh-history-date { color: #94a3b8; }
    #trh-root.trh-light .trh-history-row { border-bottom-color: #e2e8f0; }
    #trh-root.trh-light .trh-custom-field input,
    #trh-root.trh-light .trh-whatif-field input {
      background: #f1f5f9; border-color: #e2e8f0; color: #1e3a5f;
    }
    #trh-root.trh-light .trh-custom-field input:focus,
    #trh-root.trh-light .trh-whatif-field input:focus { border-color: #0284c7; }
  `;

  // Feature helpers

  function getDriftWarnings(stats, targets, roles) {
    if (history.length < 2) return [];
    const prev = history[history.length - 2];
    const prevTargets = computeTargets(prev, selectedRatio, highStat);
    const warnings = [];
    for (const [key] of Object.entries(STAT_LABELS)) {
      const isDump = selectedRatio === 'hank' && roles.tert2 === key;
      if (isDump || !targets[key] || !prevTargets[key]) continue;
      const currPct = (stats[key] || 0) / targets[key];
      const prevPct = (prev[key] || 0) / prevTargets[key];
      if (prevPct >= 0.90 && currPct < prevPct - 0.05) {
        warnings.push({ key, currPct, prevPct });
      }
    }
    return warnings;
  }

  function buildExportText(stats, targets, roles) {
    const tbs = Object.values(stats).reduce((a, v) => a + (v || 0), 0);
    const grade = overallGrade(stats, targets, roles, selectedRatio);
    const pad = (s, n) => String(s).padEnd(n);
    return [
      `=== GymIQ Export ===`,
      `Date:   ${new Date().toLocaleString()}`,
      `Ratio:  ${RATIOS[selectedRatio].label} | High: ${STAT_LABELS[highStat]}`,
      `Health: ${grade.letter} (${grade.score}%) | TBS: ${fmtNum(tbs)}`,
      ``,
      `${pad('STAT',12)} ${pad('CURRENT',12)} ${pad('TARGET',12)} STATUS`,
      ...Object.entries(STAT_LABELS).map(([key, label]) => {
        const actual = stats[key] || 0;
        const target = targets[key] || 0;
        const isDump = selectedRatio === 'hank' && roles.tert2 === key;
        const status = getStatus(actual, target, isDump);
        return `${pad(label,12)} ${pad(fmtNum(actual),12)} ${pad(isDump?'minimize':fmtNum(Math.round(target)),12)} ${status.label}`;
      }),
    ].join('\n');
  }

  // Render tabs

  function renderOverview(stats, targets, roles, ratio) {
    const warWeights = { str: 0.5, spd: 0.5, def: 1.5, dex: 1.5 };

    const rec = (() => {
      let worst = null; let worstScore = Infinity;
      for (const [key] of Object.entries(STAT_LABELS)) {
        const isDump = selectedRatio === 'hank' && roles.tert2 === key;
        if (isDump || !targets[key]) continue;
        const actual = stats[key] || 0;
        const pct = actual / targets[key];
        const weight = warMode ? (warWeights[key] || 1) : 1;
        const score = pct / weight;
        if (score < worstScore) { worstScore = score; worst = key; }
      }
      return worst;
    })();

    const grade = overallGrade(stats, targets, roles, selectedRatio);
    const tbs = Object.values(stats).reduce((a, v) => a + (v || 0), 0);
    const allFound = Object.values(stats).every(v => v !== null);
    const driftWarnings = getDriftWarnings(stats, targets, roles);
    const gradeColor = grade.score >= 90 ? '#4ade80' : grade.score >= 75 ? '#facc15' : '#f87171';

    const statRows = Object.entries(STAT_LABELS).map(([key, label]) => {
      const actual  = stats[key] || 0;
      const target  = targets[key] || 0;
      const isDump  = selectedRatio === 'hank' && roles.tert2 === key;
      const isHigh  = roles.high === key;
      const is2nd   = roles.secondary === key;
      const is3rd   = roles.tert1 === key;
      const status  = getStatus(actual, target, isDump);
      const pct     = isDump ? 0 : (target > 0 ? Math.min((actual / target) * 100, 115) : 0);
      const diff    = Math.round(target - actual);
      const diffStr = isDump ? '<span style="color:#4a6080">minimize</span>'
                    : diff > 0 ? `<span style="color:#f87171">+${fmtNum(diff)} needed</span>`
                    : diff < 0 ? `<span style="color:#fb923c">${fmtNum(Math.abs(diff))} over</span>`
                    : `<span style="color:#4ade80">perfect</span>`;

      const roleBadge = isHigh ? '<span style="font-size:9px;color:#00c4ff;letter-spacing:1px;margin-left:4px">1ST</span>'
                      : is2nd  ? '<span style="font-size:9px;color:#8ab0d0;letter-spacing:1px;margin-left:4px">2ND</span>'
                      : is3rd  ? '<span style="font-size:9px;color:#4a6080;letter-spacing:1px;margin-left:4px">3RD</span>'
                      :          '<span style="font-size:9px;color:#f87171;letter-spacing:1px;margin-left:4px">DUMP</span>';

      return `
        <div class="trh-stat-row">
          <div class="trh-stat-top">
            <div class="trh-stat-name" style="color:${STAT_COLORS[key]}">
              ${label}${roleBadge}
            </div>
            <div class="trh-stat-nums">
              <span>${fmtNum(actual)}</span> / ${fmtNum(Math.round(target))}
              &nbsp;<span style="color:${status.color}">${status.grade}</span>
            </div>
          </div>
          <div class="trh-bar-bg">
            <div class="trh-bar-fill" style="width:${Math.min(pct, 100)}%;background:${status.color};${pct > 105 ? 'box-shadow:0 0 6px '+status.color : ''}"></div>
          </div>
          <div class="trh-stat-status" style="color:${status.color}">${status.emoji} ${status.label} - ${diffStr}</div>
        </div>`;
    }).join('');

    const lastEntry = history.length >= 2 ? history[history.length - 2] : null;
    const statKeys = Object.keys(STAT_LABELS);
    const tbsDelta = lastEntry ? tbs - statKeys.reduce((a, k) => a + (lastEntry[k] || 0), 0) : null;

    return `
      ${!allFound ? '<div class="trh-warn">Warning: Some stats could not be read from this page. Try refreshing.</div>' : ''}
      ${driftWarnings.map(w => `<div class="trh-drift-alert">Drift: <strong>${STAT_LABELS[w.key]}</strong> was ${Math.round(w.prevPct*100)}% of target, now ${Math.round(w.currPct*100)}%. Consider rebalancing.</div>`).join('')}

      <div class="trh-section">
        <div style="display:flex;gap:16px;align-items:flex-start;justify-content:space-between">
          <div>
            <div class="trh-label" style="display:flex;align-items:center;gap:8px">
              Ratio Health
              ${warMode ? '<span class="trh-war-badge">WAR MODE</span>' : ''}
            </div>
            <div class="trh-score-row">
              <div class="trh-grade" style="color:${gradeColor}">${grade.letter}</div>
              <div class="trh-score-detail">
                <div><strong>${grade.score}%</strong> in ratio</div>
                <div style="margin-top:2px">${RATIOS[selectedRatio].label}</div>
              </div>
            </div>
          </div>
          <div style="text-align:right">
            <div class="trh-label">Total Battle Stats</div>
            <div class="trh-tbs">${fmtNum(tbs)}</div>
            ${tbsDelta !== null ? `<div class="trh-tbs-label" style="color:${tbsDelta>=0?'#4ade80':'#f87171'}">${tbsDelta>=0?'+':''}${fmtNum(tbsDelta)} since last visit</div>` : '<div class="trh-tbs-label">TBS</div>'}
          </div>
        </div>
      </div>

      <hr class="trh-divider">

      ${rec ? `
      <div class="trh-section">
        <div class="trh-label">Train Next</div>
        <div class="trh-rec-box">
          <div class="trh-rec-stat" style="color:${STAT_COLORS[rec]}">${STAT_LABELS[rec]}</div>
          <div style="font-size:11px;color:#4a7090">
            Currently at ${fmtNum(stats[rec])} - Target ${fmtNum(Math.round(targets[rec]))} - Need +${fmtNum(Math.round(targets[rec] - (stats[rec]||0)))}
          </div>
        </div>
      </div>
      <hr class="trh-divider">` : ''}

      <div class="trh-section">
        <div class="trh-label">Stats</div>
        ${statRows}
      </div>`;
  }

  function renderWhatIf(stats, roles) {
    const wi = readWhatIfStats(stats);
    const renderWhatIfSummary = (currentWi) => {
      const wiTargets = computeTargets(currentWi, selectedRatio, highStat);
      const wiGrade = overallGrade(currentWi, wiTargets, roles, selectedRatio);
      const wiTbs = Object.values(currentWi).reduce((a, b) => a + b, 0);
      const gradeColor = wiGrade.score >= 90 ? '#4ade80' : wiGrade.score >= 75 ? '#facc15' : '#f87171';

      const wiRows = Object.entries(STAT_LABELS).map(([key, label]) => {
        const actual = currentWi[key] || 0;
        const target = wiTargets[key] || 0;
        const isDump = selectedRatio === 'hank' && roles.tert2 === key;
        const status = getStatus(actual, target, isDump);
        return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid #0d1826;font-size:12px">
          <span style="flex:1;color:${STAT_COLORS[key]}">${label}</span>
          <span style="color:#4a6080">${fmtNum(Math.round(target))}</span>
          <span style="color:${status.color};width:20px;text-align:center">${status.emoji}</span>
        </div>`;
      }).join('');

      return `
        <div class="trh-label">Projected Ratio Health</div>
        <div style="display:flex;gap:16px;align-items:center;margin-bottom:10px">
          <div class="trh-grade" style="color:${gradeColor}">${wiGrade.letter}</div>
          <div class="trh-score-detail">
            <div><strong>${wiGrade.score}%</strong> in ratio</div>
            <div>TBS: <strong style="color:#00c4ff">${fmtNum(wiTbs)}</strong></div>
          </div>
        </div>
        ${wiRows}`;
    };

    return `
      <div class="trh-section">
        <div class="trh-label">Enter Hypothetical Stats</div>
        <div class="trh-whatif-grid" id="trh-wi-grid">
          ${Object.entries(STAT_LABELS).map(([key, label]) => `
            <div class="trh-whatif-field">
              <label style="color:${STAT_COLORS[key]}">${label}</label>
              <input id="trh-wi-${key}" type="number" value="${stats[key] || ''}" placeholder="e.g. 50000">
            </div>`).join('')}
        </div>
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div id="trh-wi-summary">${renderWhatIfSummary(wi)}</div>
      </div>`;
  }

  function renderHistory() {
    if (history.length === 0) {
      return `<div class="trh-no-history">No history yet.<br>Stats are recorded each time you visit this page.<br>Check back after training!</div>`;
    }
    const sorted = [...history].reverse().slice(0, 15);
    const headerRow = `
      <div class="trh-history-row" style="font-size:10px;color:#2a5070;text-transform:uppercase;letter-spacing:1px">
        <div class="trh-history-date">Date</div>
        ${Object.entries(STAT_LABELS).map(([k, label])=>`<div class="trh-history-stat" style="color:${STAT_COLORS[k]}">${label}</div>`).join('')}
        <div class="trh-history-stat">TBS</div>
      </div>`;
    const rows = sorted.map((entry, i) => {
      const prev = sorted[i + 1];
      const tbs = Object.keys(STAT_LABELS).reduce((a, k) => a + (entry[k] || 0), 0);
      const prevTbs = prev ? Object.keys(STAT_LABELS).reduce((a, k) => a + (prev[k] || 0), 0) : null;
      const tbsDelta = prevTbs !== null ? tbs - prevTbs : null;
      return `
        <div class="trh-history-row">
          <div class="trh-history-date">${fmtDate(entry.ts)}</div>
          ${Object.keys(STAT_LABELS).map(k => {
            const delta = prev ? (entry[k]||0) - (prev[k]||0) : null;
            return `<div class="trh-history-stat" style="color:${STAT_COLORS[k]}">
              ${fmtNum(entry[k])}
              ${delta !== null && delta !== 0 ? `<div class="trh-history-delta" style="color:${delta>0?'#4ade80':'#f87171'}">${delta>0?'+':''}${fmtNum(delta)}</div>` : ''}
            </div>`;
          }).join('')}
          <div class="trh-history-stat" style="color:#00c4ff">
            ${fmtNum(tbs)}
            ${tbsDelta !== null && tbsDelta !== 0 ? `<div class="trh-history-delta" style="color:${tbsDelta>0?'#4ade80':'#f87171'}">${tbsDelta>0?'+':''}${fmtNum(tbsDelta)}</div>` : ''}
          </div>
        </div>`;
    }).join('');

    return `
      <div class="trh-section">
        <div class="trh-label">Stat History (last ${sorted.length} snapshots)</div>
        ${headerRow}${rows}
      </div>
      <div style="text-align:right;margin-top:8px">
        <button class="trh-btn" id="trh-clear-history">Clear History</button>
      </div>`;
  }

  function renderExport(stats, targets, roles) {
    const exportText = buildExportText(stats, targets, roles);

    return `
      <div class="trh-section">
        <div class="trh-label">Export Snapshot</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Copy a plain-text summary of your current ratio health and target stats.
        </div>
        <div style="display:flex;justify-content:flex-end">
          <button class="trh-btn" id="trh-copy-export">Copy to Clipboard</button>
        </div>
        <pre id="trh-export-text" class="trh-export-box">${escapeHtml(exportText)}</pre>
      </div>`;
  }

  function renderSettings() {
    const mults = RATIOS.custom.multipliers;
    const roles = getRoles(highStat);
    const ratio = RATIOS[selectedRatio];

    const slotOrder = [
      { role: 'high',      label: '1st - highest',    mult: 1.00 },
      { role: 'secondary', label: '2nd',               mult: ratio.multipliers.secondary },
      { role: 'tert1',     label: '3rd',               mult: ratio.multipliers.tert1 },
      { role: 'tert2',     label: '4th - dump/lowest', mult: ratio.multipliers.tert2 },
    ];

    const mappingRows = slotOrder.map(({ role, label, mult }) => {
      const statKey = roles[role];
      const isDump  = mult === 0;
      return `
        <div style="display:flex;align-items:center;gap:10px;padding:5px 8px;background:#0a1828;border-radius:4px;margin-bottom:4px;font-size:12px">
          <span style="color:#2a5070;width:110px;flex-shrink:0;font-size:10px;text-transform:uppercase;letter-spacing:1px">${label}</span>
          <span style="flex:1;color:${STAT_COLORS[statKey]};font-weight:700">${STAT_LABELS[statKey]}</span>
          <span style="color:${isDump ? '#f87171' : '#4a6080'};font-size:11px">${isDump ? 'dump (x0)' : 'x' + mult.toFixed(2)}</span>
        </div>`;
    }).join('');

    return `
      <div class="trh-section">
        <div class="trh-label">Active Ratio</div>
        <div class="trh-ctrl-row">
          ${Object.entries(RATIOS).map(([k, r]) =>
            `<button class="trh-btn ${selectedRatio === k ? 'active' : ''}" data-trh-ratio="${k}">${r.short}</button>`
          ).join('')}
        </div>
      </div>
      <div class="trh-section">
        <div class="trh-label">Your Highest Stat</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Pick whichever stat you train the most. The 2nd, 3rd and 4th slots are assigned automatically. Use the Custom Ratio section below if you want different multipliers.
        </div>
        <div class="trh-ctrl-row">
          ${Object.entries(STAT_LABELS).map(([k, l]) =>
            `<button class="trh-btn ${highStat === k ? 'active' : ''}" data-trh-high="${k}">${l}</button>`
          ).join('')}
        </div>
      </div>
      ${(highStat === 'def' || highStat === 'dex') ? `
      <div class="trh-section">
        <div class="trh-label">Def/Dex Dump Preference</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          When Defense or Dexterity is your high stat, choose which offensive stat should be the dump/lowest slot.
        </div>
        <div class="trh-ctrl-row">
          <button class="trh-btn ${defensiveDump === 'str' ? 'active' : ''}" data-trh-defdump="str">Dump Strength</button>
          <button class="trh-btn ${defensiveDump === 'spd' ? 'active' : ''}" data-trh-defdump="spd">Dump Speed</button>
        </div>
      </div>` : ''}
      <div class="trh-section">
        <div class="trh-label">Current Stat Order for ${ratio.label}</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          The multiplier (x) shows what fraction of your 1st stat each one is targeted at.
        </div>
        ${mappingRows}
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Display</div>
        <div class="trh-ctrl-row">
          <button class="trh-btn ${theme==='dark'?'active':''}" data-trh-theme="dark">Dark</button>
          <button class="trh-btn ${theme==='light'?'active':''}" data-trh-theme="light">Light</button>
        </div>
      </div>
      <div class="trh-section">
        <div class="trh-label">Faction War Mode</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Reweights the "Train Next" recommendation to prioritise Defense and Dexterity during wars.
        </div>
        <button class="trh-btn ${warMode?'active':''}" id="trh-war-toggle">
          ${warMode ? 'War Mode ON' : 'War Mode OFF'}
        </button>
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Custom Ratio Multipliers</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Each number is a multiplier relative to your 1st stat. For example 0.80 means "train this stat to 80% of your highest." Set 4th to 0 for a dump stat.
        </div>
        <div class="trh-custom-grid">
          <div class="trh-custom-field">
            <label style="color:${STAT_COLORS[roles.high]}">1st - ${STAT_LABELS[roles.high]} (always 1.0)</label>
            <input type="number" value="1.00" disabled style="opacity:0.4">
          </div>
          <div class="trh-custom-field">
            <label style="color:${STAT_COLORS[roles.secondary]}">2nd - ${STAT_LABELS[roles.secondary]}</label>
            <input id="trh-c-secondary" type="number" step="0.01" min="0" max="1" value="${mults.secondary}">
          </div>
          <div class="trh-custom-field">
            <label style="color:${STAT_COLORS[roles.tert1]}">3rd - ${STAT_LABELS[roles.tert1]}</label>
            <input id="trh-c-tert1" type="number" step="0.01" min="0" max="1" value="${mults.tert1}">
          </div>
          <div class="trh-custom-field">
            <label style="color:${STAT_COLORS[roles.tert2]}">4th / Dump - ${STAT_LABELS[roles.tert2]}</label>
            <input id="trh-c-tert2" type="number" step="0.01" min="0" max="1" value="${mults.tert2}">
          </div>
        </div>
        <div style="margin-top:8px">
          <button class="trh-btn" id="trh-save-custom">Save Custom Ratio</button>
        </div>
      </div>`;
  }

  // Info tab
  function renderInfo() {
    return `
      <div class="trh-section">
        <div class="trh-label">Why not just train all stats equally?</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7">
          Torn has <strong style="color:#e2e8f0">specialist gyms</strong> that give significantly better gains than regular gyms - but they have <strong style="color:#e2e8f0">stat ratio requirements</strong> to unlock and keep access. Baldr's and Hank's ratios are the two most popular ways to structure your stats so you can use those better gyms on as many stats as possible.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Gym Dots - Why They Matter</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          Gym dots are a multiplier on your stat gains per energy spent. Higher dots = more gains per train. The values below are approximate and may vary by gym.
        </div>
        <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-size:11px">
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">George's Gym</div>
            <div style="color:#facc15;font-size:18px;font-weight:700">~7.3</div>
            <div style="color:#4a6080;font-size:10px">dots - 10e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">No requirements</div>
          </div>
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">Special Gyms</div>
            <div style="color:#fb923c;font-size:18px;font-weight:700">~7.5</div>
            <div style="color:#4a6080;font-size:10px">dots - 25e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">Ratio required</div>
          </div>
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">Elite Gyms</div>
            <div style="color:#4ade80;font-size:18px;font-weight:700">~8.0</div>
            <div style="color:#4a6080;font-size:10px">dots - 50e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">Strict ratio req.</div>
          </div>
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Baldr's Ratio - 1.25 : 1 : 0.75 : 0.75</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          A <strong style="color:#e2e8f0">balanced specialist build</strong>. Your high stat leads, secondary sits at 80% of it, and your two lower stats sit at 75% (about 60% relative to high). Your lowest stat is still around 22% of your total - a much more even spread.
        </div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          With Baldr's you unlock <strong style="color:#e2e8f0">two specialist gyms</strong>: one elite (8.0 dots) for your high stat, and one special (7.5 dots) for your secondary. Your two lower stats train at George's.
        </div>
        <div style="background:#0a2010;border:1px solid #1a4020;border-left:3px solid #4ade80;border-radius:4px;padding:8px 10px;font-size:11px;color:#86efac">
          Best for: Any stat pairing - works equally well for offensive (Str/Spd) or defensive (Def/Dex) high stat builds. Good all-around starting point for most players.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Hank's Ratio - 1.25 : 1 : 1 : ~0</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          An <strong style="color:#e2e8f0">aggressive efficiency build</strong>. Three stats are trained hard while one (the dump stat) is kept as low as possible. This unlocks <strong style="color:#e2e8f0">two specialist gyms for three stats</strong> simultaneously, giving faster overall gains.
        </div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          <strong style="color:#e2e8f0">Important:</strong> For Hank's, your high stat almost has to be Def or Dex - <em style="color:#64748b">not Str or Spd</em>. If you dump Speed, you'll miss most attacks. If you dump Strength, you'll hit often but deal little to no damage. Either way you lose fights.
        </div>
        <div style="background:#200a0a;border:1px solid #402020;border-left:3px solid #f87171;border-radius:4px;padding:8px 10px;font-size:11px;color:#fca5a5">
          Best for: Def or Dex high-stat builds in mid-to-late game. More total gains over time, but very restrictive - your dump stat must stay extremely low. Hard to switch away from later.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Stats Explained</div>
        <div style="display:flex;flex-direction:column;gap:6px;font-size:12px">
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <div><strong style="color:#f97316">Strength</strong> <span style="color:#4a6080;font-size:10px">OFFENSIVE</span><br><span style="color:#64748b">How hard you hit. More Str = more damage per successful strike. Useless if your Speed is too low to land hits.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <div><strong style="color:#22d3ee">Speed</strong> <span style="color:#4a6080;font-size:10px">OFFENSIVE</span><br><span style="color:#64748b">How often you hit. More Spd = higher hit chance each round. Str and Spd work together - you need both to deal reliable damage.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <div><strong style="color:#a78bfa">Defense</strong> <span style="color:#4a6080;font-size:10px">DEFENSIVE</span><br><span style="color:#64748b">Reduces damage you take when hit. Works independently from Dexterity. High Def = you survive longer in fights.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <div><strong style="color:#4ade80">Dexterity</strong> <span style="color:#4a6080;font-size:10px">DEFENSIVE</span><br><span style="color:#64748b">How often you dodge enemy attacks. More Dex = enemy hits less often. Also works independently from Defense.</span></div>
          </div>
        </div>
      </div>`;
  }

  // Main render
  function render(stats) {
    const roles   = getRoles(highStat);
    const targets = computeTargets(stats, selectedRatio, highStat);

    let tabContent = '';
    if (activeTab === 'overview')        tabContent = renderOverview(stats, targets, roles, selectedRatio);
    else if (activeTab === 'whatif')     tabContent = renderWhatIf(stats, roles);
    else if (activeTab === 'history')    tabContent = renderHistory();
    else if (activeTab === 'export')     tabContent = renderExport(stats, targets, roles);
    else if (activeTab === 'settings')   tabContent = renderSettings();
    else if (activeTab === 'info')       tabContent = renderInfo();

    const tabs = [
      { id: 'overview',  label: 'Overview' },
      { id: 'whatif',    label: 'What-If' },
      { id: 'history',   label: 'History' },
      { id: 'export',    label: 'Export' },
      { id: 'settings',  label: 'Settings' },
      { id: 'info',      label: 'Info' },
    ];

    return `
      <div id="trh-root" class="${theme === 'light' ? 'trh-light' : ''}">
        <div id="trh-header">
          <div id="trh-title">GymIQ</div>
          <div style="display:flex;align-items:center;gap:8px">
            ${warMode ? '<span class="trh-war-badge">WAR</span>' : ''}
            <div style="font-size:10px;color:#2a5070;letter-spacing:1px">
              ${RATIOS[selectedRatio].short.toUpperCase()} - ${STAT_LABELS[highStat].toUpperCase()} HIGH
            </div>
          </div>
        </div>
        <div id="trh-tabs">
          ${tabs.map(t => `<button class="trh-tab ${activeTab === t.id ? 'active' : ''}" data-trh-tab="${t.id}">${t.label}</button>`).join('')}
        </div>
        <div id="trh-body">
          ${tabContent}
        </div>
      </div>`;
  }

  // Inject
  function injectPanel() {
    const old = document.getElementById('trh-wrapper');
    if (old) old.remove();

    const stats = readStatsFromPage();
    recordHistory(stats);

    if (!document.getElementById('trh-style')) {
      const style = document.createElement('style');
      style.id = 'trh-style';
      style.textContent = CSS;
      (document.head || document.documentElement || document.body).appendChild(style);
    }

    const wrapper = document.createElement('div');
    wrapper.id = 'trh-wrapper';
    wrapper.innerHTML = render(stats);

    const host = [
      document.querySelector('.gym-content'),
      document.querySelector('.content-title'),
      document.querySelector('#gym-root'),
      document.querySelector('.profile-wrapper'),
      document.querySelector('.stats-info'),
      document.querySelector('#react-root'),
      document.querySelector('main'),
      document.querySelector('#mainContainer'),
      document.body,
    ].find(el => el !== null) || document.documentElement;

    host.insertBefore(wrapper, host.firstChild);
    attachEvents(wrapper, stats);
  }

  function attachEvents(wrapper, stats) {
    wrapper.addEventListener('click', (e) => {
      const btn = e.target.closest('button, [data-trh-tab], [data-trh-ratio], [data-trh-high], [data-trh-theme], [data-trh-defdump]');
      if (!btn) return;

      if (btn.dataset.trhTab)   { activeTab = btn.dataset.trhTab; injectPanel(); return; }
      if (btn.dataset.trhRatio) { selectedRatio = btn.dataset.trhRatio; store.set('trh_ratio', selectedRatio); injectPanel(); return; }
      if (btn.dataset.trhHigh)  { highStat = btn.dataset.trhHigh; store.set('trh_high', highStat); injectPanel(); return; }
      if (btn.dataset.trhTheme) { theme = btn.dataset.trhTheme; store.set('trh_theme', theme); injectPanel(); return; }
      if (btn.dataset.trhDefdump) { defensiveDump = btn.dataset.trhDefdump; store.set('trh_def_dump', defensiveDump); injectPanel(); return; }

      const id = btn.id;

      if (id === 'trh-war-toggle') {
        warMode = !warMode;
        store.set('trh_war', warMode);
        injectPanel();
        return;
      }

      if (id === 'trh-save-custom') {
        customMults = {
          high:      1.00,
          secondary: readNumberInput('trh-c-secondary', 0.80),
          tert1:     readNumberInput('trh-c-tert1', 0.60),
          tert2:     readNumberInput('trh-c-tert2', 0.60),
        };
        store.set('trh_custom', customMults);
        selectedRatio = 'custom';
        store.set('trh_ratio', 'custom');
        injectPanel();
        return;
      }

      if (id === 'trh-clear-history') {
        let confirmed = false;
        try { confirmed = confirm('Clear all stat history?'); } catch(e) { confirmed = true; }
        if (confirmed) {
          history = [];
          store.set('trh_history', []);
          injectPanel();
        }
        return;
      }

      if (id === 'trh-copy-export') {
        const text = document.getElementById('trh-export-text')?.textContent || '';
        const tryClipboard = () => {
          if (navigator.clipboard && navigator.clipboard.writeText) {
            return navigator.clipboard.writeText(text);
          }
          return Promise.reject('clipboard API not available');
        };
        const fallbackCopy = () => {
          const ta = document.createElement('textarea');
          ta.value = text;
          ta.style.position = 'fixed';
          ta.style.opacity = '0';
          document.body.appendChild(ta);
          ta.focus();
          ta.select();
          let copied = false;
          try { copied = document.execCommand('copy'); } catch(e) {}
          document.body.removeChild(ta);
          return copied;
        };

        Promise.resolve()
          .then(() => tryClipboard())
          .catch(() => {
            if (!fallbackCopy()) throw new Error('copy failed');
          })
          .then(() => {
            btn.textContent = 'Copied!';
          })
          .catch(() => {
            btn.textContent = 'Manual copy below';
          })
          .finally(() => {
            setTimeout(() => { btn.textContent = 'Copy to Clipboard'; }, 2000);
          });
        return;
      }

    });

    wrapper.addEventListener('input', (e) => {
      if (!e.target.id?.startsWith('trh-wi-')) return;
      const wi = readWhatIfStats(stats);
      const roles = getRoles(highStat);
      const wiTargets = computeTargets(wi, selectedRatio, highStat);
      const wiGrade = overallGrade(wi, wiTargets, roles, selectedRatio);
      const gradeColor = wiGrade.score >= 90 ? '#4ade80' : wiGrade.score >= 75 ? '#facc15' : '#f87171';
      const gradeEl = document.querySelector('.trh-grade');
      if (gradeEl) { gradeEl.textContent = wiGrade.letter; gradeEl.style.color = gradeColor; }
      const summaryEl = wrapper.querySelector('#trh-wi-summary');
      if (summaryEl) {
        const wiRows = Object.entries(STAT_LABELS).map(([key, label]) => {
          const actual = wi[key] || 0;
          const target = wiTargets[key] || 0;
          const isDump = selectedRatio === 'hank' && roles.tert2 === key;
          const status = getStatus(actual, target, isDump);
          return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid #0d1826;font-size:12px">
            <span style="flex:1;color:${STAT_COLORS[key]}">${label}</span>
            <span style="color:#4a6080">${fmtNum(Math.round(target))}</span>
            <span style="color:${status.color};width:20px;text-align:center">${status.emoji}</span>
          </div>`;
        }).join('');
        const wiTbs = Object.values(wi).reduce((a, b) => a + b, 0);
        summaryEl.innerHTML = `
          <div class="trh-label">Projected Ratio Health</div>
          <div style="display:flex;gap:16px;align-items:center;margin-bottom:10px">
            <div class="trh-grade" style="color:${gradeColor}">${wiGrade.letter}</div>
            <div class="trh-score-detail">
              <div><strong>${wiGrade.score}%</strong> in ratio</div>
              <div>TBS: <strong style="color:#00c4ff">${fmtNum(wiTbs)}</strong></div>
            </div>
          </div>
          ${wiRows}`;
      }
    });
  }

  // Init

  function isGymPage() {
    return location.pathname === '/gym.php' || location.href.includes('gym.php');
  }

  function removePanel() {
    const el = document.getElementById('trh-wrapper');
    if (el) el.remove();
  }

  function waitAndInject(attempts = 0) {
    if (!isGymPage()) { removePanel(); return; }

    const domReady = document.querySelector('.gym-content, .content-title, #gym-root, .stats-info, main');
    if (!domReady && attempts < 40) {
      return setTimeout(() => waitAndInject(attempts + 1), 300);
    }

    const stats = readStatsFromPage();
    const statsFound = Object.values(stats).some(v => v !== null);

    if (!statsFound && attempts < 60) {
      return setTimeout(() => waitAndInject(attempts + 1), 500);
    }

    injectPanel();
    setTimeout(backgroundStatWatch, 600);
  }

  function backgroundStatWatch() {
    if (!isGymPage() || !document.getElementById('trh-wrapper')) return;
    const warningVisible = document.querySelector('#trh-wrapper .trh-warn');
    if (!warningVisible) return;
    const stats = readStatsFromPage();
    if (Object.values(stats).some(v => v !== null)) {
      injectPanel();
    } else {
      setTimeout(backgroundStatWatch, 500);
    }
  }

  // Watch the energy bar - it drops every time you train, so we use it
  // to detect when a train completes and refresh the panel automatically
  let trainObserver = null;
  let refreshDebounce = null;
  let lastEnergyValue = null;
  let observerPaused = false;

  function getDisplayedEnergy() {
    // Torn's energy bar class name has a random suffix so we match on the partial
    const selectors = [
      '[class*="bar-value"]',
      '[class*="energy"] [class*="bar-value"]',
      '[class*="energy"] [class*="value"]',
    ];
    for (const sel of selectors) {
      try {
        const els = document.querySelectorAll(sel);
        for (const el of els) {
          const m = el.textContent.replace(/,/g, '').match(/(\d+)\s*\/\s*\d+/);
          if (m) {
            const v = parseInt(m[1]);
            if (!isNaN(v) && v >= 0 && v <= 1000) return v;
          }
        }
      } catch(e) {}
    }
    return null;
  }

  function syncEnergyBaseline() {
    const e = getDisplayedEnergy();
    if (e !== null) lastEnergyValue = e;
  }

  function startTrainObserver() {
    if (trainObserver) return;
    syncEnergyBaseline();

    trainObserver = new MutationObserver(() => {
      if (!isGymPage() || observerPaused) return;
      const currentEnergy = getDisplayedEnergy();
      if (currentEnergy === null) return;

      if (lastEnergyValue !== null && currentEnergy < lastEnergyValue) {
        lastEnergyValue = currentEnergy;
        clearTimeout(refreshDebounce);
        refreshDebounce = setTimeout(() => {
          if (!isGymPage() || !document.getElementById('trh-wrapper')) return;
          observerPaused = true;
          injectPanel();
          setTimeout(() => { syncEnergyBaseline(); observerPaused = false; }, 500);
        }, 800);
      } else {
        lastEnergyValue = currentEnergy;
      }
    });

    trainObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
  }

  // Watch for page navigation and show/hide the panel accordingly
  let lastUrl = location.href;
  new MutationObserver(() => {
    const currentUrl = location.href;
    if (currentUrl !== lastUrl) {
      lastUrl = currentUrl;
      if (isGymPage()) {
        setTimeout(() => waitAndInject(), 800);
        startTrainObserver();
      } else {
        removePanel();
        if (trainObserver) { trainObserver.disconnect(); trainObserver = null; }
      }
    }
  }).observe(document.body, { childList: true, subtree: true });

  if (isGymPage()) {
    waitAndInject();
    startTrainObserver();
  }

})();