🌺 🐫 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.

Pada tanggal 11 Oktober 2025. Lihat %(latest_version_link).

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

})();