🌺🧸 Unified Stock (stable) - Predictive + Travel Footer

One-line per item: display-case inv + foreign stk + flag/code. Predicts availability on arrival using YATA + Torn travel timings. Shows Sets & Points, single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.

La data de 11-10-2025. Vezi ultima versiune.

// ==UserScript==
// @name         🌺🧸 Unified Stock (stable) - Predictive + Travel Footer
// @namespace    http://tampermonkey.net/
// @version      5.3.0
// @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
// @description One-line per item: display-case inv + foreign stk + flag/code. Predicts availability on arrival using YATA + Torn travel timings. Shows Sets & Points, single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.
// ==/UserScript==

(function(){
  'use strict';
  if(!/page\.php\?sid=travel/.test(location.href)) return;

  // ---- CONFIG ----
  const YATA_STOCK_URL = 'https://yata.yt/api/v1/travel/export/';
  const REFRESH_MS = 45 * 1000;
  const PANEL_WIDTH = 320;
  const POINTS_PER_SET = 10;
  const TURNAROUND_MS = 2 * 60 * 1000;
  const FALLBACK_FLIGHT_MS = 45 * 60 * 1000;

  // ---- tracked items (kept from your script) ----
  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];

  // ---- styles ----
  function getPDANavHeight(){ const nav = document.querySelector('#pda-nav')||document.querySelector('.pda'); return nav?nav.offsetHeight:40; }
  GM_addStyle(`
    #uniStockPanel{position:fixed;top:${getPDANavHeight()}px;left:18px;width:${PANEL_WIDTH}px;background:#0b0b0b;color:#eaeaea;font-family:monospace;font-size:11px;border:1px solid #444;border-radius:6px;z-index:999999;box-shadow:0 6px 16px rgba(0,0,0,0.5);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;}
    #titleRow{font-weight:700;font-size:13px;cursor:pointer;}
    .uni-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.02);}
    .uni-left{display:flex;align-items:center;gap:8px;overflow:hidden;}
    .dot{width:10px;height:10px;border-radius:50%;}
    .g{background:#00c853}.o{background:#ff8f00}.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;}
    #uniTravelFooter{padding:8px;border-top:1px solid #333;text-align:center;font-size:12px;}
    .pts-btn{background:#171717;color:#eaeaea;border:1px solid #333;padding:4px 8px;border-radius:4px;cursor:pointer;}
  `);

  // ---- build DOM ----
  const panel = document.createElement('div'); panel.id='uniStockPanel';
  panel.innerHTML = ''
    + '<div id="uniHeader"><div><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" style="font-size:11px;color:#9ea6b3;margin-top:6px;">Format: Item — (inv: X | stk: Y) · inv = display case count · stk = foreign shop stock</div></div>'
    + '<div id="uniTravelFooter">✈ Fly to: —</div>';
  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 footerEl = panel.querySelector('#uniTravelFooter');
  const btnRefresh = panel.querySelector('#uniRefresh');
  const btnSetKey = panel.querySelector('#uniSetKey');

  // collapse
  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
  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));

  // ---- XHR helper ----
  function gmGetJson(url){
    return new Promise((resolve,reject)=>{
      GM_xmlhttpRequest({
        method:'GET',
        url,
        responseType:'json',
        onload:function(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'))
      });
    });
  }

  // ---- fetch Torn display + travel ----
  async function fetchDisplayAndTravel(){
    if(!apiKey) return { display:{}, travelSelection:null };
    const url = `https://api.torn.com/user/?selections=display,travel&key=${encodeURIComponent(apiKey)}`;
    try{
      const data = await gmGetJson(url);
      if(!data || data.error) return { display:{}, travelSelection:null };
      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; }
      const travelSelection = data.travel ?? data.selections?.travel ?? data;
      return { display: out, travelSelection };
    }catch(e){
      console.warn('display fetch failed', e);
      return { display:{}, travelSelection:null };
    }
  }

  // ---- fetch YATA ----
  async function fetchYata(){
    try{ const d = await gmGetJson(YATA_STOCK_URL); return d||null; }catch(e){ console.warn('YATA fetch failed', e); return null; }
  }

  // ---- timestamp helpers ----
  function parsePossibleTimestamp(val){
    if(!val) return null;
    if(typeof val==='number') return val;
    const n = Number(val);
    if(!Number.isNaN(n) && n>1000000000) return n;
    const p = Date.parse(val);
    if(!Number.isNaN(p)) return p;
    return null;
  }

  // ---- remaining flight detection (travelSelection -> window -> DOM) ----
  function parseRemainingFromTravelSelection(travelSelection){
    if(!travelSelection) return 0;
    try{
      if(travelSelection.arrival){ const arr = parsePossibleTimestamp(travelSelection.arrival); if(arr) return Math.max(0, arr - Date.now()); }
      if(travelSelection.return_in) return Number(travelSelection.return_in)||0;
      if(travelSelection.return){ const r = parsePossibleTimestamp(travelSelection.return); if(r) return Math.max(0, r - Date.now()); }
      if(travelSelection.travel_time_remaining){ const s = Number(travelSelection.travel_time_remaining); if(!Number.isNaN(s)) return s*1000; }
    }catch(e){}
    return 0;
  }
  function parseRemainingFromDOM(){
    const cands = [document.querySelector('.travelCountdown'),document.querySelector('.travelTimer'),document.querySelector('.timer'),document.querySelector('#travel-timer'),document.querySelector('.countdown')].filter(Boolean);
    for(const el of cands){
      const txt = el.textContent.trim();
      if(!txt) continue;
      const hhmmss = txt.match(/^(\d+):(\d{2}):(\d{2})$/);
      if(hhmmss) return (Number(hhmmss[1])*3600 + Number(hhmmss[2])*60 + Number(hhmmss[3]))*1000;
      const mmss = txt.match(/^(\d+):(\d{2})$/);
      if(mmss) return (Number(mmss[1])*60 + Number(mmss[2]))*1000;
      const hh = txt.match(/(\d+)\s*h/); const mm = txt.match(/(\d+)\s*m/); const ss = txt.match(/(\d+)\s*s/);
      if(hh||mm||ss) return ((hh?Number(hh[1])*3600:0)+(mm?Number(mm[1])*60:0)+(ss?Number(ss[1]):0))*1000;
      const alt = txt.match(/(\d+)\s*m/); if(alt) return Number(alt[1])*60*1000;
    }
    return 0;
  }
  function getRemainingFlightMs(travelSelection){
    let ms = 0;
    if(travelSelection) ms = parseRemainingFromTravelSelection(travelSelection) || 0;
    if(!ms && (window.tornTravel || window.travel_data || window.torn)){
      try{
        const t = window.tornTravel || window.travel_data || (window.torn && window.torn.travel);
        if(t && t.arrival){ const arr = parsePossibleTimestamp(t.arrival); if(arr) ms = Math.max(0, arr - Date.now()); }
        else if(t && t.return_in) ms = Number(t.return_in) || 0;
      }catch(e){}
    }
    if(!ms) ms = parseRemainingFromDOM() || 0;
    return ms;
  }

  // ---- travel durations parsing (selection, DOM, YATA, fallback) ----
  function parseTravelDurationsFromSelection(travelSelection){
    if(!travelSelection) return null;
    try{
      if(travelSelection.destinations && typeof travelSelection.destinations==='object'){
        const out={};
        for(const [k,v] of Object.entries(travelSelection.destinations)){
          const d = v.duration ?? v.time ?? v.ms ?? v.seconds ?? v.travel_time;
          if(d===undefined) continue;
          const num = Number(d);
          out[k.toLowerCase()] = (num>0 && num<20000)?num*1000:num;
        }
        if(Object.keys(out).length) return out;
      }
      if(Array.isArray(travelSelection)){
        const out={};
        for(const r of travelSelection){
          const code = r.code||r.country||r.destination||r.id;
          const d = r.duration ?? r.time ?? r.ms ?? r.seconds;
          if(!code||!d) continue;
          const num = Number(d);
          out[String(code).toLowerCase()] = (num>0&&num<20000)?num*1000:num;
        }
        if(Object.keys(out).length) return out;
      }
    }catch(e){}
    return null;
  }

  function parseTravelDurationsFromDOM(){
    const rows = document.querySelectorAll('.travels-table tr, .travel-row, .destination-row');
    if(!rows || rows.length===0) return null;
    const out={};
    rows.forEach(row=>{
      const txt = row.textContent || '';
      for(const [code,name] of Object.entries(COUNTRY_NAMES)){
        if(txt.includes(name) || txt.includes(code.toUpperCase())){
          let ms = null;
          const hm = txt.match(/(\d+):(\d{2}):(\d{2})/);
          if(hm) ms = (Number(hm[1])*3600 + Number(hm[2])*60 + Number(hm[3]))*1000;
          else{
            const hh = txt.match(/(\d+)\s*h/); const mm = txt.match(/(\d+)\s*m/); const ss = txt.match(/(\d+)\s*s/);
            if(hh||mm||ss) ms = ((hh?Number(hh[1])*3600:0)+(mm?Number(mm[1])*60:0)+(ss?Number(ss[1]):0))*1000;
          }
          if(ms) out[code]=ms;
        }
      }
    });
    return Object.keys(out).length?out:null;
  }

  async function fetchYataTravelDurations(){
    try{
      const data = await gmGetJson(YATA_STOCK_URL);
      if(!data) return null;
      const out={};
      const tryAdd=(k,v)=>{ const dur = v.duration ?? v.time ?? v.ms ?? v.seconds; if(dur===undefined) return; const val = typeof dur==='number'?dur:Number(dur); if(Number.isNaN(val)) return; out[k.toLowerCase()] = (val>0 && val<20000)?val*1000:val; };
      if(data.travel && typeof data.travel==='object'){ for(const [k,v] of Object.entries(data.travel)) tryAdd(k,v); if(Object.keys(out).length) return out; }
      for(const [k,v] of Object.entries(data)){ if(k==='travel') continue; if(v && typeof v==='object') tryAdd(k,v); }
      return Object.keys(out).length?out:null;
    }catch(e){ console.warn('fetchYataTravelDurations error', e); return null; } 
  }

  const HARDCODE_FLIGHT_MS = { mex:30*60*1000, can:40*60*1000, haw:60*60*1000, uni:40*60*1000, arg:80*60*1000, swi:70*60*1000, jap:90*60*1000, chi:100*60*1000, uae:110*60*1000, cay:50*60*1000, sou:75*60*1000 };
  function getFlightDurationMs(travelDurations, code){ if(!code) return FALLBACK_FLIGHT_MS; const lc = code.toLowerCase(); if(travelDurations && travelDurations[lc]) return travelDurations[lc]; if(HARDCODE_FLIGHT_MS[lc]) return HARDCODE_FLIGHT_MS[lc]; return FALLBACK_FLIGHT_MS; }

  // ---- prediction ----
  function predictStatusForArrival(itemObj, arrivalTs){
    const stock = Number(itemObj.quantity ?? itemObj.stock ?? 0) || 0;
    const depTs = parsePossibleTimestamp(itemObj.depletion);
    let restTs = parsePossibleTimestamp(itemObj.restock);
    if(!restTs && itemObj.restock_eta){ const n = Number(itemObj.restock_eta); if(!Number.isNaN(n) && n>0) restTs = Date.now() + n*1000; }
    if(stock>0 && (!depTs || depTs>arrivalTs)) return 'green';
    if(stock>0 && depTs && depTs<=arrivalTs) return 'red';
    if(stock===0 && restTs && restTs<=arrivalTs) return 'orange';
    return 'red';
  }

  // ---- build unified (predictive) ----
  function buildUnified(displayMap, yataData, travelDurationsFromSources, travelSelection){
    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] = { quantity: Number(it.quantity ?? it.qty ?? it.stock ?? 0)||0, depletion: it.depletion ?? it.deplete ?? it.depletionTime ?? null, restock: it.restock ?? it.restock_time ?? it.restockTime ?? it.restock_eta ?? null, restock_eta: it.restock_eta ?? null }; }
      }
    }
    const remainingMs = getRemainingFlightMs(travelSelection) || 0;
    const now = Date.now();
    const itemsInfo = [];
    for(const name of TRACKED_LIST){
      if(name===SPECIAL_DRUG){
        const code='sou'; const stockObj = yataStocks[code]?.[SPECIAL_DRUG] ?? { quantity:0, depletion:null, restock:null }; const inv = Number(displayMap[name] ?? 0)||0;
        itemsInfo.push({ name, inv, perCountry: { [code]: stockObj }, loc: 'SA 🇿🇦' }); continue;
      }
      const perCountry={};
      for(const [code,map] of Object.entries(yataStocks)){ const obj = map[name]; if(obj) perCountry[code]=obj; }
      const f = FLOWERS_ORDER.find(x=>x[0]===name); const p = PLUSHIES_ORDER.find(x=>x[0]===name); const knownLoc = f?f[1].loc:(p?p[1].loc:'');
      const inv = Number(displayMap[name] ?? 0)||0; itemsInfo.push({ name, inv, perCountry, loc: knownLoc });
    }

    for(const it of itemsInfo){
      const candidates=[];
      for(const [code,obj] of Object.entries(it.perCountry||{})){
        const nextFlightMs = getFlightDurationMs(travelDurationsFromSources, code);
        const arrivalTs = now + remainingMs + TURNAROUND_MS + nextFlightMs;
        const predicted = predictStatusForArrival({ quantity: obj.quantity, depletion: obj.depletion, restock: obj.restock, restock_eta: obj.restock_eta }, arrivalTs);
        const stockNow = Number(obj.quantity||0);
        candidates.push({ code, predicted, stockNow, nextFlightMs, obj, arrivalTs });
      }
      if(candidates.length===0){ it.bestCode=null; it.bestStk=0; it.bestPredicted=null; continue; }
      const rank = s=> s==='green'?3:(s==='orange'?2:(s==='red'?1:0));
      candidates.sort((A,B)=>{ const rA=rank(A.predicted), rB=rank(B.predicted); if(rA!==rB) return rB-rA; if(A.stockNow!==B.stockNow) return B.stockNow - A.stockNow; return A.nextFlightMs - B.nextFlightMs; });
      it.bestCode = candidates[0].code; it.bestStk = candidates[0].stockNow; it.bestPredicted = candidates[0].predicted; it.bestObj = candidates[0].obj; it.bestArrival = candidates[0].arrivalTs;
    }

    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.bestStk||0) < (lowest.bestStk||0)) lowest = it;
        else if((it.bestStk||0) === (lowest.bestStk||0)){
          if((it.name||'') < (lowest.name||'')) lowest = it;
        }
      }
    }
    return { itemsInfo, lowest, yataStocks };
  }

  // ---- helpers ----
  function dotClassFromPred(pred){ if(pred==='green') return 'g'; if(pred==='orange') return 'o'; return 'r'; }
  function getFlagForCode(code){ if(!code) return ''; const map={ mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' }; return map[code]||''; }
  function shortName(name){ if(!name) return ''; const words = name.split(/\s+/); if(words.length>1) return words[0][0] + ' ' + words.slice(1).join(' '); return name; }
  function colorEmoji(c){ return c==='green'?'🟢':(c==='orange'?'🟠':'🔴'); }
  function formatTime(ts){ if(!ts) return ''; try{ return new Date(ts).toLocaleTimeString(); }catch(e){ return ''; } }

  // ---- render ----
  function renderAll(displayMap, yataData, travelDurationsFromSources, travelSelection){
    const { totalSets, points } = computeSets(displayMap);
    pointsRow.textContent = `Sets: ${totalSets} | Points: ${points}`;
    const { itemsInfo, lowest } = buildUnified(displayMap, yataData, travelDurationsFromSources, travelSelection);
    const order = []; for(const [n] of FLOWERS_ORDER) order.push(n); for(const [n] of PLUSHIES_ORDER) order.push(n); order.push(SPECIAL_DRUG);
    const mapInfo = {}; for(const it of itemsInfo) mapInfo[it.name]=it;
    let html = '';
    for(const name of order){
      const it = mapInfo[name]; if(!it) continue;
      const inv = Number(it.inv||0); const stk = Number(it.bestStk||0); const pred = it.bestPredicted || null;
      const cls = dotClassFromPred(pred); const flagPart = it.loc ? ` ${it.loc}` : (it.bestCode ? ` ${it.bestCode.toUpperCase()}` : '');
      const flyNote = (lowest && name===lowest.name && it.bestCode && it.bestPredicted) ? ` <span class="fly">✈ Fly to: ${(COUNTRY_NAMES[it.bestCode]||it.bestCode.toUpperCase())} ${getFlagForCode(it.bestCode)}</span>` : '';
      const meta = `(inv: ${inv} | stk: ${stk})`;
      html += `<div class="uni-row"><div class="uni-left"><span class="dot ${cls}"></span><div class="itemname">${escapeHtml(shortName(name))}</div></div><div class="meta">${meta}${flyNote}${flagPart? ' ' + escapeHtml(flagPart):''}</div></div>`;
    }
    listEl.innerHTML = html || '<div style="color:#999;">No tracked items found.</div>';

    // footer
    if(lowest && lowest.bestCode && lowest.bestPredicted){
      const place = (COUNTRY_NAMES[lowest.bestCode]||lowest.bestCode.toUpperCase());
      const flag = getFlagForCode(lowest.bestCode);
      const sname = shortName(lowest.name);
      const emoji = colorEmoji(lowest.bestPredicted);
      const at = formatTime(lowest.bestArrival);
      footerEl.textContent = `✈ Fly to: ${place} ${flag} — for ${sname} ${emoji} ${at?`(arrives ${at})`:''}`;
    } else {
      footerEl.textContent = '✈ Fly to: —';
    }
  }

  // ---- refresh loop ----
  let timer = null;
  let lastYata = null, lastDur = null, lastDisplay = {}, lastTravelSel = null;
  async function refreshAll(force=false){
    statusEl.textContent='Updating...';
    try{
      const dispRes = await fetchDisplayAndTravel();
      const displayMap = dispRes.display || {};
      const travelSelection = dispRes.travelSelection || null;

      let yataData = null;
      try{ yataData = await fetchYata(); if(yataData) lastYata = yataData; }catch(e){ yataData = lastYata; }

      let travelDur = null;
      try{
        travelDur = parseTravelDurationsFromSelection(travelSelection) || parseTravelDurationsFromDOM() || await fetchYataTravelDurations() || lastDur || HARDCODE_FLIGHT_MS;
        if(travelDur && Object.keys(travelDur).length) lastDur = travelDur;
      }catch(e){ travelDur = lastDur || HARDCODE_FLIGHT_MS; }

      renderAll(displayMap, yataData || lastYata || {stocks:{}}, travelDur, travelSelection);

      const t = new Date();
      const yOk = yataData && yataData.stocks ? 'OK' : (lastYata && lastYata.stocks ? 'STALE' : 'ERR');
      const trOk = travelDur && Object.keys(travelDur).length ? 'OK' : 'ERR';
      statusEl.textContent = `Updated: ${t.toLocaleTimeString()} · YATA stocks: ${yOk} · Travel: ${trOk}`;

      lastDisplay = displayMap; lastTravelSel = travelSelection;
    }catch(e){
      console.warn('refreshAll error', e);
      statusEl.textContent = 'Update error (see console)';
      renderAll(lastDisplay || {}, lastYata || {stocks:{}}, lastDur || HARDCODE_FLIGHT_MS, lastTravelSel || null);
    }
  }

  // ---- computeSets & escape ----
  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);
    return { totalSets, points: totalSets*POINTS_PER_SET, fSets, pSets };
  }
  function escapeHtml(s){ if(s===null||s===undefined) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }

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

})();