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

Tính đến 11-10-2025. Xem phiên bản mới nhất.

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

})();