🌸🐻 Unified Stock (Compact Header v4.3)

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.

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.

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

// ==UserScript==
// @name         🌸🐻 Unified Stock (Compact Header v4.3)
// @namespace    http://tampermonkey.net/
// @version      4.3
// @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';

  /* ====== CONFIG ====== */
  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_3';

  /* ====== SHORT NAMES & ORDER ====== */
  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"
  };

  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', cay:'Cayman Islands', can:'Canada', haw:'Hawaii', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', jap:'Japan', chi:'China', uae:'UAE', sou:'South Africa' };

  /* ====== STYLES (compact + slide) ====== */
  GM_addStyle(`
    #${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; position:relative; z-index:999999; }
    #${PANEL_ID} .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; min-width:120px; max-width:520px; box-sizing:border-box; }
    #${PANEL_ID} .compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .compact .controls { display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .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} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:360px; max-width:40vw; min-width:240px;
      background: rgba(11,11,11,0.95); color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 10px 26px rgba(0,0,0,0.6);
      overflow:hidden; max-height:0; transition: max-height 280ms cubic-bezier(.2,.9,.3,1), opacity 220ms ease, transform 260ms cubic-bezier(.2,.9,.3,1);
      opacity:0; transform:translateY(-6px); font-size:11px; }
    #${PANEL_ID} .dropdown.open { max-height:40vh; opacity:1; transform:translateY(0); }
    #${PANEL_ID} .dropdown .head { display:flex; justify-content:space-between; align-items:center; padding:8px; gap:8px; border-bottom:1px solid rgba(255,255,255,0.03); }
    #${PANEL_ID} .points { color:#bfc9d6; font-weight:700; }
    #${PANEL_ID} .list { max-height:calc(40vh - 80px); overflow:auto; padding:6px 8px; }
    #${PANEL_ID} .row { display:flex; justify-content:space-between; gap:8px; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
    #${PANEL_ID} .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    #${PANEL_ID} .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} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .meta { color:#bfc9d6; width:170px; text-align:right; flex:0 0 170px; font-size:11px; }
    #${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:6px; text-align:right; padding:6px 8px; }
    @media (max-width:720px) {
      #${PANEL_ID} .dropdown { width:92vw; left:4vw; right:4vw; max-width:92vw; }
      #${PANEL_ID} .meta { width:120px; flex:0 0 120px; }
    }
  `);

  /* ====== DOM: build panel ====== */
  function createPanelNode() {
    const root = document.createElement('div');
    root.id = PANEL_ID;

    root.innerHTML = `
      <div class="compact" title="Click to expand">
        <div class="title"><span id="${PANEL_ID}-icon">▼</span> <span style="margin-left:4px">🌸🐻 Unified Stock</span></div>
        <div class="controls">
          <button class="btn" id="${PANEL_ID}-refresh">Refresh</button>
          <button class="btn" id="${PANEL_ID}-setkey">Set Key</button>
        </div>
      </div>
      <div class="dropdown" id="${PANEL_ID}-dropdown" aria-hidden="true">
        <div class="head">
          <div class="points" id="${PANEL_ID}-points">Sets: - | Points: -</div>
          <div style="color:#9ea6b3;font-size:11px" id="${PANEL_ID}-updated">Updated: -</div>
        </div>
        <div class="list" id="${PANEL_ID}-list"></div>
        <div class="fly" id="${PANEL_ID}-fly"></div>
      </div>
    `;

    // events
    root.querySelector('.compact').addEventListener('click', (ev) => {
      if (ev.target && (ev.target.id === `${PANEL_ID}-refresh` || ev.target.id === `${PANEL_ID}-setkey`)) return;
      toggleDropdown();
    });
    root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
    root.querySelector(`#${PANEL_ID}-setkey`).addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });

    return root;
  }

  let panelNode = createPanelNode();
  let dropdownOpen = false;
  let collapsedStored = GM_getValue(`${PANEL_ID}-collapsed`, false);

  function toggleDropdown(force) {
    const dd = panelNode.querySelector(`#${PANEL_ID}-dropdown`);
    const icon = panelNode.querySelector(`#${PANEL_ID}-icon`);
    dropdownOpen = (typeof force === 'boolean') ? force : !dropdownOpen;
    if (dropdownOpen) {
      dd.classList.add('open'); dd.setAttribute('aria-hidden','false'); icon.textContent = '▲';
    } else {
      dd.classList.remove('open'); dd.setAttribute('aria-hidden','true'); icon.textContent = '▼';
    }
    GM_setValue(`${PANEL_ID}-collapsed`, !dropdownOpen);
  }

  /* ====== Insert in header (between left & right groups) & observe ====== */
  function findHeaderGroups() {
    const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
    const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud, #topbar-right');
    return { left, right };
  }

  function insertPanel() {
    const { left, right } = findHeaderGroups();
    // remove existing
    const existing = document.getElementById(PANEL_ID);
    if (existing) existing.remove();
    // insert
    if (left && right && left.parentNode === right.parentNode) {
      left.parentNode.insertBefore(panelNode, right);
    } else {
      // fallback to top header or body
      const header = document.querySelector('header, #header, .topbar, .header') || document.body;
      header.appendChild(panelNode);
    }
    // restore collapse state
    const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
    toggleDropdown(!wasCollapsed);
    // ensure dropdown doesn't overlap too much: position dropdown under panel with absolute already set relative to panel
  }

  // reinsert if header mutated
  const headerObserver = new MutationObserver(() => {
    if (!document.body.contains(panelNode)) {
      panelNode = createPanelNode();
      insertPanel();
    }
  });
  headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true });

  insertPanel();

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

  /* ====== Torn display & YATA fetches ====== */
  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());
      // immediate refresh
      await refreshAll(true);
    }
  }

  async function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      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 (err) {
      console.warn('fetchDisplayCase error', err);
      return {};
    }
  }

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

  /* ====== Logic: build unified list, sets, lowest item ====== */
  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);
    return { totalSets, points: totalSets * POINTS_PER_SET, fSets, pSets };
  }

  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 buildUnifiedData(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; }
      }
      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 });
    }

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

  /* ====== Render dropdown content (compact lines) ====== */
  function renderDropdown(displayMap, yataData) {
    const { totalSets, points } = computeSets(displayMap);
    const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
    pointsEl.textContent = `Sets: ${totalSets} | Points: ${points}`;

    const { items, lowest } = buildUnifiedData(displayMap, yataData);
    const listEl = panelNode.querySelector(`#${PANEL_ID}-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 meta = `(inv: ${inv} | stk: ${stk}${it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : ''})`;
      html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">${escapeHtml(meta)} ${it.locFlag ? escapeHtml(it.locFlag) : ''}</div></div>`;
    }
    listEl.innerHTML = html || '<div style="color:#999">No tracked items</div>';

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

    // updated time
    const upEl = panelNode.querySelector(`#${PANEL_ID}-updated`);
    if (upEl) 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 refreshTimer = null;
  async function refreshAll(force=false) {
    const pointsNode = panelNode.querySelector(`#${PANEL_ID}-points`);
    if (pointsNode) pointsNode.textContent = 'Updating...';
    try {
      const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
      renderDropdown(displayMap||{}, yataData||null);
    } catch (e) {
      console.warn('refreshAll err', e);
      const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
      if (listEl) listEl.innerHTML = `<div style="color:#f88">Update failed</div>`;
    }
  }

  /* ====== Fetch wrappers ====== */
  async function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      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 (err) {
      console.warn('fetchDisplayCase err', err);
      return {};
    }
  }

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

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

  /* ====== Init ====== */
  // ensure panel exists & inserted
  if (!panelNode) panelNode = createPanelNode();
  insertPanel();

  // restore collapse flag and set dropdown according to stored state
  const storedCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  toggleDropdown(!storedCollapsed);

  // initial fetch + polling
  refreshAll(true);
  if (refreshTimer) clearInterval(refreshTimer);
  refreshTimer = setInterval(() => refreshAll(false), REFRESH_MS);

  // cleanup
  window.addEventListener('beforeunload', () => {
    if (refreshTimer) clearInterval(refreshTimer);
    headerObserver.disconnect();
  });

  // highlight set key button if key missing
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(() => {
      const btn = panelNode.querySelector(`#${PANEL_ID}-setkey`);
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.18)'; }
    }, 1500);
  }

})();