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

נכון ליום 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 (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); });

})();