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

Unified display-case, points, inventory + YATA stock. Fixed above PDA nav. Shows sets, points, missing, foreign stock (color-coded) and travel suggestions. 45s refresh.

נכון ליום 13-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 Display & Points (Above PDA) v3.4
// @namespace    http://tampermonkey.net/
// @version      3.4
// @description  Unified display-case, points, inventory + YATA stock. Fixed above PDA nav. Shows sets, points, missing, foreign stock (color-coded) and travel suggestions. 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';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL_DEFAULT = 'https://yata.yt/api/v1/travel/export/';

  /* -------------------- item maps -------------------- */
  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:null, flag:'🏪', short:'Sheep'}],
    ["Teddy Bear Plushie",{code:null, flag:'🏪', short:'Teddy'}],
    ["Kitten Plushie",{code:null, 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 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 -------------------- */
  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; width: 260px; z-index: 999999; font-family: "DejaVu Sans Mono", monospace; font-size:11px; }
    #${PANEL_ID} .panel { background:#0b0b0b; color:#eaeaea; border:1px solid #333; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); overflow:hidden; max-height:75vh; }
    #${PANEL_ID} .header { display:flex; align-items:center; gap:8px; padding:6px; background:#121212; cursor:pointer; user-select:none; font-weight:700; }
    #${PANEL_ID} .title { font-size:12px; }
    #${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; }
    #${PANEL_ID} button { background:#171717; color:#eaeaea; border:1px solid #333; padding:3px 7px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .body { padding:6px; display:none; }
    #${PANEL_ID} .summary { font-weight:700; margin-bottom:6px; color:#dfe7ff; font-size:12px; }
    .row { display:flex; align-items:center; justify-content:space-between; gap:6px; padding:3px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.03); }
    .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    .short { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .meta { flex:0 0 120px; text-align:right; color:#bfc9d6; font-size:11px; }
    .dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; }
    .stock-green { background:#00c853; } .stock-orange { background:#ff9800; } .stock-red { background:#ff1744; } .stock-gray { background:#9ea6b3; }
    .note { margin-top:6px; color:#9ea6b3; font-size:11px; }
    @media (max-width:740px){ #${PANEL_ID}{ left:6px; right:6px; width:auto; } .meta{ flex:0 0 110px; } }
  `);

  /* -------------------- DOM build -------------------- */
  function buildPanel(){
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="panel">
        <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>
            <button id="${PANEL_ID}-set-yata">Set Yata Key</button>
          </div>
        </div>
        <div class="body">
          <div id="${PANEL_ID}-status" class="summary">Waiting...</div>
          <div id="${PANEL_ID}-content"></div>
          <div id="${PANEL_ID}-fly" class="note"></div>
          <div class="note">Refresh: ${Math.round(REFRESH_MS/1000)}s | Points per set: ${POINTS_PER_SET}</div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

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

    // style pawn: highlight if keys missing
    setTimeout(() => {
      const torn = GM_getValue('tornAPIKey', null);
      const yata = GM_getValue('yataApiKey', null);
      if (!torn) { const b=root.querySelector(`#${PANEL_ID}-set-torn`); if (b) b.style.boxShadow='0 0 8px rgba(200,100,255,0.12)'; }
      if (!yata) { const b=root.querySelector(`#${PANEL_ID}-set-yata`); if (b) b.style.boxShadow='0 0 8px rgba(137,183,255,0.14)'; }
    },1200);

    return root;
  }

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

  /* -------------------- network helpers -------------------- */
  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 = (typeof txt === 'string' && txt.length)? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch (e){ reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  /* -------------------- fetch display/inv -------------------- */
  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 pushSrc = (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;
      }
    };
    pushSrc(data.display);
    pushSrc(data.inventory);
    return items;
  }

  function fetchDisplayViaDOM(){
    const map = {};
    const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (itemEls && itemEls.length){
      itemEls.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 -------------------- */
  async function fetchYata(){
    const yataKey = GM_getValue('yataApiKey', null);
    // second script used public YATA_URL. We support optional key param if provided by user.
    let url = YATA_URL_DEFAULT;
    if (yataKey) url += `?key=${encodeURIComponent(yataKey)}`;
    try {
      const res = await gmGetJson(url, 14000);
      return res || 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[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;
  }

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

  /* -------------------- stock color logic -------------------- */
  function stockClassByQty(q){
    // user wanted green/orange/red
    // thresholds: >1000 green, 600-1000 orange, 1-599 red, 0 gray
    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';
  }

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

  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`);
    statusEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints} | Flowers sets: ${flowers.sets} | Plush sets: ${plush.sets}`;

    let html = '';
    // Flowers
    html += `<div style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Flowers — missing to next set: ${Object.values(flowers.missing).reduce((s,v)=>s+v,0)}</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 dotClass = stockClassByQty(stk);
      const short = meta.short || name;
      const missing = flowers.missing[name] || 0;
      const needDisplay = missing > 0 ? ` | missing: ${missing}` : '';
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(short)}</div></div><div class="meta">(inv:${inv} | stk:${stk}${codePart})${needDisplay} ${meta.flag||''}</div></div>`;
    }

    // Plushies
    html += `<div style="font-weight:700;margin:10px 0 6px;color:#dfe7ff">Plushies — missing to next set: ${Object.values(plush.missing).reduce((s,v)=>s+v,0)}</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 dotClass = stockClassByQty(stk);
      const short = meta.short || name;
      const missing = plush.missing[name] || 0;
      const needDisplay = missing > 0 ? ` | missing: ${missing}` : '';
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(short)}</div></div><div class="meta">(inv:${inv} | stk:${stk}${codePart})${needDisplay} ${meta.flag||''}</div></div>`;
    }

    // Xanax (South Africa)
    {
      html += `<div style="font-weight:700;margin:10px 0 6px;color:#dfe7ff">Drugs</div>`;
      const inv = Number(displayMap[SPECIAL_DRUG]||0);
      const stk = Number(yataMap['sou']?.[SPECIAL_DRUG] || 0);
      const dotClass = stockClassByQty(stk);
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(SPECIAL_DRUG)}</div></div><div class="meta">(inv:${inv} | stk:${stk} | SOU) 🇿🇦</div></div>`;
    }

    document.getElementById(`${PANEL_ID}-content`).innerHTML = html;

    // Suggest next destination: pick lowest missing item (flowers first, then plush), and show best country with stock
    const lowFlower = lowestMissingWithBest(FLOWERS_ORDER, displayMap, yataMap, flowers.missing);
    const lowPlush = lowestMissingWithBest(PLUSHIES_ORDER, displayMap, yataMap, plush.missing);
    let flyHtml = '';
    if (lowFlower){
      if (lowFlower.bestQty > 0) flyHtml += `✈ Fly (flower): ${COUNTRY_NAMES[lowFlower.bestCode]||lowFlower.bestCode} ${flag(lowFlower.bestCode)} — ${escapeHtml(lowFlower.short)} (stk ${lowFlower.bestQty})`;
      else flyHtml += `✈ Fly (flower): No stock abroad for ${escapeHtml(lowFlower.short)}`;
    }
    if (lowPlush){
      if (flyHtml) flyHtml += '<br>';
      if (lowPlush.bestQty > 0) flyHtml += `✈ Fly (plush): ${COUNTRY_NAMES[lowPlush.bestCode]||lowPlush.bestCode} ${flag(lowPlush.bestCode)} — ${escapeHtml(lowPlush.short)} (stk ${lowPlush.bestQty})`;
      else flyHtml += `✈ Fly (plush): No stock abroad for ${escapeHtml(lowPlush.short)}`;
    }
    document.getElementById(`${PANEL_ID}-fly`).innerHTML = flyHtml;
  }

  function lowestMissingWithBest(order, displayMap, yataMap, missingMap){
    let lowest = null;
    for (const [name, meta] of order){
      const miss = Number(missingMap[name] || 0);
      if (miss <= 0) continue;
      if (!lowest || miss > 0 && miss > 0 && miss > -1 && miss > -1 && miss > 0 && miss > 0 && miss > 0 && miss > lowest.miss) {
        // not used; we want smallest missing value first (most urgent). So choose highest miss? choose largest missing? 
        // pick the item with greatest missing (largest gap) to recommend where to fly to fill
      }
    }
    // simpler: pick the first item in order that has missing>0 (preserve order). Then find best country for it.
    for (const [name, meta] of order){
      const miss = Number(missingMap[name] || 0);
      if (miss > 0){
        const best = bestCountryFor(name, yataMap);
        return { name, short: meta.short || name, miss, bestCode: best.code, bestQty: best.qty };
      }
    }
    return null;
  }

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

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

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

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

  /* -------------------- key prompts -------------------- */
  async function askTornKey(){
    const current = GM_getValue('tornAPIKey', '');
    const k = prompt('Enter Torn API key (display + inventory permissions):', current || '');
    if (k !== null) { GM_setValue('tornAPIKey', String(k).trim()); refreshAll(true); }
  }
  async function askYataKey(){
    const current = GM_getValue('yataApiKey', '');
    const k = prompt('Enter YATA API key (optional). Leave blank for public endpoint:', current || '');
    if (k !== null) { GM_setValue('yataApiKey', String(k).trim()); refreshAll(true); }
  }

  /* -------------------- boot -------------------- */
  buildPanel();
  // restore collapse state
  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); });

})();