🌸🐻 Unified Stock (Compact Header v4.2-final)

Compact one-line display-case (inv) + YATA stock (stk) embedded between Torn header left & right. Short names, sets/points shown on expand, single ✈ fly suggestion. Refresh every 45s.

Versión del día 12/10/2025. Echa un vistazo a la versión más reciente.

// ==UserScript==
// @name         🌸🐻 Unified Stock (Compact Header v4.2-final)
// @namespace    http://tampermonkey.net/
// @version      4.2
// @description  Compact one-line display-case (inv) + YATA stock (stk) embedded between Torn header left & right. Short names, sets/points shown on expand, single ✈ fly suggestion. Refresh every 45s.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      yata.yt
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';

  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const REFRESH_MS = 45 * 1000;
  const LOW_STOCK = 500;
  const POINTS_PER_SET = 10;
  const PANEL_ID = 'tm_uni_stock_compact_v4_2';

  // ----- Short name map (what you requested) -----
  const SHORT_MAP = {
    "Dahlia": "Dahlia",
    "Orchid": "Orchid",
    "African Violet": "A.Violet",
    "Cherry Blossom": "C.Blossom",
    "Peony": "Peony",
    "Ceibo Flower": "Ceibo",
    "Edelweiss": "Edelweiss",
    "Crocus": "Crocus",
    "Heather": "Heather",
    "Tribulus Omanense": "Tribulus",
    "Banana Orchid": "Banana",
    "Sheep Plushie": "Sheep",
    "Teddy Bear Plushie": "Teddy",
    "Kitten Plushie": "Kitten",
    "Jaguar Plushie": "Jaguar",
    "Wolverine Plushie": "Wolverine",
    "Nessie Plushie": "Nessie",
    "Red Fox Plushie": "R.Fox",
    "Monkey Plushie": "Monkey",
    "Chamois Plushie": "Chamois",
    "Panda Plushie": "Panda",
    "Lion Plushie": "Lion",
    "Camel Plushie": "Camel",
    "Stingray Plushie": "Stingray",
    "Xanax": "Xanax"
  };

  // define item order (flowers then plushies then xanax)
  const FLOWERS_ORDER = [
    ["Dahlia",{code:'mex',flag:'🇲🇽'}],
    ["Orchid",{code:'haw',flag:'🏝️'}],
    ["African Violet",{code:'sou',flag:'🇿🇦'}],
    ["Cherry Blossom",{code:'jap',flag:'🇯🇵'}],
    ["Peony",{code:'chi',flag:'🇨🇳'}],
    ["Ceibo Flower",{code:'arg',flag:'🇦🇷'}],
    ["Edelweiss",{code:'swi',flag:'🇨🇭'}],
    ["Crocus",{code:'can',flag:'🇨🇦'}],
    ["Heather",{code:'uni',flag:'🇬🇧'}],
    ["Tribulus Omanense",{code:'uae',flag:'🇦🇪'}],
    ["Banana Orchid",{code:'cay',flag:'🇰🇾'}]
  ];
  const PLUSHIES_ORDER = [
    ["Sheep Plushie",{code:null,flag:'🏪'}],
    ["Teddy Bear Plushie",{code:null,flag:'🏪'}],
    ["Kitten Plushie",{code:null,flag:'🏪'}],
    ["Jaguar Plushie",{code:'mex',flag:'🇲🇽'}],
    ["Wolverine Plushie",{code:'can',flag:'🇨🇦'}],
    ["Nessie Plushie",{code:'uni',flag:'🇬🇧'}],
    ["Red Fox Plushie",{code:'uni',flag:'🇬🇧'}],
    ["Monkey Plushie",{code:'arg',flag:'🇦🇷'}],
    ["Chamois Plushie",{code:'swi',flag:'🇨🇭'}],
    ["Panda Plushie",{code:'chi',flag:'🇨🇳'}],
    ["Lion Plushie",{code:'sou',flag:'🇿🇦'}],
    ["Camel Plushie",{code:'uae',flag:'🇦🇪'}],
    ["Stingray Plushie",{code:'cay',flag:'🇰🇾'}]
  ];
  const SPECIAL_DRUG = "Xanax";
  const TRACKED_ORDER = [...FLOWERS_ORDER.map(x=>x[0]), ...PLUSHIES_ORDER.map(x=>x[0]), SPECIAL_DRUG];
  const COUNTRY_NAMES = { mex:'Mexico', can:'Canada', jap:'Japan', chi:'China', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', haw:'Hawaii', uae:'UAE', cay:'Cayman Islands', sou:'South Africa' };
  // ----- end maps -----

  // CSS - semi-transparent compact header, dropdown below
  GM_addStyle(`
    /* main container we'll insert between left & right header groups */
    #${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; }
    #${PANEL_ID} .tm-compact { background: rgba(10,10,10,0.62); color:#eee; border:1px solid rgba(255,255,255,0.06); border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:12px; padding:6px 8px; display:flex; align-items:center; gap:10px; cursor:pointer; }
    #${PANEL_ID} .tm-compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .tm-compact .controls { display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .tm-compact .btn { background:transparent; color:#eaeaea; border:1px solid rgba(255,255,255,0.06); padding:2px 6px; border-radius:4px; cursor:pointer; font-size:12px; }
    #${PANEL_ID} .tm-dropdown { position:absolute; z-index:999999; margin-top:6px; background:#0b0b0b; color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); width:360px; max-height:60vh; overflow:auto; padding:8px; display:none; }
    #${PANEL_ID} .tm-header-row { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:6px; }
    #${PANEL_ID} .tm-points { color:#bfc9d6; font-weight:700; font-size:12px; }
    #${PANEL_ID} .tm-list { display:block; }
    #${PANEL_ID} .tm-row { display:flex; justify-content:space-between; gap:8px; padding:6px 4px; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
    #${PANEL_ID} .tm-left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    #${PANEL_ID} .tm-dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; }
    #${PANEL_ID} .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    #${PANEL_ID} .tm-name { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .tm-meta { color:#bfc9d6; width:160px; text-align:right; flex:0 0 160px; font-size:12px; }
    #${PANEL_ID} .tm-fly { color:#9ad0ff; font-weight:700; margin-top:6px; display:block; text-align:right; }
    /* small screens adjust */
    @media (max-width:720px) {
      #${PANEL_ID} .tm-dropdown { width: 92vw; left:4vw; right:4vw; }
      #${PANEL_ID} .tm-meta { width:120px; flex:0 0 120px; }
    }
  `);

  // create DOM elements
  function makePanel() {
    const wrap = document.createElement('div');
    wrap.id = PANEL_ID;

    // compact header (visible in header)
    const compact = document.createElement('div');
    compact.className = 'tm-compact';
    compact.innerHTML = `
      <div class="title"><span id="tm_toggle_icon">▼</span> <span style="margin-left:2px">🌸🐻 Unified Stock</span></div>
      <div class="controls">
        <button class="btn" id="tm_btn_refresh">Refresh</button>
        <button class="btn" id="tm_btn_setkey">Set Key</button>
      </div>`;
    wrap.appendChild(compact);

    // dropdown (absolute positioned below the compact header)
    const dropdown = document.createElement('div');
    dropdown.className = 'tm-dropdown';
    dropdown.innerHTML = `
      <div class="tm-header-row">
        <div class="tm-points" id="tm_points">Sets: - | Points: -</div>
        <div id="tm_updated" style="color:#9ea6b3;font-size:12px">Updated: -</div>
      </div>
      <div class="tm-list" id="tm_list"></div>
      <div class="tm-fly" id="tm_fly_hint"></div>
      <div style="font-size:11px;color:#9ea6b3;margin-top:6px">Format: ShortName — (inv: X | stk: Y | CODE)</div>
    `;
    wrap.appendChild(dropdown);

    // action wiring
    compact.addEventListener('click', (e) => {
      // don't trigger when clicking buttons
      if (e.target && (e.target.id === 'tm_btn_refresh' || e.target.id === 'tm_btn_setkey' || e.target.classList.contains('btn'))) return;
      toggleDropdown();
    });
    wrap.querySelector('#tm_btn_refresh').addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
    wrap.querySelector('#tm_btn_setkey').addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });

    return wrap;
  }

  let panelNode = makePanel();
  let dropdownVisible = false;
  let collapsed = GM_getValue('tm_uni_collapsed_v4_2', false);

  function toggleDropdown(show) {
    const dd = panelNode.querySelector('.tm-dropdown');
    const icon = panelNode.querySelector('#tm_toggle_icon');
    dropdownVisible = (typeof show === 'boolean') ? show : !dropdownVisible;
    dd.style.display = dropdownVisible ? 'block' : 'none';
    icon.textContent = dropdownVisible ? '▲' : '▼';
    // update collapsed storage: collapsed = !visible
    collapsed = !dropdownVisible;
    GM_setValue('tm_uni_collapsed_v4_2', collapsed);
    // update points visibility: show points only when expanded
    const pts = panelNode.querySelector('#tm_points');
    if (pts) pts.style.display = dropdownVisible ? 'inline-block' : 'none';
  }

  // compute available width between left & right header groups and set compact width
  function sizeCompactToGap() {
    const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
    const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud');
    const compact = panelNode.querySelector('.tm-compact');
    // fallback: place compact width minimal
    if (!left || !right) {
      compact.style.maxWidth = '280px';
      return;
    }
    try {
      const leftRect = left.getBoundingClientRect();
      const rightRect = right.getBoundingClientRect();
      const available = rightRect.left - leftRect.right - 20; // 20px padding
      if (available > 140) {
        compact.style.maxWidth = Math.min(available, 420) + 'px';
      } else {
        compact.style.maxWidth = '140px';
      }
    } catch (e) {
      compact.style.maxWidth = '280px';
    }
  }

  // try to find insertion point (between left and right)
  function insertPanel() {
    // remove existing if any
    const old = document.getElementById(PANEL_ID);
    if (old) old.remove();

    // find left and right groups
    const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
    const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud');

    if (left && right && left.parentNode === right.parentNode) {
      // insert between them
      left.parentNode.insertBefore(panelNode, right);
    } else {
      // fallback: try top header or body
      const header = document.querySelector('header, #header, .topbar, .header') || document.body;
      header.appendChild(panelNode);
    }
    sizeCompactToGap();
    if (!collapsed) toggleDropdown(true);
  }

  // watch for DOM changes (Torn often updates header) and re-insert if removed
  const observer = new MutationObserver((muts) => {
    if (!document.body.contains(panelNode)) {
      // rebuild & insert
      panelNode = makePanel();
      insertPanel();
    } else {
      // resize if header layout changed
      sizeCompactToGap();
    }
  });
  observer.observe(document.documentElement || document.body, { childList:true, subtree:true });

  // network helper using GM_xmlhttpRequest
  function gmGetJson(url, timeout = 10000) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: res => {
          let data = res.response;
          if (!data && res.responseText) {
            try { data = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
          }
          resolve(data);
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Torn display fetch (safe)
  async function askApiKey() {
    const current = GM_getValue('tornAPIKey','');
    const k = prompt('Enter Torn user API key (needs display permission):', current || '');
    if (k !== null) {
      GM_setValue('tornAPIKey', String(k).trim());
      // re-refresh
      await refreshAll(true);
    }
  }

  async function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    try {
      const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    } catch (e) {
      console.warn('fetchDisplayCase error', e);
      return {};
    }
  }

  // YATA fetch
  async function fetchYata() {
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (e) {
      console.warn('fetchYata error', e);
      return null;
    }
  }

  // compute sets & points
  function computeSets(displayMap) {
    const flowerCounts = FLOWERS_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const plushCounts  = PLUSHIES_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const fSets = flowerCounts.length ? Math.min(...flowerCounts) : 0;
    const pSets = plushCounts.length ? Math.min(...plushCounts) : 0;
    const totalSets = (isFinite(fSets) ? fSets : 0) + (isFinite(pSets) ? pSets : 0);
    const points = totalSets * POINTS_PER_SET;
    return { totalSets, points, fSets, pSets };
  }

  // merge and compute best country & totals
  function buildUnified(displayMap, yataData) {
    const yataStocks = {};
    if (yataData && yataData.stocks) {
      for (const [code, obj] of Object.entries(yataData.stocks)) {
        const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
        yataStocks[code] = {};
        for (const it of arr) {
          if (!it || !it.name) continue;
          yataStocks[code][it.name] = Number(it.quantity ?? 0) || 0;
        }
      }
    }

    const items = [];
    for (const name of TRACKED_ORDER) {
      if (name === SPECIAL_DRUG) {
        const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
        const inv = Number(displayMap[name] ?? 0) || 0;
        items.push({ name, inv, totalStk: stk, bestCode: stk>0 ? 'sou' : null, bestStk: stk, locFlag: '🇿🇦' });
        continue;
      }
      let total = 0, bestCode = null, bestStk = 0;
      for (const [code, map] of Object.entries(yataStocks)) {
        const q = Number(map[name] ?? 0) || 0;
        total += q;
        if (q > bestStk) { bestStk = q; bestCode = code; }
      }
      // find flag from lists
      let locFlag = '';
      const f = FLOWERS_ORDER.find(x=>x[0]===name); if (f) locFlag = f[1].flag;
      const p = PLUSHIES_ORDER.find(x=>x[0]===name); if (p) locFlag = p[1].flag;
      const inv = Number(displayMap[name] ?? 0) || 0;
      items.push({ name, inv, totalStk: total, bestCode, bestStk, locFlag });
    }

    // find lowest by inv then by totalStk
    let lowest = null;
    for (const it of items) {
      if (!lowest) { lowest = it; continue; }
      if ((it.inv||0) < (lowest.inv||0)) lowest = it;
      else if ((it.inv||0) === (lowest.inv||0) && (it.totalStk||0) < (lowest.totalStk||0)) lowest = it;
    }

    return { items, lowest };
  }

  function dotClass(inv, stk) {
    if ((stk > 0 && stk < LOW_STOCK) || (inv > 0 && stk > 0 && stk < LOW_STOCK)) return 'y';
    if (inv > 0 || stk > 0) return 'g';
    return 'r';
  }

  function getFlagByCode(code) {
    if (!code) return '';
    const m = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
    return m[code] || '';
  }

  // render dropdown content
  function renderDropdown(displayMap, yataData) {
    const points = computeSets(displayMap);
    const pointsEl = panelNode.querySelector('#tm_points');
    pointsEl.textContent = `Sets: ${points.totalSets} | Points: ${points.points}`;

    const { items, lowest } = buildUnified(displayMap, yataData);

    const listEl = panelNode.querySelector('#tm_list');
    let html = '';
    for (const name of TRACKED_ORDER) {
      const it = items.find(x=>x.name===name);
      if (!it) continue;
      const inv = Number(it.inv||0), stk = Number(it.totalStk||0);
      const cls = dotClass(inv, stk);
      const short = SHORT_MAP[name] || name;
      const codeTxt = it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : '';
      const meta = `(inv: ${inv} | stk: ${stk}${it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : ''})`;
      html += `<div class="tm-row"><div class="tm-left"><div class="tm-dot ${cls}"></div><div class="tm-name">${escapeHtml(short)}</div></div><div class="tm-meta">${escapeHtml(meta)} ${it.locFlag ? escapeHtml(it.locFlag) : ''}</div></div>`;
    }
    listEl.innerHTML = html || '<div style="color:#999">No tracked items</div>';

    // fly hint below
    const flyEl = panelNode.querySelector('#tm_fly_hint');
    if (lowest && lowest.bestCode && lowest.bestStk > 0) {
      flyEl.innerHTML = `✈ Fly: ${ (COUNTRY_NAMES[lowest.bestCode]||lowest.bestCode.toUpperCase()) } ${ escapeHtml(getFlagByCode(lowest.bestCode)) } — ${escapeHtml(SHORT_MAP[lowest.name]||lowest.name)}`;
    } else {
      flyEl.innerHTML = '';
    }

    // updated timestamp
    const upEl = panelNode.querySelector('#tm_updated');
    upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
  }

  function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }

  // master refresh
  let timer = null;
  async function refreshAll(force=false) {
    // show quick spinner in points while updating
    const pts = panelNode.querySelector('#tm_points');
    if (pts) pts.textContent = 'Updating...';
    try {
      const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
      renderDropdown(displayMap||{}, yataData||null);
    } catch (e) {
      console.warn('refreshAll error', e);
      const listEl = panelNode.querySelector('#tm_list');
      if (listEl) listEl.innerHTML = `<div style="color:#f88">Update failed</div>`;
    }
  }

  // fetch helpers wrappers
  function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return Promise.resolve({});
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    return gmGetJson(url).then(data => {
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    }).catch(err => { console.warn('display fetch err', err); return {}; });
  }

  function fetchYata() {
    return gmGetJson(YATA_URL).catch(err => { console.warn('YATA err', err); return null; });
  }

  // GM XHR wrapper
  function gmGetJson(url, timeout=12000) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method:'GET', url, timeout,
        onload: (res) => {
          try {
            let data = res.response;
            if (!data && res.responseText) data = JSON.parse(res.responseText);
            resolve(data);
          } catch (e) { reject(e); }
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // initial insertion & periodic refresh
  insertPanel();
  // ensure collapsed state from storage
  if (!collapsed) toggleDropdown(true); else toggleDropdown(false);
  refreshAll(true);
  timer = setInterval(() => refreshAll(false), REFRESH_MS);

  // cleanup on unload
  window.addEventListener('beforeunload', () => {
    if (timer) clearInterval(timer);
    observer.disconnect();
  });

  // subtle auto-prompt highlight if no key
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(()=> {
      const btn = panelNode.querySelector('#tm_btn_setkey');
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.25)'; }
    }, 1500);
  }

})();