Overseas sets companion — Plushies · Flowers · Prehistoric · Special · Xanax · Upgrade Planner — Torn
Verze ze dne
// ==UserScript==
// @name ✈️ Sets Tracker
// @namespace https://osdevscape.com
// @version 9.2.4
// @author Phillip_J_Fry [2184575] (OSMays8338) — OSDevscape
// @license All Rights Reserved © 2026 OSDevscape
// @homepageURL https://greatest.deepsurf.us/users/OSMays8338
// @description Overseas sets companion — Plushies · Flowers · Prehistoric · Special · Xanax · Upgrade Planner — Torn
// @match https://www.torn.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect yata.yt
// @connect api.torn.com
// @run-at document-end
// ==/UserScript==
// ╔══════════════════════════════════════════════════════════════╗
// ║ ✈️ SETS TRACKER v9.2.4 ║
// ║ ║
// ║ Author : Phillip_J_Fry [2184575] (Torn) · OSMays8338 ║
// ║ Company : OSDevscape ║
// ║ License : All Rights Reserved © 2026 OSDevscape ║
// ║ ║
// ║ Based on "Points Museum" by SuperNovae [2637223] ║
// ║ Rebuilt under OSDevscape suite architecture v7 ║
// ╚══════════════════════════════════════════════════════════════╝
(function () {
'use strict';
/* ─────────────────────────────────────────
STORAGE — dual layer: localStorage + GM
Mirrors Target Tracker / Race Tracker
───────────────────────────────────────── */
const store = {
get(k, d) {
try { const v = localStorage.getItem('lt_' + k); if (v !== null) return v; } catch(e) {}
try { return GM_getValue(k, d); } catch(e) {}
return d;
},
set(k, v) {
try { localStorage.setItem('lt_' + k, v); } catch(e) {}
try { GM_setValue(k, v); } catch(e) {}
},
getJSON(k, d) {
try { const v = localStorage.getItem('lt_' + k); if (v !== null) return JSON.parse(v); } catch(e) {}
try { return JSON.parse(GM_getValue(k, JSON.stringify(d))); } catch(e) {}
return d;
},
setJSON(k, v) {
const s = JSON.stringify(v);
try { localStorage.setItem('lt_' + k, s); } catch(e) {}
try { GM_setValue(k, s); } catch(e) {}
}
};
/* ─────────────────────────────────────────
CONFIG
───────────────────────────────────────── */
const cfg = {
get apiKey() { return store.get('lt_api', ''); },
set apiKey(v) { store.set('lt_api', v); },
get userId() { return store.get('lt_uid', ''); },
set userId(v) { store.set('lt_uid', v); },
getSectionVis() { return store.getJSON('lt_sections', { prehistoric: true, plushies: true, flowers: true, special: true, xanax: true }); },
setSectionVis(v){ store.setJSON('lt_sections', v); },
getXanCount() { return store.getJSON('lt_xan_count', 0); },
setXanCount(v) { store.setJSON('lt_xan_count', v); },
getXanCarry() { return store.getJSON('lt_xan_carry', 0); },
setXanCarry(v) { store.setJSON('lt_xan_carry', v); },
getXanPriority() { return store.getJSON('lt_xan_priority', false); },
setXanPriority(v) { store.setJSON('lt_xan_priority', v); },
getXanThreshold() { return store.getJSON('lt_xan_threshold', 50); },
setXanThreshold(v) { store.setJSON('lt_xan_threshold', v); },
getXanRuns() { return store.getJSON('lt_xan_runs', []); },
setXanRuns(v) { store.setJSON('lt_xan_runs', v); },
getXanCountry() { return store.getJSON('lt_xan_country', 'South Africa'); },
setXanCountry(v) { store.setJSON('lt_xan_country', v); },
getForceTheme() { return store.getJSON('lt_force_theme', 'auto'); }, // 'auto' | 'light' | 'dark'
setForceTheme(v) { store.setJSON('lt_force_theme', v); },
getMuseumPin() { return store.getJSON('lt_museum_pin', false); },
setMuseumPin(v) { store.setJSON('lt_museum_pin', v); },
getTravelSpeed() { return store.getJSON('lt_travel_speed', 0); }, // 0=Standard 1=Airstrip 2=PrivateJet 3=WindLines
setTravelSpeed(v) { store.setJSON('lt_travel_speed', v); },
getProfitCarry() { return store.getJSON('lt_profit_carry', 1); },
setProfitCarry(v) { store.setJSON('lt_profit_carry', v); },
getProfitItemTypes(){ return store.getJSON('lt_profit_types', { plushies: true, flowers: true, prehistoric: true, special: true, drugs: false }); },
setProfitItemTypes(v){ store.setJSON('lt_profit_types', v); },
getProfitCountries(){ return store.getJSON('lt_profit_countries', { short: true, medium: true, long: true }); },
setProfitCountries(v){ store.setJSON('lt_profit_countries', v); },
getVaultDest() { return store.getJSON('lt_vault_dest', 'faction'); }, // 'faction' | 'company' | 'property'
setVaultDest(v) { store.setJSON('lt_vault_dest', v); },
getPriceHistory(name) { return store.getJSON('lt_ph_' + name.replace(/[^a-z0-9]/gi,'_'), []); },
addPriceHistory(name, price) {
const h = store.getJSON('lt_ph_' + name.replace(/[^a-z0-9]/gi,'_'), []);
h.push({ ts: Date.now(), price: Number(price) });
if (h.length > 12) h.splice(0, h.length - 12);
store.setJSON('lt_ph_' + name.replace(/[^a-z0-9]/gi,'_'), h);
},
getStockHistory(name) { return store.getJSON('lt_sh_' + name.replace(/[^a-z0-9]/gi,'_'), []); },
addStockHistory(name, qty) {
const h = store.getJSON('lt_sh_' + name.replace(/[^a-z0-9]/gi,'_'), []);
h.push({ ts: Date.now(), qty: Number(qty) });
if (h.length > 12) h.splice(0, h.length - 12);
store.setJSON('lt_sh_' + name.replace(/[^a-z0-9]/gi,'_'), h);
},
};
/* ─────────────────────────────────────────
TORN THEME DETECTION
─────────────────────────────────────────
Torn signals light mode via one or more of:
• document.body classList → check TORN_LIGHT_CLASSES
• document.documentElement → same list
• data-theme / data-color-scheme attribute
⚠️ CONFIRM YOUR CLASS:
Open DevTools → Elements → inspect <html> or <body>
Switch Torn between light/dark and watch what class
appears/disappears. Then add it to TORN_LIGHT_CLASSES.
───────────────────────────────────────── */
// Torn/TornPDA confirmed: body has class 'dark-mode' in dark theme.
// Light mode = body does NOT have 'dark-mode' (or any other dark indicator).
const TORN_DARK_CLASSES = ['dark-mode', 'dark', 'night-mode', 'theme-dark'];
function isLightMode() {
// Check for user-forced theme first
try { const forced = store.getJSON('lt_force_theme', 'auto'); if (forced === 'light') return true; if (forced === 'dark') return false; } catch(e) {}
if (!document.body) return false;
// Primary: known dark class on body or html → dark mode
for (const cls of TORN_DARK_CLASSES) {
if (document.body.classList.contains(cls)) return false;
if (document.documentElement.classList.contains(cls)) return false;
}
// data-theme dark check
const dt = (document.documentElement.getAttribute('data-theme') || document.body.getAttribute('data-theme') || '').toLowerCase();
if (dt === 'dark' || dt === 'night') return false;
if (dt === 'light' || dt === 'day') return true;
// Legacy light class check
const LIGHT = ['light-mode','day-mode','theme-light','t-theme-day','light','daymode','l'];
for (const cls of LIGHT) {
if (document.body.classList.contains(cls)) return true;
if (document.documentElement.classList.contains(cls)) return true;
}
return false; // default dark — brightness fallback removed (caused false positives on Travel Agency page)
}
/* ─────────────────────────────────────────
COLOUR PALETTES (dark = default)
───────────────────────────────────────── */
const DARK_PALETTE = {
bg: '#001214',
bg2: '#001e24',
border: 'rgba(0,180,210,0.35)',
gold: '#00c8e0',
goldDim: 'rgba(0,200,224,0.65)',
goldGlow: 'rgba(0,200,224,0.10)',
green: '#8BC34A',
greenDim: 'rgba(139,195,74,0.7)',
text: '#d0f0f8',
textDim: 'rgba(140,220,235,0.55)',
abroad: '#7fe0f0',
stockHi: '#00ff00',
stockMid: '#ffa500',
stockLo: '#ff0000',
okay: '#66dd66',
card: 'rgba(0,16,26,0.65)', // card background
cardBorder:'rgba(0,200,224,0.28)', // card border
mono: '"Share Tech Mono",Consolas,monospace',
sans: 'Rajdhani,"Segoe UI",Arial,sans-serif',
// settings popup extras
settBg: '#000e12',
settBorder:'rgba(0,180,210,0.55)',
settNote: 'rgba(0,80,100,0.3)',
settHdr: 'rgba(0,80,110,0.3)',
};
const LIGHT_PALETTE = {
bg: '#b8dff0', // noticeably deeper blue-tint base
bg2: '#9dd0e6', // deeper secondary surface
border: 'rgba(0,70,100,0.55)',
gold: '#002a36', // very deep teal heading colour
goldDim: 'rgba(0,55,75,0.90)',
goldGlow: 'rgba(0,75,105,0.18)',
green: '#163800',
greenDim: 'rgba(20,55,4,0.90)',
text: '#000a10',
textDim: 'rgba(0,32,46,0.78)',
abroad: '#002d40',
stockHi: '#0d3600',
stockMid: '#442500',
stockLo: '#680000',
okay: '#0d3600',
card: 'rgba(0,40,60,0.18)', // card background
cardBorder:'rgba(0,65,95,0.40)', // card border
mono: '"Share Tech Mono",Consolas,monospace',
sans: 'Rajdhani,"Segoe UI",Arial,sans-serif',
// settings popup extras
settBg: '#c2e8f8',
settBorder:'rgba(0,90,120,0.65)',
settNote: 'rgba(0,110,145,0.22)',
settHdr: 'rgba(0,70,100,0.38)',
};
// C is a live proxy — always reflects current Torn theme
let C = isLightMode() ? { ...LIGHT_PALETTE } : { ...DARK_PALETTE };
function syncTheme(forceRebuild) {
const light = isLightMode();
const newPal = light ? LIGHT_PALETTE : DARK_PALETTE;
const changed = Object.keys(newPal).some(k => C[k] !== newPal[k]);
Object.assign(C, newPal);
if ((changed || forceRebuild) && panelEl && toggleEl) {
const panelBg = light
? 'linear-gradient(158deg,rgba(185,225,245,0.99),rgba(165,215,238,0.98))'
: 'linear-gradient(158deg,rgba(0,14,18,0.98),rgba(0,8,12,0.97))';
panelEl.style.setProperty('background', panelBg, 'important');
panelEl.style.setProperty('border-color', C.border, 'important');
panelEl.style.setProperty('color', C.text, 'important');
const toggleBg = light
? 'radial-gradient(circle at 38% 34%,#d0f8ff,#a8eef8)'
: 'radial-gradient(circle at 38% 34%,#001a20,#000c10)';
toggleEl.style.setProperty('background', toggleBg, 'important');
toggleEl.style.setProperty('border-color', C.border, 'important');
if (panelOpen) renderPanel();
}
}
// Watch for URL/page changes to trigger items page scrape
(function watchNavigation() {
let lastHref = window.location.href;
const navObs = new MutationObserver(() => {
const href = window.location.href;
if (href !== lastHref) {
lastHref = href;
watchItemsPage();
// Scrape travel status immediately on page change
setTimeout(scrapeTravelPage, 800);
setTimeout(scrapeTravelPage, 2000);
}
});
navObs.observe(document.body, { childList: true, subtree: true });
// Also check on load in case we're already on items page
watchItemsPage();
// Check if we're already on the travel page
setTimeout(scrapeTravelPage, 1000);
})();
// Watch <html> and <body> for class/attribute changes (user switches theme mid-session)
(function watchTheme() {
let _lastLight = null;
const observer = new MutationObserver(() => {
const nowLight = isLightMode();
if (_lastLight === nowLight) return; // no actual change
_lastLight = nowLight;
syncTheme(true); // syncTheme now handles panel/toggle rebuild internally
});
const opts = { attributes: true, attributeFilter: ['class','data-theme','data-color-scheme'] };
if (document.documentElement) observer.observe(document.documentElement, opts);
if (document.body) observer.observe(document.body, opts);
})();
/* ─────────────────────────────────────────
POINTS & THRESHOLDS
───────────────────────────────────────── */
const PRE_PTS = 25, FLO_PTS = 10, PLU_PTS = 10, MET_PTS = 15, FOS_PTS = 20;
const PLU_THRESH = 2000, FLO_THRESH = 5000;
const XANAX_ID = 206;
const POINTS_ENDPOINT = 'https://api.torn.com/v2/market/pointsmarket';
const POINTS_CACHE_DUR = 300000;
const POINTS_HIST_SIZE = 5;
let pointsPriceCache = { time: 0, price: 0, history: [] };
/* ─────────────────────────────────────────
TRAVEL TIMES (hours, one-way) per speed tier
0=Standard 1=Airstrip 2=Private Jet 3=Wind Lines
───────────────────────────────────────── */
const TRAVEL_TIMES = {
// country → [Standard, Airstrip, PrivateJet, WindLines] hours one-way
// Airstrip times confirmed from Torn Travel Agency page (player's actual times)
// Other tiers calculated from Torn's speed ratios: 1 : 0.75 : 0.5 : 0.33
'Mexico': [0.40, 0.30, 0.20, 0.13 ],
'Cayman Islands': [0.56, 0.42, 0.28, 0.18 ],
'Canada': [0.64, 0.48, 0.32, 0.21 ],
'Hawaii': [2.09, 1.57, 1.04, 0.69 ],
'UK': [2.47, 1.85, 1.23, 0.81 ],
'United Kingdom': [2.47, 1.85, 1.23, 0.81 ],
'Argentina': [2.60, 1.95, 1.30, 0.86 ],
'Switzerland': [2.73, 2.05, 1.37, 0.90 ],
'Japan': [3.51, 2.63, 1.76, 1.16 ],
'China': [3.76, 2.82, 1.88, 1.24 ],
'UAE': [4.22, 3.17, 2.11, 1.39 ],
'South Africa': [4.62, 3.47, 2.31, 1.53 ],
};
/* ─────────────────────────────────────────
LOCATION MAP
───────────────────────────────────────── */
const LOCATIONS = {
'Mexico': { flag: '🇲🇽', label: 'Mexico' },
'Hawaii': { flag: '🏝️', label: 'Hawaii' },
'South Africa': { flag: '🇿🇦', label: 'South Africa' },
'Japan': { flag: '🇯🇵', label: 'Japan' },
'China': { flag: '🇨🇳', label: 'China' },
'Argentina': { flag: '🇦🇷', label: 'Argentina' },
'Switzerland': { flag: '🇨🇭', label: 'Switzerland' },
'Canada': { flag: '🇨🇦', label: 'Canada' },
'UK': { flag: '🇬🇧', label: 'United Kingdom' },
'United Kingdom': { flag: '🇬🇧', label: 'United Kingdom' }, // alias
'UAE': { flag: '🇦🇪', label: 'UAE' },
'Cayman Islands': { flag: '🇰🇾', label: 'Cayman Islands' },
'BoB': { flag: '🏪', label: "Bits n' Bobs" },
};
/* ─────────────────────────────────────────
ITEM GROUPS
───────────────────────────────────────── */
const GROUPS = {
Prehistoric: {
pts: PRE_PTS, icon: '🪨',
items: {
'Quartz Point': { id: 619, s: 'Quartz', loc: 'Canada' },
'Chalcedony Point': { id: 620, s: 'Chalced', loc: 'Argentina' },
'Basalt Point': { id: 621, s: 'Basalt', loc: 'Hawaii' },
'Quartzite Point': { id: 622, s: 'Quartzit', loc: 'South Africa' },
'Chert Point': { id: 623, s: 'Chert', loc: 'UK' },
'Obsidian Point': { id: 624, s: 'Obsidian', loc: 'Mexico' },
}
},
Plushies: {
pts: PLU_PTS, icon: '🧸',
items: {
'Sheep Plushie': { id: 186, s: 'Sheep', loc: 'BoB' },
'Teddy Bear Plushie': { id: 187, s: 'Teddy', loc: 'BoB' },
'Kitten Plushie': { id: 215, s: 'Kitten', loc: 'BoB' },
'Jaguar Plushie': { id: 258, s: 'Jaguar', loc: 'Mexico' },
'Wolverine Plushie': { id: 261, s: 'Wolverine', loc: 'Canada' },
'Nessie Plushie': { id: 266, s: 'Nessie', loc: 'UK' },
'Red Fox Plushie': { id: 268, s: 'Fox', loc: 'UK' },
'Monkey Plushie': { id: 269, s: 'Monkey', loc: 'Argentina' },
'Chamois Plushie': { id: 273, s: 'Chamois', loc: 'Switzerland' },
'Panda Plushie': { id: 274, s: 'Panda', loc: 'China' },
'Lion Plushie': { id: 281, s: 'Lion', loc: 'South Africa' },
'Camel Plushie': { id: 384, s: 'Camel', loc: 'UAE' },
'Stingray Plushie': { id: 618, s: 'Stingray', loc: 'Cayman Islands'},
}
},
Flowers: {
pts: FLO_PTS, icon: '🌸',
items: {
'Dahlia': { id: 260, s: 'Dahlia', loc: 'Mexico' },
'Orchid': { id: 264, s: 'Orchid', loc: 'Hawaii' },
'African Violet': { id: 282, s: 'Violet', loc: 'South Africa' },
'Cherry Blossom': { id: 277, s: 'Blossoms', loc: 'Japan' },
'Peony': { id: 276, s: 'Peony', loc: 'China' },
'Ceibo Flower': { id: 271, s: 'Ceibo', loc: 'Argentina' },
'Edelweiss': { id: 272, s: 'Edelweiss', loc: 'Switzerland' },
'Crocus': { id: 263, s: 'Crocus', loc: 'Canada' },
'Heather': { id: 267, s: 'Heather', loc: 'UK' },
'Tribulus Omanense': { id: 385, s: 'Tribulus', loc: 'UAE' },
'Banana Orchid': { id: 617, s: 'B.Orchid', loc: 'Cayman Islands'},
}
},
};
const SPECIAL_ITEMS = {
'Meteorite Fragment': { id: 512, s: 'Meteor', loc: 'Argentina', pts: MET_PTS },
'Patagonian Fossil': { id: 513, s: 'Fossil', loc: 'Argentina', pts: FOS_PTS },
};
const BOB_IDS = new Set([186, 187, 215]);
const itemImg = id => `https://www.torn.com/images/items/${id}/large.png`;
/* ─────────────────────────────────────────
BUILD ID→NAME LOOKUP for YATA parsing
───────────────────────────────────────── */
function buildIdMap() {
const m = {};
Object.values(GROUPS).forEach(g => Object.entries(g.items).forEach(([name, d]) => { m[d.id] = name; }));
Object.entries(SPECIAL_ITEMS).forEach(([name, d]) => { m[d.id] = name; });
m[XANAX_ID] = 'Xanax';
return m;
}
const ID_TO_NAME = buildIdMap();
/* ─────────────────────────────────────────
YATA COUNTRY CODE → CITY NAME
YATA uses 3-letter country codes; we map to city names
matching Torn's in-game city names exactly
───────────────────────────────────────── */
const YATA_CODE_TO_CITY = {
'mex': 'Mexico City',
'haw': 'Honolulu',
'sou': 'Johannesburg',
'jap': 'Tokyo',
'chi': 'Shanghai',
'arg': 'Buenos Aires',
'swi': 'Zurich',
'can': 'Toronto',
'uni': 'London',
'uae': 'Dubai',
'cay': 'Grand Cayman',
};
// Torn travel API destination name → YATA code (for matching)
const TORN_DEST_TO_CODE = {
'Mexico': 'mex',
'Hawaii': 'haw',
'South Africa': 'sou',
'Japan': 'jap',
'China': 'chi',
'Argentina': 'arg',
'Switzerland': 'swi',
'Canada': 'can',
'United Kingdom': 'uni',
'UAE': 'uae',
'Cayman Islands': 'cay',
};
// City name (as shown on Torn travel page) → YATA code
// Torn displays the actual city name in the "X to Torn" flight string
const CITY_TO_CODE = {
'Mexico City': 'mex',
'Honolulu': 'haw',
'Johannesburg': 'sou',
'Cape Town': 'sou', // fallback alias
'Tokyo': 'jap',
'Shanghai': 'chi',
'Buenos Aires': 'arg',
'Zurich': 'swi',
'Toronto': 'can',
'London': 'uni',
'Dubai': 'uae',
'Grand Cayman': 'cay',
};
/* ─────────────────────────────────────────
STATE
───────────────────────────────────────── */
let toggleEl = null;
let panelEl = null;
let panelOpen = false;
let activeTab = 'sets'; // sets | xanax | travel
let isLoading = false;
let pollTimer = null;
// cached data
let invCache = {}; // display items { name: qty }
let abroadCache = {}; // BoB shop stock { name: qty } via torn/?selections=shopsandstocks
let bobCache = {}; // same source — BoB specific quantities for display
let xanSACache = { qty: 0, price: 0 };
let xanPersonal = 0; // populated from items page scrape, persisted via cfg.setXanCount
let xanFacCache = null;
let pointsPrice = 0;
let yataPriceCache = {}; // { itemName: price } — raw YATA overseas prices for profit calc
let countdownTimer = null; // setInterval handle for live flight countdown
let marketValueCache = {}; // { itemName: marketValue } — Torn market value for sell price
let yataCityCache = {}; // { cityCode: { city, country, stocks: [{id,name,qty,cost}] } } — per-city raw YATA data
let travelStatus = null; // null | { traveling: bool, destination: string, time_left: number }
/* ─────────────────────────────────────────
STYLESHEET — only keyframes & scrollbar
All layout uses inline styles (CSP-safe)
───────────────────────────────────────── */
function injectCSS() {
if (document.getElementById('lt-css')) return;
const s = document.createElement('style');
s.id = 'lt-css';
s.textContent = `
#lt-toggle { position:fixed; z-index:999990; user-select:none; touch-action:none; cursor:grab; }
#lt-toggle:active { cursor:grabbing; }
#lt-panel { position:fixed; z-index:999989; overflow:hidden; display:flex; flex-direction:column; }
#lt-panel .lt-body { overflow-y:auto; overflow-x:hidden; flex:1; scrollbar-width:thin; scrollbar-color:rgba(0,180,210,0.4) transparent; touch-action:pan-y; -webkit-overflow-scrolling:touch; }
#lt-panel .lt-body::-webkit-scrollbar { width:3px; }
#lt-panel .lt-body::-webkit-scrollbar-thumb { background:rgba(0,180,210,0.5); border-radius:2px; }
#lt-panel a { text-decoration:none !important; color:inherit; }
@keyframes lt-spin { to{transform:rotate(360deg)} }
@keyframes lt-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(0,200,224,0.5)} 50%{box-shadow:0 0 0 5px rgba(0,200,224,0)} }
@keyframes lt-hi-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(0,255,0,0.4)} 50%{box-shadow:0 0 0 3px rgba(0,255,0,0)} }
@keyframes lt-wa-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,165,0,0.4)} 50%{box-shadow:0 0 0 3px rgba(255,165,0,0)} }
@keyframes lt-da-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,0,0,0.4)} 50%{box-shadow:0 0 0 3px rgba(255,0,0,0)} }
@keyframes lt-float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-2px)} }
.lt-spin { animation:lt-spin 0.8s linear infinite; display:inline-block; }
.lt-float { animation:lt-float 3s ease-in-out infinite; }
.lt-hi { animation:lt-hi-pulse 2s ease-in-out infinite; }
.lt-wa { animation:lt-wa-pulse 2s ease-in-out infinite; }
.lt-da { animation:lt-da-pulse 2s ease-in-out infinite; }
`;
(document.head || document.documentElement).appendChild(s);
}
/* ─────────────────────────────────────────
TOAST
───────────────────────────────────────── */
function toast(msg, dur) {
dur = dur || 3000;
const old = document.getElementById('lt-toast'); if (old) old.remove();
const el = document.createElement('div');
el.id = 'lt-toast'; el.textContent = msg;
el.setAttribute('style',
'position:fixed !important;top:18px !important;left:50% !important;' +
'transform:translateX(-50%) !important;background:#00121a !important;' +
'border:1px solid rgba(0,200,224,0.7) !important;border-radius:8px !important;' +
'padding:10px 20px !important;color:#00c8e0 !important;font-size:13px !important;' +
'font-weight:700 !important;font-family:Arial,sans-serif !important;' +
'z-index:2147483647 !important;max-width:360px !important;text-align:center !important;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.8) !important;pointer-events:none !important;'
);
document.body.appendChild(el);
setTimeout(() => {
el.style.setProperty('opacity','0','important');
el.style.setProperty('transition','opacity 0.3s','important');
setTimeout(() => el.remove(), 320);
}, dur);
}
/* ─────────────────────────────────────────
SETTINGS POPUP — fully inline, CSP-safe
───────────────────────────────────────── */
function openSettings() {
const existing = document.getElementById('lt-settings-wrap');
if (existing) { existing.remove(); return; }
function el(tag, css) { const e = document.createElement(tag); if (css) e.setAttribute('style', css); return e; }
function imp(css) { return css.split(';').filter(Boolean).map(r => r.trim() + ' !important').join(';') + ';'; }
const S = {
wrap: 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;box-sizing:border-box;overflow-y:auto;',
box: `background:${C.settBg};border:2px solid ${C.settBorder};border-radius:12px;width:370px;max-width:94vw;color:${C.text};font-family:Arial,sans-serif;overflow:hidden;box-shadow:0 16px 60px rgba(0,0,0,0.85);margin-bottom:20px;`,
hdr: `background:${C.settHdr};padding:13px 18px;font-size:12px;font-weight:700;letter-spacing:1.4px;color:${C.gold};border-bottom:1px solid ${C.border};`,
body: 'padding:16px;',
note: `background:${C.settNote};border-left:3px solid ${C.border};padding:9px 12px;border-radius:4px;font-size:11px;color:${C.goldDim};margin-bottom:16px;line-height:1.5;`,
secHdr: `font-size:9px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:${C.goldDim};padding-bottom:5px;margin:16px 0 8px;border-bottom:1px solid ${C.border};font-family:Consolas,monospace;`,
lbl: `display:block;margin-bottom:5px;font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.green};`,
inp: `display:block;width:100%;box-sizing:border-box;padding:9px 11px;background:${C.bg};border:1px solid ${C.border};border-radius:5px;color:${C.text};font-size:13px;font-family:Consolas,monospace;outline:none;`,
hint: `font-size:9px;color:${C.textDim};margin-top:4px;font-family:Consolas,monospace;`,
btnRow: 'display:flex;gap:8px;margin-top:18px;',
btn: `flex:1;padding:10px 0;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid ${C.border};font-family:Arial,sans-serif;letter-spacing:0.5px;`,
};
const wrap = el('div'); wrap.id = 'lt-settings-wrap'; wrap.setAttribute('style', imp(S.wrap));
const box = el('div'); box.setAttribute('style', imp(S.box));
const hdr = el('div'); hdr.textContent = '⚙ LOOT TRACKER — Settings'; hdr.setAttribute('style', imp(S.hdr));
const body = el('div'); body.setAttribute('style', imp(S.body));
const note = el('div');
note.innerHTML = '🔒 Requires a <b>Limited Access</b> API key (Display permission only). Your API key and all item data stay on your device — nothing is transmitted externally.';
note.setAttribute('style', imp(S.note));
function field(labelText, id, placeholder, value, hint, isSecret) {
const g = el('div'); g.setAttribute('style', imp('margin-bottom:12px;'));
const lbl = el('label'); lbl.textContent = labelText; lbl.setAttribute('style', imp(S.lbl));
const inp = document.createElement('input');
inp.setAttribute('type', isSecret ? 'password' : 'text');
inp.id = id; inp.placeholder = placeholder; inp.value = value || '';
inp.setAttribute('style', imp(S.inp));
inp.addEventListener('focus', () => inp.style.setProperty('border-color','rgba(0,190,215,0.8)','important'));
inp.addEventListener('blur', () => inp.style.setProperty('border-color','rgba(0,140,170,0.4)','important'));
if (isSecret) {
const row = el('div'); row.setAttribute('style', imp('display:flex;align-items:center;gap:6px;'));
const tog = el('div'); tog.textContent = '👁'; tog.setAttribute('style', imp('cursor:pointer;font-size:14px;flex-shrink:0;opacity:0.45;user-select:none;padding:2px;'));
tog.addEventListener('click', () => {
const shown = inp.getAttribute('type') === 'text';
inp.setAttribute('type', shown ? 'password' : 'text');
tog.style.setProperty('opacity', shown ? '0.45' : '1', 'important');
});
row.appendChild(inp); row.appendChild(tog);
const h = el('div'); h.textContent = hint; h.setAttribute('style', imp(S.hint));
g.appendChild(lbl); g.appendChild(row); g.appendChild(h);
} else {
const h = el('div'); h.textContent = hint; h.setAttribute('style', imp(S.hint));
g.appendChild(lbl); g.appendChild(inp); g.appendChild(h);
}
return g;
}
// ── Theme Toggle ──
const themeHdr = el('div'); themeHdr.textContent = '🌙 Appearance'; themeHdr.setAttribute('style', imp(S.secHdr));
const themeRow = el('div'); themeRow.setAttribute('style', imp('display:flex;align-items:center;gap:0;margin-bottom:4px;background:' + C.bg + ';border:1px solid ' + C.border + ';border-radius:8px;overflow:hidden;'));
const themeOpts = [
{ val: 'auto', label: '⚙ Auto' },
{ val: 'light', label: '☀️ Light' },
{ val: 'dark', label: '🌙 Dark' },
];
const curTheme = cfg.getForceTheme();
themeOpts.forEach(({ val, label }) => {
const isActive = curTheme === val;
const btn = el('button'); btn.type = 'button'; btn.textContent = label;
btn.setAttribute('style', imp('flex:1;padding:8px 4px;border:none;border-right:1px solid ' + C.border + ';font-size:11px;font-weight:' + (isActive ? '700' : '400') + ';cursor:pointer;font-family:Arial,sans-serif;background:' + (isActive ? C.settNote : C.card) + ';color:' + (isActive ? C.gold : C.text) + ';transition:all 0.15s;'));
btn.addEventListener('click', () => {
cfg.setForceTheme(val);
syncTheme(true); // force full palette + panel rebuild
wrap.remove();
openSettings();
});
themeRow.appendChild(btn);
});
// remove border-right from last btn
themeRow.lastChild.style.setProperty('border-right', 'none', 'important');
const themeHint = el('div'); themeHint.textContent = "Auto follows Torn's theme. Override here if detection is off.";
themeHint.setAttribute('style', imp('font-size:9px;color:' + C.textDim + ';margin-top:4px;margin-bottom:4px;font-family:Consolas,monospace;'));
// ── Credentials ──
const credHdr = el('div'); credHdr.textContent = '🔑 Credentials'; credHdr.setAttribute('style', imp(S.secHdr));
const fAPI = field('API Key (Limited — Display only)', 'lt-si-api', 'Your 16-char Torn API key', cfg.apiKey, 'Torn → Settings → API → Limited (Display access)', true);
const fUID = field('Your User ID', 'lt-si-uid', 'Your Torn player ID', cfg.userId, 'Used to fetch your display items from the API');
const apiLink = el('div');
apiLink.setAttribute('style', imp('margin-top:-8px;margin-bottom:12px;font-size:10px;color:rgba(0,170,200,0.75);'));
apiLink.innerHTML = '🔑 No key? <a href="https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=LootTracker&access_level=1" style="color:${C.gold};font-weight:700;text-decoration:underline !important;">Create a Limited (Display) key on Torn</a>';
// ── Section Visibility ──
const secHdr = el('div'); secHdr.textContent = '👁 Section Visibility'; secHdr.setAttribute('style', imp(S.secHdr));
const vis = cfg.getSectionVis();
const sections = [
{ key: 'prehistoric', label: '🪨 Prehistoric Points' },
{ key: 'plushies', label: '🧸 Plushies' },
{ key: 'flowers', label: '🌸 Flowers' },
{ key: 'special', label: '☄️ Special Items' },
{ key: 'xanax', label: '🧪 Xanax' },
];
const visGrid = el('div'); visGrid.setAttribute('style', imp('display:flex;flex-direction:column;gap:4px;'));
sections.forEach(sec => {
const row = el('div'); row.setAttribute('style', imp(`display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;background:${C.card};border:1px solid ${C.border};cursor:pointer;`));
const chk = document.createElement('input'); chk.type = 'checkbox'; chk.id = 'lt-vis-' + sec.key; chk.checked = vis[sec.key] !== false;
chk.setAttribute('style', imp('width:14px;height:14px;cursor:pointer;accent-color:#00c8e0;flex-shrink:0;'));
const lbl = el('label'); lbl.textContent = sec.label; lbl.setAttribute('for', 'lt-vis-' + sec.key);
lbl.setAttribute('style', imp(`font-size:10px;font-weight:600;color:${C.text};cursor:pointer;flex:1;`));
row.appendChild(chk); row.appendChild(lbl);
row.addEventListener('click', e => { if (e.target !== chk) chk.checked = !chk.checked; });
visGrid.appendChild(row);
});
// ── Preferences ──
const prefHdr = el('div'); prefHdr.textContent = '💡 Preferences'; prefHdr.setAttribute('style', imp(S.secHdr));
const tooltipRow = el('div');
tooltipRow.setAttribute('style', imp(`display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;background:${C.card};border:1px solid ${C.border};cursor:pointer;`));
const tooltipChk = document.createElement('input'); tooltipChk.type = 'checkbox'; tooltipChk.id = 'lt-pref-tooltip';
let tooltipAlreadySeen = false;
try { tooltipAlreadySeen = !!localStorage.getItem('lt_tooltip_seen'); } catch(e) {}
tooltipChk.checked = !tooltipAlreadySeen;
tooltipChk.setAttribute('style', imp('width:14px;height:14px;cursor:pointer;accent-color:#00c8e0;flex-shrink:0;'));
const tooltipLbl = el('label'); tooltipLbl.textContent = '✈️ Show welcome tooltip on next load'; tooltipLbl.setAttribute('for', 'lt-pref-tooltip');
tooltipLbl.setAttribute('style', imp(`font-size:10px;font-weight:600;color:${C.text};cursor:pointer;flex:1;`));
tooltipRow.appendChild(tooltipChk); tooltipRow.appendChild(tooltipLbl);
tooltipRow.addEventListener('click', e => { if (e.target !== tooltipChk) tooltipChk.checked = !tooltipChk.checked; });
// ── 🧪 Xanax ──
const xanSettHdr = el('div'); xanSettHdr.textContent = '🧪 Xanax'; xanSettHdr.setAttribute('style', imp(S.secHdr));
// Adjust Count row
const xanCarryGroup = el('div'); xanCarryGroup.setAttribute('style', imp('margin-bottom:10px;'));
const xanCarryLbl = el('label'); xanCarryLbl.textContent = 'Carry Limit'; xanCarryLbl.setAttribute('for','lt-si-xan-carry'); xanCarryLbl.setAttribute('style', imp(S.lbl));
const xanCarryRow = el('div'); xanCarryRow.setAttribute('style', imp('display:flex;align-items:center;gap:6px;'));
const xanCarDecBtn = el('button'); xanCarDecBtn.textContent = '−'; xanCarDecBtn.setAttribute('type','button');
xanCarDecBtn.setAttribute('style', imp(`padding:6px 12px;border-radius:5px;border:1px solid ${C.border};background:${C.card};color:${C.gold};font-weight:800;font-size:15px;cursor:pointer;line-height:1;`));
const xanCarryInp = document.createElement('input'); xanCarryInp.type = 'number'; xanCarryInp.min = '0';
xanCarryInp.id = 'lt-si-xan-carry'; xanCarryInp.value = cfg.getXanCarry();
xanCarryInp.setAttribute('style', imp(`font-family:Consolas,monospace;font-size:14px;font-weight:700;text-align:center;width:72px;padding:6px 4px;background:${C.bg};border:1px solid ${C.border};border-radius:5px;color:${C.text};outline:none;-moz-appearance:textfield;`));
xanCarryInp.addEventListener('focus', () => xanCarryInp.style.setProperty('border-color','rgba(0,190,215,0.8)','important'));
xanCarryInp.addEventListener('blur', () => xanCarryInp.style.setProperty('border-color','rgba(0,140,170,0.4)','important'));
const xanCarIncBtn = el('button'); xanCarIncBtn.textContent = '+'; xanCarIncBtn.setAttribute('type','button');
xanCarIncBtn.setAttribute('style', imp('padding:6px 12px;border-radius:5px;border:1px solid rgba(0,200,224,0.35);background:rgba(0,40,60,0.5);color:#00c8e0;font-weight:800;font-size:15px;cursor:pointer;line-height:1;'));
xanCarDecBtn.addEventListener('click', () => { xanCarryInp.value = Math.max(0,(parseInt(xanCarryInp.value)||0)-1); });
xanCarIncBtn.addEventListener('click', () => { xanCarryInp.value = (parseInt(xanCarryInp.value)||0)+1; });
xanCarryRow.appendChild(xanCarDecBtn); xanCarryRow.appendChild(xanCarryInp); xanCarryRow.appendChild(xanCarIncBtn);
const xanCarryHint = el('div'); xanCarryHint.textContent = 'Max items you can carry per trip (also used by Pure Profit calculator)'; xanCarryHint.setAttribute('style', imp(S.hint));
xanCarryGroup.appendChild(xanCarryLbl); xanCarryGroup.appendChild(xanCarryRow); xanCarryGroup.appendChild(xanCarryHint);
// ── Buttons ──
const btnRow = el('div'); btnRow.setAttribute('style', imp(S.btnRow));
const btnCancel = el('button'); btnCancel.textContent = 'Cancel'; btnCancel.setAttribute('type','button');
const btnSave = el('button'); btnSave.textContent = 'Save & Refresh'; btnSave.setAttribute('type','button');
btnCancel.setAttribute('style', imp(S.btn + `background:${C.bg};color:${C.green};`));
btnSave.setAttribute('style', imp(S.btn + `background:${C.settNote};color:${C.gold};`));
btnRow.appendChild(btnCancel); btnRow.appendChild(btnSave);
body.appendChild(note);
body.appendChild(themeHdr);
body.appendChild(themeRow);
body.appendChild(themeHint);
body.appendChild(credHdr);
body.appendChild(fAPI);
body.appendChild(apiLink);
body.appendChild(fUID);
body.appendChild(secHdr);
body.appendChild(visGrid);
body.appendChild(xanSettHdr);
body.appendChild(xanCarryGroup);
// ── Museum Day toggle ──
const museumSettHdr = el('div'); museumSettHdr.textContent = '🏛️ Museum Day'; museumSettHdr.setAttribute('style', imp(S.secHdr));
const museumPinRow = el('div'); museumPinRow.setAttribute('style', imp('display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;background:rgba(255,184,48,0.04);border:1px solid rgba(255,184,48,0.18);cursor:pointer;margin-bottom:8px;'));
const museumPinChk = document.createElement('input'); museumPinChk.type = 'checkbox'; museumPinChk.id = 'lt-museum-pin';
museumPinChk.checked = cfg.getMuseumPin();
museumPinChk.setAttribute('style', imp('width:14px;height:14px;cursor:pointer;accent-color:#ffb830;flex-shrink:0;'));
const museumPinLbl = el('label'); museumPinLbl.textContent = '🏛️ Always show Museum Day bonus'; museumPinLbl.setAttribute('for', 'lt-museum-pin');
museumPinLbl.setAttribute('style', imp(`font-size:10px;font-weight:600;color:${C.text};cursor:pointer;flex:1;`));
museumPinRow.appendChild(museumPinChk); museumPinRow.appendChild(museumPinLbl);
museumPinRow.addEventListener('click', e => { if (e.target !== museumPinChk) museumPinChk.checked = !museumPinChk.checked; });
body.appendChild(museumSettHdr);
body.appendChild(museumPinRow);
// ── 💡 Preferences ──
const prefHdr2 = el('div'); prefHdr2.textContent = '💡 Tooltip Carousel'; prefHdr2.setAttribute('style', imp(S.secHdr));
body.appendChild(prefHdr2);
body.appendChild(tooltipRow);
body.appendChild(btnRow);
box.appendChild(hdr); box.appendChild(body);
wrap.appendChild(box);
document.body.appendChild(wrap);
setTimeout(() => { const i = document.getElementById('lt-si-api'); if (i) i.focus(); }, 50);
function doSave() {
const key = (document.getElementById('lt-si-api').value || '').trim();
const uid = (document.getElementById('lt-si-uid').value || '').trim();
if (!key) { toast('⚠ API key is required'); return; }
cfg.apiKey = key; cfg.userId = uid;
const newVis = {};
sections.forEach(sec => { newVis[sec.key] = document.getElementById('lt-vis-' + sec.key).checked; });
cfg.setSectionVis(newVis);
// Xanax carry, priority, threshold
cfg.setXanCarry(Math.max(0, parseInt(document.getElementById('lt-si-xan-carry').value)||0));
// Retrigger tooltip: clear seen key so carousel shows again on next load
const showTooltip = document.getElementById('lt-pref-tooltip').checked;
const museumPin = document.getElementById('lt-museum-pin').checked;
cfg.setMuseumPin(museumPin);
try {
if (showTooltip) localStorage.removeItem('lt_tooltip_seen');
else localStorage.setItem('lt_tooltip_seen', '1');
} catch(e) {}
wrap.remove();
toast('✓ Saved!');
invCache = {}; abroadCache = {}; xanSACache = { qty:0, price:0 }; xanFacCache = null; xanPersonal = 0; // bobCache intentionally kept — shows last known BoB stock while refreshing
if (panelEl) renderPanel();
refreshAll();
}
btnSave.addEventListener('click', doSave);
btnCancel.addEventListener('click', () => wrap.remove());
wrap.addEventListener('click', e => { if (e.target === wrap) wrap.remove(); });
wrap.addEventListener('keydown', e => { if (e.key === 'Escape') wrap.remove(); });
}
/* ─────────────────────────────────────────
API / FETCH
───────────────────────────────────────── */
function gmFetch(url, timeoutMs) {
timeoutMs = timeoutMs || 12000;
const req = new Promise(resolve => {
if (typeof GM_xmlhttpRequest !== 'undefined') {
GM_xmlhttpRequest({
method: 'GET', url, timeout: timeoutMs,
onload: r => { try { resolve(JSON.parse(r.responseText)); } catch { resolve({}); } },
onerror: () => resolve({}),
ontimeout: () => resolve({}),
});
} else {
fetch(url).then(r => r.json()).then(resolve).catch(() => resolve({}));
}
});
return Promise.race([req, new Promise(r => setTimeout(() => r({}), timeoutMs))]);
}
async function fetchInventory() {
if (!cfg.apiKey || !cfg.userId) throw new Error('No API key or User ID');
const uid = parseInt(String(cfg.userId).replace(/\D/g,''));
const d = await gmFetch(`https://api.torn.com/user/${uid}?selections=display&key=${cfg.apiKey}`);
if (d.error) throw new Error(d.error.error || 'API error');
const items = {};
(d.display || []).forEach(item => { items[item.name] = (items[item.name] || 0) + item.quantity; });
return items;
}
async function fetchTravelStatus() {
if (!cfg.apiKey || !cfg.userId) return null;
try {
const uid = parseInt(String(cfg.userId).replace(/\D/g,''));
// travel selection returns: { destination, time_left, departed, enroute }
// destination = "South Africa" (outbound) or "Torn" (returning)
// time_left = seconds remaining
// departed = unix timestamp of departure
const d = await gmFetch(`https://api.torn.com/user/${uid}?selections=travel,basic&key=${cfg.apiKey}`);
if (d.error || !d.travel) return null;
const t = d.travel;
const destination = (t.destination || '').trim();
const timeLeft = Number(t.time_left || 0);
const departed = Number(t.departed || 0);
if (!timeLeft) return { traveling: false, destination: '', timeLeft: 0, departed: 0, isReturn: false, origin: '' };
const isReturn = !TORN_DEST_TO_CODE[destination];
// For return flights the API only says destination="Torn".
// Try status.description which says e.g. "Returning from South Africa"
let origin = '';
if (isReturn) {
const desc = (d.status && d.status.description) ? d.status.description : '';
// "Returning from South Africa" or "Traveling to Torn" etc
const m = desc.match(/from\s+([A-Za-z ]+)/i);
if (m) {
const fromCountry = m[1].trim();
// Validate it's a known country
if (TORN_DEST_TO_CODE[fromCountry]) origin = fromCountry;
}
// If still empty, scrapeTravelPage will fill it in from the DOM
} else {
origin = 'Torn City';
}
console.log('[SetsTracker] travel API:', destination, 'timeLeft:', timeLeft, 'isReturn:', isReturn, 'origin:', origin);
return { traveling: true, destination, timeLeft, departed, isReturn, origin };
} catch(e) { console.warn('[SetsTracker] fetchTravelStatus threw:', e); return null; }
}
function scrapeTravelPage() {
if (!document.location.href.includes('travel')) return;
try {
const raw = (document.body.innerText || '').replace(/[\r\n\t]+/g, ' ').replace(/ {2,}/g, ' ');
const timeMatch = raw.match(/Remaining Flight Time\s*[-\u2013]\s*(\d+):(\d+):(\d+)/i);
if (!timeMatch) return;
const secs = parseInt(timeMatch[1])*3600 + parseInt(timeMatch[2])*60 + parseInt(timeMatch[3]);
if (!secs) return;
const landMatch = raw.match(/Landing at\s+(\d+:\d+(?::\d+)?\s*(?:AM|PM)?)/i);
const landingStr = landMatch ? landMatch[1].trim() : (travelStatus ? travelStatus.landingStr || '' : '');
// "CityName to Torn" → return flight, origin = CityName
const retMatch = raw.match(/([A-Za-z][A-Za-z \-']+?)\s+to\s+Torn\b/i);
const scrapedOriginCity = retMatch ? retMatch[1].trim() : '';
// "Torn to CityName" → outbound flight, destination = CityName
const outMatch = raw.match(/Torn\s+to\s+([A-Za-z][A-Za-z \-']+?)\s*[\.\/\-]/i);
const scrapedDestCity = outMatch ? outMatch[1].trim() : '';
// Resolve city name → country name for API matching
const scrapedDestCountry = scrapedDestCity
? (Object.keys(TORN_DEST_TO_CODE).find(k => YATA_CODE_TO_CITY[TORN_DEST_TO_CODE[k]] === scrapedDestCity) || scrapedDestCity)
: '';
if (travelStatus && travelStatus.traveling) {
// Patch return flight origin from DOM
const betterOrigin = (travelStatus.isReturn && scrapedOriginCity && (!travelStatus.origin || travelStatus.origin === 'Torn' || travelStatus.origin === 'Torn City'))
? scrapedOriginCity
: travelStatus.origin;
// Patch outbound destination if API gave us empty/wrong destination
const betterDest = (!travelStatus.isReturn && scrapedDestCountry && !TORN_DEST_TO_CODE[travelStatus.destination])
? scrapedDestCountry
: travelStatus.destination;
travelStatus = { ...travelStatus, timeLeft: secs, landingStr, origin: betterOrigin, destination: betterDest };
} else if (secs > 0) {
// API hasn't loaded yet — bootstrap from scrape
const toIsHome = !!scrapedOriginCity;
travelStatus = {
traveling: true,
destination: toIsHome ? 'Torn City' : scrapedDestCountry,
timeLeft: secs,
departed: 0,
isReturn: toIsHome,
origin: scrapedOriginCity || 'Torn City',
landingStr,
};
}
if (panelEl) renderPanel();
manageTravelCountdown();
} catch(e) { console.warn('[SetsTracker] scrapeTravelPage threw:', e); }
}
function scrapeXanaxFromItemsPage() {
// Only scrape if we're on the items page
if (!window.location.href.includes('item')) return;
try {
// Torn items page renders item names and quantities in the DOM
// Look for any element containing "Xanax" and grab the adjacent quantity
const allText = document.querySelectorAll('[class*="name"],[class*="title"],[class*="item"]');
for (const el of allText) {
if (el.textContent.trim() !== 'Xanax') continue;
// Try siblings and parent children for quantity
const parent = el.closest('[class*="item"],[class*="row"],[class*="wrap"]') || el.parentElement;
if (!parent) continue;
const qtyEl = parent.querySelector('[class*="qty"],[class*="amount"],[class*="quantity"],[class*="count"]');
if (qtyEl) {
const qty = parseInt(qtyEl.textContent.replace(/[^0-9]/g,'')) || 0;
if (qty > 0) {
cfg.setXanCount(qty);
xanPersonal = qty;
console.log('[SetsTracker] scraped xanax from items page:', qty);
if (panelEl) renderPanel();
return;
}
}
// Fallback: look for a number near the Xanax text
const nearby = parent.textContent.match(/[xX](?:\s*)(\d+)|quantity[:\s]*(\d+)|(\d+)\s*[xX]/);
if (nearby) {
const qty = parseInt(nearby[1] || nearby[2] || nearby[3]) || 0;
if (qty > 0) {
cfg.setXanCount(qty);
xanPersonal = qty;
console.log('[SetsTracker] scraped xanax (fallback):', qty);
if (panelEl) renderPanel();
return;
}
}
}
} catch(e) { console.warn('[SetsTracker] scrapeXanaxFromItemsPage threw:', e); }
}
function watchItemsPage() {
// Watch for DOM changes on items page to trigger scrape
if (!window.location.href.includes('item')) return;
console.log('[SetsTracker] on items page — scraping xanax count');
// Give the page time to render items
setTimeout(scrapeXanaxFromItemsPage, 1500);
setTimeout(scrapeXanaxFromItemsPage, 3000);
}
async function fetchYataData() {
// Single YATA fetch — populates both abroadCache and xanSACache
// YATA: { stocks: { "mex": { stocks: [{id, name, quantity, cost}] }, "sou": {...} } }
const map = {};
let sa = { qty: 0, price: 0 };
try {
const data = await gmFetch('https://yata.yt/api/v1/travel/export/');
if (!data || !data.stocks) return { map, sa };
Object.entries(data.stocks).forEach(([code, country]) => {
const isSA = code === 'sou';
const city = YATA_CODE_TO_CITY[code] || code;
const stocks = [];
(country.stocks || []).forEach(item => {
const name = ID_TO_NAME[Number(item.id)];
const qty = Number(item.quantity || 0);
const cost = Number(item.cost || 0);
if (name) {
map[name] = (map[name] || 0) + qty;
if (cost > 0 && (!yataPriceCache[name] || cost > yataPriceCache[name].price)) {
yataPriceCache[name] = { price: cost, country: country.country_name || code };
}
// Store stock history keyed by item+city so each location has its own sparkline
if (qty >= 0) cfg.addStockHistory(name + '_' + code, qty);
stocks.push({ id: Number(item.id), name, qty, cost });
}
if (isSA && Number(item.id) === XANAX_ID) {
sa = { qty, price: Number(item.cost || 0) };
}
});
yataCityCache[code] = { city, code, stocks };
});
console.log('[SetsTracker] YATA loaded — countries:', Object.keys(data.stocks).length, '| SA xanax qty:', sa.qty, 'price:', sa.price);
} catch(e) { console.warn('[SetsTracker] fetchYataData threw:', e); }
return { map, sa };
}
// fetchAbroad and fetchXanaxSA are handled by a single fetchYataData() call in refreshAll()
async function fetchBobStock() {
// torn/?selections=cityshops — confirmed working
// Shop name: "Bits 'n' Bobs" (id 103)
// Items absent when out of stock, present with in_stock count when available
if (!cfg.apiKey) return {};
const bobMap = {};
try {
const data = await gmFetch(`https://api.torn.com/torn/?selections=cityshops&key=${cfg.apiKey}`);
if (!data || data.error) { console.warn('[SetsTracker] fetchBobStock:', data && data.error ? data.error.error : 'no data'); return bobMap; }
const shops = data.cityshops || {};
Object.values(shops).forEach(shop => {
const n = (shop.name || '').toLowerCase();
if (!n.includes('bit') && !n.includes('bob')) return;
const inv = shop.inventory || {};
// Map all items we know about — plushies will appear here when in stock
Object.entries(inv).forEach(([idStr, item]) => {
const name = ID_TO_NAME[Number(idStr)];
if (name) bobMap[name] = Number(item.in_stock || 0);
});
// Explicitly zero out BoB plushies not in the response (= out of stock)
[186, 187, 215].forEach(id => {
const name = ID_TO_NAME[id];
if (name && bobMap[name] === undefined) bobMap[name] = 0;
});
});
console.log('[SetsTracker] BoB stock:', JSON.stringify(bobMap));
} catch(e) { console.warn('[SetsTracker] fetchBobStock threw:', e); }
return bobMap;
}
async function fetchXanaxFaction() {
if (!cfg.apiKey) return null;
try {
// faction/?selections=drugs — confirmed working with Full Access key
// Returns: { drugs: [ { ID, name, type, quantity }, ... ] }
const data = await gmFetch(`https://api.torn.com/faction/?selections=drugs&key=${cfg.apiKey}`);
if (!data || data.error) { console.warn('[SetsTracker] fetchXanaxFaction:', data && data.error ? JSON.stringify(data.error) : 'no data'); return null; }
const drugs = Array.isArray(data.drugs) ? data.drugs : Object.values(data.drugs || {});
const xan = drugs.find(d => Number(d.ID || d.id) === XANAX_ID);
if (xan) {
console.log('[SetsTracker] faction Xanax qty:', xan.quantity);
return Number(xan.quantity || 0);
}
console.log('[SetsTracker] faction drugs found but no Xanax. IDs:', drugs.map(d => d.ID || d.id));
return 0;
} catch(e) { console.warn('[SetsTracker] fetchXanaxFaction threw:', e); }
return null;
}
async function fetchPointsPrice() {
const now = Date.now();
if (pointsPriceCache.time && now - pointsPriceCache.time < POINTS_CACHE_DUR) return pointsPriceCache.price;
if (!cfg.apiKey) return 0;
try {
const data = await gmFetch(`${POINTS_ENDPOINT}?key=${cfg.apiKey}`);
if (data.pointsmarket) {
const listings = Object.values(data.pointsmarket).filter(l => l.quantity > 0).map(l => l.cost).sort((a,b) => a-b);
if (listings.length) {
const top = listings.slice(0, Math.min(5, listings.length));
const avg = Math.round(top.reduce((s,p) => s+p, 0) / top.length);
pointsPriceCache.history.push(avg);
if (pointsPriceCache.history.length > POINTS_HIST_SIZE) pointsPriceCache.history.shift();
const stable = Math.round(pointsPriceCache.history.reduce((s,p) => s+p, 0) / pointsPriceCache.history.length);
pointsPriceCache = { time: now, price: stable, history: pointsPriceCache.history };
pointsPrice = stable;
return stable;
}
}
} catch(e) {}
return pointsPrice || 0;
}
/* ─────────────────────────────────────────
FETCH MARKET VALUES
Torn v2 market endpoint — batch all tracked item IDs in one call
Returns { itemName: lowestMarketPrice }
───────────────────────────────────────── */
async function fetchMarketValues() {
if (!cfg.apiKey) return {};
// Collect all item IDs we care about (excluding BoB-only items which aren't overseas)
const ids = [];
Object.values(GROUPS).forEach(g => Object.entries(g.items).forEach(([, d]) => {
if (!BOB_IDS.has(d.id)) ids.push(d.id);
}));
Object.values(SPECIAL_ITEMS).forEach(d => ids.push(d.id));
if (!ids.includes(XANAX_ID)) ids.push(XANAX_ID); // include Xanax for sell value
const result = {};
try {
// Torn v1: /torn/[id1,id2,...]?selections=items
// Returns { items: { "id": { name, market_value, ... } } }
// market_value is Torn's own calculated market price — reliable single call
const data = await gmFetch(
`https://api.torn.com/torn/${ids.join(',')}?selections=items&key=${cfg.apiKey}`,
20000
);
const items = data.items || {};
Object.entries(items).forEach(([idStr, item]) => {
const name = ID_TO_NAME[Number(idStr)];
if (!name) return;
const mv = Number(item.market_value || 0);
if (mv > 0) result[name] = mv;
});
console.log('[SetsTracker] market values fetched:', Object.keys(result).length, 'items');
// Record history for sparklines
Object.entries(result).forEach(([name, price]) => { if (price > 0) cfg.addPriceHistory(name, price); });
} catch(e) { console.warn('[SetsTracker] fetchMarketValues threw:', e); }
return result;
}
function calcSet(inv, items) {
const vals = Object.keys(items).map(k => inv[k] || 0);
return vals.length ? Math.min(...vals) : 0;
}
function getSortedItems(inv, items, sets) {
return Object.entries(items)
.map(([name, data]) => ({ name, data, remaining: (inv[name] || 0) - sets }))
.sort((a, b) => a.remaining - b.remaining);
}
function getBottleneck(inv, items, sets) {
const sorted = getSortedItems(inv, items, sets).filter(i => i.remaining < 5);
if (!sorted.length) return null;
const parts = sorted.map(i => {
const locLabel = LOCATIONS[i.data.loc]?.label || i.data.loc;
return `${i.data.s} → ${locLabel}`;
});
return 'Need ' + parts.join(' & ');
}
function stockClass(name, qty) {
if (qty === 0) return 'lt-da';
if (GROUPS.Plushies.items[name]) return qty >= PLU_THRESH ? 'lt-hi' : 'lt-wa';
if (GROUPS.Flowers.items[name]) return qty >= FLO_THRESH ? 'lt-hi' : 'lt-wa';
return qty > 0 ? 'lt-hi' : 'lt-da';
}
function stockColor(name, qty) {
if (qty === 0) return C.stockLo;
if (GROUPS.Plushies.items[name]) return qty >= PLU_THRESH ? C.stockHi : C.stockMid;
if (GROUPS.Flowers.items[name]) return qty >= FLO_THRESH ? C.stockHi : C.stockMid;
return qty > 0 ? C.stockHi : C.stockLo;
}
/* ─────────────────────────────────────────
RENDER HELPERS
───────────────────────────────────────── */
function makeEmpty(icon, msg) {
const el = document.createElement('div');
el.style.cssText = `padding:28px 16px;text-align:center;color:${C.textDim};font-size:11px;line-height:1.6;font-family:Arial,sans-serif;`;
el.innerHTML = `<div style="font-size:26px;margin-bottom:8px;">${icon}</div>${msg}`;
return el;
}
function makeSectionLabel(text, setCount, pts) {
const el = document.createElement('div');
el.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:4px 10px;font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.goldDim};background:rgba(0,60,80,0.15);border-top:1px solid rgba(0,140,180,0.2);border-bottom:1px solid rgba(0,140,180,0.15);font-family:Consolas,monospace;`;
const left = document.createElement('span'); left.textContent = text;
const right = document.createElement('span');
right.style.cssText = `color:${C.gold};font-weight:700;font-size:8.5px;`;
right.textContent = setCount !== undefined ? `${setCount} sets · ${setCount * pts} pts` : '';
el.appendChild(left); el.appendChild(right);
return el;
}
function makeAlertRow(msg) {
const el = document.createElement('div');
el.style.cssText = `margin:3px 8px;padding:5px 8px 5px 20px;position:relative;background:rgba(160,20,20,0.18);border-left:2px solid rgba(220,50,50,0.8);border-radius:3px;font-size:9.5px;font-weight:600;color:#ff8888;line-height:1.3;font-family:Arial,sans-serif;`;
el.innerHTML = `<span style="position:absolute;left:6px;top:50%;transform:translateY(-50%);font-size:9px;opacity:0.9;color:#ff6666;">!</span>${msg}`;
return el;
}
/* ─────────────────────────────────────────
ITEM ROW BUILDER
───────────────────────────────────────── */
function makeItemRow(name, data, remaining, abroadQty, isBob) {
const row = document.createElement('div');
row.style.cssText = `display:grid;grid-template-columns:34px 34px 36px 1fr;gap:6px;align-items:center;padding:4px 8px;border-bottom:1px solid rgba(0,80,100,0.2);min-height:38px;transition:background 0.15s;`;
row.addEventListener('mouseover', () => row.style.background = 'rgba(0,200,224,0.05)');
row.addEventListener('mouseout', () => row.style.background = 'transparent');
// col 1 — item image
const imgWrap = document.createElement('div');
imgWrap.style.cssText = 'position:relative;width:32px;height:32px;';
const img = document.createElement('img');
img.src = itemImg(data.id); img.alt = data.s; img.title = name;
img.style.cssText = 'width:30px;height:30px;object-fit:contain;border-radius:2px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);display:block;transition:transform 0.2s;';
img.addEventListener('mouseover', () => img.style.transform = 'scale(1.15)');
img.addEventListener('mouseout', () => img.style.transform = 'scale(1)');
const imgFallback = document.createElement('span');
imgFallback.style.cssText = 'display:none;width:30px;height:30px;font-size:7px;font-weight:700;text-align:center;line-height:30px;border-radius:2px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);color:#c0e0ff;font-family:Consolas,monospace;';
imgFallback.textContent = data.s;
img.addEventListener('error', () => { img.style.display = 'none'; imgFallback.style.display = 'block'; });
imgWrap.appendChild(img); imgWrap.appendChild(imgFallback);
// col 2 — own count (remaining after sets)
const ownEl = document.createElement('div');
ownEl.style.cssText = `color:${C.green};background:rgba(0,180,210,0.08);font-weight:700;text-align:center;border:1px solid rgba(0,180,210,0.15);font-family:Consolas,monospace;padding:2px 3px;border-radius:2px;font-size:10px;`;
ownEl.title = `You have ${remaining} extra after completing sets`;
ownEl.textContent = remaining;
// col 3 — abroad / BoB button
let abroadEl;
if (isBob) {
const bobQty = bobCache[name] !== undefined ? bobCache[name] : null;
const bobCol = bobQty === null ? C.gold : bobQty > 0 ? C.okay : C.textDim;
abroadEl = document.createElement('a');
abroadEl.href = 'https://www.torn.com/shops.php?step=bitsnbobs';
abroadEl.style.cssText = `display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:${bobCol};background:${bobCol}1a;border:1px solid ${bobCol}44;border-radius:3px;padding:2px 4px;cursor:pointer;white-space:nowrap;text-decoration:none !important;transition:all 0.15s;font-family:Consolas,monospace;`;
abroadEl.textContent = bobQty !== null ? '🏪 ' + bobQty : '🏪 BoB';
abroadEl.title = bobQty !== null ? "Bits n' Bobs stock: " + bobQty + "\nClick to open shop" : "Open Bits n' Bobs shop";
abroadEl.addEventListener('mouseover', () => { abroadEl.style.background = bobCol + '33'; abroadEl.style.borderColor = bobCol + '88'; });
abroadEl.addEventListener('mouseout', () => { abroadEl.style.background = bobCol + '1a'; abroadEl.style.borderColor = bobCol + '44'; });
} else {
const sc = stockClass(name, abroadQty);
const col = stockColor(name, abroadQty);
abroadEl = document.createElement('div');
abroadEl.className = sc;
abroadEl.style.cssText = `color:${col};background:${col}1a;font-weight:700;text-align:center;border:1px solid ${col}44;font-family:Consolas,monospace;padding:2px 3px;border-radius:2px;font-size:10px;transition:all 0.3s;text-shadow:0 0 4px ${col}66;`;
abroadEl.title = `Overseas stock (Torn API): ${abroadQty}`;
abroadEl.textContent = abroadQty;
}
// col 4 — flag / location
const loc = LOCATIONS[data.loc] || { flag: '❓', label: data.loc };
const flagEl = document.createElement('div');
flagEl.style.cssText = 'display:flex;align-items:center;justify-content:center;font-size:16px;line-height:1;';
flagEl.title = loc.label;
flagEl.textContent = loc.flag;
row.appendChild(imgWrap);
row.appendChild(ownEl);
row.appendChild(abroadEl);
row.appendChild(flagEl);
return row;
}
/* ─────────────────────────────────────────
EXPANDABLE ITEM DETAIL PANEL
Wraps a makeItemRow with a tap-to-expand detail section
showing all known data: ID, buy/sell, profit, stock, history sparkline
───────────────────────────────────────── */
function makeExpandableItem(name, data, remaining, abroadQty, isBob) {
const wrap = document.createElement('div');
wrap.style.cssText = 'border-bottom:1px solid rgba(0,80,100,0.2);';
const row = makeItemRow(name, data, remaining, abroadQty, isBob);
// Remove border-bottom from inner row since wrap handles it
row.style.borderBottom = 'none';
row.style.cursor = 'pointer';
// ── Chevron indicator ──
const chevron = document.createElement('span');
chevron.style.cssText = `position:absolute;right:6px;top:50%;transform:translateY(-50%) rotate(0deg);font-size:9px;color:${C.textDim};transition:transform 0.18s;pointer-events:none;`;
chevron.textContent = '▾';
row.style.position = 'relative';
row.style.paddingRight = '18px';
row.appendChild(chevron);
// ── Detail panel ──
let isOpen = false;
const detail = document.createElement('div');
detail.style.cssText = `overflow:hidden;max-height:0;transition:max-height 0.22s ease;background:${C.bg};`;
const inner = document.createElement('div');
inner.style.cssText = 'padding:8px 10px;display:flex;flex-direction:column;gap:6px;';
// Populate detail lazily on first open
let populated = false;
function populateDetail() {
if (populated) return; populated = true;
const buyPrice = yataPriceCache[name] ? yataPriceCache[name].price : 0;
const sellPrice = marketValueCache[name] || 0;
const profit = sellPrice > buyPrice ? sellPrice - buyPrice : 0;
// Use per-country key if we know the location, else global fallback
const itemCode = data && data.loc ? (Object.values(TORN_DEST_TO_CODE).find((c,i)=>Object.keys(TORN_DEST_TO_CODE)[i]===data.loc) || '') : '';
const history = cfg.getStockHistory(name + (itemCode ? '_'+itemCode : ''));
const fmt = n => n >= 1e6 ? '$' + (n/1e6).toFixed(2) + 'M' : n >= 1e3 ? '$' + Math.round(n/1000) + 'k' : '$' + n;
const lastUpdated = history.length ? new Date(history[history.length-1].ts).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '—';
// Stat grid
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
[
{ label: 'Item ID', value: '#' + data.id, color: C.textDim },
{ label: 'Buy Price', value: buyPrice > 0 ? fmt(buyPrice) : '—', color: C.stockLo },
{ label: 'Sell Value', value: sellPrice > 0 ? fmt(sellPrice) : '—', color: C.okay },
{ label: 'Profit/ea', value: profit > 0 ? fmt(profit) : '—', color: profit > 0 ? C.okay : C.textDim },
{ label: 'YATA Stock', value: abroadQty !== undefined ? abroadQty.toLocaleString() : '—', color: C.abroad },
{ label: 'Updated', value: lastUpdated, color: C.textDim },
].forEach(({ label, value, color }) => {
const cell = document.createElement('div');
cell.style.cssText = `background:${C.card};border:1px solid ${C.cardBorder};border-radius:4px;padding:5px 4px;text-align:center;`;
cell.innerHTML = `<div style="font-size:7.5px;color:${C.textDim};font-family:Consolas,monospace;letter-spacing:.4px;margin-bottom:2px;">${label}</div>
<div style="font-size:10px;font-weight:700;color:${color};font-family:Consolas,monospace;">${value}</div>`;
grid.appendChild(cell);
});
inner.appendChild(grid);
// Sparkline — price history
if (history.length >= 2) {
const sparkWrap = document.createElement('div');
sparkWrap.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
const sparkLbl = document.createElement('div');
sparkLbl.style.cssText = `font-size:8px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:${C.goldDim};font-family:Consolas,monospace;`;
sparkLbl.textContent = '📈 Market Price History (last ' + history.length + ' fetches)';
sparkWrap.appendChild(sparkLbl);
const prices = history.map(h => h.price);
const minP = Math.min(...prices);
const maxP = Math.max(...prices);
const range = maxP - minP || 1;
const W = 260, H = 36, pad = 4;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('width', '100%');
svg.setAttribute('height', H);
svg.style.cssText = 'display:block;overflow:visible;';
// Fill area
const pts = prices.map((p, i) => {
const x = pad + (i / (prices.length - 1)) * (W - pad*2);
const y = pad + (1 - (p - minP) / range) * (H - pad*2);
return [x, y];
});
const pathD = 'M' + pts.map(([x,y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join('L');
const fillD = pathD + `L${pts[pts.length-1][0].toFixed(1)},${H}L${pts[0][0].toFixed(1)},${H}Z`;
const fill = document.createElementNS('http://www.w3.org/2000/svg','path');
fill.setAttribute('d', fillD); fill.setAttribute('fill','rgba(0,200,224,0.08)');
const line = document.createElementNS('http://www.w3.org/2000/svg','path');
line.setAttribute('d', pathD); line.setAttribute('fill','none');
line.setAttribute('stroke','rgba(0,200,224,0.7)'); line.setAttribute('stroke-width','1.5');
line.setAttribute('stroke-linejoin','round'); line.setAttribute('stroke-linecap','round');
svg.appendChild(fill); svg.appendChild(line);
// Dot on last point
const lastPt = pts[pts.length - 1];
const dot = document.createElementNS('http://www.w3.org/2000/svg','circle');
dot.setAttribute('cx', lastPt[0].toFixed(1)); dot.setAttribute('cy', lastPt[1].toFixed(1));
dot.setAttribute('r','3'); dot.setAttribute('fill','#00c8e0');
svg.appendChild(dot);
// Min/max labels
const mkTxt = (txt, x, y, anchor) => {
const t = document.createElementNS('http://www.w3.org/2000/svg','text');
t.textContent = txt; t.setAttribute('x', x); t.setAttribute('y', y);
t.setAttribute('text-anchor', anchor);
t.style.cssText = 'font-size:7px;font-family:Consolas,monospace;fill:rgba(140,220,235,0.45);';
return t;
};
const fmtShort = n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? Math.round(n/1000)+'k' : String(n);
svg.appendChild(mkTxt(fmtShort(maxP), pad+2, pad+6, 'start'));
svg.appendChild(mkTxt(fmtShort(minP), pad+2, H-2, 'start'));
sparkWrap.appendChild(svg);
inner.appendChild(sparkWrap);
} else {
const noHistory = document.createElement('div');
noHistory.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;text-align:center;padding:4px 0;`;
noHistory.textContent = 'Price history builds up over time as data refreshes';
inner.appendChild(noHistory);
}
detail.appendChild(inner);
}
row.addEventListener('click', () => {
isOpen = !isOpen;
if (isOpen) populateDetail();
detail.style.maxHeight = isOpen ? '300px' : '0';
chevron.style.transform = isOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%) rotate(0deg)';
});
wrap.appendChild(row);
wrap.appendChild(detail);
return wrap;
}
/* ─────────────────────────────────────────
COLUMN HEADER ROW
───────────────────────────────────────── */
function makeColHeader() {
const row = document.createElement('div');
row.style.cssText = `display:grid;grid-template-columns:34px 34px 36px 1fr;gap:6px;padding:3px 8px;background:rgba(0,200,224,0.04);border-bottom:1px solid rgba(0,150,180,0.15);`;
['', 'Own', 'Abroad', ''].forEach((txt, i) => {
const s = document.createElement('span');
s.textContent = txt;
s.style.cssText = `font:600 8px Consolas,monospace;color:rgba(0,200,224,0.45);text-align:center;text-transform:uppercase;letter-spacing:.4px;`;
if (txt === 'Own') s.title = 'Your display items minus completed sets. Lowest = bottleneck.';
if (txt === 'Abroad') s.title = 'Live overseas stock from Torn API.\n🟢 Plushie ≥2000 / Flower ≥5000\n🟠 Below threshold 🔴 Zero\n🏪 BoB = Bits n\' Bobs shop shortcut';
row.appendChild(s);
});
return row;
}
/* ─────────────────────────────────────────
TAB: SETS
───────────────────────────────────────── */
function renderSetsBody() {
const body = panelEl.querySelector('.lt-body');
body.innerHTML = '';
if (!cfg.apiKey) {
body.appendChild(makeEmpty('🔑', 'Set your API key in Settings<br>to track your item sets'));
return;
}
if (!Object.keys(invCache).length) {
body.appendChild(makeEmpty('◌', 'Loading your items…'));
return;
}
const vis = cfg.getSectionVis();
const runs = cfg.getXanRuns();
const activeRun = runs.find(r => r.active);
const xanPriority = cfg.getXanPriority();
const _xanFocusCtry = cfg.getXanCountry();
const focusMode = !!(activeRun || xanPriority);
// ── FOCUS BANNER ──
if (focusMode) {
const isRun = !!activeRun;
const bannerCol = isRun ? '#00c8e0' : C.gold;
const banner = document.createElement('div');
banner.style.cssText = `margin:6px 8px 4px;padding:7px 10px;border-radius:6px;background:${bannerCol}0f;border:1px solid ${bannerCol}44;display:flex;align-items:center;gap:8px;`;
if (isRun) {
const total = activeRun.trips.reduce((s,t)=>s+t.bought,0);
const remaining = Math.max(0, activeRun.contractQty - total);
banner.innerHTML = `<span style="font-size:14px;flex-shrink:0;">✈</span>
<div style="flex:1;font-family:Consolas,monospace;">
<div style="font-size:9.5px;font-weight:700;color:${bannerCol};letter-spacing:.4px;">RUN: ${activeRun.client}</div>
<div style="font-size:8.5px;color:rgba(0,200,224,0.6);margin-top:1px;">
${total} collected · ${remaining} needed · ${activeRun.contractQty} total
</div>
</div>
<div style="font-size:11px;font-weight:700;font-family:Consolas,monospace;color:${bannerCol};">${Math.min(100,Math.round(total/activeRun.contractQty*100))}%</div>`;
} else {
// personal priority banner
const threshold = cfg.getXanThreshold();
const personal = xanPersonal;
const needed = Math.max(0, threshold - personal);
banner.innerHTML = `<span style="font-size:14px;flex-shrink:0;">🧪</span>
<div style="flex:1;font-family:Consolas,monospace;">
<div style="font-size:9.5px;font-weight:700;color:${bannerCol};letter-spacing:.4px;">XANAX PRIORITY</div>
<div style="font-size:8.5px;color:rgba(255,180,0,0.6);margin-top:1px;">
${personal} collected · ${needed} needed · ${threshold} target
</div>
</div>
<div style="font-size:11px;font-weight:700;font-family:Consolas,monospace;color:${needed===0?'#66bb66':bannerCol};">${personal}/${threshold}</div>`;
}
body.appendChild(banner);
}
// ── Museum Day Bonus Line ──
(function() {
const now = new Date();
const museumStart = new Date(Date.UTC(2026, 4, 17, 10, 0, 0)); // May 17 10:00 TCT
const museumEnd = new Date(Date.UTC(2026, 4, 19, 0, 0, 0)); // May 19 (48h window)
const msUntil = museumStart - now;
const daysUntil = msUntil / 86400000;
const isActive = now >= museumStart && now <= museumEnd;
const isPast = now > museumEnd;
const inWindow = isActive || (!isPast && daysUntil <= 7);
const pinned = cfg.getMuseumPin();
if (!(inWindow || pinned)) return;
if (!pointsPrice || !Object.keys(invCache).length) return;
const vis2 = cfg.getSectionVis();
let tSets = 0, tPts = 0;
Object.entries(GROUPS).forEach(([n, g]) => {
if (vis2[n.toLowerCase()] === false) return;
const s = calcSet(invCache, g.items);
tSets += s;
tPts += s * g.pts;
});
if (vis2.special !== false) {
Object.entries(SPECIAL_ITEMS).forEach(([name, data]) => {
const qty = invCache[name] || 0;
tSets += qty;
tPts += qty * data.pts;
});
}
if (!tSets) return;
const bonusSets = Math.floor(tSets * 0.1); // 200 sets ÷ 10% = 20
const musSets = tSets + bonusSets; // 200 + 20 = 220
const musPts = Math.round(tPts * 1.1); // pts scale proportionally
const musVal = musPts * pointsPrice;
const musFmt = musVal >= 1e9 ? `$${(musVal/1e9).toFixed(2)}B`
: musVal >= 1e6 ? `$${(musVal/1e6).toFixed(1)}M`
: `$${Math.round(musVal/1000)}k`;
let timeTag = '';
if (isActive) timeTag = ' · 🟢 ACTIVE';
else if (daysUntil <= 1) timeTag = ' · tomorrow';
else if (daysUntil <= 7) timeTag = ` · in ${Math.ceil(daysUntil)}d`;
const GOLD = '#ffb830';
const GOLD_DIM = 'rgba(255,184,48,0.7)';
const GOLD_BG = 'rgba(255,184,48,0.06)';
const GOLD_BDR = 'rgba(255,184,48,0.22)';
const museumRow = document.createElement('div');
museumRow.style.cssText = `display:flex;align-items:center;gap:6px;padding:4px 8px;background:${GOLD_BG};border-bottom:1px solid ${GOLD_BDR};font-family:Consolas,monospace;font-size:8px;`;
museumRow.title = "Museum Day (May 17-19) gives 10% bonus on point redemptions. The bonus shown is what you would earn extra by waiting.";
const icon = document.createElement('span');
icon.textContent = '🏛️';
icon.style.cssText = 'font-size:10px;flex-shrink:0;';
const label = document.createElement('span');
label.style.cssText = `color:${GOLD};font-weight:700;letter-spacing:.5px;text-transform:uppercase;flex:1;`;
label.textContent = `Museum Day Bonus: ${musSets.toLocaleString()} sets · ${musPts.toLocaleString()} pts · ${musFmt}${timeTag}`;
museumRow.appendChild(icon);
museumRow.appendChild(label);
body.appendChild(museumRow);
})();
// ── GROUPS (hidden in focus mode) ──
if (!focusMode) {
Object.entries(GROUPS).forEach(([groupName, g]) => {
if (vis[groupName.toLowerCase()] === false) return;
const sets = calcSet(invCache, g.items);
const warn = getBottleneck(invCache, g.items, sets);
body.appendChild(makeSectionLabel(g.icon + ' ' + groupName.toUpperCase(), sets, g.pts));
if (warn) body.appendChild(makeAlertRow(warn));
body.appendChild(makeColHeader());
getSortedItems(invCache, g.items, sets).forEach(({ name, data, remaining }) => {
const isBob = BOB_IDS.has(data.id);
const abroad = abroadCache[name] || 0;
body.appendChild(makeExpandableItem(name, data, remaining, abroad, isBob));
});
});
// ── SPECIAL ──
if (vis.special !== false) {
const specCount = Object.values(SPECIAL_ITEMS).reduce((s, d) => {
const name = Object.keys(SPECIAL_ITEMS).find(k => SPECIAL_ITEMS[k] === d);
return s + (invCache[name] || 0);
}, 0);
body.appendChild(makeSectionLabel('☄️ SPECIAL', specCount, 0));
body.appendChild(makeColHeader());
Object.entries(SPECIAL_ITEMS).forEach(([name, data]) => {
const own = invCache[name] || 0;
const abroad = abroadCache[name] || 0;
body.appendChild(makeExpandableItem(name, data, own, abroad, false));
});
}
}
// ── XANAX FOCUS ROW (shown in focus mode only) ──
if (focusMode) {
const xanOwn = xanPersonal;
const xanAbroad = xanSACache.qty || 0;
body.appendChild(makeSectionLabel('🧪 XANAX', xanOwn, 0));
body.appendChild(makeColHeader());
body.appendChild(makeItemRow('Xanax', { id: XANAX_ID, s: 'Xanax', loc: _xanFocusCtry || 'South Africa' }, xanOwn, xanAbroad, false));
}
}
/* ─────────────────────────────────────────
TAB: XANAX
───────────────────────────────────────── */
/* ─────────────────────────────────────────
XANAX RUN — helper functions
───────────────────────────────────────── */
function loadActiveXanRun() {
const runs = cfg.getXanRuns();
activeXanRun = runs.find(r => r.active) || null;
}
function saveXanRun(run) {
const runs = cfg.getXanRuns();
const idx = runs.findIndex(r => r.id === run.id);
if (idx >= 0) runs[idx] = run; else runs.push(run);
cfg.setXanRuns(runs);
}
function openXanTripLogModal(run, zIndex) {
const overlay = document.createElement('div');
overlay.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,0.78);z-index:${zIndex||1000020};display:flex;align-items:center;justify-content:center;padding:16px;`;
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
const box = document.createElement('div');
box.style.cssText = `background:${C.settBg};border:1px solid ${C.settBorder};border-radius:12px;padding:18px;width:100%;max-width:290px;max-height:75vh;display:flex;flex-direction:column;font-family:Arial,sans-serif;`;
const hdr = document.createElement('div');
hdr.style.cssText = 'display:flex;align-items:center;margin-bottom:12px;';
const htitle = document.createElement('div');
htitle.textContent = '✈ Trip Log — ' + run.client;
htitle.style.cssText = `font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;color:${C.gold};flex:1;`;
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.textContent='✕';
closeBtn.style.cssText = `background:none;border:none;color:${C.textDim};font-size:14px;cursor:pointer;padding:0 2px;`;
closeBtn.addEventListener('click', () => overlay.remove());
hdr.appendChild(htitle); hdr.appendChild(closeBtn);
box.appendChild(hdr);
// Summary strip
const total = run.trips.reduce((s,t) => s+t.bought, 0);
const carry = cfg.getXanCarry() || 0;
const summary = document.createElement('div');
summary.style.cssText = 'display:flex;gap:6px;margin-bottom:10px;font-size:8.5px;font-family:Consolas,monospace;flex-wrap:wrap;';
const mkBadge = (txt, col) => {
const s = document.createElement('span');
s.textContent = txt;
s.style.cssText = `color:${col};background:${col}18;border:1px solid ${col}44;border-radius:3px;padding:2px 7px;`;
return s;
};
summary.appendChild(mkBadge(run.trips.length + ' trips', C.goldDim));
summary.appendChild(mkBadge(total + ' 🧪 total', C.okay));
box.appendChild(summary);
// Trip list
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:4px;margin-bottom:12px;';
if (run.trips.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'No trips logged yet.';
empty.style.cssText = `font-size:10px;color:${C.textDim};font-family:Consolas,monospace;text-align:center;padding:20px 0;`;
list.appendChild(empty);
} else {
[...run.trips].reverse().forEach((t, i) => {
const tripNum = run.trips.length - i;
const d = new Date(t.ts);
const time = d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' });
const date = d.toLocaleDateString([], { month:'short', day:'numeric' });
const row = document.createElement('div');
row.style.cssText = `display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:5px;background:${C.card};border:1px solid ${C.cardBorder};font-size:8.5px;font-family:Consolas,monospace;`;
row.innerHTML = `<span style="color:${C.goldDim};width:28px;flex-shrink:0;">T${tripNum}</span>
<span style="flex:1;color:${C.textDim};">${date} ${time}</span>
<span style="color:${C.okay};font-weight:700;">+${t.bought} 🧪</span>`;
list.appendChild(row);
});
}
box.appendChild(list);
// Log trip + close buttons
const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;';
const doneBtn = document.createElement('button'); doneBtn.type='button'; doneBtn.textContent='Done';
doneBtn.style.cssText = `flex:1;padding:9px 0;border-radius:7px;border:1px solid ${C.border};background:${C.bg};color:${C.textDim};font-size:11px;cursor:pointer;font-family:Arial,sans-serif;`;
doneBtn.addEventListener('click', () => overlay.remove());
if (run.active) {
const logBtn = document.createElement('button'); logBtn.type='button';
logBtn.textContent = carry > 0 ? `✈ Log Trip (+${carry})` : '✈ Log Trip';
logBtn.style.cssText = `flex:1;padding:9px 0;border-radius:7px;border:1px solid ${C.border};background:${C.card};color:${C.gold};font-size:11px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;`;
logBtn.addEventListener('click', () => {
logXanTrip(run.id);
overlay.remove();
openXanTripLogModal(cfg.getXanRuns().find(r => r.id === run.id) || run, zIndex);
});
btnRow.appendChild(logBtn);
}
btnRow.appendChild(doneBtn);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
function openXanCountryPicker() {
const existing = document.getElementById('lt-xan-ctry-picker');
if (existing) { existing.remove(); return; }
const overlay = document.createElement('div');
overlay.id = 'lt-xan-ctry-picker';
overlay.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000060;display:flex;align-items:center;justify-content:center;padding:16px;`;
overlay.addEventListener('click', e => { if (e.target===overlay) overlay.remove(); });
const box = document.createElement('div');
box.style.cssText = `background:${C.settBg};border:1px solid ${C.settBorder};border-radius:12px;padding:18px;width:100%;max-width:280px;font-family:Arial,sans-serif;`;
const ttl = document.createElement('div'); ttl.textContent = '✈ Xanax Run Country';
ttl.style.cssText = `font-size:12px;font-weight:700;letter-spacing:1px;color:${C.gold};margin-bottom:14px;`;
box.appendChild(ttl);
const FLAGS = {'Mexico':'🇲🇽','Hawaii':'🏝️','South Africa':'🇿🇦','Japan':'🇯🇵','China':'🇨🇳','Argentina':'🇦🇷','Switzerland':'🇨🇭','Canada':'🇨🇦','United Kingdom':'🇬🇧','UAE':'🇦🇪','Cayman Islands':'🇰🇾'};
// Only countries confirmed to have Xanax in YATA overseas stock
// Dynamically check yataCityCache, fallback to known list
const XANAX_COUNTRIES_FALLBACK = ['South Africa','Canada','United Kingdom','Japan'];
const xanaxCountries = Object.entries(yataCityCache).length > 0
? Object.entries(yataCityCache)
.filter(([code, data]) => data.stocks.some(s => s.id === XANAX_ID))
.map(([code]) => Object.keys(TORN_DEST_TO_CODE).find(k => TORN_DEST_TO_CODE[k] === code))
.filter(Boolean)
: XANAX_COUNTRIES_FALLBACK;
const cur = cfg.getXanCountry();
xanaxCountries.forEach(country => {
const btn = document.createElement('button'); btn.type='button';
const isActive = country === cur;
btn.style.cssText = `display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border-radius:7px;border:1px solid ${isActive ? C.gold : C.border};background:${isActive ? C.goldGlow : C.card};margin-bottom:5px;cursor:pointer;font-family:Arial,sans-serif;`;
btn.innerHTML = `<span style="font-size:16px;">${FLAGS[country]||'✈️'}</span><span style="font-size:10.5px;font-weight:${isActive?'700':'400'};color:${isActive?C.gold:C.text};">${country}</span>${isActive?`<span style="margin-left:auto;font-size:9px;color:${C.gold};">✓ Selected</span>`:''}`;
btn.addEventListener('click', () => {
cfg.setXanCountry(country);
overlay.remove();
if (panelEl && activeTab==='xanax') setTimeout(()=>renderPanel(), 0);
});
box.appendChild(btn);
});
const cancelBtn = document.createElement('button'); cancelBtn.type='button'; cancelBtn.textContent='Cancel';
cancelBtn.style.cssText = `width:100%;padding:8px 0;border-radius:7px;border:1px solid ${C.border};background:${C.bg};color:${C.textDim};font-size:11px;cursor:pointer;font-family:Arial,sans-serif;margin-top:4px;`;
cancelBtn.addEventListener('click', () => overlay.remove());
box.appendChild(cancelBtn);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
function openXanRunModal(existing) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.72);z-index:1000010;display:flex;align-items:center;justify-content:center;padding:16px;';
const box = document.createElement('div');
box.style.cssText = `background:${C.settBg};border:1px solid ${C.settBorder};border-radius:12px;padding:18px;width:100%;max-width:290px;font-family:Arial,sans-serif;`;
const title = document.createElement('div');
title.textContent = existing ? '✎ Edit Run' : '✈ New Xanax Run';
title.style.cssText = `font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.gold};margin-bottom:14px;`;
box.appendChild(title);
function field(lbl, placeholder, val, type) {
const g = document.createElement('div'); g.style.cssText = 'margin-bottom:11px;';
const l = document.createElement('div'); l.textContent = lbl;
l.style.cssText = `font-size:9px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;color:${C.goldDim};margin-bottom:4px;`;
const inp = document.createElement('input'); inp.type = type||'text'; inp.placeholder = placeholder;
inp.value = val !== undefined && val !== null ? val : '';
inp.style.cssText = `width:100%;box-sizing:border-box;background:${C.bg};border:1px solid ${C.border};border-radius:6px;color:${C.text};font-size:13px;font-family:Consolas,monospace;padding:7px 10px;outline:none;`;
inp.addEventListener('focus', () => inp.style.borderColor = C.gold);
inp.addEventListener('blur', () => inp.style.borderColor = C.border);
g.appendChild(l); g.appendChild(inp);
box.appendChild(g);
return inp;
}
const clientInp = field('Client / Faction Name', 'Client or Faction name', existing ? existing.client : '');
const qtyInp = field('Contract Qty (xanax)', 'Total Xanax to deliver', existing ? existing.contractQty : '', 'number');
const priceInp = field('Your Sell Price ($ / xanax)', 'Price per Xanax', existing ? existing.manualPrice : '', 'number');
const _modalCtryCache = (() => { const code = TORN_DEST_TO_CODE[cfg.getXanCountry()]||'sou'; const city = yataCityCache[code]; const s = city ? city.stocks.find(x=>x.id===XANAX_ID) : null; return s ? {qty:s.qty,price:s.cost} : (code==='sou'?xanSACache:{qty:0,price:0}); })();
if (_modalCtryCache.price > 0) {
const hint = document.createElement('div');
hint.style.cssText = `font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;margin-top:-7px;margin-bottom:11px;`;
const updateHint = () => {
const margin = (parseInt(priceInp.value)||0) - _modalCtryCache.price;
hint.textContent = `${cfg.getXanCountry()}: $${_modalCtryCache.price.toLocaleString()} — margin: ${margin>=0?'+':''}$${margin.toLocaleString()} /xan`;
};
updateHint();
priceInp.addEventListener('input', updateHint);
box.appendChild(hint);
}
const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;margin-top:4px;';
const saveBtn = document.createElement('button'); saveBtn.type='button';
saveBtn.textContent = existing ? 'Save Changes' : '⚡ Start Run';
saveBtn.style.cssText = `flex:1;padding:9px 0;border-radius:7px;border:1px solid ${C.border};background:${C.card};color:${C.gold};font-size:11px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;`;
const cancelBtn = document.createElement('button'); cancelBtn.type='button'; cancelBtn.textContent='Cancel';
cancelBtn.style.cssText = `padding:9px 14px;border-radius:7px;border:1px solid ${C.border};background:${C.bg};color:${C.textDim};font-size:11px;cursor:pointer;font-family:Arial,sans-serif;`;
cancelBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
saveBtn.addEventListener('click', () => {
const client = clientInp.value.trim();
const qty = parseInt(qtyInp.value) || 0;
const price = parseInt(priceInp.value) || 0;
if (!client) { clientInp.style.borderColor = '#ff4444'; return; }
if (!qty) { qtyInp.style.borderColor = '#ff4444'; return; }
const run = existing ? { ...existing } : {
id: Date.now(), active: true, startedAt: Date.now(),
endedAt: null, trips: [], payment: 'unpaid',
};
run.client = client; run.contractQty = qty;
run.manualPrice = price; run.saPrice = _modalCtryCache.price; run.country = cfg.getXanCountry();
saveXanRun(run);
overlay.remove();
if (panelEl && activeTab === 'xanax') renderXanaxBody();
toast('✈ Run started!', 2000);
});
btnRow.appendChild(saveBtn); btnRow.appendChild(cancelBtn);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
setTimeout(() => clientInp.focus(), 80);
}
function logXanTrip(runId) {
const runs = cfg.getXanRuns();
const run = runs.find(r => r.id === runId);
if (!run) return;
const carry = cfg.getXanCarry() || 0;
run.trips.push({ ts: Date.now(), bought: carry });
saveXanRun(run);
if (panelEl && activeTab === 'xanax') renderXanaxBody();
toast(`✈ Trip logged — +${carry} 🧪`, 1800);
}
function endXanRun(runId) {
const runs = cfg.getXanRuns();
const run = runs.find(r => r.id === runId);
if (!run) return;
run.active = false; run.endedAt = Date.now();
saveXanRun(run);
if (panelEl && activeTab === 'xanax') renderXanaxBody();
toast('✓ Run complete!', 2000);
}
function renderXanRunSection(wrap, secTitle) {
const runs = cfg.getXanRuns();
const active = runs.filter(r => r.active);
const past = runs.filter(r => !r.active);
const sec = document.createElement('div');
// Xanax Runs header with country selector gear
const xanRunsHdr = document.createElement('div');
xanRunsHdr.style.cssText = `display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid ${C.border};`;
const xanRunsTitle = document.createElement('div');
xanRunsTitle.textContent = '✈ Xanax Runs';
xanRunsTitle.style.cssText = `font-size:9px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:${C.goldDim};font-family:Consolas,monospace;`;
// Country selector pill
const xanCtryBtn = document.createElement('button'); xanCtryBtn.type='button';
const _curCtry = cfg.getXanCountry();
const _ctryFlag = (Object.entries(TORN_DEST_TO_CODE).find(([k])=>k===_curCtry)||[])[0] ?
({'Mexico':'🇲🇽','Hawaii':'🏝️','South Africa':'🇿🇦','Japan':'🇯🇵','China':'🇨🇳','Argentina':'🇦🇷','Switzerland':'🇨🇭','Canada':'🇨🇦','United Kingdom':'🇬🇧','UAE':'🇦🇪','Cayman Islands':'🇰🇾'}[_curCtry]||'✈️') : '✈️';
xanCtryBtn.textContent = _ctryFlag + ' ' + _curCtry + ' ⚙';
xanCtryBtn.style.cssText = `font-size:8px;font-family:Consolas,monospace;font-weight:600;color:${C.gold};background:${C.goldGlow};border:1px solid ${C.border};border-radius:10px;padding:2px 8px;cursor:pointer;`;
xanCtryBtn.addEventListener('click', () => openXanCountryPicker());
xanRunsHdr.appendChild(xanRunsTitle); xanRunsHdr.appendChild(xanCtryBtn);
sec.appendChild(xanRunsHdr);
// ── Start button + Priority toggle area ──
const startWrap = document.createElement('div');
startWrap.style.cssText = `background:${C.bg2};border:1px solid ${C.border};border-radius:8px;overflow:hidden;margin-bottom:${(active.length||past.length)?'10px':'0'};`;
// Main start button
const startBtn = document.createElement('button'); startBtn.type='button';
startBtn.style.cssText = `width:100%;padding:10px 0;border-radius:0;font-size:11px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:transparent;border:none;border-bottom:1px solid ${C.border};color:${C.gold};letter-spacing:.5px;`;
startBtn.textContent = '⚡ Start New Run';
startBtn.addEventListener('click', () => openXanRunModal(null));
// Priority toggle row
const priRow = document.createElement('div');
priRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;user-select:none;';
const priChk = document.createElement('input'); priChk.type = 'checkbox';
priChk.checked = cfg.getXanPriority();
priChk.style.cssText = 'width:14px;height:14px;cursor:pointer;accent-color:#00c8e0;flex-shrink:0;';
const priLbl = document.createElement('span');
priLbl.style.cssText = `font-size:9.5px;font-weight:600;color:${cfg.getXanPriority() ? '#00c8e0' : C.text};font-family:Arial,sans-serif;flex:1;transition:color 0.15s;`;
priLbl.textContent = '🚨 Prioritize Personal Runs in Travel Planner';
// Threshold input — only visible when priority is on
const threshWrap = document.createElement('div');
threshWrap.style.cssText = `overflow:hidden;max-height:${cfg.getXanPriority() ? '80px' : '0'};transition:max-height 0.2s ease;border-top:${cfg.getXanPriority() ? `1px solid ${C.border}` : 'none'};`;
const threshInner = document.createElement('div');
threshInner.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 10px;';
const threshLblEl = document.createElement('span');
threshLblEl.style.cssText = `font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;flex:1;`;
threshLblEl.textContent = 'Stop above';
const threshInp = document.createElement('input'); threshInp.type = 'number'; threshInp.min = '0';
threshInp.value = cfg.getXanThreshold();
threshInp.style.cssText = `font-family:Consolas,monospace;font-size:12px;font-weight:700;text-align:center;width:56px;padding:4px 3px;background:${C.bg};border:1px solid ${C.border};border-radius:4px;color:${C.text};outline:none;-moz-appearance:textfield;`;
threshInp.addEventListener('focus', () => threshInp.style.borderColor = 'rgba(0,190,215,0.8)');
threshInp.addEventListener('blur', () => { threshInp.style.borderColor = 'rgba(0,140,170,0.4)'; cfg.setXanThreshold(Math.max(0, parseInt(threshInp.value)||0)); });
threshInp.addEventListener('change', () => cfg.setXanThreshold(Math.max(0, parseInt(threshInp.value)||0)));
const threshUnit = document.createElement('span');
threshUnit.style.cssText = `font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;`;
threshUnit.textContent = 'Xanax';
threshInner.appendChild(threshLblEl); threshInner.appendChild(threshInp); threshInner.appendChild(threshUnit);
threshWrap.appendChild(threshInner);
const togglePriority = () => {
const on = priChk.checked;
cfg.setXanPriority(on);
priLbl.style.color = on ? '#00c8e0' : C.text;
threshWrap.style.maxHeight = on ? '80px' : '0';
threshWrap.style.borderTop = on ? `1px solid ${C.border}` : 'none';
// Defer renderPanel — calling it synchronously destroys the element firing this event
if (panelEl) setTimeout(() => renderPanel(), 0);
};
priRow.addEventListener('click', e => {
if (e.target !== priChk) {
// Toggling checked fires the 'change' event which calls togglePriority
priChk.checked = !priChk.checked;
priChk.dispatchEvent(new Event('change'));
}
// If target IS priChk, the browser already fired 'change' natively — don't double-call
});
priChk.addEventListener('change', togglePriority);
priRow.appendChild(priChk); priRow.appendChild(priLbl);
startWrap.appendChild(startBtn);
startWrap.appendChild(priRow);
startWrap.appendChild(threshWrap);
sec.appendChild(startWrap);
// ── Active runs ──
if (active.length > 0) {
const actHdr = document.createElement('div');
actHdr.textContent = `⚡ ACTIVE (${active.length})`;
actHdr.style.cssText = `font-size:9px;font-weight:700;letter-spacing:1px;color:${C.goldDim};font-family:Consolas,monospace;margin-bottom:6px;`;
sec.appendChild(actHdr);
active.forEach(r => {
const total = r.trips.reduce((s,t) => s+t.bought, 0);
const carry = cfg.getXanCarry() || 0;
const pct = r.contractQty > 0 ? Math.min(100, Math.round(total / r.contractQty * 100)) : 0;
const margin = r.manualPrice && r.saPrice ? r.manualPrice - r.saPrice : null;
const profit = margin !== null && total > 0 ? margin * total : null;
const payCol = r.payment === 'paid' ? C.okay : C.stockMid;
const barCol = pct >= 100 ? C.okay : pct > 50 ? C.gold : C.stockMid;
const card = document.createElement('div');
card.style.cssText = `background:${C.bg2};border:1px solid ${C.border};border-radius:8px;padding:11px;margin-bottom:8px;box-shadow:0 2px 8px rgba(0,0,0,0.12);`;
// Header: client name + edit btn
const hdr = document.createElement('div'); hdr.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:7px;';
const nm = document.createElement('span'); nm.textContent = r.client;
nm.style.cssText = `font-size:13px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;flex:1;`;
const editBtn = document.createElement('button'); editBtn.type='button'; editBtn.textContent='✎';
editBtn.style.cssText = `padding:2px 7px;border-radius:4px;font-size:11px;cursor:pointer;background:${C.card};border:1px solid ${C.border};color:${C.goldDim};font-family:Arial,sans-serif;`;
editBtn.addEventListener('click', () => openXanRunModal(r));
hdr.appendChild(nm); hdr.appendChild(editBtn);
card.appendChild(hdr);
// Progress bar
const pw = document.createElement('div'); pw.style.cssText = `background:${C.card};border-radius:4px;height:7px;margin-bottom:7px;overflow:hidden;`;
const pb = document.createElement('div'); pb.style.cssText = `height:100%;width:${pct}%;background:${barCol};border-radius:4px;transition:width 0.4s;`;
pw.appendChild(pb); card.appendChild(pw);
// Stats grid
const stats = document.createElement('div'); stats.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;margin-bottom:7px;';
[
{ label:'Bought', val:`${total}/${r.contractQty}`, col: pct>=100?'#66bb66':C.okay },
{ label:'Trips', val: r.trips.length, col: C.gold },
{ label:'Progress', val: pct+'%', col: barCol },
].forEach(({ label, val, col }) => {
const c = document.createElement('div');
c.style.cssText = `background:${C.card};border:1px solid ${C.cardBorder};border-radius:5px;padding:5px 3px;text-align:center;`;
c.innerHTML = `<div style="font-size:7.5px;color:${C.textDim};font-family:Consolas,monospace;margin-bottom:1px;">${label}</div>
<div style="font-size:12px;font-weight:700;color:${col};font-family:Consolas,monospace;">${val}</div>`;
stats.appendChild(c);
});
card.appendChild(stats);
// Price strip
if (r.manualPrice || r.saPrice) {
const ps = document.createElement('div'); ps.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;font-size:8px;font-family:Consolas,monospace;margin-bottom:7px;';
const mkTag = (t, col) => { const s=document.createElement('span'); s.textContent=t; s.style.cssText=`color:${col};background:${col}18;border:1px solid ${col}44;border-radius:3px;padding:2px 5px;`; return s; };
if (r.manualPrice) ps.appendChild(mkTag('Sell $'+r.manualPrice.toLocaleString(), C.gold));
if (r.saPrice) ps.appendChild(mkTag('SA $'+r.saPrice.toLocaleString(), C.textDim));
if (margin!==null) { const col=margin>=0?C.okay:C.stockLo; ps.appendChild(mkTag((margin>=0?'+':'')+'$'+margin.toLocaleString()+'/xan', col)); }
if (profit!==null) { const col=profit>=0?C.okay:C.stockLo; ps.appendChild(mkTag((profit>=0?'+':'')+'$'+profit.toLocaleString()+' profit', col)); }
card.appendChild(ps);
}
// Action row: 📋 Log | ✈ Log Trip | payment toggle | ✓ End
const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;';
const logBtn = document.createElement('button'); logBtn.type='button'; logBtn.textContent='📋 Log';
logBtn.style.cssText = `padding:7px 10px;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:${C.card};border:1px solid ${C.border};color:${C.goldDim};`;
logBtn.addEventListener('click', () => openXanTripLogModal(cfg.getXanRuns().find(x=>x.id===r.id)||r));
const tripBtn = document.createElement('button'); tripBtn.type='button';
tripBtn.textContent = carry > 0 ? `✈ +${carry}` : '✈ Trip';
tripBtn.style.cssText = `flex:1;padding:7px 0;border-radius:6px;font-size:10.5px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:${C.card};border:1px solid ${C.border};color:${C.gold};`;
tripBtn.addEventListener('click', () => logXanTrip(r.id));
const payBtn = document.createElement('button'); payBtn.type='button';
payBtn.textContent = r.payment === 'paid' ? '✓ Paid' : '$ Unpaid';
payBtn.style.cssText = `padding:7px 9px;border-radius:6px;font-size:9.5px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:${payCol}18;border:1px solid ${payCol}55;color:${payCol};`;
payBtn.addEventListener('click', () => {
const all = cfg.getXanRuns();
const idx = all.findIndex(x=>x.id===r.id);
if (idx<0) return;
all[idx].payment = all[idx].payment === 'paid' ? 'unpaid' : 'paid';
cfg.setXanRuns(all);
renderXanaxBody();
});
const endBtn = document.createElement('button'); endBtn.type='button'; endBtn.textContent='✓ End';
endBtn.style.cssText = `padding:7px 10px;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:rgba(102,187,102,0.12);border:1px solid rgba(102,187,102,0.45);color:${C.okay};`;
endBtn.addEventListener('click', () => { if (confirm(`End run for ${r.client}?`)) endXanRun(r.id); });
btnRow.appendChild(logBtn); btnRow.appendChild(tripBtn); btnRow.appendChild(payBtn); btnRow.appendChild(endBtn);
card.appendChild(btnRow);
sec.appendChild(card);
});
}
// ── Past runs button ──
if (past.length > 0) {
const pastBtn = document.createElement('button'); pastBtn.type='button';
pastBtn.textContent = `📋 Past Runs (${past.length})`;
pastBtn.style.cssText = `width:100%;padding:9px 0;border-radius:7px;font-size:11px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:${C.card};border:1px solid ${C.border};color:${C.goldDim};letter-spacing:.4px;margin-top:${active.length?'4px':'0'};`;
pastBtn.addEventListener('click', () => openPastRunsOverlay());
sec.appendChild(pastBtn);
}
wrap.appendChild(sec);
}
function openPastRunsOverlay() {
const runs = cfg.getXanRuns();
const past = runs.filter(r => !r.active).slice().reverse();
const overlay = document.createElement('div');
overlay.style.cssText = `position:fixed;inset:0;background:${C.settBg};z-index:1000030;display:flex;flex-direction:column;font-family:Arial,sans-serif;color:${C.text};`;
// ── Header bar ──
const hdr = document.createElement('div');
hdr.style.cssText = `display:flex;align-items:center;gap:10px;padding:14px 16px;background:${C.settHdr};border-bottom:1px solid ${C.border};flex-shrink:0;`;
const htitle = document.createElement('div');
htitle.textContent = `📋 Past Runs (${past.length})`;
htitle.style.cssText = `font-size:13px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;color:${C.gold};flex:1;`;
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.textContent='✕ Close';
closeBtn.style.cssText = `padding:6px 14px;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer;background:${C.card};border:1px solid ${C.border};color:${C.gold};font-family:Arial,sans-serif;`;
closeBtn.addEventListener('click', () => overlay.remove());
hdr.appendChild(htitle); hdr.appendChild(closeBtn);
overlay.appendChild(hdr);
// ── Scrollable list ──
const list = document.createElement('div');
list.style.cssText = `flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:8px;background:${C.bg};`;
if (past.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'No completed runs yet.';
empty.style.cssText = `font-size:11px;color:${C.textDim};font-family:Consolas,monospace;text-align:center;padding:40px 0;`;
list.appendChild(empty);
} else {
past.forEach(r => {
const total = r.trips.reduce((s,t)=>s+t.bought,0);
const profit = r.manualPrice && r.saPrice ? (r.manualPrice - r.saPrice) * total : null;
const payCol = r.payment === 'paid' ? C.okay : C.stockMid;
const card = document.createElement('div');
card.style.cssText = `background:${C.bg2};border:1px solid ${C.border};border-radius:9px;padding:12px 14px;box-shadow:0 2px 8px rgba(0,0,0,0.12);`;
// Top row: client + date
const top = document.createElement('div'); top.style.cssText = 'display:flex;align-items:baseline;gap:6px;margin-bottom:7px;';
const nm = document.createElement('span'); nm.textContent = r.client;
nm.style.cssText = `font-size:14px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;flex:1;`;
const dt = document.createElement('span');
dt.textContent = new Date(r.startedAt).toLocaleDateString([],{month:'short',day:'numeric',year:'numeric'});
dt.style.cssText = `font-size:9px;color:${C.textDim};font-family:Consolas,monospace;`;
top.appendChild(nm); top.appendChild(dt);
// Tags row
const mid = document.createElement('div'); mid.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;font-size:8.5px;font-family:Consolas,monospace;margin-bottom:10px;';
const mkTag = (t, col) => { const s=document.createElement('span'); s.textContent=t; s.style.cssText=`color:${col};background:${col}18;border:1px solid ${col}44;border-radius:3px;padding:2px 7px;`; return s; };
mkTag(r.contractQty + ' contracted', C.textDim);
mid.appendChild(mkTag(r.trips.length + ' trips', C.goldDim));
mid.appendChild(mkTag(total + ' 🧪 delivered', C.okay));
if (r.manualPrice) mid.appendChild(mkTag('$' + r.manualPrice.toLocaleString() + '/xan', C.gold));
if (profit !== null) { const col = profit >= 0 ? C.okay : C.stockLo; mid.appendChild(mkTag((profit>=0?'+':'')+'$'+profit.toLocaleString()+' profit', col)); }
mid.appendChild(mkTag(r.payment === 'paid' ? '✓ Paid' : '$ Unpaid', payCol));
// Actions
const acts = document.createElement('div'); acts.style.cssText = 'display:flex;gap:7px;';
const viewBtn = document.createElement('button'); viewBtn.type='button'; viewBtn.textContent='📋 Trip Log';
viewBtn.style.cssText = `padding:6px 12px;border-radius:5px;font-size:10px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:${C.card};border:1px solid ${C.border};color:${C.goldDim};`;
viewBtn.addEventListener('click', () => openXanTripLogModal(r, 1000040));
if (r.payment !== 'paid') {
const mkPaid = document.createElement('button'); mkPaid.type='button'; mkPaid.textContent='✓ Mark Paid';
mkPaid.style.cssText = `padding:6px 12px;border-radius:5px;font-size:10px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;background:rgba(102,187,102,0.12);border:1px solid rgba(102,187,102,0.45);color:${C.okay};`;
mkPaid.addEventListener('click', () => {
const all = cfg.getXanRuns(); const idx = all.findIndex(x=>x.id===r.id);
if (idx>=0) { all[idx].payment='paid'; cfg.setXanRuns(all); }
// refresh overlay
overlay.remove(); openPastRunsOverlay();
if (panelEl && activeTab === 'xanax') renderXanaxBody();
});
acts.appendChild(mkPaid);
}
const delBtn = document.createElement('button'); delBtn.type='button'; delBtn.textContent='🗑 Delete';
delBtn.style.cssText = `padding:6px 12px;border-radius:5px;font-size:10px;cursor:pointer;font-family:Arial,sans-serif;background:rgba(120,30,30,0.15);border:1px solid rgba(180,50,50,0.4);color:${C.stockLo};margin-left:auto;`;
delBtn.addEventListener('click', () => {
if (!confirm('Delete this run?')) return;
cfg.setXanRuns(cfg.getXanRuns().filter(x=>x.id!==r.id));
overlay.remove();
if (past.length - 1 > 0) openPastRunsOverlay();
if (panelEl && activeTab === 'xanax') renderXanaxBody();
toast('🗑 Deleted');
});
acts.appendChild(viewBtn); acts.appendChild(delBtn);
card.appendChild(top); card.appendChild(mid); card.appendChild(acts);
list.appendChild(card);
});
}
overlay.appendChild(list);
document.body.appendChild(overlay);
}
function renderXanaxBody() {
const body = panelEl.querySelector('.lt-body');
body.innerHTML = '';
const xanCount = xanPersonal;
const xanCarry = cfg.getXanCarry();
// Get live data for selected xanax country (not just SA)
const xanSelectedCountry = cfg.getXanCountry();
const xanSelectedCode = TORN_DEST_TO_CODE[xanSelectedCountry] || 'sou';
const xanSelectedCity = yataCityCache[xanSelectedCode];
const xanSelectedStock = xanSelectedCity ? xanSelectedCity.stocks.find(s => s.id === XANAX_ID) : null;
// Use selected country's YATA data, fallback to xanSACache for South Africa
const xanDisplayCache = xanSelectedCode === 'sou'
? xanSACache
: xanSelectedStock
? { qty: xanSelectedStock.qty, price: xanSelectedStock.cost }
: { qty: 0, price: 0 };
const vis = cfg.getSectionVis();
if (vis.xanax === false) {
body.appendChild(makeEmpty('🧪', 'Xanax section is hidden.<br>Enable it in Settings.'));
return;
}
const wrap = document.createElement('div');
wrap.style.cssText = 'padding:10px;display:flex;flex-direction:column;gap:10px;';
function secTitle(text) {
const t = document.createElement('div'); t.textContent = text;
t.style.cssText = `font-size:9px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:${C.goldDim};margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid rgba(0,140,170,0.3);font-family:Consolas,monospace;`;
return t;
}
// ── Personal count card ──
const cardSec = document.createElement('div'); cardSec.appendChild(secTitle('💊 Supply Count'));
// Never-scraped notice — show redirect banner if count has never been set
const neverScraped = xanPersonal === 0 && cfg.getXanCount() === 0;
if (neverScraped) {
const notice = document.createElement('a');
notice.href = 'https://www.torn.com/item.php';
notice.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;background:rgba(255,160,0,0.08);border:1px solid rgba(255,160,0,0.4);text-decoration:none !important;cursor:pointer;margin-bottom:2px;`;
notice.innerHTML = `<span style="font-size:20px;flex-shrink:0;">📦</span>
<div style="flex:1;">
<div style="font-size:9.5px;font-weight:700;color:#ffaa00;font-family:Arial,sans-serif;letter-spacing:.3px;">VISIT YOUR ITEMS PAGE</div>
<div style="font-size:8.5px;color:rgba(255,160,0,0.9);font-family:Consolas,monospace;margin-top:2px;">Tap to open Items — Sets Tracker will<br>auto-read your Xanax count on arrival.</div>
</div>
<span style="font-size:14px;color:rgba(255,160,0,0.6);">›</span>`;
cardSec.appendChild(notice);
}
const card = document.createElement('div');
card.style.cssText = `background:${C.bg2};border:1px solid ${C.border};border-radius:8px;padding:12px;display:flex;align-items:center;gap:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1);`;
const hasFaction = xanFacCache !== null;
const xImg = document.createElement('img');
xImg.src = itemImg(XANAX_ID); xImg.alt = 'Xanax';
xImg.style.cssText = 'width:44px;height:44px;object-fit:contain;border-radius:3px;flex-shrink:0;';
if (hasFaction) {
// Layout: [Personal] [🧪 img centred] [Faction]
// Personal — left
const persEl = document.createElement('div'); persEl.style.cssText = 'flex:1;text-align:left;';
const persVal = document.createElement('div');
persVal.style.cssText = `font-size:34px;font-weight:700;color:${C.gold};font-family:Consolas,monospace;line-height:1;`;
persVal.textContent = xanCount;
const persLbl = document.createElement('div');
persLbl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:2px;`;
persLbl.textContent = 'Personal';
persEl.appendChild(persVal); persEl.appendChild(persLbl);
// Xanax image — centred between the two counts
xImg.style.cssText = 'width:44px;height:44px;object-fit:contain;border-radius:3px;flex-shrink:0;margin:0 4px;';
// Faction — right
const facEl = document.createElement('div'); facEl.style.cssText = 'flex:1;text-align:right;';
const facVal = document.createElement('div');
facVal.style.cssText = `font-size:34px;font-weight:700;color:${C.green};font-family:Consolas,monospace;line-height:1;`;
facVal.textContent = xanFacCache.toLocaleString();
const facLbl = document.createElement('div');
facLbl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:2px;`;
facLbl.textContent = 'Faction';
facEl.appendChild(facVal); facEl.appendChild(facLbl);
card.appendChild(persEl); card.appendChild(xImg); card.appendChild(facEl);
} else {
// No faction perms — image left, personal count pushed right
const spacer = document.createElement('div'); spacer.style.cssText = 'flex:1;';
const persEl = document.createElement('div'); persEl.style.cssText = 'text-align:right;flex-shrink:0;';
const persVal = document.createElement('div');
persVal.style.cssText = `font-size:36px;font-weight:700;color:${C.gold};font-family:Consolas,monospace;line-height:1;`;
persVal.textContent = xanCount;
const persLbl = document.createElement('div');
persLbl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:2px;`;
persLbl.textContent = 'Personal';
persEl.appendChild(persVal); persEl.appendChild(persLbl);
card.appendChild(xImg); card.appendChild(spacer); card.appendChild(persEl);
}
cardSec.appendChild(card);
// ── Live Data ──
const infSec = document.createElement('div'); infSec.appendChild(secTitle('📡 Live Data'));
const infGrid = document.createElement('div'); infGrid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:8px;';
const ptsEquiv = (xanDisplayCache.price > 0 && pointsPrice > 0)
? (xanDisplayCache.price / pointsPrice).toFixed(2) + ' pts'
: '—';
const ctryShort = xanSelectedCountry === 'South Africa' ? 'SA'
: xanSelectedCountry === 'United Kingdom' ? 'UK'
: xanSelectedCountry === 'Cayman Islands' ? 'KY'
: xanSelectedCountry.slice(0,3).toUpperCase();
[
{ label: ctryShort+' Stock', value: xanDisplayCache.qty > 0 ? xanDisplayCache.qty.toLocaleString() : '0', color: xanDisplayCache.qty > 0 ? C.okay : C.textDim, sub: null },
{ label: ctryShort+' Price', value: xanDisplayCache.price > 0 ? '$' + xanDisplayCache.price.toLocaleString() : '—', color: C.gold, sub: ptsEquiv !== '—' ? ptsEquiv : null },
{ label: 'Carry Lmt', value: xanCarry || '—', color: C.goldDim, sub: null },
].forEach(item => {
const c = document.createElement('div');
c.style.cssText = `background:${C.bg2};border:1px solid ${C.border};border-radius:6px;padding:8px 10px;text-align:center;`;
c.innerHTML = `<div style="font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;letter-spacing:.5px;margin-bottom:3px;">${item.label}</div>
<div style="font-size:16px;font-weight:700;color:${item.color};font-family:Consolas,monospace;">${item.value}</div>
${item.sub ? `<div style="font-size:8px;color:${C.goldDim};font-family:Consolas,monospace;margin-top:2px;">${item.sub}</div>` : ''}`;
infGrid.appendChild(c);
});
infSec.appendChild(infGrid);
// ── Trip Cost card (SA Price × Carry Limit) ──
const saPrice = xanDisplayCache.price || 0;
const tripCost = saPrice * (xanCarry || 0);
const tripCostFormatted = tripCost > 0 ? '$' + tripCost.toLocaleString() : '—';
const tripCostRaw = tripCost > 0 ? String(tripCost) : '';
// Vault destination URLs
// Vault always = Property Vault, pre-fill with trip cost (buy cost + profit target)
const vaultUrl = tripCost > 0
? 'https://www.torn.com/properties.php#/p=options&tab=vault&amount=' + tripCost
: 'https://www.torn.com/properties.php#/p=options&tab=vault';
const vaultLabel = 'Property Vault';
const tripCard = document.createElement('div');
tripCard.style.cssText = `margin-top:8px;background:${C.bg2};border:1px solid ${C.border};border-radius:6px;padding:8px 12px;display:flex;align-items:center;gap:8px;`;
// Left: label
const tripLabel = document.createElement('div');
tripLabel.style.cssText = `font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;letter-spacing:.5px;flex-shrink:0;`;
tripLabel.textContent = 'Trip Cost';
// Middle: vault icon link (centred in the remaining space)
const vaultLink = document.createElement('a');
vaultLink.href = vaultUrl;
vaultLink.title = 'Open ' + vaultLabel;
vaultLink.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;text-decoration:none !important;';
// Vault door SVG
vaultLink.innerHTML = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity:0.55;transition:opacity 0.15s;">
<rect x="2" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5"/>
<circle cx="11" cy="12" r="4" stroke="currentColor" stroke-width="1.4"/>
<circle cx="11" cy="12" r="1.5" fill="currentColor"/>
<line x1="11" y1="8" x2="11" y2="6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="11" y1="18" x2="11" y2="16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="7" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="17" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="20" y1="8" x2="22" y2="8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="20" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="20" y1="16" x2="22" y2="16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>`;
vaultLink.style.color = C.goldDim;
vaultLink.addEventListener('mouseover', () => { vaultLink.querySelector('svg').style.opacity = '1'; vaultLink.style.color = C.gold; });
vaultLink.addEventListener('mouseout', () => { vaultLink.querySelector('svg').style.opacity = '0.55'; vaultLink.style.color = C.goldDim; });
// Right: value + copy icon
const tripValueWrap = document.createElement('div');
tripValueWrap.style.cssText = 'display:flex;align-items:center;gap:6px;flex-shrink:0;';
const tripValue = document.createElement('div');
tripValue.style.cssText = `font-size:16px;font-weight:700;color:${C.gold};font-family:Consolas,monospace;`;
tripValue.textContent = tripCostFormatted;
const copyIcon = document.createElement('span');
copyIcon.style.cssText = `color:${C.goldDim};font-size:11px;opacity:${tripCost > 0 ? '0.55' : '0'};transition:opacity 0.15s;user-select:none;flex-shrink:0;cursor:${tripCost > 0 ? 'pointer' : 'default'};`;
copyIcon.title = 'Click to copy amount (no $ sign)';
copyIcon.textContent = '⎘';
tripValueWrap.appendChild(tripValue);
tripValueWrap.appendChild(copyIcon);
tripCard.appendChild(tripLabel);
tripCard.appendChild(vaultLink);
tripCard.appendChild(tripValueWrap);
if (tripCost > 0) {
// Hover feedback on whole card (but not when hovering the vault link)
tripCard.addEventListener('mouseover', () => { tripCard.style.borderColor = 'rgba(0,200,224,0.4)'; copyIcon.style.opacity = '1'; });
tripCard.addEventListener('mouseout', () => { tripCard.style.borderColor = C.border; copyIcon.style.opacity = '0.55'; });
// Copy on click of the copy icon only
const doCopy = () => {
navigator.clipboard.writeText(tripCostRaw).then(() => {
tripValue.textContent = '✓ Copied!';
tripValue.style.color = C.okay;
setTimeout(() => { tripValue.textContent = tripCostFormatted; tripValue.style.color = C.gold; }, 1400);
}).catch(() => {
try {
const ta = document.createElement('textarea');
ta.value = tripCostRaw; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
tripValue.textContent = '✓ Copied!';
tripValue.style.color = C.okay;
setTimeout(() => { tripValue.textContent = tripCostFormatted; tripValue.style.color = C.gold; }, 1400);
} catch(e) {}
});
};
copyIcon.addEventListener('click', e => { e.stopPropagation(); doCopy(); });
tripValue.style.cursor = 'pointer';
tripValue.addEventListener('click', e => { e.stopPropagation(); doCopy(); });
}
infSec.appendChild(tripCard);
wrap.appendChild(cardSec); wrap.appendChild(infSec);
renderXanRunSection(wrap, secTitle);
body.appendChild(wrap);
}
/* ─────────────────────────────────────────
PURE PROFIT POPUP
───────────────────────────────────────── */
function openProfitPopup() {
const existing = document.getElementById('lt-profit-popup');
if (existing) { existing.remove(); return; }
const SPEED_NAMES = ['Standard', 'Airstrip', 'Private Jet', 'Wind Lines'];
const SPEED_SUB = ['No upgrades', 'Level 1', 'Level 2', 'Level 3'];
// Country buckets (one-way hours at Standard)
const SHORT_CTRY = ['Mexico', 'Hawaii'];
const MEDIUM_CTRY = ['Canada', 'UK', 'Cayman Islands', 'Switzerland', 'Japan'];
const LONG_CTRY = ['Argentina', 'China', 'UAE', 'South Africa'];
// Item type → group key
const TYPE_KEYS = { plushies: 'Plushies', flowers: 'Flowers', prehistoric: 'Prehistoric', special: 'special' };
// Load persisted settings
let curSpeed = cfg.getTravelSpeed();
let curCarry = cfg.getXanCarry() || 1;
let curTypes = { ...cfg.getProfitItemTypes() };
let curCountries= { ...cfg.getProfitCountries() };
// ── Overlay ──
const overlay = document.createElement('div');
overlay.id = 'lt-profit-popup';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.72);z-index:1000050;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;box-sizing:border-box;overflow-y:auto;';
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
const box = document.createElement('div');
box.style.cssText = `background:${C.settBg};border:1px solid ${C.settBorder};border-radius:13px;width:310px;max-width:96vw;font-family:Arial,sans-serif;overflow:hidden;box-shadow:0 16px 60px rgba(0,0,0,0.9);margin-bottom:20px;color:${C.text};`;
// ── Header ──
const hdr = document.createElement('div');
hdr.style.cssText = `display:flex;align-items:center;padding:12px 14px;background:${C.settHdr};border-bottom:1px solid ${C.border};`;
const hdrTitle = document.createElement('div');
hdrTitle.style.cssText = `font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:${C.gold};flex:1;`;
hdrTitle.textContent = '💰 Pure Profit';
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.textContent='✕';
closeBtn.style.cssText = `background:none;border:none;color:${C.goldDim};font-size:14px;cursor:pointer;padding:0 2px;`;
closeBtn.addEventListener('click', () => overlay.remove());
hdr.appendChild(hdrTitle); hdr.appendChild(closeBtn);
box.appendChild(hdr);
// ── Results area ──
const resultsDiv = document.createElement('div');
resultsDiv.style.cssText = 'padding:10px 12px 2px;';
function fmtMoney(n) {
return n >= 1e9 ? `$${(n/1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n/1e6).toFixed(1)}M` : n >= 1e3 ? `$${Math.round(n/1000)}k` : `$${n}`;
}
function renderResults() {
resultsDiv.innerHTML = '';
// Build allowed country set
const allowed = new Set();
if (curCountries.short) SHORT_CTRY.forEach(c => allowed.add(c));
if (curCountries.medium) MEDIUM_CTRY.forEach(c => allowed.add(c));
if (curCountries.long) LONG_CTRY.forEach(c => allowed.add(c));
// Build item list from selected types
const allItems = [];
Object.entries(TYPE_KEYS).forEach(([typeKey, groupName]) => {
if (!curTypes[typeKey]) return;
if (groupName === 'special') {
Object.entries(SPECIAL_ITEMS).forEach(([name, data]) => {
if (allowed.has(data.loc)) allItems.push({ name, loc: data.loc, id: data.id });
});
} else {
const g = GROUPS[groupName];
if (!g) return;
Object.entries(g.items).forEach(([name, data]) => {
if (!BOB_IDS.has(data.id) && allowed.has(data.loc)) allItems.push({ name, loc: data.loc, id: data.id });
});
}
});
// Drugs — Xanax only
if (curTypes.drugs && allowed.has('South Africa')) {
allItems.push({ name: 'Xanax', loc: 'South Africa', id: XANAX_ID });
}
const scored = [];
allItems.forEach(item => {
const cached = yataPriceCache[item.name];
if (!cached || !cached.price) return;
const buyPrice = cached.price;
const sellPrice = item.name === 'Xanax' ? (xanSACache.price || 0) : (marketValueCache[item.name] || 0);
if (!sellPrice || sellPrice <= buyPrice) return;
const profit = sellPrice - buyPrice;
const times = TRAVEL_TIMES[item.loc];
if (!times) return;
const travelHr = times[curSpeed] || times[0];
const totalHr = travelHr * 2 + (90 / 3600);
const profitPerHr = (profit * curCarry) / totalHr;
scored.push({ name: item.name, id: item.id, loc: item.loc, buyPrice, sellPrice, profit, profitPerHr: Math.round(profitPerHr), tripProfit: profit * curCarry, travelHr });
});
scored.sort((a, b) => b.profitPerHr - a.profitPerHr);
const top3 = scored.slice(0, 3);
if (!top3.length) {
const empty = document.createElement('div');
const hasYata = Object.keys(yataPriceCache).length > 0;
const hasMarket = Object.keys(marketValueCache).length > 0;
empty.style.cssText = `font-size:9.5px;color:${C.textDim};font-family:Consolas,monospace;padding:14px 0;text-align:center;`;
empty.textContent = !hasYata ? 'Waiting for YATA price data…' : !hasMarket ? 'Waiting for Torn market values…' : 'No profitable items match your filters';
resultsDiv.appendChild(empty);
return;
}
// Speed pill
const pill = document.createElement('div');
pill.style.cssText = `display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;background:${C.goldGlow};border:1px solid ${C.gold};font-size:8px;font-family:Consolas,monospace;color:${C.gold};margin-bottom:8px;`;
pill.textContent = '✈ ' + SPEED_NAMES[curSpeed] + ' · carry ' + curCarry;
resultsDiv.appendChild(pill);
const medals = ['🥇','🥈','🥉'];
const medalColors = ['#ffd700','#c0c0c0','#cd7f32'];
top3.forEach((item, idx) => {
const loc = LOCATIONS[item.loc] || { flag:'❓', label: item.loc };
const col = medalColors[idx];
const card = document.createElement('div');
card.style.cssText = `display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:7px;background:${col}0d;border:1px solid ${col}33;margin-bottom:5px;`;
const imgWrap = document.createElement('div'); imgWrap.style.cssText = 'position:relative;flex-shrink:0;';
const img = document.createElement('img'); img.src = itemImg(item.id); img.alt = item.name;
img.style.cssText = 'width:32px;height:32px;object-fit:contain;border-radius:3px;border:1px solid rgba(255,255,255,0.08);display:block;';
img.addEventListener('error', () => { img.style.display='none'; });
const medal = document.createElement('span'); medal.textContent = medals[idx];
medal.style.cssText = 'position:absolute;bottom:-4px;right:-4px;font-size:11px;line-height:1;';
imgWrap.appendChild(img); imgWrap.appendChild(medal);
const mid = document.createElement('div'); mid.style.cssText = 'flex:1;min-width:0;';
const nameEl = document.createElement('div'); nameEl.textContent = item.name;
nameEl.style.cssText = `font-size:10px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const subRow = document.createElement('div'); subRow.style.cssText = 'display:flex;align-items:center;gap:4px;margin-top:2px;';
const flagEl = document.createElement('span'); flagEl.textContent = loc.flag; flagEl.style.cssText = 'font-size:11px;';
const locEl = document.createElement('span'); locEl.textContent = loc.label; locEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;`;
const timeEl = document.createElement('span'); timeEl.textContent = item.travelHr + 'h'; timeEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;opacity:0.6;`;
subRow.appendChild(flagEl); subRow.appendChild(locEl); subRow.appendChild(timeEl);
const buyFmt = fmtMoney(item.buyPrice); const sellFmt = fmtMoney(item.sellPrice);
const priceRow = document.createElement('div'); priceRow.style.cssText = 'display:flex;align-items:center;gap:3px;margin-top:1px;';
const buyEl = document.createElement('span'); buyEl.textContent = buyFmt; buyEl.style.cssText = `font-size:7.5px;color:${C.stockLo};font-family:Consolas,monospace;`;
const arrEl = document.createElement('span'); arrEl.textContent = '→'; arrEl.style.cssText = `font-size:7px;color:${C.textDim};`;
const sellEl = document.createElement('span'); sellEl.textContent = sellFmt; sellEl.style.cssText = `font-size:7.5px;color:${C.okay};font-family:Consolas,monospace;`;
priceRow.appendChild(buyEl); priceRow.appendChild(arrEl); priceRow.appendChild(sellEl);
mid.appendChild(nameEl); mid.appendChild(subRow); mid.appendChild(priceRow);
const right = document.createElement('div'); right.style.cssText = 'text-align:right;flex-shrink:0;';
const phrEl = document.createElement('div'); phrEl.textContent = fmtMoney(item.profitPerHr) + '/hr';
phrEl.style.cssText = `font-size:12px;font-weight:700;color:${col};font-family:Consolas,monospace;`;
const tripEl = document.createElement('div'); tripEl.textContent = fmtMoney(item.tripProfit) + ' profit';
tripEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;`;
right.appendChild(phrEl); right.appendChild(tripEl);
card.appendChild(imgWrap); card.appendChild(mid); card.appendChild(right);
resultsDiv.appendChild(card);
});
}
box.appendChild(resultsDiv);
// ── Settings section ──
const settDiv = document.createElement('div');
settDiv.style.cssText = 'padding:10px 12px 14px;border-top:1px solid rgba(255,184,0,0.15);';
function settSecHdr(txt) {
const d = document.createElement('div'); d.textContent = txt;
d.style.cssText = `font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:rgba(255,184,0,0.6);margin:10px 0 6px;padding-bottom:4px;border-bottom:1px solid rgba(255,184,0,0.15);font-family:Consolas,monospace;`;
return d;
}
// Item types
settDiv.appendChild(settSecHdr('Items'));
const typeGrid = document.createElement('div'); typeGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
const typeOpts = [
{ key:'plushies', label:'🧸 Plushies' },
{ key:'flowers', label:'🌸 Flowers' },
{ key:'prehistoric', label:'🪨 Prehistoric' },
{ key:'special', label:'☄️ Special' },
{ key:'drugs', label:'💊 Drugs' },
];
typeOpts.forEach(opt => {
const btn = document.createElement('button'); btn.type='button';
const active = () => curTypes[opt.key];
const setStyle = () => {
btn.style.cssText = `padding:4px 9px;border-radius:5px;font-size:9px;font-weight:700;cursor:pointer;font-family:Arial,sans-serif;transition:all 0.15s;` +
(active() ? 'background:rgba(255,184,0,0.18);border:1px solid rgba(255,184,0,0.55);color:#ffb800;'
: 'background:${C.card};border:1px solid ${C.border};color:${C.textDim};');
};
btn.textContent = opt.label; setStyle();
btn.addEventListener('click', () => {
curTypes[opt.key] = !curTypes[opt.key];
setStyle();
cfg.setProfitItemTypes({ ...curTypes });
renderResults();
});
typeGrid.appendChild(btn);
});
settDiv.appendChild(typeGrid);
// Country filter
settDiv.appendChild(settSecHdr('Countries'));
const countryGrid = document.createElement('div'); countryGrid.style.cssText = 'display:flex;gap:4px;';
const countryOpts = [
{ key:'short', label:'Short', sub:'MX HI' },
{ key:'medium', label:'Medium', sub:'CA UK CH JP KY' },
{ key:'long', label:'Long', sub:'AR CN AE ZA' },
];
countryOpts.forEach(opt => {
const btn = document.createElement('button'); btn.type='button';
const active = () => curCountries[opt.key];
const setStyle = () => {
btn.style.cssText = `flex:1;padding:6px 4px;border-radius:5px;cursor:pointer;text-align:center;font-family:Arial,sans-serif;transition:all 0.15s;` +
(active() ? 'background:rgba(255,184,0,0.18);border:1px solid rgba(255,184,0,0.55);'
: 'background:${C.card};border:1px solid ${C.border};');
};
btn.innerHTML = `<div style="font-size:9px;font-weight:700;color:${active() ? C.gold : C.text};font-family:Arial,sans-serif;">${opt.label}</div><div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${opt.sub}</div>`;
setStyle();
btn.addEventListener('click', () => {
curCountries[opt.key] = !curCountries[opt.key];
// Re-render button innerHTML with updated colour
btn.innerHTML = `<div style="font-size:9px;font-weight:700;color:${active() ? C.gold : C.text};font-family:Arial,sans-serif;">${opt.label}</div><div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${opt.sub}</div>`;
setStyle();
cfg.setProfitCountries({ ...curCountries });
renderResults();
});
countryGrid.appendChild(btn);
});
settDiv.appendChild(countryGrid);
// Travel speed
settDiv.appendChild(settSecHdr('Travel Speed'));
const speedGrid = document.createElement('div'); speedGrid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:4px;';
SPEED_NAMES.forEach((sName, si) => {
const btn = document.createElement('button'); btn.type='button';
const isActive = () => curSpeed === si;
const setStyle = () => {
btn.style.cssText = `padding:6px 4px;border-radius:5px;cursor:pointer;text-align:center;font-family:Arial,sans-serif;transition:all 0.15s;` +
(isActive() ? 'background:rgba(0,200,224,0.15);border:1px solid rgba(0,200,224,0.55);'
: 'background:${C.card};border:1px solid ${C.border};');
};
const renderInner = () => {
btn.innerHTML = `<div style="font-size:9px;font-weight:700;color:${isActive() ? C.gold : C.text};font-family:Arial,sans-serif;">${sName}</div><div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${SPEED_SUB[si]}</div>`;
};
setStyle(); renderInner();
btn.addEventListener('click', () => {
curSpeed = si;
cfg.setTravelSpeed(si);
speedGrid.querySelectorAll('button').forEach((b, bi) => {
b.style.cssText = `padding:6px 4px;border-radius:5px;cursor:pointer;text-align:center;font-family:Arial,sans-serif;transition:all 0.15s;` +
(bi === si ? 'background:rgba(0,200,224,0.15);border:1px solid rgba(0,200,224,0.55);'
: 'background:${C.card};border:1px solid ${C.border};');
const nameDiv = b.querySelector('div');
if (nameDiv) nameDiv.style.color = bi === si ? C.gold : C.textDim;
});
renderResults();
});
speedGrid.appendChild(btn);
});
settDiv.appendChild(speedGrid);
box.appendChild(settDiv);
overlay.appendChild(box);
document.body.appendChild(overlay);
renderResults();
}
/* ─────────────────────────────────────────
TAB: TRAVEL
───────────────────────────────────────── */
function renderTravelBody() {
const body = panelEl.querySelector('.lt-body');
body.innerHTML = '';
const vis = cfg.getSectionVis();
const wrap = document.createElement('div');
wrap.style.cssText = 'padding:10px;display:flex;flex-direction:column;gap:10px;';
function secTitle(text) {
const t = document.createElement('div'); t.textContent = text;
t.style.cssText = `font-size:9px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:${C.goldDim};margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid rgba(0,140,170,0.3);font-family:Consolas,monospace;`;
return t;
}
// ── Pure Profit / Xanax Priority section ──
const _prioCheck = cfg.getXanPriority() && xanPersonal < cfg.getXanThreshold();
if (_prioCheck) {
// Always open section when priority is active
if (typeof renderTravelBody._xanOpen === 'undefined') renderTravelBody._xanOpen = true;
// Priority active — render xanax country cards DIRECTLY (no collapsible wrapper)
const xanSec = document.createElement('div');
// Collapsible header — same pattern as Pure Profit
if (typeof renderTravelBody._xanOpen === 'undefined') renderTravelBody._xanOpen = true;
let xanSecOpen = renderTravelBody._xanOpen;
const xanSecHdr = document.createElement('div');
xanSecHdr.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:4px 10px;font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.goldDim};background:${C.goldGlow};border-top:1px solid ${C.border};border-bottom:1px solid ${C.border};font-family:Consolas,monospace;cursor:pointer;user-select:none;`;
const xanSecLbl = document.createElement('span'); xanSecLbl.textContent = '🧪 Xanax Runs — sorted by $/hr'; xanSecLbl.style.cssText = 'flex:1;';
const xanSecChev = document.createElement('span');
xanSecChev.style.cssText = `color:${C.goldDim};font-size:10px;display:inline-block;transition:transform 0.2s;transform:${xanSecOpen ? 'rotate(180deg)' : 'rotate(0deg)'};`;
xanSecChev.textContent = '▾';
xanSecHdr.appendChild(xanSecLbl); xanSecHdr.appendChild(xanSecChev);
xanSec.appendChild(xanSecHdr);
const xanContent = document.createElement('div');
xanContent.style.cssText = `overflow:hidden;max-height:${xanSecOpen ? '2000px' : '0'};transition:max-height 0.25s ease;padding:${xanSecOpen ? '8px 0 2px' : '0'};`;
xanSecHdr.addEventListener('click', () => {
xanSecOpen = !xanSecOpen;
renderTravelBody._xanOpen = xanSecOpen;
xanContent.style.maxHeight = xanSecOpen ? '2000px' : '0';
xanContent.style.padding = xanSecOpen ? '8px 0 2px' : '0';
xanSecChev.style.transform = xanSecOpen ? 'rotate(180deg)' : 'rotate(0deg)';
});
const speedTier2 = cfg.getTravelSpeed();
const carry2 = cfg.getXanCarry() || 1;
const fmt2 = n => n >= 1e6 ? '$'+(n/1e6).toFixed(1)+'M' : n >= 1e3 ? '$'+Math.round(n/1000)+'k' : '$'+n;
// Use active run sell price as the best proxy for Xanax sell value.
// Torn market_value for drugs is 0 — we need a user-defined or run-based price.
const _xanRuns2 = cfg.getXanRuns();
const _activeRun2 = _xanRuns2.find(r => r.active && r.manualPrice > 0);
const xanSellPrice2 = _activeRun2 ? _activeRun2.manualPrice
: (marketValueCache['Xanax'] > 0 ? marketValueCache['Xanax'] : 0);
const xanCtries2 = Object.entries(yataCityCache)
.map(([code, data]) => {
const s = data.stocks.find(st => st.id === XANAX_ID);
if (!s) return null; // show even 0-stock countries so user can plan
const cKey = Object.keys(TORN_DEST_TO_CODE).find(k => TORN_DEST_TO_CODE[k] === code);
const times = cKey ? TRAVEL_TIMES[cKey] : null;
const tHr = times ? (times[speedTier2] || times[0]) : 99;
const totalHr = tHr * 2 + (90/3600);
const profit = xanSellPrice2 > 0 && xanSellPrice2 > s.cost ? xanSellPrice2 - s.cost : 0;
// If no sell price set, use YATA buy price / r/t as efficiency metric
const profPerHr = profit > 0 ? Math.round((profit * carry2) / totalHr) : 0;
const hasRealProfit = profit > 0;
const loc = cKey ? (LOCATIONS[cKey] || {flag:'✈️', label:cKey}) : {flag:'✈️', label:data.city};
return { code, data, s, cKey, loc, tHr, profit, tripP: profit*carry2, tripCostV: s.cost*carry2, profPerHr, sellPrice: xanSellPrice2, hasRealProfit };
})
.filter(Boolean)
.sort((a, b) => b.profPerHr - a.profPerHr);
if (!xanCtries2.length) {
const empty = document.createElement('div');
empty.style.cssText = `font-size:9.5px;color:${C.textDim};font-family:Consolas,monospace;padding:8px 10px;text-align:center;`;
empty.textContent = 'No Xanax overseas stock found — try refreshing';
xanContent.appendChild(empty);
} else {
xanCtries2.forEach(({ code, data, s, loc, tHr, profit, tripP, tripCostV, profPerHr, hasRealProfit }) => {
const stockH = cfg.getStockHistory('Xanax_' + code);
const xWrap = document.createElement('div');
xWrap.style.cssText = `border-radius:7px;border:1px solid ${C.border};background:${C.card};margin-bottom:5px;overflow:hidden;`;
const xHdr = document.createElement('div');
xHdr.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;position:relative;padding-right:20px;';
xHdr.addEventListener('mouseover', () => xHdr.style.background = C.goldGlow);
xHdr.addEventListener('mouseout', () => xHdr.style.background = 'transparent');
const xImg = document.createElement('img'); xImg.src = itemImg(XANAX_ID); xImg.alt='Xanax';
xImg.style.cssText = 'width:32px;height:32px;object-fit:contain;border-radius:3px;border:1px solid rgba(255,255,255,0.08);flex-shrink:0;';
xImg.addEventListener('error', ()=>xImg.style.display='none');
const xMid = document.createElement('div'); xMid.style.cssText = 'flex:1;min-width:0;';
xMid.innerHTML = `<div style="font-size:10px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;">${data.city}</div>
<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">
<span style="font-size:13px;">${loc.flag}</span>
<span style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;">${loc.label} · ${tHr}h r/t</span>
</div>`;
const xRight = document.createElement('div'); xRight.style.cssText = 'text-align:right;flex-shrink:0;';
const prColor = hasRealProfit ? C.okay : C.goldDim;
const prLabel = hasRealProfit ? fmt2(profPerHr)+'/hr' : fmt2(profPerHr)+'/hr ⓘ';
xRight.innerHTML = `<div style="font-size:12px;font-weight:700;color:${prColor};font-family:Consolas,monospace;" title="${hasRealProfit ? 'Profit/hr based on run price' : 'Buy cost/hr — set a run price for real profit'}">${prLabel}</div>
<div style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${s.qty.toLocaleString()} 🧪</div>`;
const xChev = document.createElement('span');
xChev.style.cssText = `position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;color:${C.textDim};transition:transform 0.18s;`;
xChev.textContent = '▾';
xHdr.appendChild(xImg); xHdr.appendChild(xMid); xHdr.appendChild(xRight); xHdr.appendChild(xChev);
let xOpen=false, xPop=false;
const xDetail = document.createElement('div');
xDetail.style.cssText = `overflow:hidden;max-height:0;transition:max-height 0.25s ease;background:${C.bg};`;
xHdr.addEventListener('click', () => {
xOpen=!xOpen;
xChev.style.transform = xOpen?'translateY(-50%) rotate(180deg)':'translateY(-50%)';
xDetail.style.maxHeight = xOpen?'400px':'0';
if (xOpen && !xPop) {
xPop=true;
const xInner = document.createElement('div'); xInner.style.cssText='padding:8px 10px;display:flex;flex-direction:column;gap:6px;';
const xGrid = document.createElement('div'); xGrid.style.cssText='display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
[
{label:'Stock', value:s.qty.toLocaleString(), color:C.okay},
{label:'Buy/ea', value:fmt2(s.cost), color:C.stockLo},
{label:'Sell/ea', value:xanSellPrice2>0?fmt2(xanSellPrice2):'Set run price', color:xanSellPrice2>0?C.okay:C.textDim},
{label:'Profit/ea', value:profit>0?fmt2(profit):'—', color:'#66dd66'},
{label:'Trip Cost', value:fmt2(tripCostV), color:C.textDim},
{label:'Trip Profit',value:profit>0?fmt2(tripP):'—',color:'#66dd66'},
{label:'Travel', value:tHr+'h r/t', color:C.textDim},
{label:'$/hr', value:profPerHr>0?fmt2(profPerHr):'—',color:C.okay},
{label:'Carry', value:carry2+' 🧪', color:C.goldDim},
].forEach(({label,value,color})=>{
const cell=document.createElement('div');
cell.style.cssText=`background:${C.card};border:1px solid ${C.cardBorder};border-radius:4px;padding:5px 4px;text-align:center;`;
cell.innerHTML=`<div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-bottom:2px;">${label}</div><div style="font-size:9px;font-weight:700;color:${color};font-family:Consolas,monospace;">${value}</div>`;
xGrid.appendChild(cell);
});
xInner.appendChild(xGrid);
if (stockH.length>=2) {
const sLbl=document.createElement('div'); sLbl.style.cssText=`font-size:8px;font-weight:700;color:${C.goldDim};font-family:Consolas,monospace;`;
sLbl.textContent='📦 YATA Xanax Stock History ('+stockH.length+' fetches)';
xInner.appendChild(sLbl);
const vals=stockH.map(h=>h.qty!==undefined?h.qty:h.price);
const minV=Math.min(...vals),maxV=Math.max(...vals),rng=maxV-minV||1;
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('viewBox','0 0 260 36'); svg.setAttribute('width','100%'); svg.setAttribute('height','36');
const pts=vals.map((v,i)=>[4+(i/(vals.length-1))*252, 4+(1-(v-minV)/rng)*28]);
const pd='M'+pts.map(([x,y])=>x.toFixed(1)+','+y.toFixed(1)).join('L');
const f=document.createElementNS('http://www.w3.org/2000/svg','path'); f.setAttribute('d',pd+'L'+pts[pts.length-1][0].toFixed(1)+',36L'+pts[0][0].toFixed(1)+',36Z'); f.setAttribute('fill','rgba(102,187,102,0.1)');
const l=document.createElementNS('http://www.w3.org/2000/svg','path'); l.setAttribute('d',pd); l.setAttribute('fill','none'); l.setAttribute('stroke','rgba(102,187,102,0.8)'); l.setAttribute('stroke-width','1.5');
const d=document.createElementNS('http://www.w3.org/2000/svg','circle'); d.setAttribute('cx',pts[pts.length-1][0].toFixed(1)); d.setAttribute('cy',pts[pts.length-1][1].toFixed(1)); d.setAttribute('r','3'); d.setAttribute('fill','#66cc66');
svg.appendChild(f); svg.appendChild(l); svg.appendChild(d);
xInner.appendChild(svg);
}
xDetail.appendChild(xInner);
}
});
xWrap.appendChild(xHdr); xWrap.appendChild(xDetail);
xanContent.appendChild(xWrap);
});
}
xanSec.appendChild(xanContent);
wrap.appendChild(xanSec);
} else {
(function renderPureProfit() {
const profitSec = document.createElement('div');
const speedTier = cfg.getTravelSpeed();
const carry = cfg.getXanCarry() || 1;
const speedNames = ['Standard', 'Airstrip', 'Private Jet', 'Wind Lines'];
const curTypes = cfg.getProfitItemTypes();
const curCtry = cfg.getProfitCountries();
const SHORT_CTRY = ['Mexico', 'Hawaii'];
const MEDIUM_CTRY = ['Canada', 'UK', 'Cayman Islands', 'Switzerland', 'Japan'];
const LONG_CTRY = ['Argentina', 'China', 'UAE', 'South Africa'];
const TYPE_KEYS = { plushies: 'Plushies', flowers: 'Flowers', prehistoric: 'Prehistoric', special: 'special' };
if (typeof renderPureProfit._open === 'undefined') renderPureProfit._open = false;
let isOpen = renderPureProfit._open;
// Check xanax priority early — force section open if active
const _xp2thresh_early = cfg.getXanThreshold();
const xanPrioActive2 = cfg.getXanPriority() && xanPersonal < _xp2thresh_early;
if (xanPrioActive2 && !isOpen) {
isOpen = true;
renderPureProfit._open = true;
}
// ── Header row: title | ⚙ | ▾ ──
const hdrRow = document.createElement('div');
hdrRow.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:4px 10px;font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.goldDim};background:rgba(0,60,80,0.15);border-top:1px solid rgba(0,140,180,0.2);border-bottom:1px solid rgba(0,140,180,0.15);font-family:Consolas,monospace;user-select:none;`;
const hdrLeft = document.createElement('span');
hdrLeft.style.cssText = 'cursor:pointer;flex:1;';
hdrLeft.textContent = '💰 Pure Profit';
const hdrRight = document.createElement('div');
hdrRight.style.cssText = 'display:flex;align-items:center;gap:6px;';
// ⚙ settings icon
const gearBtn = document.createElement('span');
gearBtn.title = 'Profit settings';
gearBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.93 2.93l1.41 1.41M11.66 11.66l1.41 1.41M2.93 13.07l1.41-1.41M11.66 4.34l1.41-1.41" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>';
gearBtn.style.cssText = `cursor:pointer;display:flex;align-items:center;color:${C.goldDim};opacity:0.7;transition:opacity 0.15s;padding:2px;`;
gearBtn.addEventListener('mouseover', () => { gearBtn.style.opacity = '1'; gearBtn.style.color = C.gold; });
gearBtn.addEventListener('mouseout', () => { gearBtn.style.opacity = '0.7'; gearBtn.style.color = C.goldDim; });
gearBtn.addEventListener('click', e => { e.stopPropagation(); openProfitPopup(); });
// ▾ chevron
const chevron = document.createElement('span');
chevron.style.cssText = `color:${C.gold};font-size:10px;display:inline-block;transition:transform 0.2s;transform:${isOpen ? 'rotate(180deg)' : 'rotate(0deg)'};cursor:pointer;`;
chevron.textContent = '▾';
hdrRight.appendChild(gearBtn);
hdrRight.appendChild(chevron);
hdrRow.appendChild(hdrLeft);
hdrRow.appendChild(hdrRight);
profitSec.appendChild(hdrRow);
// clicking title or chevron toggles open/close
const toggleOpen = () => {
isOpen = !isOpen;
renderPureProfit._open = isOpen;
content.style.maxHeight = isOpen ? '600px' : '0';
content.style.padding = isOpen ? '8px 0 2px' : '0';
chevron.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)';
};
hdrLeft.addEventListener('click', toggleOpen);
chevron.addEventListener('click', toggleOpen);
// Collapsible content
const content = document.createElement('div');
content.style.cssText = `overflow:hidden;transition:max-height 0.25s ease;max-height:${isOpen ? '600px' : '0'};padding:${isOpen ? '8px 0 2px' : '0'};`;
// Build allowed country set
const allowed = new Set();
if (curCtry.short) SHORT_CTRY.forEach(c => allowed.add(c));
if (curCtry.medium) MEDIUM_CTRY.forEach(c => allowed.add(c));
if (curCtry.long) LONG_CTRY.forEach(c => allowed.add(c));
// Build item list
const allItems = [];
Object.entries(TYPE_KEYS).forEach(([typeKey, groupName]) => {
if (!curTypes[typeKey]) return;
if (groupName === 'special') {
Object.entries(SPECIAL_ITEMS).forEach(([name, data]) => {
if (allowed.has(data.loc)) allItems.push({ name, loc: data.loc, id: data.id });
});
} else {
const g = GROUPS[groupName]; if (!g) return;
Object.entries(g.items).forEach(([name, data]) => {
if (!BOB_IDS.has(data.id) && allowed.has(data.loc)) allItems.push({ name, loc: data.loc, id: data.id });
});
}
});
if (curTypes.drugs && allowed.has('South Africa')) {
allItems.push({ name: 'Xanax', loc: 'South Africa', id: XANAX_ID });
}
// Score
const scored = [];
allItems.forEach(item => {
const cached = yataPriceCache[item.name];
if (!cached || !cached.price) return;
const buyPrice = cached.price;
const sellPrice = item.name === 'Xanax' ? (xanSACache.price || 0) : (marketValueCache[item.name] || 0);
if (!sellPrice || sellPrice <= buyPrice) return;
const profit = sellPrice - buyPrice;
const times = TRAVEL_TIMES[item.loc]; if (!times) return;
const travelHr = times[speedTier] || times[0];
const totalHr = travelHr * 2 + (90 / 3600);
scored.push({
name: item.name, id: item.id, loc: item.loc,
buyPrice, sellPrice, profit,
profitPerHr: Math.round((profit * carry) / totalHr),
tripProfit: profit * carry,
travelHr,
});
});
scored.sort((a, b) => b.profitPerHr - a.profitPerHr);
const top3 = scored.slice(0, 3);
// ── When xanax priority active AND below threshold, replace top-3 with xanax country cards ──
// (xanPrioActive2 already computed above, isOpen already forced open)
if (xanPrioActive2) {
const _xanRuns3 = cfg.getXanRuns();
const _activeRun3 = _xanRuns3.find(r => r.active && r.manualPrice > 0);
const xanSellPrice3 = _activeRun3 ? _activeRun3.manualPrice
: (marketValueCache['Xanax'] > 0 ? marketValueCache['Xanax'] : 0);
const xanCtries = Object.entries(yataCityCache)
.map(([code, data]) => {
const s = data.stocks.find(st => st.id === XANAX_ID);
if (!s || s.qty <= 0) return null;
const cKey = Object.keys(TORN_DEST_TO_CODE).find(k => TORN_DEST_TO_CODE[k] === code);
const times = cKey ? TRAVEL_TIMES[cKey] : null;
const tHr = times ? (times[speedTier] || times[0]) : 99;
const totalHr = tHr * 2 + (90/3600);
const profit = xanSellPrice3 > s.cost ? xanSellPrice3 - s.cost : 0;
const tripP = profit * carry;
const tripCostV = s.cost * carry;
const profPerHr = profit > 0 ? Math.round((profit * carry) / totalHr) : 0;
const hasRealProfit3 = profit > 0;
const loc = cKey ? (LOCATIONS[cKey] || {flag:'✈️', label: cKey}) : {flag:'✈️', label: data.city};
return { code, data, s, cKey, loc, tHr, profit, tripP, tripCostV, profPerHr };
})
.filter(Boolean)
.sort((a, b) => b.profPerHr - a.profPerHr);
if (!xanCtries.length) {
const empty2 = document.createElement('div');
empty2.style.cssText = `font-size:9.5px;color:${C.textDim};font-family:Consolas,monospace;padding:8px 10px;text-align:center;`;
empty2.textContent = 'No Xanax overseas stock found — try refreshing';
content.appendChild(empty2);
} else {
const xHdr2 = document.createElement('div');
xHdr2.style.cssText = `display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;background:${C.goldGlow};border:1px solid ${C.border};font-size:8px;font-family:Consolas,monospace;color:${C.goldDim};margin:0 10px 7px;`;
xHdr2.textContent = '🧪 Xanax Runs — sorted by $/hr';
content.appendChild(xHdr2);
const fmt2 = n => n >= 1e6 ? '$'+(n/1e6).toFixed(1)+'M' : n >= 1e3 ? '$'+Math.round(n/1000)+'k' : '$'+n;
xanCtries.forEach(({ data, s, loc, tHr, profit, tripP, tripCostV, profPerHr }) => {
const stockH2 = cfg.getStockHistory('Xanax_' + code);
const xWrap = document.createElement('div');
xWrap.style.cssText = `border-radius:7px;border:1px solid ${C.border};background:${C.card};margin:0 8px 5px;overflow:hidden;`;
const xCardHdr = document.createElement('div');
xCardHdr.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;position:relative;padding-right:20px;';
xCardHdr.addEventListener('mouseover', () => xCardHdr.style.background = C.goldGlow);
xCardHdr.addEventListener('mouseout', () => xCardHdr.style.background = 'transparent');
const xImgEl = document.createElement('img'); xImgEl.src = itemImg(XANAX_ID); xImgEl.alt = 'Xanax';
xImgEl.style.cssText = 'width:32px;height:32px;object-fit:contain;border-radius:3px;border:1px solid rgba(255,255,255,0.08);flex-shrink:0;';
xImgEl.addEventListener('error', () => xImgEl.style.display='none');
const xMidEl = document.createElement('div'); xMidEl.style.cssText = 'flex:1;min-width:0;';
xMidEl.innerHTML = `<div style="font-size:10px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;">${data.city}</div>
<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">
<span style="font-size:13px;">${loc.flag}</span>
<span style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;">${loc.label} · ${tHr}h r/t</span>
</div>`;
const xRightEl = document.createElement('div'); xRightEl.style.cssText = 'text-align:right;flex-shrink:0;';
xRightEl.innerHTML = `<div style="font-size:12px;font-weight:700;color:${C.okay};font-family:Consolas,monospace;">${fmt2(profPerHr)}/hr</div>
<div style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${s.qty.toLocaleString()} 🧪</div>`;
const xChev = document.createElement('span');
xChev.style.cssText = `position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;color:${C.textDim};transition:transform 0.18s;`;
xChev.textContent = '▾';
xCardHdr.appendChild(xImgEl); xCardHdr.appendChild(xMidEl); xCardHdr.appendChild(xRightEl); xCardHdr.appendChild(xChev);
let xOpen = false;
const xDetailEl = document.createElement('div');
xDetailEl.style.cssText = `overflow:hidden;max-height:0;transition:max-height 0.25s ease;background:${C.bg};`;
let xPop = false;
xCardHdr.addEventListener('click', () => {
xOpen = !xOpen;
xChev.style.transform = xOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)';
xDetailEl.style.maxHeight = xOpen ? '400px' : '0';
if (xOpen && !xPop) {
xPop = true;
const xInner = document.createElement('div'); xInner.style.cssText = 'padding:8px 10px;display:flex;flex-direction:column;gap:6px;';
const xGrid = document.createElement('div'); xGrid.style.cssText = 'display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
[
{ label:'Stock', value:s.qty.toLocaleString(), color:C.okay },
{ label:'Buy/ea', value:fmt2(s.cost), color:C.stockLo },
{ label:'Sell/ea', value:xanSellPrice3>0?fmt2(xanSellPrice3):'Set run price', color:xanSellPrice3>0?C.okay:C.textDim },
{ label:'Profit/ea', value:profit>0?fmt2(profit):'—', color:'#66dd66' },
{ label:'Trip Cost', value:fmt2(tripCostV), color:C.textDim },
{ label:'Trip Profit', value:profit>0?fmt2(tripP):'—', color:'#66dd66' },
{ label:'Travel', value:tHr+'h r/t', color:C.textDim },
{ label:'$/hr', value:profPerHr>0?fmt2(profPerHr):'—', color:'#66cc66' },
{ label:'Carry', value:carry+' 🧪', color:C.goldDim },
].forEach(({ label, value, color }) => {
const cell = document.createElement('div');
cell.style.cssText = 'background:${C.card};border:1px solid ${C.cardBorder};border-radius:4px;padding:5px 4px;text-align:center;';
cell.innerHTML = `<div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-bottom:2px;">${label}</div><div style="font-size:9px;font-weight:700;color:${color};font-family:Consolas,monospace;">${value}</div>`;
xGrid.appendChild(cell);
});
xInner.appendChild(xGrid);
if (stockH2.length >= 2) {
const sLbl2 = document.createElement('div'); sLbl2.style.cssText = `font-size:8px;font-weight:700;letter-spacing:.5px;color:${C.goldDim};font-family:Consolas,monospace;`;
sLbl2.textContent = '📦 YATA Xanax Stock History ('+stockH2.length+' fetches)';
xInner.appendChild(sLbl2);
const vals2 = stockH2.map(h => h.qty !== undefined ? h.qty : h.price);
const minV2=Math.min(...vals2), maxV2=Math.max(...vals2), rng2=maxV2-minV2||1;
const W3=260,H3=36,p3=4;
const svg3=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg3.setAttribute('viewBox','0 0 '+W3+' '+H3); svg3.setAttribute('width','100%'); svg3.setAttribute('height',H3);
const pts3=vals2.map((v,i)=>[p3+(i/(vals2.length-1))*(W3-p3*2), p3+(1-(v-minV2)/rng2)*(H3-p3*2)]);
const pd3='M'+pts3.map(([x,y])=>x.toFixed(1)+','+y.toFixed(1)).join('L');
const f3=document.createElementNS('http://www.w3.org/2000/svg','path'); f3.setAttribute('d',pd3+'L'+pts3[pts3.length-1][0].toFixed(1)+','+H3+'L'+pts3[0][0].toFixed(1)+','+H3+'Z'); f3.setAttribute('fill','rgba(102,187,102,0.1)');
const l3=document.createElementNS('http://www.w3.org/2000/svg','path'); l3.setAttribute('d',pd3); l3.setAttribute('fill','none'); l3.setAttribute('stroke','rgba(102,187,102,0.8)'); l3.setAttribute('stroke-width','1.5');
const d3=document.createElementNS('http://www.w3.org/2000/svg','circle'); d3.setAttribute('cx',pts3[pts3.length-1][0].toFixed(1)); d3.setAttribute('cy',pts3[pts3.length-1][1].toFixed(1)); d3.setAttribute('r','3'); d3.setAttribute('fill','#66cc66');
svg3.appendChild(f3); svg3.appendChild(l3); svg3.appendChild(d3);
xInner.appendChild(svg3);
}
xDetailEl.appendChild(xInner);
}
});
xWrap.appendChild(xCardHdr); xWrap.appendChild(xDetailEl);
content.appendChild(xWrap);
});
}
} else if (!top3.length) {
const empty = document.createElement('div');
empty.style.cssText = `font-size:9.5px;color:${C.textDim};font-family:Consolas,monospace;padding:8px 10px;text-align:center;`;
const hasYata = Object.keys(yataPriceCache).length > 0;
const hasMarket = Object.keys(marketValueCache).length > 0;
empty.textContent = !hasYata ? 'Waiting for YATA price data…'
: !hasMarket ? 'Waiting for Torn market values…'
: 'No profitable items match your filters';
content.appendChild(empty);
} else {
const tierPill = document.createElement('div');
tierPill.style.cssText = `display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;background:rgba(0,200,224,0.08);border:1px solid rgba(0,200,224,0.25);font-size:8px;font-family:Consolas,monospace;color:${C.goldDim};margin:0 10px 7px;`;
tierPill.textContent = '✈ ' + speedNames[speedTier];
content.appendChild(tierPill);
const medals = ['🥇','🥈','🥉'];
const medalColors = ['#ffd700','#c0c0c0','#cd7f32'];
top3.forEach((item, idx) => {
const loc = LOCATIONS[item.loc] || { flag:'❓', label: item.loc };
const col = medalColors[idx];
const fmt = n => n >= 1e9 ? `$${(n/1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n/1e6).toFixed(1)}M` : n >= 1e3 ? `$${Math.round(n/1000)}k` : `$${n}`;
const fmtPts = n => pointsPrice > 0 ? (n/pointsPrice).toFixed(2)+' pts' : '';
const phrFmt = fmt(item.profitPerHr) + '/hr';
const tripFmt = fmt(item.tripProfit) + ' profit';
const buyFmt = fmt(item.buyPrice);
const sellFmt = fmt(item.sellPrice);
const stockH = cfg.getStockHistory(item.name);
// Expandable wrapper
const cardWrap = document.createElement('div');
cardWrap.style.cssText = `border-radius:7px;border:1px solid ${col}33;background:${col}0d;margin:0 8px 5px;overflow:hidden;`;
// Header (always visible)
const cardHdr = document.createElement('div');
cardHdr.style.cssText = `display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;position:relative;padding-right:20px;`;
cardHdr.addEventListener('mouseover', () => cardHdr.style.background = col+'18');
cardHdr.addEventListener('mouseout', () => cardHdr.style.background = 'transparent');
const imgWrap = document.createElement('div'); imgWrap.style.cssText = 'position:relative;flex-shrink:0;';
const img = document.createElement('img'); img.src = itemImg(item.id); img.alt = item.name;
img.style.cssText = 'width:32px;height:32px;object-fit:contain;border-radius:3px;border:1px solid rgba(255,255,255,0.08);display:block;';
img.addEventListener('error', () => { img.style.display='none'; });
const medal = document.createElement('span'); medal.textContent = medals[idx];
medal.style.cssText = 'position:absolute;bottom:-4px;right:-4px;font-size:11px;line-height:1;';
imgWrap.appendChild(img); imgWrap.appendChild(medal);
const mid = document.createElement('div'); mid.style.cssText = 'flex:1;min-width:0;';
const nameEl = document.createElement('div'); nameEl.textContent = item.name;
nameEl.style.cssText = `font-size:10px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const subRow = document.createElement('div'); subRow.style.cssText = 'display:flex;align-items:center;gap:4px;margin-top:2px;';
const flagEl = document.createElement('span'); flagEl.textContent = loc.flag; flagEl.style.cssText = 'font-size:11px;';
const locEl = document.createElement('span'); locEl.textContent = loc.label; locEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;`;
const timeEl = document.createElement('span'); timeEl.textContent = item.travelHr+'h'; timeEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;opacity:0.6;`;
subRow.appendChild(flagEl); subRow.appendChild(locEl); subRow.appendChild(timeEl);
mid.appendChild(nameEl); mid.appendChild(subRow);
const right = document.createElement('div'); right.style.cssText = 'text-align:right;flex-shrink:0;';
const phrEl = document.createElement('div'); phrEl.textContent = phrFmt;
phrEl.style.cssText = `font-size:12px;font-weight:700;color:${col};font-family:Consolas,monospace;`;
const tripEl2 = document.createElement('div'); tripEl2.textContent = tripFmt;
tripEl2.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;`;
right.appendChild(phrEl); right.appendChild(tripEl2);
const profChev = document.createElement('span');
profChev.style.cssText = `position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;color:${C.textDim};transition:transform 0.18s;`;
profChev.textContent = '▾';
cardHdr.appendChild(imgWrap); cardHdr.appendChild(mid); cardHdr.appendChild(right); cardHdr.appendChild(profChev);
// Expandable detail
let pOpen = false;
const pDetail = document.createElement('div');
pDetail.style.cssText = `overflow:hidden;max-height:0;transition:max-height 0.25s ease;background:${C.bg};border-top:0px solid ${col}22;`;
let pPopulated = false;
cardHdr.addEventListener('click', () => {
pOpen = !pOpen;
profChev.style.transform = pOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)';
pDetail.style.maxHeight = pOpen ? '400px' : '0';
pDetail.style.borderTopWidth = pOpen ? '1px' : '0';
if (pOpen && !pPopulated) {
pPopulated = true;
const pInner = document.createElement('div'); pInner.style.cssText = 'padding:8px 10px;display:flex;flex-direction:column;gap:6px;';
// Stat grid
const pGrid = document.createElement('div'); pGrid.style.cssText = 'display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
const ptsEq = fmtPts(item.buyPrice);
[
{ label:'Item ID', value:'#'+item.id, color:C.textDim },
{ label:'Buy Price', value:buyFmt, color:C.stockLo },
{ label:'Sell Value', value:sellFmt, color:C.okay },
{ label:'Profit/item', value:fmt(item.profit), color:'#66dd66' },
{ label:'Trip Profit', value:tripFmt, color:col },
{ label:'$/hr', value:phrFmt, color:col },
{ label:'Trip Cost', value:fmt(item.buyPrice*carry), color:C.textDim },
{ label:'Travel Time', value:item.travelHr+'h r/t', color:C.textDim },
{ label:'Pts Equiv', value:ptsEq||'—', color:C.goldDim },
].forEach(({ label, value, color }) => {
const cell = document.createElement('div');
cell.style.cssText = `background:${C.card};border:1px solid ${C.cardBorder};border-radius:4px;padding:5px 4px;text-align:center;`;
cell.innerHTML = `<div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;margin-bottom:2px;">${label}</div><div style="font-size:9px;font-weight:700;color:${color};font-family:Consolas,monospace;">${value}</div>`;
pGrid.appendChild(cell);
});
pInner.appendChild(pGrid);
// YATA Stock sparkline
if (stockH.length >= 2) {
const sLbl = document.createElement('div'); sLbl.style.cssText = `font-size:8px;font-weight:700;letter-spacing:.5px;color:${C.goldDim};font-family:Consolas,monospace;`;
sLbl.textContent = '📦 YATA Stock History ('+stockH.length+' fetches)';
pInner.appendChild(sLbl);
const vals = stockH.map(h => h.qty !== undefined ? h.qty : h.price);
const minV = Math.min(...vals), maxV = Math.max(...vals), rng = maxV-minV||1;
const W2=260,H2=36,p2=4;
const svg2 = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg2.setAttribute('viewBox','0 0 '+W2+' '+H2); svg2.setAttribute('width','100%'); svg2.setAttribute('height',H2);
const pts2 = vals.map((v,i)=>[p2+(i/(vals.length-1))*(W2-p2*2), p2+(1-(v-minV)/rng)*(H2-p2*2)]);
const pd2 = 'M'+pts2.map(([x,y])=>x.toFixed(1)+','+y.toFixed(1)).join('L');
const f2=document.createElementNS('http://www.w3.org/2000/svg','path'); f2.setAttribute('d',pd2+'L'+pts2[pts2.length-1][0].toFixed(1)+','+H2+'L'+pts2[0][0].toFixed(1)+','+H2+'Z'); f2.setAttribute('fill','rgba(0,200,224,0.08)');
const l2=document.createElementNS('http://www.w3.org/2000/svg','path'); l2.setAttribute('d',pd2); l2.setAttribute('fill','none'); l2.setAttribute('stroke',col); l2.setAttribute('stroke-width','1.5');
const d2=document.createElementNS('http://www.w3.org/2000/svg','circle'); d2.setAttribute('cx',pts2[pts2.length-1][0].toFixed(1)); d2.setAttribute('cy',pts2[pts2.length-1][1].toFixed(1)); d2.setAttribute('r','3'); d2.setAttribute('fill',col);
svg2.appendChild(f2); svg2.appendChild(l2); svg2.appendChild(d2);
pInner.appendChild(svg2);
} else {
const noH = document.createElement('div'); noH.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;text-align:center;padding:4px 0;`;
noH.textContent = 'Stock history builds up over refreshes';
pInner.appendChild(noH);
}
pDetail.appendChild(pInner);
}
});
cardWrap.appendChild(cardHdr); cardWrap.appendChild(pDetail);
content.appendChild(cardWrap);
});
} // end normal top3 (inside xanPrioActive2 else)
profitSec.appendChild(content);
wrap.appendChild(profitSec);
})();
} // end else (not priority)
// ── Loot Run Planner ──
const escSec = document.createElement('div'); escSec.appendChild(secTitle('💰 Loot Run Planner'));
const lootRuns = [
{
flag: '🇲🇽', name: 'Mexico', col: '#ffb830', time: '~1.5h',
loot: ['Dahlia', 'Jaguar Plushie', 'Obsidian Point'],
},
{
flag: '🇦🇷', name: 'Argentina', col: '#74c9ff', time: '~14h',
loot: ['Ceibo Flower', 'Monkey Plushie', 'Chalcedony Point', 'Meteorite Fragment', 'Patagonian Fossil'],
},
{
flag: '🇬🇧', name: 'United Kingdom', col: '#cc88ff', time: '~10h',
loot: ['Heather', 'Nessie Plushie', 'Red Fox Plushie', 'Chert Point', 'Xanax'],
},
{
flag: '🇨🇦', name: 'Canada', col: '#ff7070', time: '~9h',
loot: ['Crocus', 'Wolverine Plushie', 'Quartz Point', 'Xanax'],
},
{
flag: '🇿🇦', name: 'South Africa', col: '#60cc60', time: '~16h',
loot: ['African Violet', 'Lion Plushie', 'Quartzite Point', 'Xanax'],
},
{
flag: '🇨🇭', name: 'Switzerland', col: '#ff9999', time: '~11h',
loot: ['Edelweiss', 'Chamois Plushie'],
},
{
flag: '🇯🇵', name: 'Japan', col: '#ffaacc', time: '~12h',
loot: ['Cherry Blossom', 'Xanax'],
},
{
flag: '🇨🇳', name: 'China', col: '#ff6040', time: '~14h',
loot: ['Peony', 'Panda Plushie'],
},
{
flag: '🏝️', name: 'Hawaii', col: '#ffe066', time: '~6h',
loot: ['Orchid', 'Basalt Point'],
},
{
flag: '🇦🇪', name: 'UAE', col: '#88ddaa', time: '~14h',
loot: ['Tribulus Omanense', 'Camel Plushie'],
},
{
flag: '🇰🇾', name: 'Cayman Islands', col: '#66ccff', time: '~10h',
loot: ['Banana Orchid', 'Stingray Plushie'],
},
];
lootRuns.forEach(run => {
let needed = 0, total = 0;
run.loot.forEach(itemName => {
// Determine which group (and thus which section key) this item belongs to
let groupKey = null;
for (const [gName, g] of Object.entries(GROUPS)) {
if (g.items[itemName]) { groupKey = gName.toLowerCase(); break; }
}
if (itemName === 'Meteorite Fragment' || itemName === 'Patagonian Fossil') groupKey = 'special';
// Skip items whose section is hidden — don't count them at all
if (groupKey && vis[groupKey] === false) return;
total++;
let sets = 0;
for (const g of Object.values(GROUPS)) {
if (g.items[itemName]) { sets = calcSet(invCache, g.items); break; }
}
const have = (invCache[itemName] || 0) - sets;
if (have < 5) needed++;
});
run.needed = needed; run.total = total;
});
// Focus mode: active run beats personal priority
const _xanFocusCtryLoot = cfg.getXanCountry(); // declare early — used in banner innerHTML
const runs = cfg.getXanRuns();
const activeRun = runs.find(r => r.active);
const xanPriority = cfg.getXanPriority();
const xanThreshold = cfg.getXanThreshold();
const xanBelowThresh = xanPriority && xanPersonal < xanThreshold;
const focusMode = !!(activeRun || xanBelowThresh);
lootRuns.sort((a, b) => {
if (focusMode) {
const aIsSA = a.name === 'South Africa';
const bIsSA = b.name === 'South Africa';
if (aIsSA && !bIsSA) return -1;
if (bIsSA && !aIsSA) return 1;
}
return b.needed - a.needed;
});
const runGrid = document.createElement('div'); runGrid.style.cssText = 'display:flex;flex-direction:column;gap:5px;';
// Focus banner
if (focusMode) {
const bannerCol = activeRun ? C.gold : C.gold;
const xanBanner = document.createElement('div');
xanBanner.style.cssText = `display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;background:${bannerCol}12;border:1px solid ${bannerCol}44;margin-bottom:2px;`;
if (activeRun) {
const total = activeRun.trips.reduce((s,t)=>s+t.bought,0);
const remaining = Math.max(0, activeRun.contractQty - total);
xanBanner.innerHTML = `<span style="font-size:14px;">✈</span><div style="flex:1;"><div style="font-size:9.5px;font-weight:700;color:${bannerCol};font-family:Arial,sans-serif;">RUN: ${activeRun.client}</div><div style="font-size:8.5px;color:${bannerCol}99;font-family:Consolas,monospace;margin-top:1px;">${total} collected · ${remaining} needed · ${_xanFocusCtryLoot} only</div></div>`;
} else {
xanBanner.innerHTML = `<span style="font-size:14px;">🧪</span><div style="flex:1;"><div style="font-size:9.5px;font-weight:700;color:${bannerCol};font-family:Arial,sans-serif;">Xanax Priority Active</div><div style="font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${_xanFocusCtryLoot} only — ${xanPersonal} / ${xanThreshold} collected</div></div>`;
}
runGrid.appendChild(xanBanner);
}
lootRuns.forEach(run => {
if (run.total === 0) return;
if (focusMode && run.name !== _xanFocusCtryLoot) return;
const urgency = run.needed / run.total;
const isSAxanPinned = focusMode && run.name === _xanFocusCtryLoot;
const a = document.createElement('div');
// SA xanax-pinned: always full opacity with green xanax border, even if loot is complete
const borderCol = isSAxanPinned ? C.border : `${run.col}${urgency > 0.5 ? '66' : '28'}`;
const bgCol = isSAxanPinned ? C.goldGlow : `${run.col}${urgency > 0.5 ? '14' : '07'}`;
const opacity = (urgency === 0 && !isSAxanPinned) ? '0.35' : '1';
a.style.cssText = `display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:7px;cursor:pointer;border:1px solid ${borderCol};background:${bgCol};transition:opacity 0.2s;opacity:${opacity};`;
a.addEventListener('mouseover', () => { if (urgency > 0 || isSAxanPinned) a.style.opacity = '0.8'; });
a.addEventListener('mouseout', () => { a.style.opacity = opacity; });
a.onclick = function() {
var FULL_NAMES = {};
var travelName = FULL_NAMES[run.name] || run.name;
var onTravelPage = (location.pathname + location.search).indexOf('sid=travel') !== -1;
if (onTravelPage) {
clickCountry(travelName);
} else {
window.location.href = 'https://www.torn.com/page.php?sid=travel#lt_travel=' + encodeURIComponent(travelName);
}
};
const flag = document.createElement('span'); flag.textContent = run.flag; flag.style.cssText = 'font-size:16px;flex-shrink:0;';
const mid = document.createElement('div'); mid.style.cssText = 'flex:1;min-width:0;';
const nameRow = document.createElement('div'); nameRow.style.cssText = 'display:flex;align-items:baseline;gap:5px;';
const nameEl = document.createElement('span'); nameEl.textContent = run.name; nameEl.style.cssText = `font-size:10.5px;font-weight:700;color:${isSAxanPinned ? C.gold : run.col};font-family:Arial,sans-serif;`;
const timeEl = document.createElement('span'); timeEl.textContent = run.time; timeEl.style.cssText = `font-size:8.5px;color:${C.textDim};font-family:Consolas,monospace;`;
nameRow.appendChild(nameEl); nameRow.appendChild(timeEl);
if (isSAxanPinned) {
const xanTag = document.createElement('span');
xanTag.textContent = '🧪 Xanax';
xanTag.style.cssText = `font-size:7.5px;font-family:Consolas,monospace;font-weight:700;color:${C.goldDim};background:${C.goldGlow};border:1px solid ${C.border};border-radius:3px;padding:1px 5px;margin-left:2px;`;
nameRow.appendChild(xanTag);
}
const tagWrap = document.createElement('div'); tagWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:3px;margin-top:3px;';
// In focus mode SA: show regular items + Xanax stock info
// In focus mode: show loot items + always show Xanax tag for the pinned xanax country
const lootItems = run.loot;
lootItems.forEach(itemName => {
// Xanax tag — show as special pill, not normal loot tag
if (itemName === 'Xanax') {
const code = TORN_DEST_TO_CODE[run.name];
const hasXan = code && yataCityCache[code] && yataCityCache[code].stocks.some(s => s.id === XANAX_ID);
if (!hasXan) return;
// In focus mode the isSAxanPinned block already shows the Xanax tag
if (!isSAxanPinned) {
const xTag = document.createElement('span');
xTag.textContent = '🧪 Xanax';
xTag.style.cssText = `font-size:8px;font-family:Consolas,monospace;color:${C.green};background:rgba(102,187,102,0.15);border:1px solid rgba(102,187,102,0.4);border-radius:3px;padding:1px 4px;`;
tagWrap.appendChild(xTag);
}
return;
}
// Skip items whose section is hidden
let groupKey = null;
for (const [gName, g] of Object.entries(GROUPS)) { if (g.items[itemName]) { groupKey = gName.toLowerCase(); break; } }
if (itemName === 'Meteorite Fragment' || itemName === 'Patagonian Fossil') groupKey = 'special';
if (groupKey && vis[groupKey] === false) return;
let sets = 0;
for (const g of Object.values(GROUPS)) { if (g.items[itemName]) { sets = calcSet(invCache, g.items); break; } }
const have = (invCache[itemName] || 0) - sets;
// In focus mode never highlight xanax as "needed" based on loot surplus — run progress handles that
const isNeeded = focusMode ? false : have < 5;
const tag = document.createElement('span');
tag.textContent = itemName;
tag.style.cssText = isNeeded
? `font-size:8px;font-family:Consolas,monospace;color:#ff9966;background:rgba(200,80,20,0.2);border:1px solid rgba(200,80,20,0.45);border-radius:3px;padding:1px 4px;`
: `font-size:8px;font-family:Consolas,monospace;color:${C.textDim};background:${C.card};border:1px solid ${C.border};border-radius:3px;padding:1px 4px;opacity:0.7;`;
tagWrap.appendChild(tag);
});
mid.appendChild(nameRow); mid.appendChild(tagWrap);
const badge = document.createElement('div'); badge.style.cssText = 'flex-shrink:0;text-align:center;';
if (isSAxanPinned) {
// Focus mode: show trips needed
const carry = cfg.getXanCarry() || 1;
let xanNeeded = 0;
if (activeRun) {
const collected = activeRun.trips.reduce((s,t)=>s+t.bought,0);
xanNeeded = Math.max(0, activeRun.contractQty - collected);
} else {
xanNeeded = Math.max(0, xanThreshold - xanPersonal);
}
const tripsExact = xanNeeded / carry;
const tripsNeeded = Math.ceil(tripsExact);
const overage = tripsNeeded > 0 ? (tripsNeeded * carry) - xanNeeded : 0;
if (xanNeeded <= 0) {
badge.innerHTML = `<div style="font-size:11px;color:${C.green};font-family:Consolas,monospace;">✓</div>`;
} else {
badge.innerHTML = `<div style="font-size:16px;font-weight:700;color:#00c8e0;font-family:Consolas,monospace;line-height:1;">${tripsNeeded}</div>
<div style="font-size:7px;color:${C.textDim};font-family:Consolas,monospace;">trips</div>
${overage > 0 ? `<div style="font-size:7px;color:${C.goldDim};font-family:Consolas,monospace;margin-top:1px;">+${overage} over</div>` : ''}`;
}
} else if (run.needed > 0) {
badge.innerHTML = `<div style="font-size:14px;font-weight:700;color:${run.col};font-family:Consolas,monospace;line-height:1;">${run.needed}</div><div style="font-size:7.5px;color:${C.textDim};font-family:Consolas,monospace;">needed</div>`;
} else {
badge.innerHTML = `<div style="font-size:11px;color:${C.green};font-family:Consolas,monospace;">✓</div>`;
}
a.appendChild(flag); a.appendChild(mid); a.appendChild(badge);
runGrid.appendChild(a);
});
escSec.appendChild(runGrid);
// ── Items per Country (collapsible) ──
const carSec = document.createElement('div');
if (typeof renderTravelBody._itemsOpen === 'undefined') renderTravelBody._itemsOpen = false;
let itemsOpen = renderTravelBody._itemsOpen;
const carHdrRow = document.createElement('div');
carHdrRow.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:4px 10px;font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${C.goldDim};background:rgba(0,60,80,0.15);border-top:1px solid rgba(0,140,180,0.2);border-bottom:1px solid rgba(0,140,180,0.15);font-family:Consolas,monospace;cursor:pointer;user-select:none;`;
const carHdrLeft = document.createElement('span'); carHdrLeft.textContent = '🎯 Items per Country';
const carChevron = document.createElement('span');
carChevron.style.cssText = `color:${C.gold};font-size:10px;display:inline-block;transition:transform 0.2s;transform:${itemsOpen ? 'rotate(180deg)' : 'rotate(0deg)'};`;
carChevron.textContent = '▾';
carHdrRow.appendChild(carHdrLeft); carHdrRow.appendChild(carChevron);
carSec.appendChild(carHdrRow);
const carContent = document.createElement('div');
carContent.style.cssText = `overflow:hidden;max-height:${itemsOpen ? '2000px' : '0'};transition:max-height 0.25s ease;padding:${itemsOpen ? '4px 8px 6px' : '0 8px'};`;
carHdrRow.addEventListener('click', () => {
itemsOpen = !itemsOpen;
renderTravelBody._itemsOpen = itemsOpen;
carContent.style.maxHeight = itemsOpen ? '2000px' : '0';
carContent.style.padding = itemsOpen ? '4px 8px 6px' : '0 8px';
carChevron.style.transform = itemsOpen ? 'rotate(180deg)' : 'rotate(0deg)';
});
const countryGuide = [
{ flag: '🇲🇽', name: 'Mexico', items: ['Dahlia', 'Jaguar Plushie', 'Obsidian Point'] },
{ flag: '🏝️', name: 'Hawaii', items: ['Orchid', 'Basalt Point'] },
{ flag: '🇿🇦', name: 'South Africa', items: ['African Violet', 'Lion Plushie', 'Quartzite Point'] },
{ flag: '🇯🇵', name: 'Japan', items: ['Cherry Blossom'] },
{ flag: '🇨🇳', name: 'China', items: ['Peony', 'Panda Plushie'] },
{ flag: '🇦🇷', name: 'Argentina', items: ['Ceibo Flower', 'Monkey Plushie', 'Chalcedony Point', 'Meteorite Fragment', 'Patagonian Fossil'] },
{ flag: '🇨🇭', name: 'Switzerland', items: ['Edelweiss', 'Chamois Plushie'] },
{ flag: '🇨🇦', name: 'Canada', items: ['Crocus', 'Wolverine Plushie', 'Quartz Point'] },
{ flag: '🇬🇧', name: 'United Kingdom', items: ['Heather', 'Nessie Plushie', 'Red Fox Plushie', 'Chert Point'] },
{ flag: '🇦🇪', name: 'UAE', items: ['Tribulus Omanense', 'Camel Plushie'] },
{ flag: '🇰🇾', name: 'Cayman Islands', items: ['Banana Orchid', 'Stingray Plushie'] },
{ flag: '🏪', name: "Bits n' Bobs", items: ['Sheep Plushie', 'Teddy Bear Plushie', 'Kitten Plushie'] },
];
// Build full item list per country from GROUPS + SPECIAL_ITEMS data
const countryItemMap = {};
Object.entries(GROUPS).forEach(([, g]) => {
Object.entries(g.items).forEach(([iName, iData]) => {
if (!countryItemMap[iData.loc]) countryItemMap[iData.loc] = [];
if (!countryItemMap[iData.loc].includes(iName)) countryItemMap[iData.loc].push(iName);
});
});
Object.entries(SPECIAL_ITEMS).forEach(([iName, iData]) => {
if (!countryItemMap[iData.loc]) countryItemMap[iData.loc] = [];
if (!countryItemMap[iData.loc].includes(iName)) countryItemMap[iData.loc].push(iName);
});
countryGuide.forEach(({ flag, name, items: fallbackItems }) => {
// BoB items use loc='BoB', not the display name
const lookupKey = name === "Bits n' Bobs" ? 'BoB' : name;
const allForCountry = countryItemMap[lookupKey] || fallbackItems || [];
const visibleItems = allForCountry.filter(iName => {
let groupKey = null;
for (const [gName, g] of Object.entries(GROUPS)) { if (g.items[iName]) { groupKey = gName.toLowerCase(); break; } }
if (iName === 'Meteorite Fragment' || iName === 'Patagonian Fossil') groupKey = 'special';
return !(groupKey && vis[groupKey] === false);
});
if (!visibleItems.length) return;
// Country wrapper — header tap-to-expand
const cWrap = document.createElement('div');
cWrap.style.cssText = 'border-bottom:1px solid rgba(0,80,100,0.2);';
const cHdr = document.createElement('div');
cHdr.style.cssText = `display:flex;align-items:center;gap:8px;padding:5px 6px;cursor:pointer;user-select:none;transition:background 0.15s;`;
cHdr.addEventListener('mouseover', () => cHdr.style.background = 'rgba(0,200,224,0.04)');
cHdr.addEventListener('mouseout', () => cHdr.style.background = 'transparent');
const cFlagEl = document.createElement('div');
cFlagEl.textContent = flag; cFlagEl.style.cssText = 'font-size:15px;flex-shrink:0;';
const cNameEl = document.createElement('div');
cNameEl.style.cssText = `font-size:10px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;flex:1;`;
cNameEl.textContent = name;
const cBadge = document.createElement('span');
cBadge.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-right:4px;`;
cBadge.textContent = visibleItems.length + (visibleItems.length === 1 ? ' item' : ' items');
const cChev = document.createElement('span');
cChev.style.cssText = 'font-size:9px;color:rgba(0,180,210,0.5);transition:transform 0.18s;display:inline-block;';
cChev.textContent = '▾';
cHdr.appendChild(cFlagEl); cHdr.appendChild(cNameEl); cHdr.appendChild(cBadge); cHdr.appendChild(cChev);
let cOpen = false;
const cDetail = document.createElement('div');
cDetail.style.cssText = 'overflow:hidden;max-height:0;transition:max-height 0.25s ease;visibility:hidden;';
let cPopulated = false;
cHdr.addEventListener('click', () => {
cOpen = !cOpen;
cChev.style.transform = cOpen ? 'rotate(180deg)' : 'rotate(0deg)';
cDetail.style.maxHeight = cOpen ? '800px' : '0';
cDetail.style.visibility = cOpen ? 'visible' : 'hidden';
if (cOpen && !cPopulated) {
cPopulated = true;
const fmt = n => n >= 1e6 ? '$'+(n/1e6).toFixed(1)+'M' : n >= 1e3 ? '$'+Math.round(n/1000)+'k' : n > 0 ? '$'+n : '—';
visibleItems.forEach(iName => {
let iData = null;
for (const g of Object.values(GROUPS)) { if (g.items[iName]) { iData = g.items[iName]; break; } }
if (!iData) iData = SPECIAL_ITEMS[iName];
const iId = iData ? iData.id : null;
const abroad = abroadCache[iName] !== undefined ? abroadCache[iName] : null;
const buyP = yataPriceCache[iName] ? yataPriceCache[iName].price : 0;
const sellP = marketValueCache[iName] || 0;
const profit = sellP > buyP && buyP > 0 ? sellP - buyP : 0;
const carry = cfg.getXanCarry() || 1;
const isBoBItem = !iData || BOB_IDS.has(iData.id); // BoB items have no overseas stock
const iRow = document.createElement('div');
iRow.style.cssText = `display:flex;align-items:center;gap:8px;padding:6px 8px;border-top:1px solid ${C.border};background:${C.card};`;
if (iId) {
const iImg = document.createElement('img');
iImg.src = itemImg(iId); iImg.alt = iName;
iImg.style.cssText = 'width:26px;height:26px;object-fit:contain;border-radius:2px;flex-shrink:0;';
iImg.addEventListener('error', () => { iImg.style.display='none'; });
iRow.appendChild(iImg);
}
const iMid = document.createElement('div'); iMid.style.cssText = 'flex:1;min-width:0;';
const iNameEl = document.createElement('div'); iNameEl.textContent = iName;
iNameEl.style.cssText = `font-size:9px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const iSub = document.createElement('div'); iSub.style.cssText = 'display:flex;gap:4px;margin-top:3px;flex-wrap:wrap;';
const mkPill = (txt, col) => { const s = document.createElement('span'); s.textContent = txt; s.style.cssText = `font-size:7.5px;font-weight:600;color:${col};background:${col}28;border:1px solid ${col}55;border-radius:3px;padding:1px 5px;font-family:Consolas,monospace;`; return s; };
if (isBoBItem) {
const bobQty = iId !== null ? (bobCache[iName] !== undefined ? bobCache[iName] : null) : null;
const bobCol = bobQty === null ? C.goldDim : bobQty > 0 ? C.okay : C.stockLo;
iSub.appendChild(mkPill('🏪 ' + (bobQty !== null ? 'BoB: '+bobQty : 'BoB only'), bobCol));
if (sellP > 0) iSub.appendChild(mkPill('Market: '+fmt(sellP), C.okay));
} else {
if (abroad !== null && abroad > 0) iSub.appendChild(mkPill('Stock: '+Number(abroad).toLocaleString(), C.abroad));
else if (abroad === 0) iSub.appendChild(mkPill('No stock', C.stockLo));
if (buyP > 0) iSub.appendChild(mkPill('Buy: '+fmt(buyP), C.stockLo));
if (sellP > 0) iSub.appendChild(mkPill('Sell: '+fmt(sellP), C.okay));
if (profit> 0) iSub.appendChild(mkPill('Profit: '+fmt(profit)+' · trip: '+fmt(profit*carry), '#66dd66'));
}
iMid.appendChild(iNameEl); iMid.appendChild(iSub);
iRow.appendChild(iMid);
cDetail.appendChild(iRow);
});
}
});
cWrap.appendChild(cHdr); cWrap.appendChild(cDetail);
carContent.appendChild(cWrap);
});
carSec.appendChild(carContent);
wrap.appendChild(escSec); wrap.appendChild(carSec);
body.appendChild(wrap);
}
/* ─────────────────────────────────────────
TAB: DESTINATION (shown when traveling)
───────────────────────────────────────── */
function renderDestBody() {
const body = panelEl.querySelector('.lt-body');
body.innerHTML = '';
if (!travelStatus || !travelStatus.traveling) {
body.appendChild(makeEmpty('✈️', 'You are not currently traveling.'));
return;
}
const dest = travelStatus.destination || '';
const isReturn = travelStatus.isReturn || !TORN_DEST_TO_CODE[dest];
const code = TORN_DEST_TO_CODE[dest];
const cityData = code ? yataCityCache[code] : null;
const secsLeft = travelStatus.timeLeft || 0;
// ── Resolve From / To display info ──
// travelStatus.origin is either a country name (API) or city name (DOM scrape)
// Resolve it to a YATA code so we can get the flag + label from LOCATIONS
const TORN_LOC = { flag: '🌐', label: 'Torn City' };
function resolveLocation(nameOrCity) {
if (!nameOrCity || nameOrCity === 'Torn City' || nameOrCity === 'Torn') return null;
// Try as country name first (API returns country names)
const codeByCountry = TORN_DEST_TO_CODE[nameOrCity];
if (codeByCountry) return { code: codeByCountry, loc: LOCATIONS[nameOrCity] || { flag: '✈️', label: nameOrCity } };
// Try as city name (DOM scrape returns city names e.g. "Johannesburg")
const codeByCity = CITY_TO_CODE[nameOrCity];
if (codeByCity) {
// Find the LOCATIONS country key for this code
const countryKey = Object.keys(TORN_DEST_TO_CODE).find(k => TORN_DEST_TO_CODE[k] === codeByCity);
return { code: codeByCity, loc: countryKey ? LOCATIONS[countryKey] : { flag: '✈️', label: nameOrCity } };
}
return { code: null, loc: { flag: '✈️', label: nameOrCity } };
}
let fromDisplay, toDisplay, fromCityName, toCityName;
if (isReturn) {
// Returning: from foreign city → to Torn City
// For return flights: travelStatus.destination = the foreign country (e.g. "South Africa")
// travelStatus.origin = same (set by fetchTravelStatus)
const originName = travelStatus.origin || dest || '';
const resolved = resolveLocation(originName);
const code2 = resolved ? resolved.code : null;
fromCityName = code2 ? (YATA_CODE_TO_CITY[code2] || originName) : originName || 'Abroad';
fromDisplay = resolved ? resolved.loc : { flag: '✈️', label: originName || 'Abroad' };
toDisplay = TORN_LOC;
toCityName = 'Torn City';
} else {
// Outbound: from Torn City → to foreign city
fromDisplay = TORN_LOC;
fromCityName = 'Torn City';
const resolved = resolveLocation(dest);
toDisplay = resolved ? resolved.loc : { flag: '✈️', label: dest };
toCityName = cityData ? cityData.city : dest;
}
const landingStr = travelStatus.landingStr || '';
// ── Arrival time — always computed as now + timeLeft ──
// departed (unix ts) + time_left = arrival unix ts, but departed may be 0 from old scrape data
// Most reliable: just use Date.now() + secsLeft * 1000
const arrivalDate = new Date(Date.now() + secsLeft * 1000);
const arrivalLocal = arrivalDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const arrivalDay = arrivalDate.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
// ── Trip duration: departed is a unix ts from the API ──
const departed = travelStatus.departed || 0;
let tripDurStr = '';
if (departed > 0) {
const totalSecs = (Date.now() / 1000 - departed) + secsLeft;
const th = Math.floor(totalSecs / 3600);
const tm = Math.floor((totalSecs % 3600) / 60);
tripDurStr = th > 0 ? `${th}h ${tm}m total` : `${tm}m total`;
}
// ── Remaining time countdown ──
const remHrs = Math.floor(secsLeft / 3600);
const remMin = Math.floor((secsLeft % 3600) / 60);
const remSecs = secsLeft % 60;
const remStr = remHrs > 0 ? `${remHrs}h ${remMin}m` : remMin > 0 ? `${remMin}m ${remSecs}s` : `${remSecs}s`;
// ── Travel Card ──
const card = document.createElement('div');
card.style.cssText = `margin:10px 10px 6px;background:${C.card};border:1px solid ${C.cardBorder};border-radius:10px;overflow:hidden;`;
// Route row: From → [plane] → To
const routeRow = document.createElement('div');
routeRow.style.cssText = 'display:flex;align-items:center;padding:12px 14px 10px;gap:0;';
const mkCity = (flag, city, label, align) => {
const d = document.createElement('div');
d.style.cssText = `flex:1;text-align:${align};`;
d.innerHTML = `<div style="font-size:22px;line-height:1;margin-bottom:3px;">${flag}</div>
<div style="font-size:11px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;">${city}</div>
<div style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;">${label}</div>`;
return d;
};
const fromEl = mkCity(fromDisplay.flag, fromCityName, fromDisplay.label, 'left');
const toEl = mkCity(toDisplay.flag, toCityName, toDisplay.label, 'right');
const planeWrap = document.createElement('div');
planeWrap.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;';
// Dashed line with plane
const lineWrap = document.createElement('div');
lineWrap.style.cssText = 'width:100%;display:flex;align-items:center;gap:2px;';
const dash1 = document.createElement('div'); dash1.style.cssText = `flex:1;height:1px;background:linear-gradient(to right,${C.border},rgba(0,200,224,0.6));`;
const planeIcon = document.createElement('span'); planeIcon.textContent = '✈'; planeIcon.style.cssText = `color:${C.gold};font-size:13px;`;
const dash2 = document.createElement('div'); dash2.style.cssText = `flex:1;height:1px;background:linear-gradient(to right,rgba(0,200,224,0.6),${C.border});`;
lineWrap.appendChild(dash1); lineWrap.appendChild(planeIcon); lineWrap.appendChild(dash2);
const durLabel = document.createElement('div');
durLabel.style.cssText = `font-size:8px;color:${C.goldDim};font-family:Consolas,monospace;`;
durLabel.textContent = tripDurStr || '';
planeWrap.appendChild(lineWrap); planeWrap.appendChild(durLabel);
routeRow.appendChild(fromEl); routeRow.appendChild(planeWrap); routeRow.appendChild(toEl);
card.appendChild(routeRow);
// Stats row: time remaining | arrival
const statsRow = document.createElement('div');
statsRow.style.cssText = `display:grid;grid-template-columns:1fr 1fr;border-top:1px solid rgba(0,140,170,0.2);`;
[
{ label: 'Time Remaining', value: remStr, color: secsLeft < 600 ? C.okay : C.gold },
{ label: 'Arrives (local)', value: arrivalLocal, sub: arrivalDay, color: C.text },
].forEach((item, i) => {
const cell = document.createElement('div');
cell.style.cssText = `padding:8px 12px;${i === 0 ? 'border-right:1px solid rgba(0,140,170,0.2);' : ''}`;
cell.innerHTML = `<div style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;letter-spacing:.4px;margin-bottom:3px;">${item.label}</div>
<div style="font-size:13px;font-weight:700;color:${item.color};font-family:Consolas,monospace;">${item.value}</div>
${item.sub ? `<div style="font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;">${item.sub}</div>` : ''}`;
statsRow.appendChild(cell);
});
card.appendChild(statsRow);
body.appendChild(card);
// ── Return flight — no item list ──
if (isReturn || !cityData || !cityData.stocks.length) {
if (!isReturn && (!cityData || !cityData.stocks.length)) {
body.appendChild(makeEmpty('📡', 'No YATA stock data for this destination.<br>Try refreshing.'));
}
return;
}
// ── Column header ──
const colHdr = document.createElement('div');
colHdr.style.cssText = `display:grid;grid-template-columns:34px 1fr 52px 60px;gap:6px;padding:3px 10px;background:rgba(0,200,224,0.04);border-bottom:1px solid rgba(0,150,180,0.15);`;
['', 'Item', 'Stock', 'Profit'].forEach(txt => {
const s = document.createElement('span');
s.textContent = txt;
s.style.cssText = `font:600 8px Consolas,monospace;color:rgba(0,200,224,0.45);text-align:${txt === 'Stock' || txt === 'Profit' ? 'center' : 'left'};text-transform:uppercase;letter-spacing:.4px;`;
colHdr.appendChild(s);
});
body.appendChild(colHdr);
// ── Item rows — sorted by profit desc ──
const rows = cityData.stocks.map(stock => {
const sellPrice = marketValueCache[stock.name] || 0;
const profit = sellPrice > stock.cost ? sellPrice - stock.cost : 0;
return { ...stock, sellPrice, profit };
}).sort((a, b) => b.profit - a.profit);
rows.forEach(stock => {
// Build a data-compatible object for makeExpandableItem
// Dest tab uses stock objects from YATA; expandable needs data.id and data.s
const destItemData = { id: stock.id, s: stock.name.slice(0, 8), loc: dest };
const destWrap = document.createElement('div');
destWrap.style.cssText = 'border-bottom:1px solid rgba(0,80,100,0.2);';
// Header row: image | name+buyprice | stock | profit — with expand chevron
const destRow = document.createElement('div');
destRow.style.cssText = `display:grid;grid-template-columns:34px 1fr 52px 60px;gap:6px;align-items:center;padding:5px 10px;min-height:38px;transition:background 0.15s;cursor:pointer;position:relative;padding-right:18px;`;
destRow.addEventListener('mouseover', () => destRow.style.background = 'rgba(0,200,224,0.05)');
destRow.addEventListener('mouseout', () => destRow.style.background = 'transparent');
const destImgWrap = document.createElement('div'); destImgWrap.style.cssText = 'position:relative;width:32px;height:32px;';
const destImg = document.createElement('img'); destImg.src = itemImg(stock.id); destImg.alt = stock.name;
destImg.style.cssText = 'width:30px;height:30px;object-fit:contain;border-radius:2px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);display:block;';
destImg.addEventListener('error', () => { destImg.style.display='none'; });
destImgWrap.appendChild(destImg);
const destNameWrap = document.createElement('div'); destNameWrap.style.cssText = 'min-width:0;';
const destNameEl = document.createElement('div'); destNameEl.textContent = stock.name;
destNameEl.style.cssText = `font-size:9.5px;font-weight:700;color:${C.text};font-family:Arial,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const destBuyFmt = stock.cost >= 1e3 ? '$'+Math.round(stock.cost/1000)+'k' : '$'+stock.cost;
const destPriceEl = document.createElement('div');
destPriceEl.style.cssText = `font-size:8px;color:${C.textDim};font-family:Consolas,monospace;margin-top:1px;`;
destPriceEl.textContent = 'Buy ' + destBuyFmt;
destNameWrap.appendChild(destNameEl); destNameWrap.appendChild(destPriceEl);
const destStockEl = document.createElement('div');
const destStockCol = stock.qty === 0 ? C.stockLo : stock.qty < 50 ? C.stockMid : C.stockHi;
destStockEl.style.cssText = `color:${destStockCol};background:${destStockCol}1a;font-weight:700;text-align:center;border:1px solid ${destStockCol}44;font-family:Consolas,monospace;padding:2px 3px;border-radius:3px;font-size:10px;`;
destStockEl.textContent = stock.qty.toLocaleString();
const destProfitEl = document.createElement('div'); destProfitEl.style.cssText = 'text-align:center;';
if (stock.profit > 0) {
const pFmt = stock.profit >= 1e6 ? '$'+(stock.profit/1e6).toFixed(1)+'M' : stock.profit >= 1e3 ? '$'+Math.round(stock.profit/1000)+'k' : '$'+stock.profit;
destProfitEl.innerHTML = `<div style="font-size:10px;font-weight:700;color:${C.okay};font-family:Consolas,monospace;">${pFmt}</div>`;
} else { destProfitEl.innerHTML = `<div style="font-size:9px;color:${C.textDim};font-family:Consolas,monospace;">—</div>`; }
const destChevron = document.createElement('span');
destChevron.style.cssText = `position:absolute;right:6px;top:50%;transform:translateY(-50%) rotate(0deg);font-size:9px;color:${C.textDim};transition:transform 0.18s;pointer-events:none;`;
destChevron.textContent = '▾';
destRow.appendChild(destImgWrap); destRow.appendChild(destNameWrap);
destRow.appendChild(destStockEl); destRow.appendChild(destProfitEl); destRow.appendChild(destChevron);
// Expandable detail
let destExpOpen = false;
const destDetail = document.createElement('div');
destDetail.style.cssText = 'overflow:hidden;max-height:0;transition:max-height 0.22s ease;background:${C.bg};';
const destInner = document.createElement('div'); destInner.style.cssText = 'padding:8px 10px;display:flex;flex-direction:column;gap:6px;';
let destPopulated = false;
destRow.addEventListener('click', () => {
destExpOpen = !destExpOpen;
if (destExpOpen && !destPopulated) {
destPopulated = true;
const destCode = TORN_DEST_TO_CODE[dest] || '';
const history = cfg.getStockHistory(stock.name + (destCode ? '_'+destCode : ''));
const sellPrice = marketValueCache[stock.name] || 0;
const profit = sellPrice > stock.cost ? sellPrice - stock.cost : 0;
const fmt = n => n >= 1e6 ? '$'+(n/1e6).toFixed(2)+'M' : n >= 1e3 ? '$'+Math.round(n/1000)+'k' : '$'+n;
const lastUpdated = history.length ? new Date(history[history.length-1].ts).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '—';
const grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
[
{ label:'Item ID', value:'#'+stock.id, color: C.textDim },
{ label:'Buy Price', value: fmt(stock.cost), color: C.stockLo },
{ label:'Sell Value', value: sellPrice > 0 ? fmt(sellPrice): '—', color: C.okay },
{ label:'Profit/ea', value: profit > 0 ? fmt(profit) : '—', color: profit > 0 ? C.okay : C.textDim },
{ label:'YATA Stock', value: stock.qty.toLocaleString(), color: C.abroad },
{ label:'Updated', value: lastUpdated, color: C.textDim },
].forEach(({ label, value, color }) => {
const cell = document.createElement('div');
cell.style.cssText = 'background:${C.card};border:1px solid ${C.cardBorder};border-radius:4px;padding:5px 4px;text-align:center;';
cell.innerHTML = `<div style="font-size:7.5px;color:${C.textDim};font-family:Consolas,monospace;letter-spacing:.4px;margin-bottom:2px;">${label}</div><div style="font-size:10px;font-weight:700;color:${color};font-family:Consolas,monospace;">${value}</div>`;
grid.appendChild(cell);
});
destInner.appendChild(grid);
if (history.length >= 2) {
const prices = history.map(h => h.price);
const minP = Math.min(...prices), maxP = Math.max(...prices), range = maxP - minP || 1;
const W = 260, H = 36, pad = 4;
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('viewBox','0 0 '+W+' '+H); svg.setAttribute('width','100%'); svg.setAttribute('height',H);
const pts = prices.map((p,i) => [pad+(i/(prices.length-1))*(W-pad*2), pad+(1-(p-minP)/range)*(H-pad*2)]);
const pd = 'M'+pts.map(([x,y])=>x.toFixed(1)+','+y.toFixed(1)).join('L');
const fill = document.createElementNS('http://www.w3.org/2000/svg','path');
fill.setAttribute('d', pd+'L'+pts[pts.length-1][0].toFixed(1)+','+H+'L'+pts[0][0].toFixed(1)+','+H+'Z');
fill.setAttribute('fill','rgba(0,200,224,0.08)');
const line = document.createElementNS('http://www.w3.org/2000/svg','path');
line.setAttribute('d',pd); line.setAttribute('fill','none'); line.setAttribute('stroke','rgba(0,200,224,0.7)'); line.setAttribute('stroke-width','1.5');
const dot = document.createElementNS('http://www.w3.org/2000/svg','circle');
dot.setAttribute('cx',pts[pts.length-1][0].toFixed(1)); dot.setAttribute('cy',pts[pts.length-1][1].toFixed(1)); dot.setAttribute('r','3'); dot.setAttribute('fill','#00c8e0');
svg.appendChild(fill); svg.appendChild(line); svg.appendChild(dot);
const sparkLbl = document.createElement('div'); sparkLbl.style.cssText = `font-size:8px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:${C.goldDim};font-family:Consolas,monospace;`;
sparkLbl.textContent = '📈 Price history ('+history.length+' fetches)';
destInner.appendChild(sparkLbl); destInner.appendChild(svg);
}
destDetail.appendChild(destInner);
}
destDetail.style.maxHeight = destExpOpen ? '300px' : '0';
destChevron.style.transform = destExpOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%) rotate(0deg)';
});
destWrap.appendChild(destRow); destWrap.appendChild(destDetail);
body.appendChild(destWrap);
});
}
/* ─────────────────────────────────────────
STATUS BAR
───────────────────────────────────────── */
function renderStatusBar() {
const bar = panelEl ? panelEl.querySelector('#lt-sbar') : null;
if (!bar) return;
bar.innerHTML = '';
if (activeTab === 'dest') {
const span = document.createElement('span'); span.style.cssText = `color:${C.goldDim};`;
span.textContent = travelStatus && travelStatus.destination ? '📍 ' + travelStatus.destination : '📍 Destination';
bar.appendChild(span);
} else if (activeTab === 'sets') {
const vis = cfg.getSectionVis();
let totalSets = 0, totalPts = 0;
Object.entries(GROUPS).forEach(([n, g]) => {
if (vis[n.toLowerCase()] === false) return;
const s = calcSet(invCache, g.items); totalSets += s; totalPts += s * g.pts;
});
// Special items: each individual item counts toward pts (no "set" mechanic)
if (vis.special !== false) {
Object.entries(SPECIAL_ITEMS).forEach(([name, data]) => {
const qty = invCache[name] || 0;
totalPts += qty * data.pts;
totalSets += qty; // each special item counts as its own unit
});
}
const sSpan = document.createElement('span'); sSpan.style.cssText = `color:${C.goldDim};font-weight:700;`; sSpan.textContent = totalSets + ' sets · ' + totalPts + ' pts';
bar.appendChild(sSpan);
if (pointsPrice > 0 && totalPts > 0) {
const pv = totalPts * pointsPrice;
const pvFmt = pv >= 1e6 ? `$${(pv/1e6).toFixed(1)}M` : pv >= 1000 ? `$${Math.round(pv/1000)}k` : `$${pv}`;
const vSpan = document.createElement('span');
vSpan.style.cssText = `margin-left:auto;color:${C.gold};font-weight:700;`;
vSpan.title = `${totalPts} pts × $${pointsPrice.toLocaleString()} per pt`;
vSpan.textContent = pvFmt;
bar.appendChild(vSpan);
}
} else if (activeTab === 'xanax') {
const span = document.createElement('span'); span.style.cssText = `color:${C.goldDim};font-weight:700;`;
const _xanCtryBar = cfg.getXanCountry();
const _xanFlagBar = {'Mexico':'🇲🇽','Hawaii':'🏝️','South Africa':'🇿🇦','Japan':'🇯🇵','China':'🇨🇳','Argentina':'🇦🇷','Switzerland':'🇨🇭','Canada':'🇨🇦','United Kingdom':'🇬🇧','UAE':'🇦🇪','Cayman Islands':'🇰🇾'}[_xanCtryBar] || '✈️';
span.textContent = _xanFlagBar + ' ' + _xanCtryBar + ' runs';
bar.appendChild(span);
} else if (activeTab === 'travel') {
const span = document.createElement('span'); span.style.cssText = `color:${C.goldDim};`;
span.textContent = 'Loot runs ranked by items needed';
bar.appendChild(span);
}
if (isLoading) {
const sp = document.createElement('span'); sp.className = 'lt-spin'; sp.textContent = '◌';
sp.style.cssText = `margin-left:auto;color:${C.goldDim};`;
bar.appendChild(sp);
}
}
/* ─────────────────────────────────────────
MAIN PANEL RENDER
───────────────────────────────────────── */
function renderPanel() {
if (!panelEl) return;
syncTheme();
// ── Dynamically update the first tab (Sets ↔ Dest) based on travel state ──
const tabFirstEl = panelEl.querySelector('#lt-tab-first');
const tabsEl = panelEl.querySelector('#lt-tabs');
if (tabFirstEl && tabsEl) {
const isTraveling = !!(travelStatus && travelStatus.traveling && travelStatus.timeLeft > 0);
const firstKey = isTraveling ? 'dest' : 'sets';
const firstLbl = isTraveling ? '📍 Dest' : '🎒 Sets';
// Update label and key
tabFirstEl.textContent = firstLbl;
tabFirstEl.dataset.tab = firstKey;
// If activeTab got stranded on the wrong key, redirect it
if (isTraveling && activeTab === 'sets') activeTab = 'dest';
if (!isTraveling && activeTab === 'dest') activeTab = 'sets';
// Hide Xanax and Travel tabs while traveling — only Dest is shown
tabsEl.querySelectorAll('[data-tab]').forEach(t => {
const key = t.dataset.tab;
const hide = isTraveling && (key === 'xanax' || key === 'travel');
t.style.display = hide ? 'none' : '';
});
// Hide the whole tab bar border if only one tab is visible
tabsEl.style.borderBottom = isTraveling ? 'none' : '';
// Sync active highlight
const tabDimCol = isLightMode() ? 'rgba(0,60,85,0.75)' : 'rgba(0,160,190,0.45)';
tabsEl.querySelectorAll('[data-tab]').forEach(t => {
const isActive = t.dataset.tab === activeTab;
t.classList.toggle('lt-tab-active', isActive);
t.style.color = isActive ? C.gold : tabDimCol;
t.style.borderBottomColor = isActive ? C.gold : 'transparent';
t.style.background = isActive ? C.goldGlow : 'transparent';
});
}
renderStatusBar();
if (activeTab === 'dest') renderDestBody();
if (activeTab === 'sets') renderSetsBody();
if (activeTab === 'xanax') renderXanaxBody();
if (activeTab === 'travel') renderTravelBody();
}
/* ─────────────────────────────────────────
BUILD PANEL
───────────────────────────────────────── */
function buildPanel() {
const light = isLightMode();
const panelBg = light
? 'linear-gradient(158deg,rgba(240,253,255,0.99),rgba(225,248,252,0.98))'
: 'linear-gradient(158deg,rgba(0,14,18,0.98),rgba(0,8,12,0.97))';
const tabDimCol = light ? 'rgba(0,60,85,0.75)' : 'rgba(0,160,190,0.45)';
panelEl.setAttribute('style',
'position:fixed !important;width:0 !important;opacity:0 !important;' +
'max-height:84vh !important;' +
`background:${panelBg} !important;` +
`border:1px solid ${C.border} !important;` +
'border-radius:11px !important;z-index:999989 !important;' +
'display:flex !important;flex-direction:column !important;' +
`box-shadow:0 10px 44px ${light ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.9)'} !important;` +
'backdrop-filter:blur(14px) !important;overflow:hidden !important;' +
'transition:width 0.26s ease,opacity 0.2s ease !important;' +
`font-family:Arial,sans-serif !important;color:${C.text} !important;`
);
// ── HEADER ──
const hdr = document.createElement('div');
hdr.setAttribute('style', `padding:8px 12px;background:${C.settHdr};border-bottom:1px solid ${C.border};display:flex;align-items:center;gap:7px;flex-shrink:0;`);
const titleEl = document.createElement('span');
titleEl.setAttribute('style', `font-size:10.5px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:${C.gold};flex:1;white-space:nowrap;font-family:Arial,sans-serif;`);
titleEl.innerHTML = '<span class="lt-float" style="display:inline-block;margin-right:4px;">✈</span> Sets Tracker <small style="font-size:7px;font-weight:400;opacity:0.5;letter-spacing:0.5px;vertical-align:middle;">v9.2.4</small>';
function iconBtn(svg, title) {
const b = document.createElement('span'); b.title = title; b.innerHTML = svg;
b.setAttribute('style', `width:20px;height:20px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:${C.goldDim};transition:color 0.18s;`);
b.addEventListener('mouseover', () => b.style.color = C.gold);
b.addEventListener('mouseout', () => b.style.color = C.goldDim);
return b;
}
const SVG_REFRESH = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5c1.8 0 3.4.87 4.4 2.2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><polyline points="11,1 13.5,3.5 11,6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SVG_GEAR = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.93 2.93l1.41 1.41M11.66 11.66l1.41 1.41M2.93 13.07l1.41-1.41M11.66 4.34l1.41-1.41" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>';
const btnRefresh = iconBtn(SVG_REFRESH, 'Refresh data');
const btnSettings = iconBtn(SVG_GEAR, 'Settings');
btnRefresh.addEventListener('click', () => {
invCache = {}; abroadCache = {}; xanSACache = { qty:0, price:0 }; xanFacCache = null; xanPersonal = cfg.getXanCount(); yataPriceCache = {}; marketValueCache = {}; yataCityCache = {}; travelStatus = null; // xanPersonal preserved from last scrape
renderPanel(); refreshAll(); toast('Refreshing…', 1500);
});
btnSettings.addEventListener('click', openSettings);
hdr.appendChild(titleEl); hdr.appendChild(btnRefresh); hdr.appendChild(btnSettings);
// ── TABS ──
const tabs = document.createElement('div');
tabs.id = 'lt-tabs';
tabs.setAttribute('style', `display:flex;flex-shrink:0;border-bottom:1px solid ${C.border};`);
function makeTab(label, key) {
const t = document.createElement('div');
t.textContent = label; t.dataset.tab = key;
t.setAttribute('style', `flex:1;padding:6px 3px;font-size:9.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:${tabDimCol};cursor:pointer;text-align:center;border-bottom:2px solid transparent;transition:all 0.2s;user-select:none;font-family:Arial,sans-serif;`);
t.addEventListener('mouseover', () => { if (!t.classList.contains('lt-tab-active')) t.style.color = C.gold; });
t.addEventListener('mouseout', () => { if (!t.classList.contains('lt-tab-active')) t.style.color = tabDimCol; });
t.addEventListener('click', () => {
tabs.querySelectorAll('[data-tab]').forEach(x => {
x.classList.remove('lt-tab-active');
x.style.color = tabDimCol;
x.style.borderBottomColor = 'transparent';
x.style.background = 'transparent';
});
t.classList.add('lt-tab-active');
t.style.color = C.gold;
t.style.borderBottomColor = C.gold;
t.style.background = C.goldGlow;
activeTab = key;
renderPanel();
});
return t;
}
// Build tabs with placeholders — renderPanel will update the first tab dynamically
const tabFirst = makeTab('🎒 Sets', 'sets');
tabFirst.id = 'lt-tab-first';
const tabXanax = makeTab('🧪 Xanax', 'xanax');
const tabTravel = makeTab('✈ Travel', 'travel');
tabFirst.classList.add('lt-tab-active');
tabFirst.style.color = C.gold; tabFirst.style.borderBottomColor = C.gold; tabFirst.style.background = C.goldGlow;
tabs.appendChild(tabFirst); tabs.appendChild(tabXanax); tabs.appendChild(tabTravel);
// ── STATUS BAR ──
const sbar = document.createElement('div');
sbar.id = 'lt-sbar';
sbar.setAttribute('style', `padding:3px 10px;font-size:9px;letter-spacing:.4px;color:${C.textDim};border-bottom:1px solid ${C.border};display:flex;gap:8px;align-items:center;flex-shrink:0;font-family:Consolas,monospace;`);
sbar.textContent = 'Loading…';
// ── BODY ──
const bdy = document.createElement('div'); bdy.className = 'lt-body';
panelEl.appendChild(hdr);
panelEl.appendChild(tabs);
panelEl.appendChild(sbar);
panelEl.appendChild(bdy);
}
/* ─────────────────────────────────────────
TOGGLE BUTTON
───────────────────────────────────────── */
function buildToggle() {
const SVG = `<svg width="28" height="28" viewBox="0 0 44 44" fill="none">
<path d="M6 22 L38 10 L32 22 L38 34 Z" fill="rgba(0,200,224,0.18)" stroke="rgba(0,200,224,0.8)" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M20 22 L32 22 L38 34 L20 28 Z" fill="rgba(0,200,224,0.12)" stroke="rgba(0,200,224,0.55)" stroke-width="1" stroke-linejoin="round"/>
<path d="M18 16 L26 12 L28 18 L18 22 Z" fill="rgba(0,200,224,0.15)" stroke="rgba(0,190,220,0.6)" stroke-width="0.9" stroke-linejoin="round"/>
<circle cx="14" cy="22" r="2" fill="rgba(0,200,224,0.7)"/>
</svg>`;
toggleEl.innerHTML = SVG;
toggleEl.title = 'Loot Tracker · Right-click = Settings';
const toggleBg = isLightMode()
? 'radial-gradient(circle at 38% 34%,#d0f8ff,#a8eef8)'
: 'radial-gradient(circle at 38% 34%,#001a20,#000c10)';
toggleEl.setAttribute('style',
'position:fixed !important;width:46px !important;height:46px !important;' +
`background:${toggleBg} !important;` +
`border:2px solid ${C.border} !important;` +
'border-radius:50% !important;' +
'display:flex !important;align-items:center !important;justify-content:center !important;' +
'cursor:grab !important;z-index:999990 !important;' +
`box-shadow:0 0 16px ${C.goldGlow},inset 0 0 12px rgba(0,0,0,0.2) !important;` +
'user-select:none !important;touch-action:none !important;overflow:visible !important;'
);
toggleEl.addEventListener('mouseover', () => {
toggleEl.style.setProperty('box-shadow', `0 0 24px ${C.gold}aa,inset 0 0 12px rgba(0,0,0,0.2)`, 'important');
toggleEl.style.setProperty('border-color', C.gold, 'important');
});
toggleEl.addEventListener('mouseout', () => {
toggleEl.style.setProperty('box-shadow', `0 0 16px ${C.goldGlow},inset 0 0 12px rgba(0,0,0,0.2)`, 'important');
toggleEl.style.setProperty('border-color', C.border, 'important');
});
}
/* ─────────────────────────────────────────
PANEL OPEN/CLOSE + POSITION
───────────────────────────────────────── */
function positionPanel() {
const tr = toggleEl.getBoundingClientRect();
panelEl.style.setProperty('top', Math.min(tr.top, window.innerHeight - 80) + 'px', 'important');
panelEl.style.setProperty('bottom', 'auto', 'important');
if (toggleEl._side === 'left') {
panelEl.style.setProperty('left', (tr.right + 7) + 'px', 'important');
panelEl.style.setProperty('right', 'auto', 'important');
} else {
panelEl.style.setProperty('right', (window.innerWidth - tr.left + 7) + 'px', 'important');
panelEl.style.setProperty('left', 'auto', 'important');
}
}
function openPanel() { panelOpen = true; panelEl.style.setProperty('width','310px','important'); panelEl.style.setProperty('opacity','1','important'); positionPanel(); renderPanel(); }
function closePanel() { panelOpen = false; panelEl.style.setProperty('width','0','important'); panelEl.style.setProperty('opacity','0','important'); }
/* ─────────────────────────────────────────
DRAG TO SNAP
───────────────────────────────────────── */
function setupDrag() {
const SZ = 46, EDGE = 6;
function snap(side, top) {
toggleEl._side = side;
const t = Math.max(EDGE, Math.min(top, window.innerHeight - SZ - EDGE));
toggleEl.style.setProperty('top', t + 'px', 'important');
toggleEl.style.setProperty('bottom', 'auto', 'important');
toggleEl.style.setProperty('left', side === 'left' ? EDGE + 'px' : 'auto', 'important');
toggleEl.style.setProperty('right', side === 'right' ? EDGE + 'px' : 'auto', 'important');
if (panelOpen) positionPanel();
}
try {
const saved = JSON.parse(store.get('lt_pos', '{}'));
snap(saved.side || 'right', saved.top || 280);
} catch(e) { snap('right', 280); }
let dragging = false, moved = false, sX, sY, sL, sT;
function start(cx, cy) { moved = false; dragging = true; sX = cx; sY = cy; const r = toggleEl.getBoundingClientRect(); sL = r.left; sT = r.top; toggleEl.style.setProperty('opacity','0.7','important'); toggleEl.style.setProperty('transform','scale(1.1)','important'); }
function move(cx, cy) {
if (!dragging) return;
const dx = cx - sX, dy = cy - sY;
if (!moved && Math.hypot(dx, dy) < 5) return;
moved = true;
toggleEl.style.setProperty('left', Math.max(EDGE, Math.min(sL + dx, window.innerWidth - SZ - EDGE)) + 'px', 'important');
toggleEl.style.setProperty('right', 'auto', 'important');
toggleEl.style.setProperty('top', Math.max(EDGE, Math.min(sT + dy, window.innerHeight - SZ - EDGE)) + 'px', 'important');
toggleEl.style.setProperty('bottom', 'auto', 'important');
if (panelOpen) positionPanel();
}
function end() {
if (!dragging) return; dragging = false;
toggleEl.style.setProperty('opacity','1','important'); toggleEl.style.setProperty('transform','none','important');
if (!moved) return;
const r = toggleEl.getBoundingClientRect();
const side = (r.left + SZ / 2) < window.innerWidth / 2 ? 'left' : 'right';
snap(side, r.top);
store.set('lt_pos', JSON.stringify({ side, top: r.top }));
}
toggleEl.addEventListener('mousedown', e => { if (e.button !== 0) return; start(e.clientX, e.clientY); });
document.addEventListener('mousemove', e => move(e.clientX, e.clientY));
document.addEventListener('mouseup', end);
toggleEl.addEventListener('touchstart', e => { const t = e.touches[0]; start(t.clientX, t.clientY); }, { passive: true });
toggleEl.addEventListener('touchmove', e => { if (!dragging) return; e.preventDefault(); const t = e.touches[0]; move(t.clientX, t.clientY); }, { passive: false });
toggleEl.addEventListener('touchend', end);
toggleEl.addEventListener('click', () => { if (moved) return; if (panelOpen) closePanel(); else openPanel(); });
toggleEl.addEventListener('contextmenu', e => { e.preventDefault(); openSettings(); });
/* ── Carousel Tooltip ── */
(function buildCarousel() {
const slides = [
{ icon: '✈️', title: 'Sets Tracker', body: 'Your overseas sets companion. Track Plushie, Flower, Prehistoric & Special sets, live Xanax data, and plan supply runs — all in one panel.' },
{ icon: '🎒', title: 'Sets Tab', body: 'Shows every item sorted by bottleneck. Own = extras after completing sets. Abroad = live overseas stock from YATA. When a run or priority is active, all groups collapse to show only Xanax.' },
{ icon: '🟢', title: 'Stock Colours', body: 'Green = stock above threshold (Plushie ≥2000 / Flower ≥5000). Orange = below threshold. Red = zero stock abroad. 🏪 = Bits n\' Bobs live stock count.' },
{ icon: '🧪', title: 'Xanax Tab', body: 'Personal count is auto-read when you visit your Items page — no manual entry needed. Shows carry limit, SA live stock & price, and faction Xanax supply. Trip Cost shows SA Price × Carry Limit — click to copy the amount.' },
{ icon: '✈', title: 'Xanax Runs', body: 'Start a supply run contract from the Xanax tab. Set a client name, total qty, and sell price. Log each SA trip with one tap — the tracker counts progress, calculates profit vs SA buy price, and tracks payment.' },
{ icon: '📊', title: 'Run Focus Mode', body: 'When a run is active, Sets and Travel tabs collapse to show only South Africa and Xanax. The SA card shows trips needed (contract ÷ carry limit) with overage. Ends automatically when you tap ✓ End.' },
{ icon: '🎯', title: 'Xanax Priority', body: 'Enable Xanax Priority in Settings to pin South Africa to the top of Travel and collapse Sets to Xanax only — until your personal count hits your set threshold.' },
{ icon: '✈', title: 'Travel Tab', body: 'Loot Run Planner ranks all destinations by items needed. In focus mode only SA shows, with a trips-needed badge. Best Items section shows the top loot per country for quick reference.' },
{ icon: '🔑', title: 'API Key', body: 'Requires a Full Access key from Torn for faction drug data, and a Limited Access (Display) key for inventory. Both stay on your device.' },
{ icon: '🔄', title: 'Manual Refresh', body: 'Tap ↺ in the panel header to re-fetch inventory, YATA overseas stock, BoB shop stock, faction Xanax, and points price all at once.' },
{ icon: '📱', title: 'PDA Compatible', body: 'Built for Torn PDA on mobile. Drag the ✈ button anywhere on screen — it snaps to the nearest edge. Works in browser too.' },
{ icon: '⚙', title: 'Settings', body: 'Right-click the ✈ button (or tap ⚙ in the header). Control API key, User ID, section visibility, carry limit, Xanax priority threshold, and tooltip.' },
];
let cur = 0;
const tip = document.createElement('div');
tip.id = 'lt-carousel';
tip.setAttribute('style',
'position:fixed !important;bottom:72px !important;right:12px !important;' +
'width:234px !important;' +
'background:linear-gradient(145deg,rgba(0,12,18,0.97),rgba(0,8,14,0.97)) !important;' +
'border:1px solid rgba(0,180,210,0.45) !important;border-radius:10px !important;' +
'z-index:999995 !important;padding:12px 14px 10px !important;' +
'box-shadow:0 6px 28px rgba(0,0,0,0.85) !important;' +
'font-family:Arial,sans-serif !important;color:#e8f0d0 !important;' +
'opacity:0 !important;transition:opacity 0.35s ease !important;'
);
const iconEl = document.createElement('div');
const titleEl = document.createElement('div');
const bodyEl = document.createElement('div');
const dotsWrap = document.createElement('div');
const navWrap = document.createElement('div');
iconEl.setAttribute('style',
'font-size:24px !important;margin-bottom:5px !important;line-height:1 !important;');
titleEl.setAttribute('style',
'font-size:11px !important;font-weight:700 !important;color:#00c8e0 !important;' +
'margin-bottom:5px !important;letter-spacing:.5px !important;text-transform:uppercase !important;');
bodyEl.setAttribute('style',
'font-size:10.5px !important;line-height:1.55 !important;' +
'color:rgba(190,240,248,0.88) !important;min-height:48px !important;');
dotsWrap.setAttribute('style',
'display:flex !important;justify-content:center !important;gap:6px !important;' +
'margin-top:2px !important;align-items:center !important;');
navWrap.setAttribute('style',
'display:flex !important;justify-content:space-between !important;' +
'align-items:center !important;margin-top:9px !important;');
function makeDot(i) {
const d = document.createElement('div');
d.setAttribute('style',
'width:6px !important;height:6px !important;border-radius:50% !important;' +
'cursor:pointer !important;flex-shrink:0 !important;transition:background 0.2s !important;' +
'background:' + (i === cur ? 'rgba(0,200,224,0.9)' : 'rgba(120,120,120,0.3)') + ' !important;'
);
(function(idx) { d.addEventListener('click', function() { clearInterval(autoTimer); go(idx); }); })(i);
return d;
}
function renderDots() {
dotsWrap.innerHTML = '';
for (let i = 0; i < slides.length; i++) dotsWrap.appendChild(makeDot(i));
}
function go(n) {
cur = (n + slides.length) % slides.length;
iconEl.textContent = slides[cur].icon;
titleEl.textContent = slides[cur].title;
bodyEl.textContent = slides[cur].body;
renderDots();
}
const prevBtn = document.createElement('div');
prevBtn.textContent = '◀';
prevBtn.setAttribute('style',
'cursor:pointer !important;font-size:12px !important;color:rgba(0,190,215,0.8) !important;' +
'padding:3px 8px !important;user-select:none !important;border-radius:4px !important;' +
'border:1px solid rgba(0,170,205,0.3) !important;'
);
prevBtn.addEventListener('click', function() { clearInterval(autoTimer); go(cur - 1); });
const nextBtn = document.createElement('div');
nextBtn.textContent = '▶';
nextBtn.setAttribute('style',
'cursor:pointer !important;font-size:12px !important;color:rgba(0,190,215,0.8) !important;' +
'padding:3px 8px !important;user-select:none !important;border-radius:4px !important;' +
'border:1px solid rgba(0,170,205,0.3) !important;'
);
nextBtn.addEventListener('click', function() { clearInterval(autoTimer); go(cur + 1); });
const closeBtn = document.createElement('div');
closeBtn.textContent = '✕';
closeBtn.setAttribute('style',
'position:absolute !important;top:8px !important;right:10px !important;' +
'cursor:pointer !important;font-size:11px !important;' +
'color:rgba(0,190,215,0.65) !important;line-height:1 !important;user-select:none !important;'
);
closeBtn.addEventListener('click', function() {
clearInterval(autoTimer);
tip.style.setProperty('opacity', '0', 'important');
setTimeout(function() { if (tip.parentNode) tip.parentNode.removeChild(tip); }, 350);
});
navWrap.appendChild(prevBtn);
navWrap.appendChild(dotsWrap);
navWrap.appendChild(nextBtn);
tip.appendChild(closeBtn);
tip.appendChild(iconEl);
tip.appendChild(titleEl);
tip.appendChild(bodyEl);
tip.appendChild(navWrap);
go(0);
let autoTimer = setInterval(function() { go(cur + 1); }, 5000);
// Only show once — persisted in localStorage
const SEEN_KEY = 'lt_tooltip_seen';
let alreadySeen = false;
try { alreadySeen = !!localStorage.getItem(SEEN_KEY); } catch(e) {}
if (alreadySeen) return;
document.body.appendChild(tip);
setTimeout(function() { tip.style.setProperty('opacity', '1', 'important'); }, 1800);
function markSeen() {
try { localStorage.setItem(SEEN_KEY, '1'); } catch(e) {}
}
closeBtn.addEventListener('click', markSeen);
// Also mark seen after cycling through all slides once
let seenCount = 0;
const origGo = go;
go = function(n) {
origGo(n);
seenCount++;
if (seenCount >= slides.length) markSeen();
};
})();
}
/* ─────────────────────────────────────────
REFRESH
───────────────────────────────────────── */
/* ─────────────────────────────────────────
LIVE COUNTDOWN — ticks every second while traveling
Updates travelStatus.timeLeft and re-renders Dest body
Works on any Torn page since it uses setInterval, not DOM
───────────────────────────────────────── */
function manageTravelCountdown() {
const traveling = !!(travelStatus && travelStatus.traveling && travelStatus.timeLeft > 0);
if (traveling && !countdownTimer) {
// Start ticking
countdownTimer = setInterval(() => {
if (!travelStatus || !travelStatus.traveling) {
clearInterval(countdownTimer); countdownTimer = null; return;
}
travelStatus.timeLeft = Math.max(0, (travelStatus.timeLeft || 0) - 1);
// Update status bar countdown if on dest tab
if (panelEl && panelOpen && activeTab === 'dest') {
renderDestBody();
}
// Landing detection — fire when we tick to zero
if (travelStatus.timeLeft <= 0) {
clearInterval(countdownTimer); countdownTimer = null;
onLanded();
}
}, 1000);
} else if (!traveling && countdownTimer) {
clearInterval(countdownTimer); countdownTimer = null;
}
}
function onLanded() {
// Capture landing info before wiping travelStatus
const wasReturn = travelStatus && travelStatus.isReturn;
const landedDest = travelStatus && travelStatus.destination; // country name for outbound
const landedOrig = travelStatus && travelStatus.origin; // city name for return scrape
travelStatus = { traveling: false, destination: '', timeLeft: 0, departed: 0, isReturn: false, origin: '' };
if (panelEl) renderPanel();
if (wasReturn) {
// Landed back in Torn City
toast('✅ Landed! Welcome back to Torn City.', 4000);
} else {
// Landed in a foreign country — build city + country label
const code = landedDest ? TORN_DEST_TO_CODE[landedDest] : null;
const cityName = code ? (YATA_CODE_TO_CITY[code] || landedDest) : (landedOrig || landedDest || 'your destination');
const ctryName = landedDest || '';
const msg = ctryName && cityName !== ctryName
? `✅ Landed! Welcome to ${cityName}, ${ctryName}.`
: `✅ Landed! Welcome to ${cityName}.`;
toast(msg, 5000);
}
}
async function refreshAll() {
isLoading = true;
renderStatusBar();
await Promise.allSettled([
fetchInventory()
.then(d => { invCache = d; })
.catch(() => {}),
// xanPersonal is scraped from items page via watchItemsPage()
fetchYataData()
.then(result => {
if (result && result.map) abroadCache = result.map;
if (result && result.sa) xanSACache = result.sa;
console.log('[SetsTracker] xanSACache set:', JSON.stringify(xanSACache));
})
.catch(e => { console.warn('[SetsTracker] YATA assign failed:', e); }),
fetchBobStock()
.then(d => { bobCache = d; })
.catch(() => {}),
fetchXanaxFaction()
.then(d => { if (d !== null) xanFacCache = d; })
.catch(() => {}),
fetchPointsPrice()
.then(p => { pointsPrice = p; })
.catch(() => {}),
fetchMarketValues()
.then(d => { marketValueCache = d; })
.catch(() => {}),
fetchTravelStatus()
.then(s => { travelStatus = s; scrapeTravelPage(); })
.catch(() => {}),
]);
isLoading = false;
if (panelEl) renderPanel();
// Start/stop live countdown based on travel state
manageTravelCountdown();
}
async function mainLoop() {
await refreshAll();
pollTimer = setTimeout(mainLoop, 45000);
}
/* ─────────────────────────────────────────
INIT
───────────────────────────────────────── */
// Click a country row on the travel page — works in both TornPDA and Tampermonkey
function clickCountry(name) {
var tLow = name.toLowerCase();
var doc = (typeof unsafeWindow !== 'undefined' && unsafeWindow.document) ? unsafeWindow.document : document;
var cells = doc.querySelectorAll('div[class*="flagAndName"]');
for (var i = 0; i < cells.length; i++) {
var txt = (cells[i].textContent || '').trim().toLowerCase();
if (txt.indexOf(tLow) !== -1) {
var parent = cells[i].parentElement;
if (parent) { parent.click(); return true; }
}
}
return false;
}
function init() {
xanPersonal = cfg.getXanCount(); // restore last known count from previous scrape
// Auto-click via page-context script injection (bypasses GM sandbox)
(function() {
var target = null;
try {
var m = (location.hash || '').match(/lt_travel=([^&]+)/);
if (m) target = decodeURIComponent(m[1]);
} catch(e) {}
if (!target || (location.pathname + location.search).indexOf('sid=travel') === -1) return;
var tName = target;
// Try once after 3s with debug
// Poll every 300ms for up to 15s
var _attempts = 0;
var _poll = setInterval(function() {
_attempts++;
if (_attempts > 50) { clearInterval(_poll); return; }
if (clickCountry(tName)) { clearInterval(_poll); }
}, 300);
})();
if (document.getElementById('lt-toggle')) return;
try { injectCSS(); } catch(e) { console.warn('[LT] CSS inject failed:', e); }
// Sync theme before building — computed styles may not be ready at parse time
syncTheme();
toggleEl = document.createElement('div'); toggleEl.id = 'lt-toggle';
panelEl = document.createElement('div'); panelEl.id = 'lt-panel';
document.body.appendChild(panelEl);
document.body.appendChild(toggleEl);
buildToggle();
buildPanel();
// Re-check theme 800ms later — TornPDA applies its theme class async
// Always force a syncTheme rebuild so forced light/dark is applied even
// if C was initialised with wrong palette at parse time
setTimeout(() => {
const beforeSync = isLightMode();
syncTheme(true); // force rebuild regardless
if (beforeSync !== isLightMode()) {
// Palette actually flipped — rebuild whole panel
if (toggleEl) buildToggle();
if (panelEl) { panelEl.innerHTML = ''; buildPanel(); }
if (panelOpen) openPanel();
}
}, 800);
setupDrag();
if (!cfg.apiKey) {
setTimeout(openSettings, 600);
} else {
mainLoop();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 400));
} else {
setTimeout(init, 400);
}
})();