🌺🧸 Unified Stock (v5.1) - Predictive Fly Suggestion + Points (Torn travel-aware)

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.

Versão de: 11/10/2025. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         🌺🧸 Unified Stock (v5.1) - Predictive Fly Suggestion + Points (Torn travel-aware)
// @namespace    http://tampermonkey.net/
// @version      5.1.0
// @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.
// @author       Nova (enhanced)
// @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_STOCK_URL = 'https://yata.yt/api/v1/travel/export/';   // original export with stocks
  const YATA_TRAVEL_URL = 'https://yata.yt/api/v1/travel/export/';  // durations (same export endpoint often contains travel meta)
  const REFRESH_MS = 45 * 1000;
  const PANEL_WIDTH = 320;
  const LOW_STOCK = 500; // threshold for "low abroad" (yellow fallback)
  const POINTS_PER_SET = 10;
  const TURNAROUND_MS = 2 * 60 * 1000; // time on ground to refuel/reselect, 2 minutes by default
  const FALLBACK_FLIGHT_MS = 45 * 60 * 1000; // fallback flight time if none available

  // ===== 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"; // show only for 'sou'

  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 (keeps original styling, adds orange class) =====
  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:"DejaVu Sans Mono",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; user-select:none; }
    #uniContent { padding:8px; }
    #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; } .o { background:#ff8f00; }
    .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 (unchanged) =====
  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>
  `;
  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');

  // collapse toggle (unchanged)
  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 (unchanged)
  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 (unchanged) =====
  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'))
      });
    });
  }

  // ===== fetch display-case only (unchanged) =====
  async function fetchDisplayCaseSafe() {
    if (!apiKey) return {};
    const url = `https://api.torn.com/user/?selections=display,travel&key=${encodeURIComponent(apiKey)}`;
    // Note: we ask for travel selection too so we can get travel info from Torn API if available
    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 { display: out, travelSelection: data.travel ?? data.selections?.travel ?? null };
    } catch (e) {
      console.warn('display fetch failed', e);
      return { display: {}, travelSelection: null };
    }
  }

  // ===== fetch YATA (unchanged endpoint variable) =====
  async function fetchYataSafe() {
    try {
      const data = await gmGetJson(YATA_STOCK_URL);
      return data || null;
    } catch (e) {
      console.warn('YATA fetch failed', e);
      return null;
    }
  }

  // ===== compute sets & points (unchanged) =====
  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 };
  }

  // ===== utilities for timestamps/parsing =====
  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; // likely ms/epoch
    const d = Date.parse(val);
    if (!Number.isNaN(d)) return d;
    return null;
  }

  // ===== get remaining flight time from Torn sources =====
  // Try multiple sources (Torn API travel selection if present, window.tornTravel, DOM timer)
  function parseRemainingFromTravelSelection(travelSelection) {
    // travelSelection may be in different shapes; look for 'return' or 'return_in' or 'arrival'
    if (!travelSelection) return 0;
    try {
      // If Torn API returns a 'travel' object like {destination: ..., arrival: timestamp}
      if (travelSelection.arrival) {
        const arr = parsePossibleTimestamp(travelSelection.arrival);
        if (arr) return Math.max(0, arr - Date.now());
      }
      // Some variants: travelSelection.return_in (ms), travelSelection.return (timestamp)
      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());
      }
      // older shapes might have 'travel_time_remaining' in seconds
      if (travelSelection.travel_time_remaining) {
        const s = Number(travelSelection.travel_time_remaining);
        if (!Number.isNaN(s)) return s * 1000;
      }
    } catch (e) { /* ignore and fallback */ }
    return 0;
  }

  function parseRemainingFromDOM() {
    // Torn shows timers in a few DOM places; be permissive
    const possible = [
      document.querySelector('.travelCountdown'), // potential class
      document.querySelector('.travelTimer'),     // potential class
      document.querySelector('.timer'),           // generic timer
      document.querySelector('#travel-timer'),    // other
      document.querySelector('.countdown')        // other
    ].filter(Boolean);
    for (const el of possible) {
      const txt = el.textContent.trim();
      if (!txt) continue;
      // formats: "00:31:20" or "31m 20s" or "31m"
      // try HH:MM:SS or MM:SS:
      const hhmmss = txt.match(/^(\d+):(\d{2}):(\d{2})$/);
      if (hhmmss) {
        const ms = (Number(hhmmss[1])*3600 + Number(hhmmss[2])*60 + Number(hhmmss[3])) * 1000;
        return ms;
      }
      const mmss = txt.match(/^(\d+):(\d{2})$/);
      if (mmss) {
        const ms = (Number(mmss[1])*60 + Number(mmss[2])) * 1000;
        return ms;
      }
      const mms = txt.match(/(\d+)\s*h/);
      const mms2 = txt.match(/(\d+)\s*m/);
      const mms3 = txt.match(/(\d+)\s*s/);
      const h = mms ? Number(mms[1]) : 0;
      const m = mms2 ? Number(mms2[1]) : 0;
      const s = mms3 ? Number(mms3[1]) : 0;
      if (h || m || s) return ((h*3600)+(m*60)+s)*1000;
      // fallback parse numbers like "31m 20s"
      const alt = txt.match(/(\d+)\s*m/);
      if (alt) return Number(alt[1]) * 60 * 1000;
    }
    return 0;
  }

  function getRemainingFlightMs(travelSelection) {
    // priority: travelSelection -> window.tornTravel / global -> DOM
    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) { /* ignore */ }
    }
    if (!ms) ms = parseRemainingFromDOM() || 0;
    return ms;
  }

  // ===== flight duration lookup helpers (Torn travel info / YATA fallback / hardcoded) =====
  // Torn travel durations: we will attempt to read from Torn API travel selection (if it includes a travel table),
  // else try DOM travel table (page.php?sid=travel), else fallback to YATA travel export, else hardcoded map.
  function parseTravelDurationsFromSelection(travelSelection) {
    // travelSelection may include an object listing durations per destination; try common shapes
    if (!travelSelection) return null;
    try {
      // possibility: travelSelection.destinations { code: { time: ms } }
      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;
          if (typeof d === 'number') {
            out[k.toLowerCase()] = (d > 0 && d < 20000) ? d * 1000 : d;
          } else {
            const n = Number(d);
            if (!Number.isNaN(n)) out[k.toLowerCase()] = (n > 0 && n < 20000) ? n * 1000 : n;
          }
        }
        if (Object.keys(out).length) return out;
      }
      // other shapes: travelSelection.table with rows that include 'code' and 'duration'
      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) { /* ignore */ }
    return null;
  }

  function parseTravelDurationsFromDOM() {
    // parse travel table on page.php?sid=travel - look for time cells
    const rows = document.querySelectorAll('.travels-table tr, .travel-row, .destination-row');
    if (!rows || rows.length === 0) return null;
    const out = {};
    rows.forEach(row => {
      try {
        const text = row.textContent || '';
        // try to find a country code in a known list
        for (const [code,name] of Object.entries(COUNTRY_NAMES)) {
          if (text.includes(name) || text.includes(code.toUpperCase())) {
            // find a time token like "1h 20m" or "80m" or "1:20:00" or "75m"
            const match = text.match(/(\d+\s*h(?:ours?)?\s*)?(\d+\s*m)?(\s*\d+\s*s)?/i);
            let ms = null;
            // prefer hh:mm:ss
            const hm = text.match(/(\d+):(\d{2}):(\d{2})/);
            if (hm) ms = (Number(hm[1])*3600 + Number(hm[2])*60 + Number(hm[3]))*1000;
            else {
              const mh = text.match(/(\d+)\s*h/);
              const mm = text.match(/(\d+)\s*m/);
              const msn = text.match(/(\d+)\s*s/);
              const h = mh ? Number(mh[1]) : 0;
              const m = mm ? Number(mm[1]) : 0;
              const s = msn ? Number(msn[1]) : 0;
              if (h || m || s) ms = (h*3600 + m*60 + s) * 1000;
            }
            if (ms) out[code] = ms;
          }
        }
      } catch (e) { /* ignore */ }
    });
    if (Object.keys(out).length) return out;
    return null;
  }

  async function fetchYataTravelDurations() {
    try {
      const data = await gmGetJson(YATA_TRAVEL_URL);
      // YATA sometimes includes travel meta at root; try common shapes
      if (!data) return null;
      const out = {};
      if (data.travel && typeof data.travel === 'object') {
        for (const [k,v] of Object.entries(data.travel)) {
          const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
          if (!dur) continue;
          if (typeof dur === 'number') out[k.toLowerCase()] = (dur > 0 && dur < 20000) ? dur * 1000 : dur;
          else {
            const n = Number(dur);
            if (!Number.isNaN(n)) out[k.toLowerCase()] = (n > 0 && n < 20000) ? n * 1000 : n;
          }
        }
        if (Object.keys(out).length) return out;
      }
      // sometimes YATA returns top-level with codes
      for (const [k,v] of Object.entries(data)) {
        if (v && (v.duration || v.time || v.ms || v.seconds)) {
          const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
          const val = typeof dur === 'number' ? dur : Number(dur);
          if (!Number.isNaN(val)) out[k.toLowerCase()] = (val > 0 && val < 20000) ? val * 1000 : val;
        }
      }
      if (Object.keys(out).length) return out;
      return null;
    } catch (e) {
      console.warn('YATA travel durations fetch failed', e);
      return null;
    }
  }

  // fallback hardcoded (keeps reasonable defaults)
  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 (travelDurations && travelDurations[code]) return travelDurations[code];
    if (HARDCODE_FLIGHT_MS[lc]) return HARDCODE_FLIGHT_MS[lc];
    return FALLBACK_FLIGHT_MS;
  }

  // ===== Prediction logic =====
  // For a given yata item object and a computed arrival timestamp, returns status 'green'|'orange'|'red'
  function predictStatusForArrival(itemObj, arrivalTs) {
    const stock = Number(itemObj.quantity ?? itemObj.stock ?? 0) || 0;
    const depTs = parsePossibleTimestamp(itemObj.depletion);
    const restTs = parsePossibleTimestamp(itemObj.restock);

    // currently stocked and deplete unknown or after arrival -> green
    if (stock > 0 && (!depTs || depTs > arrivalTs)) return 'green';
    // currently stocked but depletes before arrival -> red
    if (stock > 0 && depTs && depTs <= arrivalTs) return 'red';
    // currently empty but will restock before arrival -> orange
    if (stock === 0 && restTs && restTs <= arrivalTs) return 'orange';
    // otherwise treat as red/unavailable
    return 'red';
  }

  // ===== build unified list & fly suggestion (now predictive) =====
  // This function returns itemsInfo list, lowest item, and yataStocks map (as original)
  function buildUnified(displayMap, yataData, travelDurationsFromSources, travelSelection) {
    // build map countryCode -> {itemName: { quantity, depletion, restock } }
    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 ?? null
          };
        }
      }
    }

    // Precompute remaining flight back to Torn (if any)
    const remainingMs = getRemainingFlightMs(travelSelection) || 0;
    const now = Date.now();

    // Construct itemsInfo for tracked list
    const itemsInfo = [];
    for (const name of TRACKED_LIST) {
      if (name === SPECIAL_DRUG) {
        // Only sou for Xanax
        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;
      }
      // include known home loc text (for UI)
      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 each item, evaluate candidate countries with predicted status at arrival
    for (const it of itemsInfo) {
      const candidates = [];
      for (const [code, obj] of Object.entries(it.perCountry || {})) {
        // compute nextFlight duration from the best available source
        const nextFlightMs = getFlightDurationMs(travelDurationsFromSources, code);
        const arrivalTs = now + remainingMs + TURNAROUND_MS + nextFlightMs;
        const predicted = predictStatusForArrival({ quantity: obj.quantity, depletion: obj.depletion, restock: obj.restock }, 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;
      }

      // rank: green > orange > red ; tie-breaker: stockNow desc ; then flightMs asc
      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;
    }

    // find lowest by display count (inv). Prefer items with inv==0. Tie-breaker: smaller bestStk then lexicographic.
    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 };
  }

  // ===== dot class from predictive status =====
  function dotClassFromPred(pred) {
    if (pred === 'green') return 'g';
    if (pred === 'orange') return 'o';
    if (pred === 'red') return 'r';
    return 'r';
  }

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

  // ===== render one-line layout (keeps original look, uses predictive dot) =====
  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 [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.bestStk || 0);
      const predicted = it.bestPredicted || null;
      const cls = dotClassFromPred(predicted);
      const locText = it.loc || (it.bestCode ? (COUNTRY_NAMES[it.bestCode] ? `${COUNTRY_NAMES[it.bestCode]} ${it.bestCode.toUpperCase()}` : it.bestCode.toUpperCase()) : '');
      const flagPart = it.loc ? ` ${it.loc}` : (it.bestCode ? ` ${it.bestCode.toUpperCase()}` : '');
      // fly suggestion only if this is the lowest item AND there's a best country with predicted availability of any kind
      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(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>`;
  }

  // ===== master refresh (now fetches travel durations from Torn API if possible, else fallback to DOM/YATA/hardcoded) =====
  let timer = null;
  // last-known caches (preserve on failure)
  let lastYataData = null;
  let lastTravelDurations = null;
  let lastDisplayMap = {};
  let lastTravelSelection = null;

  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      // fetch display + travel selection together (display includes user.display; travel selection may be present)
      const dispRes = await fetchDisplayCaseSafe();
      const displayMap = dispRes.display || {};
      const travelSelection = dispRes.travelSelection || null;

      // fetch YATA stocks (prefer fresh)
      let yataData = null;
      try {
        yataData = await fetchYataSafe();
        if (yataData) lastYataData = yataData;
      } catch (e) {
        yataData = lastYataData;
      }

      // try to get travel durations from Torn API travelSelection, then DOM, then YATA, then fallback map
      let travelDurations = null;
      try {
        travelDurations = parseTravelDurationsFromSelection(travelSelection) || parseTravelDurationsFromDOM() || await fetchYataTravelDurations() || lastTravelDurations || HARDCODE_FLIGHT_MS;
        if (travelDurations && Object.keys(travelDurations).length) lastTravelDurations = travelDurations;
      } catch (e) {
        travelDurations = lastTravelDurations || HARDCODE_FLIGHT_MS;
      }

      // render everything
      renderAll(displayMap, yataData || lastYataData, travelDurations, travelSelection);
      // update status
      const t = new Date();
      const yOk = yataData && yataData.stocks ? 'OK' : (lastYataData && lastYataData.stocks ? 'STALE' : 'ERR');
      const trOk = travelDurations && Object.keys(travelDurations).length ? 'OK' : 'ERR';
      statusEl.textContent = `Updated: ${t.toLocaleTimeString()} · YATA stocks: ${yOk} · Travel: ${trOk}`;
      // save last maps for fallback
      lastDisplayMap = displayMap;
      lastTravelSelection = travelSelection;
    } catch (e) {
      console.warn('refreshAll error', e);
      statusEl.textContent = 'Update error (see console)';
      // show last known data if any
      renderAll(lastDisplayMap || {}, lastYataData || {stocks:{}}, lastTravelDurations || HARDCODE_FLIGHT_MS, lastTravelSelection || null);
    }
  }

  // ===== fetchYataTravelDurations helper (used in refreshAll) =====
  async function fetchYataTravelDurations() {
    try {
      const data = await gmGetJson(YATA_TRAVEL_URL);
      if (!data) return null;
      // Try to extract durations out of data similar to fetchYataTravelDurations earlier
      const out = {};
      // YATA structure may contain codes at top-level or under 'travel'
      const tryAdd = (k,v) => {
        const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
        if (dur === undefined || dur === null) return;
        const val = typeof dur === 'number' ? dur : Number(dur);
        if (!Number.isNaN(val)) 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);
      }
      for (const [k,v] of Object.entries(data)) {
        if (k === 'travel') continue;
        if (v && typeof v === 'object') tryAdd(k,v);
      }
      if (Object.keys(out).length) return out;
      return null;
    } catch (e) {
      console.warn('fetchYataTravelDurations error', e);
      return null;
    }
  }

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

  async function fetchDisplayCaseSafe() {
    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;
      }
      // travel selection may be in different spots depending on Torn API format
      const travelSelection = data.travel ?? data.selections?.travel ?? data;
      return { display: out, travelSelection };
    } catch (e) {
      console.warn('display fetch error', e);
      return { display: {}, travelSelection: null };
    }
  }

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

})();