🌺🧸 Unified Stock (Compact Inline)

Inline one-line stock panel between Torn navs, compact width, fly suggestion, sets & points. Refresh 45s.

Versione datata 11/10/2025. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         🌺🧸 Unified Stock (Compact Inline)
// @namespace    http://tampermonkey.net/
// @version      4.1.4
// @description  Inline one-line stock panel between Torn navs, compact width, fly suggestion, sets & points. Refresh 45s.
// @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;

    const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
    const REFRESH_MS = 45*1000;
    const LOW_STOCK = 500;
    const POINTS_PER_SET = 10;
    const PANEL_WIDTH = 250;

    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 TRACKED_LIST = [...FLOWERS_ORDER.map(x=>x[0]),...PLUSHIES_ORDER.map(x=>x[0]),SPECIAL_DRUG];
    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' };

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

    const leftNav = document.querySelector('.nav-left');
    const rightNav = document.querySelector('.nav-right');
    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:4px;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) · lowest item shows "✈ Fly to"</div>
        </div>
    `;
    if(leftNav && rightNav && leftNav.parentNode===rightNav.parentNode){ leftNav.parentNode.insertBefore(panel,rightNav); }
    else{ document.body.appendChild(panel); }

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

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

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

    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{}; try{ const data=await gmGetJson(`https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`); 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(e){console.warn('display fetch error',e); 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}; }

    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 escapeHtml(s){if(s===null||s===undefined)return'';return String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
    function getFlagForCode(code){ if(!code)return''; const map={ mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦'}; return map[code]||''; }

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

    function buildUnified(displayMap,yataData){
        const yataStocks={};
        if(yataData?.stocks){ for(const [code,obj] of Object.entries(yataData.stocks)){ yataStocks[code]={}; for(const it of obj.stocks||[]){ if(!it?.name)continue; yataStocks[code][it.name]=Number(it.quantity||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); itemsInfo.push({name,inv,totalStk:stk,bestCode:stk>0?'sou':null,bestStk:stk,loc:'SA 🇿🇦'}); continue; }
            let total=0,bestCode=null,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)&&(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=[...FLOWERS_ORDER.map(x=>x[0]),...PLUSHIES_ORDER.map(x=>x[0]),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>`;
    }

    refreshAll(true); setInterval(()=>refreshAll(false),REFRESH_MS);
})();