🌺 🐫 Points & Stock Tracker (Unified PDA Panel) v4.0

Unified panel: your flower/plush totals + foreign shop stock (YATA). Single refresh every 45s. Works while flying. Xanax shown for South Africa at bottom.

As of 2025-10-11. See the latest 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         🌺 🐫 Points & Stock Tracker (Unified PDA Panel) v4.0
// @namespace    http://tampermonkey.net/
// @version      4.0.0
// @description  Unified panel: your flower/plush totals + foreign shop stock (YATA). Single refresh every 45s. Works while flying. Xanax shown for South Africa at bottom.
// @author       Nova
// @match        https://www.torn.com/page.php?sid=travel*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      yata.yt
// @connect      api.torn.com
// @run-at       document-end
// ==/UserScript==

(function() {
  'use strict';

  if (!/page\.php\?sid=travel/.test(location.href)) return;

  // CONFIG
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const POLL_MS = 45 * 1000; // unified 45s refresh
  const PANEL_WIDTH = 320;

  // Items of interest
  const FLOWERS_MAP = {
    "Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
    "Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii" },
    "African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa" },
    "Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan" },
    "Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China" },
    "Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina" },
    "Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland" },
    "Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada" },
    "Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE" },
    "Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands" }
  };

  const PLUSHIES_MAP = {
    "Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City" },
    "Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City" },
    "Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City" },
    "Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico" },
    "Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada" },
    "Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina" },
    "Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland" },
    "Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China" },
    "Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa" },
    "Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE" },
    "Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands" }
  };

  // tracked set for quick lookup
  const TRACKED_SET = new Set([
    ...Object.keys(FLOWERS_MAP),
    ...Object.keys(PLUSHIES_MAP),
    "Xanax"
  ]);

  // map country codes to readable name (YATA codes)
  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'
  };

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

  GM_addStyle(`
    #ptsStockPanel {
      position: fixed;
      top: ${getPDANavHeight()}px;
      left: 18px;
      width: ${PANEL_WIDTH}px;
      background: #0b0b0b;
      color: #eaeaea;
      font-family: "DejaVu Sans Mono", monospace;
      font-size: 11px;
      border: 1px solid #444;
      border-radius: 6px;
      z-index: 999999;
      box-shadow: 0 6px 16px rgba(0,0,0,0.5);
      max-height: 70vh;
      overflow-y: auto;
      line-height: 1.15;
    }
    #ptsHeader {
      background: #121212;
      padding: 6px 8px;
      cursor: pointer;
      font-weight:700;
      font-size:13px;
      border-bottom:1px solid #333;
      user-select:none;
      display:flex;
      justify-content:space-between;
      align-items:center;
      gap:8px;
    }
    #ptsContent { padding:8px; display:block; }
    .controls { margin-bottom:8px; display:flex; gap:6px; flex-wrap:wrap; }
    .controls button {
      font-size:11px;
      padding:4px 8px;
      background:#171717;
      color:#eaeaea;
      border:1px solid #333;
      border-radius:4px;
      cursor:pointer;
    }
    .summary-line { font-weight:700; margin-bottom:6px; font-size:12px; color:#dfe7ff; }
    .section-title { font-weight:700; margin-top:6px; margin-bottom:6px; font-size:11px; border-bottom:1px dashed #222; padding-bottom:4px; }
    .row { display:flex; align-items:center; gap:8px; padding:2px 0; white-space:nowrap; }
    .name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .count { width:48px; text-align:right; font-weight:700; }
    .avail { width:80px; text-align:right; color:#bfc9d6; font-size:11px; }
    .dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; vertical-align:middle; }
    .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    .country-block { margin-bottom:6px; padding-top:6px; border-top:1px dashed #222; }
    .country-title { display:flex; justify-content:space-between; font-weight:700; font-size:11px; margin-bottom:4px; }
    .small-note { font-size:11px; color:#9ea6b3; margin-top:6px; }
  `);

  // build DOM
  const panel = document.createElement('div');
  panel.id = 'ptsStockPanel';
  panel.innerHTML = `
    <div id="ptsHeader">
      <div id="ptsHeaderLeft">▶ 🌺 🐫 Points & Stock</div>
      <div id="ptsHeaderRight">
        <button id="pts_refresh">Refresh</button>
        <button id="pts_setkey">Set API Key</button>
        <button id="pts_toggle">Collapse</button>
      </div>
    </div>
    <div id="ptsContent">
      <div class="controls">
        <div id="pts_status" style="flex:1 1 auto; color:#bdbdbd;">Initializing...</div>
      </div>

      <div id="myInventorySection">
        <div class="section-title">My Inventory & Display</div>
        <div id="mySummary" class="summary-line"></div>
        <div id="myList"></div>
      </div>

      <div id="foreignSection">
        <div class="section-title">Foreign Stock (YATA)</div>
        <div id="foreignList"></div>
      </div>

      <div class="small-note">Data sync: Torn (your items) + YATA (shop stock). Updates every 45s.</div>
    </div>
  `;
  document.body.appendChild(panel);

  // elements
  const headerLeft = panel.querySelector('#ptsHeaderLeft');
  const headerRight = panel.querySelector('#ptsHeaderRight');
  const btnRefresh = panel.querySelector('#pts_refresh');
  const btnSetKey = panel.querySelector('#pts_setkey');
  const btnToggle = panel.querySelector('#pts_toggle');
  const ptsContent = panel.querySelector('#ptsContent');
  const statusEl = panel.querySelector('#pts_status');
  const mySummary = panel.querySelector('#mySummary');
  const myList = panel.querySelector('#myList');
  const foreignList = panel.querySelector('#foreignList');

  // collapse
  let collapsed = GM_getValue('pts_collapsed', false);
  function updateCollapse() {
    ptsContent.style.display = collapsed ? 'none' : 'block';
    headerLeft.textContent = (collapsed ? '▶' : '▼') + ' 🌺 🐫 Points & Stock';
    btnToggle.textContent = collapsed ? 'Expand' : 'Collapse';
    GM_setValue('pts_collapsed', collapsed);
  }
  updateCollapse();
  headerLeft.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
  btnToggle.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });

  // API key handling (user key)
  let apiKey = GM_getValue('tornAPIKey', null);
  btnSetKey.addEventListener('click', async () => {
    const k = prompt('Enter your Torn API key (user key; items permission recommended):', apiKey || '');
    if (k) {
      apiKey = k.trim();
      GM_setValue('tornAPIKey', apiKey);
      statusEl.textContent = 'API key saved.';
      await refreshAll(true);
    }
  });

  btnRefresh.addEventListener('click', () => refreshAll(true));

  // helper: safe fetch via GM_xmlhttpRequest
  function gmGetJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'json',
        onload: (res) => {
          let data = res.response;
          if (!data && res.responseText) {
            try { data = JSON.parse(res.responseText); } catch(e) {}
          }
          if (!data) return reject(new Error('No JSON'));
          resolve(data);
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Torn fetch: try items (works while flying), fallback to display+inventory if present
  async function fetchTornItems() {
    if (!apiKey) {
      throw new Error('No API key');
    }
    // prefer items selection (flight-safe)
    const urlItems = `https://api.torn.com/user/?selections=items,display,inventory&key=${encodeURIComponent(apiKey)}`;
    // requesting items plus display+inventory - server will return available selections; we handle presence.
    const data = await gmGetJson(urlItems);
    if (data.error) throw new Error(`Torn API error: ${data.error.error} (${data.error.code})`);

    // combine sources robustly
    const itemsAgg = {};

    // helper to add entries (handles array or object)
    function addFrom(src) {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries) {
        if (!e) continue;
        // some item objects in /items contain 'name' and 'quantity'; display/inventory entries vary
        const name = e.name || e.item_name || e.title || e.item || null;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? e.count ?? 0) || 0;
        if (!name) continue;
        itemsAgg[name] = (itemsAgg[name] || 0) + qty;
      }
    }

    // items selection often appears under data.items
    addFrom(data.items);
    addFrom(data.display);
    addFrom(data.inventory);

    return itemsAgg; // map: name -> total qty
  }

  // YATA fetch
  async function fetchYataExport() {
    const data = await gmGetJson(YATA_URL);
    // structure: { stocks: { mex: {update, stocks: [ {id,name,quantity,cost}, ... ] }, ... }, timestamp }
    return data;
  }

  // color helper
  function dotClass(q) {
    if (q <= 0) return 'r';
    if (q <= 10) return 'y';
    return 'g';
  }

  // Render: My inventory (flowers & plushies)
  function renderMyInventory(itemsAgg) {
    // build totals for tracked items (flowers + plushies)
    const flowerShorts = Object.values(FLOWERS_MAP).map(o => o.short);
    const plushShorts = Object.values(PLUSHIES_MAP).map(o => o.short);

    // produce counts keyed by short name as per original UI
    const flowerTotals = {};
    const plushTotals = {};

    // initialize
    flowerShorts.forEach(s => flowerTotals[s] = 0);
    plushShorts.forEach(s => plushTotals[s] = 0);

    // map full names to short names
    for (const full of Object.keys(FLOWERS_MAP)) {
      const short = FLOWERS_MAP[full].short;
      const q = itemsAgg[full] || 0;
      flowerTotals[short] = (flowerTotals[short] || 0) + q;
    }
    for (const full of Object.keys(PLUSHIES_MAP)) {
      const short = PLUSHIES_MAP[full].short;
      const q = itemsAgg[full] || 0;
      plushTotals[short] = (plushTotals[short] || 0) + q;
    }

    // compute sets and remainder
    const fCountsArr = Object.values(flowerTotals);
    const pCountsArr = Object.values(plushTotals);
    const fSets = fCountsArr.length ? Math.min(...fCountsArr) : 0;
    const pSets = pCountsArr.length ? Math.min(...pCountsArr) : 0;
    const totalSets = fSets + pSets;
    const totalPoints = totalSets * 10;

    // summary
    mySummary.textContent = `Total sets: ${totalSets} | Points: ${totalPoints}`;

    // render lists
    let html = '';
    html += `<div style="font-weight:700; margin-bottom:6px;">Flowers — sets: ${fSets}</div>`;
    Object.keys(flowerTotals).forEach(name => {
      const total = flowerTotals[name] || 0;
      const dclass = dotClass(total);
      html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getFlowerLoc(name)}</div></div>`;
    });

    html += `<div style="font-weight:700; margin:8px 0 6px;">Plushies — sets: ${pSets}</div>`;
    Object.keys(plushTotals).forEach(name => {
      const total = plushTotals[name] || 0;
      const dclass = dotClass(total);
      html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getPlushLoc(name)}</div></div>`;
    });

    myList.innerHTML = html;
  }

  function getFlowerLoc(short) {
    // find by short in FLOWERS_MAP
    for (const full in FLOWERS_MAP) {
      if (FLOWERS_MAP[full].short === short) return FLOWERS_MAP[full].loc;
    }
    return '';
  }
  function getPlushLoc(short) {
    for (const full in PLUSHIES_MAP) {
      if (PLUSHIES_MAP[full].short === short) return PLUSHIES_MAP[full].loc;
    }
    return '';
  }

  // Render foreign stock: only show tracked items (flowers, plushies, and Xanax only in South Africa)
  function renderForeignStock(yataData) {
    if (!yataData || !yataData.stocks) {
      foreignList.innerHTML = `<div style="color:#999;">No foreign stock data.</div>`;
      return;
    }
    const stocks = yataData.stocks;
    const countryCodes = Object.keys(stocks).sort();

    let html = '';
    for (const code of countryCodes) {
      const c = stocks[code];
      if (!c) continue;
      const items = Array.isArray(c.stocks) ? c.stocks : [];
      // filter tracked items; Xanax only for 'sou'
      const filtered = items.filter(it => {
        if (!it || !it.name) return false;
        if (!TRACKED_SET.has(it.name)) return false;
        if (it.name === 'Xanax' && code !== 'sou') return false;
        return true;
      });
      if (!filtered.length) continue;

      const cname = COUNTRY_NAMES[code] || code.toUpperCase();
      const upd = c.update ? new Date(c.update * 1000).toUTCString() : '';
      html += `<div class="country-block"><div class="country-title"><div>${escapeHtml(cname)}</div><div style="font-size:11px;color:#9ea6b3">${upd}</div></div>`;

      // show flowers then plushies (keep grouping readable)
      // sort by tracked order: flowers first then plushies then Xanax
      filtered.sort((a,b) => {
        const aIsFlower = !!FLOWERS_MAP[a.name];
        const bIsFlower = !!FLOWERS_MAP[b.name];
        if (aIsFlower !== bIsFlower) return aIsFlower ? -1 : 1;
        if (a.name === 'Xanax') return 1;
        if (b.name === 'Xanax') return -1;
        return a.name.localeCompare(b.name);
      });

      for (const it of filtered) {
        const q = Number(it.quantity ?? 0);
        const dclass = dotClass(q);
        const availText = q <= 0 ? 'Out' : (q <= 10 ? `${q} low` : `${q} available`);
        html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(it.name)}</div><div class="count">${availText}</div><div class="avail">${code}</div></div>`;
      }

      html += `</div>`;
    }

    // ensure Xanax for SA appears at bottom if present in data but not included above
    // already included by filter for code === 'sou' so no extra step needed.

    foreignList.innerHTML = html || `<div style="color:#999;">No tracked items found in foreign shops.</div>`;
  }

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

  // master refresh: fetch Torn and YATA, then render both
  let pollHandle = null;
  let lastYataTs = 0;
  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      // fetch both in parallel
      const tornPromise = apiKey ? fetchTornItems().catch(e => { throw new Error('Torn:' + e.message); }) : Promise.reject(new Error('No Torn API key set'));
      const yataPromise = fetchYataExport().catch(e => { throw new Error('YATA:' + (e.message || e)); });

      const [itemsAgg, yataData] = await Promise.allSettled([tornPromise, yataPromise])
        .then(results => {
          // results[0] for Torn, results[1] for YATA
          const tornRes = results[0];
          const yataRes = results[1];

          if (tornRes.status === 'rejected') {
            // show note but continue with empty inventory
            statusEl.textContent = `Torn fetch failed: ${tornRes.reason.message || tornRes.reason}`;
          }
          if (yataRes.status === 'rejected') {
            statusEl.textContent = (statusEl.textContent ? statusEl.textContent + ' | ' : '') + `YATA fetch failed`;
          }
          return [
            tornRes.status === 'fulfilled' ? tornRes.value : {},
            yataRes.status === 'fulfilled' ? yataRes.value : null
          ];
        });

      // render my inventory
      renderMyInventory(itemsAgg || {});

      // only re-render foreign stock if new payload or force
      if (yataData) {
        if (force || !yataData.timestamp || yataData.timestamp !== lastYataTs) {
          renderForeignStock(yataData);
          lastYataTs = yataData.timestamp || lastYataTs;
        }
      }

      // status
      const tNow = new Date().toLocaleTimeString();
      statusEl.textContent = `Updated: ${tNow}`;
    } catch (err) {
      statusEl.textContent = `Update error: ${err.message || err}`;
    }
  }

  function startPolling() {
    if (pollHandle) return;
    refreshAll(true);
    pollHandle = setInterval(() => refreshAll(false), POLL_MS);
  }
  function stopPolling() {
    if (!pollHandle) return;
    clearInterval(pollHandle);
    pollHandle = null;
  }

  // init: if api key stored, start
  if (apiKey) {
    startPolling();
  } else {
    statusEl.textContent = 'No Torn API key set. Click Set API Key.';
    // still fetch YATA so foreign stock visible without key
    fetchYataExport().then(data => renderForeignStock(data)).catch(()=>{/*ignore*/});
  }

  // store cleanly when unloading
  window.addEventListener('beforeunload', () => stopPolling());

})();