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

Slim semi-transparent unified panel above PDA: compact left-anchored, display+inventory, YATA public stock, sets, points, missing, concise travel suggestions, Xanax shown. 45s refresh.

Fra og med 13.10.2025. Se den nyeste version.

// ==UserScript==
// @name         🌺🧸 Unified Display & Points (Above PDA) v3.4.3
// @namespace    http://tampermonkey.net/
// @version      3.4.3
// @description  Slim semi-transparent unified panel above PDA: compact left-anchored, display+inventory, YATA public stock, sets, points, missing, concise travel suggestions, Xanax shown. 45s refresh.
// @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_display_v3_4_3';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';

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

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

  GM_addStyle(`
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 18px; z-index: 999999; width: 260px; pointer-events:auto; font-family: "DejaVu Sans Mono", monospace; font-size:12px; transform: none; }
    #${PANEL_ID} .card { background: rgba(6,6,6,0.78); color:#e9eef8; border:1px solid rgba(255,255,255,0.04); border-radius:6px; box-shadow: 0 10px 30px rgba(0,0,0,0.6); overflow:hidden; }
    #${PANEL_ID} .header { display:flex; align-items:center; padding:6px 8px; height:34px; box-sizing:border-box; cursor:pointer; user-select:none; gap:8px; }
    #${PANEL_ID} .title { font-weight:700; font-size:12px; line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} button { background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:4px 7px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .body { padding:6px 6px 8px; display:none; max-height:68vh; overflow:auto; font-size:12px; line-height:1.08; }
    .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; }
    .footer { padding:6px 8px; border-top:1px solid rgba(255,255,255,0.03); color:#bfc9d6; font-size:12px; display:flex; justify-content:space-between; gap:8px; align-items:center; }
    .footer .left { flex:1 1 auto; }
    .footer .right { flex:0 0 auto; color:#9ea6b3; }
    /* collapsed state: hide controls to avoid covering nav icons */
    #${PANEL_ID}.collapsed .controls { display:none !important; }
    @media (max-width:900px){ #${PANEL_ID}{ left:6px; width:92vw; } .col-st{ flex:0 0 56px; } .col-av{ flex:0 0 36px; } .col-mis{ flex:0 0 34px; } }
  `);

  function buildPanel(){
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="card">
        <div class="header">
          <div class="title">▶ 🌺🧸 Unified Display & Points</div>
          <div class="controls">
            <button id="${PANEL_ID}-refresh">Refresh</button>
            <button id="${PANEL_ID}-set-torn">Set Torn Key</button>
          </div>
        </div>
        <div class="body">
          <div id="${PANEL_ID}-status" style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Waiting...</div>

          <div id="${PANEL_ID}-flowers-section">
            <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-section" 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-section" 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="footer">
          <div class="left" id="${PANEL_ID}-flyline"></div>
          <div class="right" id="${PANEL_ID}-meta">Points/set: ${POINTS_PER_SET} | Refresh: ${Math.round(REFRESH_MS/1000)}s</div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    const hdr = root.querySelector('.header');
    hdr.addEventListener('click', (e) => {
      if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.target.id === `${PANEL_ID}-set-torn`)) return;
      toggleBody();
    });
    root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
    root.querySelector(`#${PANEL_ID}-set-torn`).addEventListener('click', (ev) => { ev.stopPropagation(); askTornKey(); });

    setTimeout(()=> {
      const torn = GM_getValue('tornAPIKey', null);
      if (!torn) { const b=root.querySelector(`#${PANEL_ID}-set-torn`); if (b) b.style.boxShadow='0 0 8px rgba(137,183,255,0.14)'; }
    },1200);

    return root;
  }

  function toggleBody(force){
    const root = document.getElementById(PANEL_ID);
    const body = root.querySelector('.body');
    const title = root.querySelector('.title');
    const open = (typeof force === 'boolean') ? force : (body.style.display !== 'block');
    body.style.display = open ? 'block' : 'none';
    title.textContent = (open ? '▼' : '▶') + ' 🌺🧸 Unified Display & Points';
    root.classList.toggle('collapsed', !open);
    GM_setValue(`${PANEL_ID}-collapsed`, !open);
  }

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

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

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

  function stockClassByQty(q){ q = Number(q||0); if(q===0) return 'stock-gray'; if(q>1000) return 'stock-green'; if(q>=600) return 'stock-orange'; return 'stock-red'; }

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

  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 codeInfo = best.code ? ` | ${String(best.code).toUpperCase()}` : '';
      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;
  }

  function firstMissingWithBest(order, missingMap, yataMap){
    const res=[];
    for (const [name, meta] of order){
      const miss = Number(missingMap[name]||0);
      if (miss>0){
        const best = bestCountryFor(name, yataMap);
        res.push({ name, short: meta.short||name, miss, bestCode: best.code, bestQty: best.qty, flag: meta.flag, code: meta.code });
        if(res.length>=3) break;
      }
    }
    return res;
  }

  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 statusEl = document.getElementById(`${PANEL_ID}-status`);
    if(statusEl) statusEl.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
    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>`;

    // suggestions (compact)
    const flowersSug = firstMissingWithBest(FLOWERS_ORDER, flowers.missing, yataMap);
    const plushSug = firstMissingWithBest(PLUSHIES_ORDER, plush.missing, yataMap);
    const suggestions = [...flowersSug, ...plushSug].slice(0,3);
    const sugParts = suggestions.map(s => s.bestQty>0 ? `${String(s.bestCode||'??').toUpperCase()} (${s.short})` : `${s.short} (no stock)`);
    const flyline = document.getElementById(`${PANEL_ID}-flyline`);
    flyline.textContent = sugParts.length ? `Fly to: ${sugParts.join(' | ')}` : 'Fly to: —';
  }

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

    } catch(e){
      console.warn('refreshAll err', e);
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if(statusEl) statusEl.textContent = 'Update failed';
    }
  }

  function askTornKey(){ const cur=GM_getValue('tornAPIKey',''); const k = prompt('Enter Torn API key (display + inventory permissions):', cur||''); if(k!==null){ GM_setValue('tornAPIKey', String(k).trim()); refreshAll(true); } }

  buildPanel();
  const collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  toggleBody(!collapsed);
  refreshAll(true);
  if(timer) clearInterval(timer);
  timer = setInterval(()=>refreshAll(false), REFRESH_MS);
  window.addEventListener('beforeunload', ()=>{ if(timer) clearInterval(timer); });

  function reposition(){ const root = document.getElementById(PANEL_ID); if(!root) return; const top = getPDANavHeight(); root.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 });

})()