🌺🧸 Unified Stock (v4.1) - One-line + Fly Suggestion + Points (Inline Nav)

One-line per item: display-case inv + foreign stk + flag/code. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Inline between left and right nav.

נכון ליום 11-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 Stock (v4.1) - One-line + Fly Suggestion + Points (Inline Nav)
// @namespace    http://tampermonkey.net/
// @version      4.1.2
// @description  One-line per item: display-case inv + foreign stk + flag/code. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Inline between left and right nav.
// @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 REFRESH_MS = 45 * 1000;
    const LOW_STOCK = 500; // threshold for "low abroad" (yellow)
    const POINTS_PER_SET = 10;

    // ===== TRACKED ITEMS =====
    const FLOWERS_ORDER = [
        ["Dahlia", {code:'mex', loc:'MX 🇲🇽'}],
        ["Orchid", {code:'haw', loc:'HW 🏝️'}],
        ["African Violet", {code:'sou', loc:'SA 🇿🇦'}],
        ["Cherry Blossom", {code:'jap', loc:'JP 🇯🇵'}],
        ["Peony", {code:'chi', loc:'CN 🇨🇳'}],
        ["Ceibo Flower", {code:'arg', loc:'AR 🇦🇷'}],
        ["Edelweiss", {code:'swi', loc:'CH 🇨🇭'}],
        ["Crocus", {code:'can', loc:'CA 🇨🇦'}],
        ["Heather", {code:'uni', loc:'UK 🇬🇧'}],
        ["Tribulus Omanense", {code:'uae', loc:'AE 🇦🇪'}],
        ["Banana Orchid", {code:'cay', loc:'KY 🇰🇾'}]
    ];

    const PLUSHIES_ORDER = [
        ["Sheep Plushie", {code:null, loc:'B.B 🏪'}],
        ["Teddy Bear Plushie", {code:null, loc:'B.B 🏪'}],
        ["Kitten Plushie", {code:null, loc:'B.B 🏪'}],
        ["Jaguar Plushie", {code:'mex', loc:'MX 🇲🇽'}],
        ["Wolverine Plushie", {code:'can', loc:'CA 🇨🇦'}],
        ["Nessie Plushie", {code:'uni', loc:'UK 🇬🇧'}],
        ["Red Fox Plushie", {code:'uni', loc:'UK 🇬🇧'}],
        ["Monkey Plushie", {code:'arg', loc:'AR 🇦🇷'}],
        ["Chamois Plushie", {code:'swi', loc:'CH 🇨🇭'}],
        ["Panda Plushie", {code:'chi', loc:'CN 🇨🇳'}],
        ["Lion Plushie", {code:'sou', loc:'SA 🇿🇦'}],
        ["Camel Plushie", {code:'uae', loc:'AE 🇦🇪'}],
        ["Stingray Plushie", {code:'cay', loc:'KY 🇰🇾'}]
    ];

    const SPECIAL_DRUG = "Xanax";

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

    const TRACKED_LIST = [
        ...FLOWERS_ORDER.map(x=>x[0]),
        ...PLUSHIES_ORDER.map(x=>x[0]),
        SPECIAL_DRUG
    ];

    // ===== UI styling =====
    GM_addStyle(`
      #uniStockPanel { display:inline-block; margin:0 10px; vertical-align:middle; background:#0b0b0b; color:#eaeaea; font-family:"DejaVu Sans Mono",monospace; font-size:11px; border:1px solid #444; border-radius:6px; max-height:70vh; overflow-y:auto; line-height:1.15; }
      #uniHeader { background:#121212; padding:6px 8px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #333; user-select:none; }
      #uniHeaderLeft { display:flex; flex-direction:column; gap:2px; }
      #titleRow { font-weight:700; font-size:13px; cursor:pointer; }
      #pointsRow { color:#bfc9d6; font-size:11px; }
      .uni-row { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:4px 0; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
      .uni-left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
      .dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex:0 0 10px; }
      .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
      .itemname { min-width:0; overflow:hidden; text-overflow:ellipsis; }
      .meta { color:#bfc9d6; width:150px; text-align:right; font-size:11px; flex:0 0 150px; }
      #uni_status { color:#9ea6b3; margin:6px 0; }
      .fly { color:#9ad0ff; font-weight:700; margin-left:8px; }
      .small { font-size:11px; color:#9ea6b3; margin-top:6px; }
      .pts-btn { background:#171717; color:#eaeaea; border:1px solid #333; padding:4px 8px; border-radius:4px; cursor:pointer; }
    `);

    // ===== DOM build =====
    const leftNav = document.querySelector('#nav-left'); // replace with correct left selector
    const rightNav = document.querySelector('#nav-right'); // replace with correct right selector

    const panel = document.createElement('div');
    panel.id = 'uniStockPanel';
    panel.innerHTML = `
      <div id="uniHeader">
        <div id="uniHeaderLeft">
          <div id="titleRow">▶ 🌺🧸 Unified Stock</div>
          <div id="pointsRow">Sets: - | Points: -</div>
        </div>
        <div style="display:flex;gap:6px;align-items:center">
          <button id="uniRefresh" class="pts-btn">Refresh</button>
          <button id="uniSetKey" class="pts-btn">Set Key</button>
        </div>
      </div>
      <div id="uniContent">
        <div id="uni_status">Initializing...</div>
        <div id="uniList"></div>
        <div class="small">Format: Item — (inv: X | stk: Y) · inv = display case count · stk = foreign shop stock · lowest item shows "✈ Fly to: CODE 🇿🇦"</div>
      </div>
    `;

    if (leftNav && rightNav && leftNav.parentNode === rightNav.parentNode) {
        leftNav.parentNode.insertBefore(panel, rightNav);
    } else {
        document.body.appendChild(panel); // fallback
    }

    const titleRow = panel.querySelector('#titleRow');
    const pointsRow = panel.querySelector('#pointsRow');
    const statusEl = panel.querySelector('#uni_status');
    const listEl = panel.querySelector('#uniList');
    const btnRefresh = panel.querySelector('#uniRefresh');
    const btnSetKey = panel.querySelector('#uniSetKey');

    // collapse toggle
    let collapsed = GM_getValue('uni_collapsed', false);
    function updateCollapse(){
        const content = panel.querySelector('#uniContent');
        content.style.display = collapsed ? 'none' : 'block';
        titleRow.textContent = (collapsed ? '▶' : '▼') + ' 🌺🧸 Unified Stock';
        GM_setValue('uni_collapsed', collapsed);
    }
    titleRow.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
    updateCollapse();

    // API key save/load
    let apiKey = GM_getValue('tornAPIKey', null);
    btnSetKey.addEventListener('click', () => {
        const k = prompt('Enter Torn user API key (needs display permission):', apiKey || '');
        if (k) {
            apiKey = k.trim();
            GM_setValue('tornAPIKey', apiKey);
            statusEl.textContent = 'API key saved.';
            refreshAll(true);
        }
    });
    btnRefresh.addEventListener('click', () => refreshAll(true));

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

    async function fetchDisplayCaseSafe() {
        if (!apiKey) return {};
        const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
        try {
            const data = await gmGetJson(url);
            if (!data || data.error) return {};
            const out = {};
            const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
            for (const e of entries) {
                if (!e) continue;
                const name = e.name || e.item_name || e.title || e.item;
                const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
                if (!name) continue;
                out[name] = (out[name] || 0) + qty;
            }
            return out;
        } catch (err) {
            console.warn('display fetch error', err);
            return {};
        }
    }

    async function fetchYataSafe() {
        try {
            const data = await gmGetJson(YATA_URL);
            return data || null;
        } catch (e) {
            console.warn('YATA fetch error', e);
            return null;
        }
    }

    function computeSets(displayMap) {
        const flowerCounts = FLOWERS_ORDER.map(([name]) => Number(displayMap[name] || 0));
        const plushCounts  = PLUSHIES_ORDER.map(([name]) => Number(displayMap[name] || 0));
        const fSets = flowerCounts.length ? Math.min(...flowerCounts) : 0;
        const pSets = plushCounts.length ? Math.min(...plushCounts) : 0;
        const totalSets = (isFinite(fSets) ? fSets : 0) + (isFinite(pSets) ? pSets : 0);
        const points = totalSets * POINTS_PER_SET;
        return { totalSets, points, fSets, pSets };
    }

    function dotClass(inv, stk) {
        if ((stk > 0 && stk < LOW_STOCK) || (inv > 0 && stk > 0 && stk < LOW_STOCK)) return 'y';
        if (inv > 0 || stk > 0) return 'g';
        return 'r';
    }

    function buildUnified(displayMap, yataData) {
        const yataStocks = {};
        if (yataData && yataData.stocks) {
            for (const [code, obj] of Object.entries(yataData.stocks)) {
                const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
                yataStocks[code] = {};
                for (const it of arr) {
                    if (!it || !it.name) continue;
                    yataStocks[code][it.name] = Number(it.quantity ?? 0) || 0;
                }
            }
        }

        const itemsInfo = [];

        for (const name of TRACKED_LIST) {
            if (name === SPECIAL_DRUG) {
                const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
                const inv = Number(displayMap[name] ?? 0) || 0;
                itemsInfo.push({ name, inv, totalStk: stk, bestCode: stk > 0 ? 'sou' : null, bestStk: stk, loc: 'SA 🇿🇦' });
                continue;
            }
            let total = 0;
            let bestCode = null;
            let bestStk = 0;
            for (const [code, map] of Object.entries(yataStocks)) {
                const q = Number(map[name] ?? 0) || 0;
                total += q;
                if (q > bestStk) { bestStk = q; bestCode = code; }
            }
            let loc = '';
            const f = FLOWERS_ORDER.find(x => x[0] === name);
            const p = PLUSHIES_ORDER.find(x => x[0] === name);
            if (f) loc = f[1].loc;
            else if (p) loc = p[1].loc;
            const inv = Number(displayMap[name] ?? 0) || 0;
            itemsInfo.push({ name, inv, totalStk: total, bestCode, bestStk, loc });
        }

        let lowest = null;
        for (const it of itemsInfo) {
            if (!lowest) { lowest = it; continue; }
            if ((it.inv || 0) < (lowest.inv || 0)) lowest = it;
            else if ((it.inv || 0) === (lowest.inv || 0)) {
                if ((it.totalStk || 0) < (lowest.totalStk || 0)) lowest = it;
            }
        }

        return { itemsInfo, lowest, yataStocks };
    }

    function renderAll(displayMap, yataData) {
        const { totalSets, points } = computeSets(displayMap);
        pointsRow.textContent = `Sets: ${totalSets} | Points: ${points}`;

        const { itemsInfo, lowest } = buildUnified(displayMap, yataData);

        const order = [];
        for (const [name] of FLOWERS_ORDER) order.push(name);
        for (const [name] of PLUSHIES_ORDER) order.push(name);
        order.push(SPECIAL_DRUG);

        const infoByName = {};
        for (const it of itemsInfo) infoByName[it.name] = it;

        let html = '';
        for (const name of order) {
            const it = infoByName[name];
            if (!it) continue;
            const inv = Number(it.inv || 0);
            const stk = Number(it.totalStk || 0);
            const cls = dotClass(inv, stk);
            const flyNote = (lowest && name === lowest.name && lowest.bestCode && (lowest.bestStk > 0))
                ? ` <span class="fly">✈ Fly to: ${ (COUNTRY_NAMES[lowest.bestCode] || lowest.bestCode.toUpperCase()) } ${ getFlagForCode(lowest.bestCode) }</span>`
                : '';
            const meta = `(inv: ${inv} | stk: ${stk})${flyNote}`;
            html += `<div class="uni-row"><div class="uni-left"><span class="dot ${cls}"></span><div class="itemname">${escapeHtml(name)}</div></div><div class="meta">${meta}</div></div>`;
        }

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

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

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

    async function refreshAll(force=false) {
        statusEl.textContent = 'Updating...';
        try {
            const [displayMap, yataData] = await Promise.all([ fetchDisplayCaseSafe(), fetchYataSafe() ]);
            renderAll(displayMap || {}, yataData || null);
            statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
        } catch (e) {
            console.warn('refreshAll error', e);
            statusEl.textContent = 'Update error';
        }
    }

    refreshAll(true);
    const timer = setInterval(() => refreshAll(false), REFRESH_MS);
    window.addEventListener('beforeunload', () => { clearInterval(timer); });

})();