🌺🧸 Unified Display & Points (Above PDA) v3.4.2

Slim top-center toggle + fixed panel. Display + inventory + YATA public stock (flowers, plushies, Xanax). Refresh button, color-coded stk. 45s refresh. Collapsed shows only "🌺 🧸 Exporter".

2025-10-13 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

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 Display & Points (Above PDA) v3.4.2
// @namespace    http://tampermonkey.net/
// @version      3.4.2.2
// @description  Slim top-center toggle + fixed panel. Display + inventory + YATA public stock (flowers, plushies, Xanax). Refresh button, color-coded stk. 45s refresh. Collapsed shows only "🌺 🧸 Exporter".
// @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 PANEL_ID = 'unified_points_v3_4_2';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';

  // Items: [FullName, {code, flag, short}]
  const FLOWERS_ORDER = [
    ["Dahlia",{code:'MEX',flag:'🇲🇽',short:'Dahlia'}],
    ["Orchid",{code:'HAW',flag:'🏝️',short:'Orchid'}],
    ["African Violet",{code:'SOU',flag:'🇿🇦',short:'A.Violet'}],
    ["Cherry Blossom",{code:'JAP',flag:'🇯🇵',short:'C.Blossom'}],
    ["Peony",{code:'CHI',flag:'🇨🇳',short:'Peony'}],
    ["Ceibo Flower",{code:'ARG',flag:'🇦🇷',short:'Ceibo'}],
    ["Edelweiss",{code:'SWI',flag:'🇨🇭',short:'Edelweiss'}],
    ["Crocus",{code:'CAN',flag:'🇨🇦',short:'Crocus'}],
    ["Heather",{code:'UNI',flag:'🇬🇧',short:'Heather'}],
    ["Tribulus Omanense",{code:'UAE',flag:'🇦🇪',short:'Tribulus'}],
    ["Banana Orchid",{code:'CAY',flag:'🇰🇾',short:'Banana'}]
  ];

  const PLUSHIES_ORDER = [
    ["Sheep Plushie",{code:'B.B',flag:'🏪',short:'Sheep'}],
    ["Teddy Bear Plushie",{code:'B.B',flag:'🏪',short:'Teddy'}],
    ["Kitten Plushie",{code:'B.B',flag:'🏪',short:'Kitten'}],
    ["Jaguar Plushie",{code:'MEX',flag:'🇲🇽',short:'Jaguar'}],
    ["Wolverine Plushie",{code:'CAN',flag:'🇨🇦',short:'Wolverine'}],
    ["Nessie Plushie",{code:'UNI',flag:'🇬🇧',short:'Nessie'}],
    ["Red Fox Plushie",{code:'UNI',flag:'🇬🇧',short:'R.Fox'}],
    ["Monkey Plushie",{code:'ARG',flag:'🇦🇷',short:'Monkey'}],
    ["Chamois Plushie",{code:'SWI',flag:'🇨🇭',short:'Chamois'}],
    ["Panda Plushie",{code:'CHI',flag:'🇨🇳',short:'Panda'}],
    ["Lion Plushie",{code:'SOU',flag:'🇿🇦',short:'Lion'}],
    ["Camel Plushie",{code:'UAE',flag:'🇦🇪',short:'Camel'}],
    ["Stingray Plushie",{code:'CAY',flag:'🇰🇾',short:'Stingray'}]
  ];

  const SPECIAL_DRUG = 'Xanax';
  const SHORT_COUNTRY = { MEX:'MEX', CAN:'CAN', JAP:'JAP', CHI:'CHI', UNI:'GBR', ARG:'ARG', SWI:'SWI', HAW:'HAW', UAE:'UAE', CAY:'CAY', SOU:'S.A', 'B.B':'TORN' };
  const COUNTRY_DISPLAY = { 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', 'B.B':'Torn' };

  function getPDANavHeight(){
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
    return nav ? nav.offsetHeight : 40;
  }

  // CSS: slim toggle, fixed top-center, card width tuned to not block nav
  GM_addStyle(`
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 50%; transform: translateX(-50%); z-index: 999999; pointer-events:auto; font-family:"DejaVu Sans Mono",monospace; font-size:12px; }
    .${PANEL_ID}-toggle { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; color:#dfe7ff; user-select:none; border-radius:4px; transition:background .12s; }
    .${PANEL_ID}-toggle:hover { background: rgba(255,255,255,0.02); }
    .${PANEL_ID}-card { margin-top:6px; width: 340px; max-width:92vw; background: rgba(8,8,8,0.78); color:#e9eef8; border-radius:6px; box-shadow:0 10px 30px rgba(0,0,0,0.6); border:1px solid rgba(255,255,255,0.04); overflow:hidden; }
    .${PANEL_ID}.collapsed .${PANEL_ID}-card { display:none; }
    .${PANEL_ID}-header { display:flex; align-items:center; padding:8px; gap:8px; }
    .${PANEL_ID}-refresh { margin-left:auto; background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:4px 7px; border-radius:4px; cursor:pointer; }
    .${PANEL_ID}-body { padding:8px; font-size:12px; line-height:1.06; max-height:68vh; overflow:auto; }
    .tbl-head { display:flex; gap:6px; padding:4px 2px; color:#bfc9d6; font-weight:700; font-size:11px; border-bottom:1px solid rgba(255,255,255,0.03); margin-bottom:6px; }
    .tbl-row { display:flex; gap:6px; align-items:center; padding:4px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .col-dot { flex:0 0 18px; display:flex; align-items:center; justify-content:flex-start; }
    .col-av { flex:0 0 44px; text-align:right; color:#cfe8c6; }
    .col-st { flex:0 0 72px; text-align:right; color:#f7b3b3; }
    .col-mis { flex:0 0 40px; text-align:right; color:#f0d08a; }
    .col-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; color:#e9eef8; }
    .dot { width:10px; height:10px; border-radius:50%; margin-right:6px; flex:0 0 10px; }
    .stock-green{ background:#00c853; } .stock-orange{ background:#ff9800; } .stock-red{ background:#ff1744; } .stock-gray{ background:#9ea6b3; }
    .${PANEL_ID}-bottom { padding:8px; border-top:1px solid rgba(255,255,255,0.03); color:#bfc9d6; font-size:12px; display:flex; flex-direction:column; gap:4px; }
    .${PANEL_ID}-meta { color:#9ea6b3; font-size:11px; }
    @media (max-width:900px){ #${PANEL_ID}{ left:6px; transform:none; } .col-st{ flex:0 0 56px; } .col-av{ flex:0 0 36px; } .col-mis{ flex:0 0 34px; } .${PANEL_ID}-card { width: 92vw; } }
  `);

  // Create UII
  function buildUI(){
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    root.className = PANEL_ID + (GM_getValue(`${PANEL_ID}-collapsed`, false) ? ' collapsed' : '');

    root.innerHTML = `
      <div class="${PANEL_ID}-toggle" id="${PANEL_ID}-toggle">🌺 🧸 Exporter</div>
      <div class="${PANEL_ID}-card" role="region" aria-label="Unified Display & Points">
        <div class="${PANEL_ID}-header">
          <div style="font-weight:700;color:#dfe7ff">Unified Display & Points</div>
          <button class="${PANEL_ID}-refresh" id="${PANEL_ID}-refresh">Refresh</button>
        </div>

        <div class="${PANEL_ID}-body">
          <div id="${PANEL_ID}-status" style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Waiting...</div>

          <div id="${PANEL_ID}-flowers">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Flower</div></div>
            <div id="${PANEL_ID}-flowers-rows"></div>
          </div>

          <div id="${PANEL_ID}-plush" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Plushie</div></div>
            <div id="${PANEL_ID}-plush-rows"></div>
          </div>

          <div id="${PANEL_ID}-drugs" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis"></div><div class="col-name">Drugs</div></div>
            <div id="${PANEL_ID}-drugs-rows"></div>
          </div>
        </div>

        <div class="${PANEL_ID}-bottom" id="${PANEL_ID}-bottom">
          <div id="${PANEL_ID}-flylines"></div>
          <div class="${PANEL_ID}-meta" id="${PANEL_ID}-meta">Points/set: ${POINTS_PER_SET} | Refresh: ${Math.round(REFRESH_MS/1000)}s</div>
        </div>
      </div>
    `;

    document.body.appendChild(root);

    // Toggle expand/collapse
    const toggle = document.getElementById(`${PANEL_ID}-toggle`);
    toggle.addEventListener('click', () => {
      const root = document.getElementById(PANEL_ID);
      const collapsed = root.classList.toggle('collapsed');
      GM_setValue(`${PANEL_ID}-collapsed`, collapsed);
    });

    // Refresh button inside header (only visible when expanded because card hides when collapsed)
    const refreshBtn = document.getElementById(`${PANEL_ID}-refresh`);
    refreshBtn.addEventListener('click', (e) => { e.stopPropagation(); refreshAll(true); });

    // Double-click toggle quick refresh
    toggle.addEventListener('dblclick', (e) => { e.stopPropagation(); refreshAll(true); });

    return root;
  }

  // network helper
  function gmGetJson(url, timeout = 14000){
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: res => {
          try {
            const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
            const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch(e){ reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Torn display + inventory
  async function fetchTornDisplayInventory(){
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return null;
    const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return null;
      return aggregateFromApiResponse(data);
    } catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
  }
  function aggregateFromApiResponse(data){
    const items = {};
    const push = (src) => {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries){
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item || null;
        if (!name) continue;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
        items[name] = (items[name] || 0) + qty;
      }
    };
    push(data.display);
    push(data.inventory);
    return items;
  }
  function fetchDisplayViaDOM(){
    const map = {};
    const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (els && els.length){
      els.forEach(el => {
        let name=''; let qty=0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
        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;
  }

  // YATA (public endpoint)
  async function fetchYata(){
    try {
      const data = await gmGetJson(YATA_URL, 14000);
      return data || null;
    } catch (e) { console.warn('fetchYata', e); return null; }
  }
  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[String(code).toUpperCase()] = 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;
  }

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

  // stk color logic per your thresholds:
  // Green: >= 1500
  // Orange: 321 - 749
  // Red: 0
  // Other counts: gray
  function stockClassByQty(q){
    q = Number(q || 0);
    if (q === 0) return 'stock-red';
    if (q >= 1500) return 'stock-green';
    if (q >= 321 && q <= 749) return 'stock-orange';
    return 'stock-gray';
  }

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

  // render rows compact: AV | STK (+best code) | MIS | Name (flag + abbr)
  function renderGroupRows(containerId, order, countsMap, yataMap, missingMap){
    const el = document.getElementById(containerId);
    if (!el) return;
    let html = '';
    for (const [name, meta] of order){
      const av = Number(countsMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const miss = Number(missingMap[name] || 0);
      const dotClass = stockClassByQty(stk);
      const best = bestCountryFor(name, yataMap);
      const bestCode = best.code ? String(best.code).toUpperCase() : '';
      const codeInfo = bestCode ? ` | ${bestCode}` : '';
      html += `<div class="tbl-row"><div class="col-dot"><div class="dot ${dotClass}"></div></div><div class="col-av">${av}</div><div class="col-st">${stk}${codeInfo}</div><div class="col-mis">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''} ${meta.code?(' | '+meta.code):''}</div></div>`;
    }
    el.innerHTML = html;
  }

  // build flight lines: one per missing item (flowers first then plush), "Fly to <ABBR> for <Short>"
  function buildFlyLines(flowersMissing, plushMissing, yataMap){
    const lines = [];
    const collect = (order, missing) => {
      for (const [name, meta] of order){
        if (lines.length >= 6) break;
        const miss = Number(missing[name] || 0);
        if (miss <= 0) continue;
        const best = bestCountryFor(name, yataMap);
        const code = best.code ? String(best.code).toUpperCase() : (meta.code || '');
        // map 'SOU' to 'S.A' display as user prefers
        const displayCode = (code === 'SOU' || code === 'SOA') ? 'S.A' : (SHORT_COUNTRY[code] || code || (meta.code || ''));
        lines.push(`Fly to ${displayCode} for ${meta.short || name}`);
      }
    };
    collect(FLOWERS_ORDER, flowersMissing);
    collect(PLUSHIES_ORDER, plushMissing);
    return lines.length ? lines.slice(0,4) : ['Fly to —'];
  }

  // render UI main
  function renderUI(displayMap, yataData){
    const yataMap = buildYataMap(yataData);
    const flowers = computeForGroup(displayMap, FLOWERS_ORDER);
    const plush = computeForGroup(displayMap, PLUSHIES_ORDER);
    const totalSets = (flowers.sets || 0) + (plush.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;

    const status = document.getElementById(`${PANEL_ID}-status`);
    if (status) status.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets:${totalSets} Points:${totalPoints}`;

    renderGroupRows(`${PANEL_ID}-flowers-rows`, FLOWERS_ORDER, flowers.countsMap, yataMap, flowers.missing);
    renderGroupRows(`${PANEL_ID}-plush-rows`, PLUSHIES_ORDER, plush.countsMap, yataMap, plush.missing);

    // Xanax (SOU) abroad stock included
    const drugsEl = document.getElementById(`${PANEL_ID}-drugs-rows`);
    const xanInv = Number(displayMap[SPECIAL_DRUG] || 0);
    const xanStk = Number(yataMap['SOU']?.[SPECIAL_DRUG] || 0);
    const xanDot = stockClassByQty(xanStk);
    drugsEl.innerHTML = `<div class="tbl-row"><div class="col-dot"><div class="dot ${xanDot}"></div></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | SOU</div><div class="col-mis">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;

    // Fly lines: show concise one-per-line as requested
    const flyLines = buildFlyLines(flowers.missing, plush.missing, yataMap);
    const flyContainer = document.getElementById(`${PANEL_ID}-flylines`);
    flyContainer.innerHTML = flyLines.map(l => `<div>${escapeHtml(l)}</div>`).join('');
  }

  // refresh flow
  let timer = null;
  async function refreshAll(force=false){
    try {
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Fetching...';

      const tornPromise = fetchTornDisplayInventory();
      const yataPromise = fetchYata();

      const [displayFromApi, yataData] = await Promise.all([tornPromise, yataPromise]);

      let displayMap = {};
      if (displayFromApi && Object.keys(displayFromApi).length > 0) displayMap = displayFromApi;
      else {
        const dom = fetchDisplayViaDOM();
        displayMap = dom || displayFromApi || {};
      }

      renderUI(displayMap, yataData || null);

      if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets:${(displayMap? computeForGroup(displayMap, FLOWERS_ORDER).sets : 0) + (displayMap? computeForGroup(displayMap, PLUSHIES_ORDER).sets : 0)} Points shown.`;
    } catch (e){
      console.warn('refreshAll err', e);
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Update failed';
    }
  }

  // Torn fetch helpers
  async function fetchTornDisplayInventory(){
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return null;
    const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return null;
      return aggregateFromApiResponse(data);
    } catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
  }
  function aggregateFromApiResponse(data){
    const items = {};
    const push = (src) => {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries){
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item || null;
        if (!name) continue;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
        items[name] = (items[name] || 0) + qty;
      }
    };
    push(data.display);
    push(data.inventory);
    return items;
  }
  function fetchDisplayViaDOM(){
    const map = {};
    const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (els && els.length){
      els.forEach(el => {
        let name=''; let qty=0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
        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;
  }

  // boot
  buildUI();
  const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  const rootEl = document.getElementById(PANEL_ID);
  if (wasCollapsed) rootEl.classList.add('collapsed');

  refreshAll(true);
  if (timer) clearInterval(timer);
  timer = setInterval(()=>refreshAll(false), REFRESH_MS);
  window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });

  // reposition when header height changes
  function reposition(){
    const r = document.getElementById(PANEL_ID);
    if (!r) return;
    const top = getPDANavHeight();
    r.style.top = top + 'px';
  }
  reposition();
  window.addEventListener('resize', reposition);
  const obs = new MutationObserver(reposition);
  obs.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });

  // helpers used earlier
  function gmGetJson(url, timeout = 14000){
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: res => {
          try {
            const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
            const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch(e){ reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

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

})();