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

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

// ==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); });

})();