🌺🧸 Display Case Unified Compact v2.8 (Top Centered)

Compact display-case + YATA stock: flowers, plushies, Xanax (SA). inv | stk | CODE one-line rows, sets, missing, flight hints. 45s refresh, 210px width. Fixed centered under header.

Fra og med 12.10.2025. Se den nyeste version.

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         🌺🧸 Display Case Unified Compact v2.8 (Top Centered)
// @namespace    http://tampermonkey.net/
// @version      2.8.3
// @description  Compact display-case + YATA stock: flowers, plushies, Xanax (SA). inv | stk | CODE one-line rows, sets, missing, flight hints. 45s refresh, 210px width. Fixed centered under header.
// @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 PANEL_ID = 'dc_unified_compact_v2_8';
  const POINTS_PER_SET = 10;

  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 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' };

  /* ---------- Styles (narrow 210px, compact, top-centered) ---------- */
  GM_addStyle(`
    /* root */
    #${PANEL_ID} { position:fixed; left:50%; transform:translateX(-50%); top:8px; z-index:999999; width:210px; pointer-events:auto; }
    #${PANEL_ID} .compact { background: rgba(8,8,8,0.64); color:#e9eef8; border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:11px; padding:6px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; box-sizing:border-box; border:1px solid #333; box-shadow:0 6px 18px rgba(0,0,0,0.6); opacity:0.98; }
    #${PANEL_ID} .compact .title { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    #${PANEL_ID} .compact .btns{ margin-left:auto; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .btn { background:transparent; border:1px solid rgba(255,255,255,0.06); color:#eaeaea; padding:2px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .dropdown { position:absolute; left:50%; transform:translateX(-50%); top:calc(100% + 6px); width:210px; min-width:210px; max-width:210px; background: rgba(10,10,10,0.94); border:1px solid #222; border-radius:6px; box-shadow: 0 10px 22px rgba(0,0,0,0.6); overflow:hidden; opacity:0; transform-origin:top center; transition:opacity .18s, transform .22s, max-height .28s; max-height:0; }
    #${PANEL_ID} .dropdown.open { opacity:1; transform:translateX(-50%) translateY(0); max-height:60vh; }
    #${PANEL_ID} .head { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.03); font-size:11px; color:#bfc9d6; }
    #${PANEL_ID} .list { padding:6px 6px 8px 6px; font-size:11px; max-height:calc(60vh - 110px); overflow:auto; }
    .section-title { font-weight:700; color:#dfe7ff; margin:6px 0 4px; font-size:12px; }
    .row { display:flex; justify-content:space-between; gap:6px; padding:3px 0; align-items:center; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .left { display:flex; align-items:center; gap:6px; min-width:0; overflow:hidden; }
    .dot { width:9px; height:9px; border-radius:50%; flex:0 0 9px; }
    .stock-high { background:#00c853; } .stock-low { background:#ff1744; } .stock-zero { background:#ff9800; } .stock-mid { background:#9ea6b3; }
    .name { overflow:hidden; text-overflow:ellipsis; min-width:0; font-size:11px; }
    .meta { color:#bfc9d6; flex:0 0 135px; text-align:right; font-size:11px; }
    .fly { padding:6px 8px; color:#9ad0ff; font-weight:700; font-size:11px; text-align:right; line-height:1.1; }
    .small { padding:6px 8px 8px; color:#9ea6b3; font-size:11px; }
    @media (max-width:740px){ #${PANEL_ID} .dropdown{ width:92vw; left:4vw; transform:none; min-width:unset; } .meta{ flex:0 0 110px; } }
  `);

  /* ---------- Build DOM ---------- */
  function createPanel() {
    const root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="compact" title="Click to expand">
        <div class="title">🌺🧸 Display Unified</div>
        <div class="btns">
          <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 id="${PANEL_ID}-points">Sets: - | Points: -</div><div id="${PANEL_ID}-updated" style="color:#9ea6b3;font-size:11px">Updated: -</div></div>
        <div class="list" id="${PANEL_ID}-list"></div>
        <div class="fly" id="${PANEL_ID}-fly"></div>
        <div class="small">Format: Short — (inv: X | stk: Y | CODE)</div>
      </div>
    `;
    // events:
    const compact = root.querySelector('.compact');
    compact.addEventListener('click', (e) => {
      if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.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;
  }

  // append or re-append so we own the fixed element
  let panel = document.getElementById(PANEL_ID) || createPanel();
  if (!document.body.contains(panel)) document.body.appendChild(panel);

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

  /* ---------- Positioning: center under header (fixed) ---------- */
  function getHeaderElem() {
    // try common header selectors (Torn UI variations)
    return document.querySelector('header') ||
           document.querySelector('#header') ||
           document.querySelector('.topbar') ||
           document.querySelector('.header') ||
           document.querySelector('#topbar') ||
           document.querySelector('.topPanel') ||
           null;
  }

  function positionPanelUnderHeader() {
    // compute header height and set top to header bottom + 4px
    const header = getHeaderElem();
    const root = document.getElementById(PANEL_ID);
    if (!root) return;
    let topPx = 6; // fallback
    if (header) {
      const rect = header.getBoundingClientRect();
      // if header is fixed at top, rect.top will be 0; rect.height is header height
      topPx = Math.ceil(rect.bottom) + 4;
    } else {
      // attempt to use first visible top bar item height
      const topBar = document.querySelector('.header-links-left, .headerLeft, .leftLinks, .header-links-right, .headerRight, .hud, #topbar-right');
      if (topBar) {
        const r = topBar.getBoundingClientRect();
        topPx = Math.ceil(r.bottom) + 4;
      }
    }
    // ensure it doesn't push into notch / very top: min 6
    if (topPx < 6) topPx = 6;
    root.style.top = topPx + 'px';
    // keep centered horizontally
    root.style.left = '50%';
    root.style.transform = 'translateX(-50%)';
  }

  // reposition on load, resize, and header changes
  positionPanelUnderHeader();
  window.addEventListener('resize', () => positionPanelUnderHeader());
  const headerObserver = new MutationObserver(() => positionPanelUnderHeader());
  headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });

  /* ---------- Networking wrappers ---------- */
  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'))
      });
    });
  }

  /* ---------- Fetchers ---------- */
  async function askApiKey() {
    const current = GM_getValue('tornAPIKey', '');
    const v = prompt('Enter your Torn user API key (display permission):', current || '');
    if (v !== null) {
      GM_setValue('tornAPIKey', String(v).trim());
      await refreshAll(true);
    }
  }

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

  function fetchDisplayViaDOM() {
    // fallback: parse DOM display case if on displaycase page
    const map = {};
    const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item');
    if (itemEls && itemEls.length) {
      itemEls.forEach(el => {
        let name = '';
        let qty = 0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a');
        if (nameEl) name = nameEl.innerText.trim();
        const qtyEl = el.querySelector('.item-amount, .count, .qty, .quantity') || el.querySelector('.item-qty');
        if (qtyEl) qty = parseInt(qtyEl.innerText.replace(/\D/g,'')) || 0;
        if (name) map[name] = (map[name] || 0) + qty;
      });
    }
    return map;
  }

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

  /* ---------- Helpers: YATA map, sums ---------- */
  function buildYataMap(yataData) {
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
      const m = {};
      for (const it of arr) if (it && it.name) m[it.name] = Number(it.quantity ?? 0) || 0;
      map[code] = m;
    }
    return map;
  }

  function sumYataFor(itemName, yataMap) {
    let total = 0;
    for (const c of Object.keys(yataMap || {})) total += Number(yataMap[c][itemName] || 0);
    return total;
  }

  function bestCountryFor(itemName, yataMap) {
    let best = { code: null, qty: 0 };
    for (const [code, m] of Object.entries(yataMap || {})) {
      const q = Number(m[itemName] || 0);
      if (q > best.qty) best = { code, qty: q };
    }
    return best;
  }

  /* ---------- Computation: sets, missing ---------- */
  function computeSetInfo(displayMap, groupOrder) {
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    const need = counts.reduce((s,c) => s + Math.max(0, (sets+1) - (c || 0)), 0);
    return { sets, need, countsMap: groupOrder.reduce((acc,[name])=>{acc[name]=Number(displayMap[name]||0);return acc;},{}) };
  }

  function stkClass(q) {
    if (q === 0) return 'stock-zero';
    if (q > 1000) return 'stock-high';
    if (q < 600) return 'stock-low';
    return 'stock-mid';
  }

  /* ---------- Render ---------- */
  function renderDisplayAndStock(displayMap, yataData) {
    const yataMap = buildYataMap(yataData);
    const flowers = computeSetInfo(displayMap, FLOWERS_ORDER);
    const plush = computeSetInfo(displayMap, PLUSHIES_ORDER);
    const totalSets = (flowers.sets || 0) + (plush.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;
    const pointsEl = panel.querySelector(`#${PANEL_ID}-points`);
    if (pointsEl) pointsEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;

    let html = '';

    // Flowers
    html += `<div class="section-title">Flowers — Missing: ${flowers.need}</div>`;
    for (const [name, meta] of FLOWERS_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const best = bestCountryFor(name, yataMap);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClass(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
    }

    // Plushies
    html += `<div class="section-title" style="margin-top:6px">Plushies — Missing: ${plush.need}</div>`;
    for (const [name, meta] of PLUSHIES_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const best = bestCountryFor(name, yataMap);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClass(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
    }

    // Xanax (SA)
    {
      const name = SPECIAL_DRUG;
      const inv = Number(displayMap[name] || 0);
      const stk = Number(yataMap['sou']?.[name] || 0);
      const cls = stkClass(stk);
      const codePart = stk>0? ' | SOU' : '';
      html += `<div class="section-title" style="margin-top:6px">Drugs</div>`;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
    }

    const listEl = panel.querySelector(`#${PANEL_ID}-list`);
    if (listEl) listEl.innerHTML = html;

    // Flight suggestions
    const bestFlower = lowestWithBest(FLOWERS_ORDER, displayMap, yataMap);
    const bestPlush = lowestWithBest(PLUSHIES_ORDER, displayMap, yataMap);
    let flyHtml = '';
    if (bestFlower) {
      if (bestFlower.bestCode && bestFlower.bestQty>0) flyHtml += `✈ Fly (flowers): ${(COUNTRY_NAMES[bestFlower.bestCode]||bestFlower.bestCode.toUpperCase())} ${flag(bestFlower.bestCode)} — ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
      else flyHtml += `✈ Fly (flowers): No stock abroad for ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
    }
    if (bestPlush) {
      if (flyHtml) flyHtml += '<br>';
      if (bestPlush.bestCode && bestPlush.bestQty>0) flyHtml += `✈ Fly (plushies): ${(COUNTRY_NAMES[bestPlush.bestCode]||bestPlush.bestCode.toUpperCase())} ${flag(bestPlush.bestCode)} — ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
      else flyHtml += `✈ Fly (plushies): No stock abroad for ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
    }
    const flyEl = panel.querySelector(`#${PANEL_ID}-fly`);
    if (flyEl) flyEl.innerHTML = flyHtml;

    const upEl = panel.querySelector(`#${PANEL_ID}-updated`);
    if (upEl) upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
  }

  function lowestWithBest(groupOrder, displayMap, yataMap) {
    let lowest = null;
    for (const [name] of groupOrder) {
      const inv = Number(displayMap[name]||0);
      const totalStk = sumYataFor(name, yataMap);
      if (!lowest || inv < lowest.inv || (inv === lowest.inv && totalStk < lowest.totalStk)) lowest = { name, inv, totalStk };
    }
    if (!lowest) return null;
    const best = bestCountryFor(lowest.name, yataMap);
    return { name: lowest.name, inv: lowest.inv, bestCode: best.code, bestQty: best.qty };
  }

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

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

  /* ---------- Master refresh flow ---------- */
  let timer = null;
  async function refreshAll(force=false) {
    try {
      const apiKey = GM_getValue('tornAPIKey', null);
      const displayPromise = apiKey ? fetchDisplayViaApi().then(m=>m||{}) : Promise.resolve({});
      const yataPromise = fetchYata();

      const [displayFromApi, yataData] = await Promise.all([displayPromise, yataPromise]);
      let displayMap = {};
      if (displayFromApi && Object.keys(displayFromApi).length>0) displayMap = displayFromApi;
      else {
        const domMap = fetchDisplayViaDOM();
        displayMap = domMap || displayFromApi || {};
      }
      renderDisplayAndStock(displayMap, yataData || null);
    } catch (e) {
      console.warn('refreshAll err', e);
      const listEl = panel.querySelector(`#${PANEL_ID}-list`);
      if (listEl) listEl.innerHTML = `<div style="color:#f88;padding:8px">Update failed</div>`;
    }
  }

  // start polling
  refreshAll(true);
  if (timer) clearInterval(timer);
  timer = setInterval(()=>refreshAll(false), REFRESH_MS);

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

  // prompt hint visual for missing key
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(()=> {
      const btn = panel.querySelector(`#${PANEL_ID}-setkey`);
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.14)'; }
    },1200);
  }

})();