🌸🐻 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.

اعتبارا من 12-10-2025. شاهد أحدث إصدار.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(I already have a user script manager, let me install it!)

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==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);
  }

})();