🌺 🐫 YATA Travel Stock Viewer (Above PDA)

Read-only: shows foreign shop stock from YATA export above PDA. Auto-refresh every 20s. No Torn API key required.

Versión del día 11/10/2025. Echa un vistazo a la versión más reciente.

// ==UserScript==
// @name         🌺 🐫 YATA Travel Stock Viewer (Above PDA)
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Read-only: shows foreign shop stock from YATA export above PDA. Auto-refresh every 20s. No Torn API key required.
// @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
// @run-at       document-end
// ==/UserScript==

(function() {
  'use strict';

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

  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const POLL_MS = 20 * 1000;

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

  GM_addStyle(`
    #yataStockPanel {
      position: fixed;
      top: ${getPDANavHeight()}px;
      left: 18px;
      width: 320px;
      background: #080808;
      color: #e9eef6;
      font-family: "DejaVu Sans Mono", monospace;
      font-size: 11px;
      border: 1px solid #333;
      border-radius: 6px;
      z-index: 999999;
      box-shadow: 0 6px 18px rgba(0,0,0,0.6);
      max-height: 70vh;
      overflow-y: auto;
      line-height: 1.1;
      padding-bottom:6px;
    }
    #yataHeader {
      background:#101010;
      padding:6px 8px;
      cursor:pointer;
      font-weight:700;
      font-size:12px;
      border-bottom:1px solid #2b2b2b;
      user-select:none;
    }
    #yataContent { padding:8px; display:block; }
    .yata-controls { margin-bottom:8px; }
    .yata-controls button {
      margin:2px 6px 6px 0;
      font-size:11px;
      padding:4px 8px;
      background:#121212;
      color:#e9eef6;
      border:1px solid #2b2b2b;
      border-radius:4px;
      cursor:pointer;
    }
    .yata-controls button:hover { background:#1b1b1b; }
    #yataStatus { font-size:11px; color:#bdbdbd; margin-bottom:8px; }
    .country-block { margin-bottom:8px; border-top:1px dashed #222; padding-top:6px; }
    .country-title { font-weight:700; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center; }
    .country-title .ct-left { font-size:12px; }
    .country-upd { font-size:10px; color:#9ea6b3; }
    .item-row { display:flex; justify-content:space-between; gap:8px; padding:2px 0; align-items:center; }
    .item-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .qty { width:64px; text-align:right; font-weight:700; }
    .cost { width:56px; text-align:right; color:#aeb7c4; font-size:11px; }
    .status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; vertical-align:middle; }
    .dot-green { background:#00c853; }
    .dot-yellow { background:#ffb300; }
    .dot-red { background:#ff1744; }
    .small-note { font-size:11px; color:#9ea6b3; margin-top:6px; }
  `);

  // Panel
  const panel = document.createElement('div');
  panel.id = 'yataStockPanel';
  panel.innerHTML = `
    <div id="yataHeader">▶ 🌺 🐫 YATA Stock (foreign shops)</div>
    <div id="yataContent">
      <div class="yata-controls">
        <button id="yata_refresh">Refresh</button>
        <button id="yata_toggle_all">Collapse</button>
      </div>
      <div id="yataStatus">Loading...</div>
      <div id="yataList"></div>
      <div class="small-note">Data from yata.yt · read-only · auto refresh 20s</div>
    </div>
  `;
  document.body.appendChild(panel);

  const header = panel.querySelector('#yataHeader');
  const content = panel.querySelector('#yataContent');
  const statusEl = panel.querySelector('#yataStatus');
  const listEl = panel.querySelector('#yataList');
  const btnRefresh = panel.querySelector('#yata_refresh');
  const btnToggle = panel.querySelector('#yata_toggle_all');

  let collapsed = GM_getValue('yata_collapsed', false);
  function updateCollapseUI() {
    if (collapsed) {
      content.style.display = 'none';
      header.textContent = '▶ 🌺 🐫 YATA Stock (foreign shops)';
      btnToggle.textContent = 'Expand';
    } else {
      content.style.display = 'block';
      header.textContent = '▼ 🌺 🐫 YATA Stock (foreign shops)';
      btnToggle.textContent = 'Collapse';
    }
    GM_setValue('yata_collapsed', collapsed);
  }
  collapsed = !!collapsed;
  updateCollapseUI();

  header.addEventListener('click', () => {
    collapsed = !collapsed;
    updateCollapseUI();
  });
  btnToggle.addEventListener('click', () => {
    collapsed = !collapsed;
    updateCollapseUI();
  });
  btnRefresh.addEventListener('click', () => fetchAndRender(true));

  // helper: friendly country name map (codes in YATA docs)
  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'
  };

  // color by quantity
  function qtyClass(q) {
    if (q <= 0) return 'dot-red';
    if (q <= 10) return 'dot-yellow';
    return 'dot-green';
  }

  // safe JSON fetch using GM_xmlhttpRequest (avoids CORS issues)
  function gmFetchJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        responseType: 'json',
        onload: (res) => {
          // Tampermonkey may return response as text if responseType unsupported
          let data = res.response;
          if (!data && res.responseText) {
            try { data = JSON.parse(res.responseText); } catch(e) { /* fallthrough */ }
          }
          if (!data) return reject(new Error('No JSON returned'));
          resolve(data);
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('Request timeout'))
      });
    });
  }

  // render function
  function renderExport(data) {
    if (!data || !data.stocks) {
      statusEl.textContent = 'No stock data.';
      listEl.innerHTML = '';
      return;
    }

    const ts = data.timestamp ? new Date(data.timestamp * 1000) : null;
    statusEl.textContent = ts ? `Last payload: ${ts.toUTCString()}` : 'Live data';

    const stocks = data.stocks;
    const countryKeys = Object.keys(stocks).sort();

    let html = '';
    countryKeys.forEach(code => {
      const c = stocks[code];
      if (!c) return;
      const name = COUNTRY_NAMES[code] || code.toUpperCase();
      const upd = c.update ? new Date(c.update * 1000) : null;
      html += `<div class="country-block" data-country="${code}">
        <div class="country-title">
          <div class="ct-left">${name} <span class="country-upd">${upd ? upd.toUTCString() : ''}</span></div>
          <div class="ct-right">${code}</div>
        </div>`;

      const items = Array.isArray(c.stocks) ? c.stocks : [];
      if (!items.length) {
        html += `<div style="font-size:11px;color:#9ea6b3">No items listed</div>`;
      } else {
        items.forEach(it => {
          const q = Number(it.quantity ?? 0);
          const cost = it.cost != null ? Number(it.cost) : null;
          const dot = `<span class="status-dot ${qtyClass(q)}"></span>`;
          html += `<div class="item-row">
            <div class="item-name">${dot}${escapeHtml(it.name)}</div>
            <div class="cost">${cost != null ? formatNumber(cost) : '-'}</div>
            <div class="qty">${q}</div>
          </div>`;
        });
      }

      html += `</div>`; // country-block
    });

    listEl.innerHTML = html;
  }

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

  function formatNumber(n) {
    return n.toLocaleString('en-US');
  }

  let pollHandle = null;
  let lastPayloadTimestamp = 0;

  async function fetchAndRender(force=false) {
    try {
      statusEl.textContent = 'Fetching YATA export...';
      const data = await gmFetchJson(YATA_URL);

      // optional: skip re-render if same payload (cache)
      if (!force && data && data.timestamp && data.timestamp === lastPayloadTimestamp) {
        statusEl.textContent = `No change. payload ts ${new Date(data.timestamp*1000).toUTCString()}`;
        return;
      }
      lastPayloadTimestamp = data.timestamp || lastPayloadTimestamp;
      renderExport(data);
    } catch (err) {
      statusEl.textContent = `Fetch error: ${err.message || err}`;
      listEl.innerHTML = '';
    }
  }

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

  // init
  startPolling();
  window.addEventListener('beforeunload', stopPolling);

})();