Live abroad item stock, restock countdown timers & travel profit on Torn's travel page — mobile panels + desktop table.
// ==UserScript==
// @name Torn Foreign Stock
// @namespace RussianRob
// @version 0.9.10
// @description Live abroad item stock, restock countdown timers & travel profit on Torn's travel page — mobile panels + desktop table.
// @author RussianRob
// @license GPL-3.0-or-later
// @match https://www.torn.com/page.php*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect api.prombot.co.uk
// @connect raw.githubusercontent.com
// @connect yata.yt
// @connect api.torn.com
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
var SCRIPT_VERSION = "0.9.10";
var YATA_URL = "https://yata.yt/api/v1/travel/export/";
var PROMBOT_URL = "https://api.prombot.co.uk/api/travel";
var TORN_ITEMS_URL = "https://api.torn.com/v2/torn?selections=items&key=";
var MODEL_URL = "https://raw.githubusercontent.com/russianrob/torn-foreign-restock/main/restock-model.json";
var MODEL_TTL = 600;
var STOCK_TTL = 60, PRICE_TTL = 21600, STALE_MIN = 30, TRAVEL_TTL = 30;
var TORN_TRAVEL_URL = "https://api.torn.com/user/?selections=travel,basic&key=";
var SAFETY = 1.15, MARGIN_SAFE_MIN = 8;
var IS_PDA = (function () {
try {
if (typeof window !== "undefined" && window.flutter_inappwebview) return true;
return typeof navigator !== "undefined" && /TornPDA|tornpda|DalvikTornPDA|com\.manuito/i.test(navigator.userAgent || "");
} catch (e) { return false; }
})();
// ─── pure helpers (unit-tested) ──────────────────────────
var COUNTRY_MAP = {
"mexico": "mex", "cayman islands": "cay", "canada": "can", "hawaii": "haw",
"united kingdom": "uni", "uk": "uni", "argentina": "arg", "switzerland": "swi",
"japan": "jap", "china": "chi", "uae": "uae", "united arab emirates": "uae",
"south africa": "sou"
};
function normalizeCountryName(name) {
if (!name) return null;
var k = String(name).trim().toLowerCase().replace(/\s+/g, " ");
return COUNTRY_MAP[k] || null;
}
function parseYataExport(json) {
var out = {}, stocks = (json && json.stocks) || {};
for (var code in stocks) {
if (!Object.prototype.hasOwnProperty.call(stocks, code)) continue;
var c = stocks[code] || {};
var items = (c.stocks || []).map(function (it) {
return { id: it.id, name: it.name, qty: it.quantity, cost: it.cost, nextRestock: (it.nextRestock != null ? it.nextRestock : null) };
});
out[code] = { update: c.update || 0, items: items };
}
return out;
}
function groupThousands(n) {
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function fmtMoney(n) {
if (typeof n !== "number" || !isFinite(n)) return "—";
return "$" + groupThousands(Math.round(n));
}
function fmtProfit(n) {
if (typeof n !== "number" || !isFinite(n)) return "—";
return (n < 0 ? "-$" : "+$") + groupThousands(Math.round(Math.abs(n)));
}
function formatAge(updateSec, nowSecVal) {
var diff = Math.max(0, Math.floor(nowSecVal - updateSec));
var mins = Math.floor(diff / 60), text;
if (diff < 60) text = "just now";
else if (mins < 60) text = mins + "m ago";
else text = Math.floor(mins / 60) + "h " + (mins % 60) + "m ago";
return { text: text, stale: mins >= STALE_MIN };
}
function buildRows(items, opts) {
opts = opts || {};
var mode = opts.mode || "stock";
var getValue = opts.getValue || function () { return undefined; };
return items.map(function (it) {
var value = (mode === "profit") ? getValue(it.id) : undefined;
value = (typeof value === "number" && isFinite(value)) ? value : null;
var profit = (value == null) ? null : (value - it.cost);
return { id: it.id, name: it.name, qty: it.qty, cost: it.cost, value: value, profit: profit, nextRestock: it.nextRestock || null };
});
}
function restockEta(nextRestock, nowMs) {
if (!nextRestock) return null;
var t = Date.parse(nextRestock);
if (isNaN(t)) return null;
if (t > nowMs) {
var mins = Math.ceil((t - nowMs) / 60000);
return { mins: mins, text: (mins < 60) ? (mins + "m") : (Math.floor(mins / 60) + "h " + (mins % 60) + "m"), due: false };
}
if ((nowMs - t) <= 3600000) return { mins: 0, text: "due", due: true };
return null;
}
function fmtDuration(sec) {
var m = Math.round(sec / 60);
return (m < 60) ? (m + "m") : (Math.floor(m / 60) + "h " + (m % 60) + "m");
}
function modelEstimate(entry, nowMs) {
if (!entry || !entry.interval) return null;
var nowSec = Math.floor(nowMs / 1000);
var interval = entry.interval;
var since = nowSec - entry.last;
var leftSec = (since < 0) ? -since : (interval - (since % interval));
if (leftSec <= 0) leftSec = interval;
return "~every " + fmtDuration(interval) + " · ~" + fmtDuration(leftSec) + " (" + (entry.rel || "low") + ")";
}
function restockDisplay(nextRestock, entry, nowMs, qty) {
var live = restockEta(nextRestock, nowMs);
if (live && !live.due) return "restocks in " + live.text;
var est = modelEstimate(entry, nowMs);
if (est) return est;
if (live && live.due) return "restock due";
return (qty > 0) ? "in stock" : "out of stock";
}
var BASE_MIN = { mex: 26, cay: 35, can: 41, haw: 134, uni: 159, arg: 167, swi: 175, jap: 225, chi: 242, uae: 271, sou: 297 };
var METHOD_MULT = { standard: 1, airstrip: 0.7, private: 0.5, business: 0.3 };
function parseFlightMinutes(text) {
if (text == null) return null;
var s = String(text).trim();
if (!s) return null;
var hm = s.match(/(\d+)\s*h\s*(\d+)\s*m/i);
if (hm) return Number(hm[1]) * 60 + Number(hm[2]);
var clock = s.match(/^(\d{1,3}):(\d{1,2})(?::(\d{2}))?$/);
if (clock) {
if (clock[3] != null) return Math.round(Number(clock[1]) * 60 + Number(clock[2]) + Number(clock[3]) / 60);
return Number(clock[1]) * 60 + Number(clock[2]);
}
return null;
}
function nextRestockSec(nextRestock, entry, nowSec) {
var best = null;
if (nextRestock) {
var t = Date.parse(nextRestock);
if (!isNaN(t)) { var s = Math.floor(t / 1000); if (s > nowSec) best = s; }
}
if (entry && entry.interval && entry.last != null && (entry.rel || "low") !== "low") {
var interval = entry.interval, since = nowSec - entry.last, leftSec;
if (since < 0) leftSec = -since; else leftSec = interval - (since % interval);
if (leftSec <= 0) leftSec = interval;
var slot = nowSec + leftSec;
if (best == null || slot < best) best = slot;
}
return best;
}
function landVerdict(ctx) {
ctx = ctx || {};
if (!ctx.sellReady || ctx.flightMinutes == null) return null;
var qty = ctx.qty, F = ctx.flightMinutes;
var lowConf = (ctx.srel === "low");
var nowSec = Math.floor((ctx.nowMs != null ? ctx.nowMs : Date.now()) / 1000);
var rSec = nextRestockSec(ctx.nextRestock, ctx.restockEntry, nowSec);
var R = (rSec == null) ? null : (rSec - nowSec) / 60;
if (qty <= 0) {
if (R != null && R <= F) {
return { state: "GONE_THEN_RESTOCKED", text: "🔄 Restocks ~" + Math.max(1, Math.round(R)) + "m before you land", lowConf: lowConf };
}
return { state: "GONE", text: "❌ Out of stock", lowConf: lowConf };
}
if (ctx.sellRate < 0 || !isFinite(ctx.sellRate)) return null;
var bufferedRate = ctx.sellRate * SAFETY;
var M = (bufferedRate > 0 && isFinite(bufferedRate)) ? (qty / bufferedRate) : Infinity;
var margin = M - F;
if (margin >= MARGIN_SAFE_MIN || M >= 1.5 * F) {
var safeText = "✅ In stock when you land";
if (!lowConf) safeText += " (~" + Math.max(1, Math.round(margin)) + "m buffer)";
return { state: "SAFE", text: safeText, lowConf: lowConf };
}
if (margin >= 0) {
var riskyText = "⚠️ Cutting it close — selling fast";
if (!lowConf) riskyText += " (~" + Math.max(1, Math.round(margin)) + "m to spare)";
return { state: "RISKY", text: riskyText, lowConf: lowConf };
}
if (R != null && R <= F && R > M) {
return { state: "GONE_THEN_RESTOCKED", text: "🔄 Sells out, but restocks ~" + Math.max(1, Math.round(R)) + "m before you land", lowConf: lowConf };
}
return { state: "GONE", text: "❌ Will sell out before you land", lowConf: lowConf };
}
function parseTravelState(api) {
if (!api || !api.status || !api.status.state) return null;
var state = api.status.state;
var travel = api.travel || {};
if (state === "Traveling") {
var dest = travel.destination;
if (dest === "Torn") return null;
var code = normalizeCountryName(dest);
if (!code) return null;
return { mode: "flight", code: code, countryName: dest, arrivalSec: travel.timestamp, timeLeftSec: travel.time_left };
}
if (state === "Abroad") {
var name = null;
var m = String(api.status.description || "").match(/^In (.+)$/);
if (m) name = m[1].trim();
var ccode = normalizeCountryName(name);
if (!ccode) { name = travel.destination; ccode = normalizeCountryName(name); }
if (!ccode) return null;
return { mode: "abroad", code: ccode, countryName: name, arrivalSec: null, timeLeftSec: null };
}
return null;
}
function sortRows(rows, mode, nowMs) {
if (nowMs == null) nowMs = Date.now();
var arr = rows.slice();
arr.sort(function (a, b) {
var ai = a.qty > 0, bi = b.qty > 0;
if (ai !== bi) return ai ? -1 : 1;
if (ai) {
var pa, pb;
if (mode === "profit") { pa = (a.profit == null ? -Infinity : a.profit); pb = (b.profit == null ? -Infinity : b.profit); }
else { pa = a.cost; pb = b.cost; }
if (pb !== pa) return pb - pa;
if (b.qty !== a.qty) return b.qty - a.qty;
return String(a.name).localeCompare(String(b.name));
}
var ea = restockEta(a.nextRestock, nowMs), eb = restockEta(b.nextRestock, nowMs);
var ma = ea ? (ea.due ? 1e9 : ea.mins) : Infinity, mb = eb ? (eb.due ? 1e9 : eb.mins) : Infinity;
if (ma !== mb) return ma - mb;
return String(a.name).localeCompare(String(b.name));
});
return arr;
}
var ITEM_CATEGORY = {4:"Weapon",8:"Weapon",11:"Weapon",20:"Weapon",21:"Weapon",26:"Weapon",31:"Weapon",50:"Armor",63:"Weapon",99:"Weapon",108:"Weapon",110:"Weapon",111:"Weapon",175:"Weapon",177:"Weapon",178:"Armor",196:"Drug",197:"Drug",198:"Drug",199:"Drug",200:"Drug",201:"Drug",203:"Drug",204:"Drug",205:"Drug",206:"Drug",217:"Weapon",218:"Weapon",219:"Weapon",220:"Weapon",221:"Weapon",222:"Weapon",223:"Weapon",224:"Weapon",225:"Weapon",226:"Weapon",227:"Weapon",228:"Weapon",229:"Weapon",230:"Weapon",231:"Weapon",232:"Weapon",233:"Weapon",234:"Weapon",235:"Weapon",236:"Weapon",237:"Weapon",238:"Weapon",239:"Weapon",240:"Weapon",241:"Weapon",242:"Weapon",243:"Weapon",244:"Weapon",245:"Weapon",246:"Weapon",247:"Weapon",248:"Weapon",249:"Weapon",250:"Weapon",251:"Weapon",252:"Weapon",253:"Weapon",255:"Weapon",256:"Weapon",257:"Weapon",258:"Plushie",260:"Flower",261:"Plushie",263:"Flower",264:"Flower",266:"Plushie",267:"Flower",268:"Plushie",269:"Plushie",271:"Flower",272:"Flower",273:"Plushie",274:"Plushie",276:"Flower",277:"Flower",281:"Plushie",282:"Flower",332:"Armor",333:"Armor",334:"Armor",382:"Weapon",384:"Plushie",385:"Flower",387:"Weapon",388:"Weapon",391:"Weapon",395:"Weapon",397:"Weapon",398:"Weapon",399:"Weapon",400:"Weapon",402:"Weapon",435:"Flower",438:"Weapon",439:"Weapon",440:"Weapon",612:"Weapon",613:"Weapon",614:"Weapon",615:"Weapon",616:"Weapon",617:"Flower",618:"Plushie",640:"Armor",641:"Armor",645:"Armor",651:"Armor",652:"Armor",653:"Armor",654:"Armor"};
function itemCategory(id) { return ITEM_CATEGORY[id] || "Other"; }
function rowVisible(row, mode, filters) {
if (filters.hideOos && row.qty === 0) return false;
if (mode === "profit" && filters.hideNeg && row.profit != null && row.profit < 0) return false;
if (filters.excludedCats && filters.excludedCats.indexOf(itemCategory(row.id)) !== -1) return false;
return true;
}
function countryVisible(code, filters) {
return !(filters.hiddenCountries && filters.hiddenCountries.indexOf(code) !== -1);
}
// ─── GM / data layer ─────────────────────────────────────
var _fetchJson = function (url) {
return new Promise(function (resolve, reject) {
GM_xmlhttpRequest({
method: "GET", url: url, timeout: 15000,
onload: function (r) { try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } },
onerror: function () { reject(new Error("network")); },
ontimeout: function () { reject(new Error("timeout")); }
});
});
};
var _nowSec = function () { return Math.floor(Date.now() / 1000); };
function gmGet(key, def) {
try {
var g = (typeof GM_getValue === "function") ? GM_getValue : (typeof globalThis !== "undefined" ? globalThis.GM_getValue : null);
var v = g ? g(key, null) : null;
return v == null ? def : JSON.parse(v);
} catch (e) { return def; }
}
function gmSet(key, val) {
try {
var s = (typeof GM_setValue === "function") ? GM_setValue : (typeof globalThis !== "undefined" ? globalThis.GM_setValue : null);
if (s) s(key, JSON.stringify(val));
} catch (e) {}
}
function getFilters() {
return {
hideOos: gmGet("tfs_hide_oos", false),
hideNeg: gmGet("tfs_hide_negprofit", false),
excludedCats: gmGet("tfs_cats", []),
hiddenCountries: gmGet("tfs_hidden_countries", [])
};
}
function getStock(force) {
var cached = gmGet("tfs_stock", null);
if (!force && cached && (_nowSec() - cached.t) < STOCK_TTL) return Promise.resolve(cached.data);
function store(json) { var data = parseYataExport(json); gmSet("tfs_stock", { t: _nowSec(), data: data }); return data; }
return _fetchJson(PROMBOT_URL).then(store).catch(function () {
return _fetchJson(YATA_URL).then(store).catch(function () { return cached ? cached.data : null; });
});
}
function getPrices(key) {
if (!key) return Promise.resolve({});
var cached = gmGet("tfs_prices", null);
if (cached && cached.key === key && (_nowSec() - cached.t) < PRICE_TTL) return Promise.resolve(cached.map);
return _fetchJson(TORN_ITEMS_URL + encodeURIComponent(key)).then(function (json) {
if (json && json.error) throw new Error((json.error && json.error.error) || "API error");
var items = (json && json.items) || {};
var list = Array.isArray(items) ? items : Object.keys(items).map(function (k) { var o = items[k] || {}; if (o.id == null) o.id = Number(k); return o; });
var map = {};
list.forEach(function (it) {
var v = (it.value && it.value.market_price != null) ? it.value.market_price : (it.market_value != null ? it.market_value : it.marketValue);
if (v != null) map[it.id] = v;
});
gmSet("tfs_prices", { t: _nowSec(), key: key, map: map });
return map;
});
}
function getModel() {
var cached = gmGet("tfs_model", null);
if (cached && (_nowSec() - cached.t) < MODEL_TTL) return Promise.resolve(cached.data);
return _fetchJson(MODEL_URL).then(function (json) {
var data = (json && json.items) ? json.items : {};
gmSet("tfs_model", { t: _nowSec(), data: data });
return data;
}).catch(function () { return cached ? cached.data : {}; });
}
function getTravelState() {
if (typeof document !== "undefined" && (document.querySelector('span[class*="country___"]') || document.querySelector('[class*="destinationList___"]'))) return Promise.resolve(null);
var key = getKey();
if (!key) return Promise.resolve(null);
var cached = gmGet("tfs_travel", null);
if (cached && (_nowSec() - cached.t) < TRAVEL_TTL) return Promise.resolve(cached.state);
function keepLast() {
var last = cached ? cached.state : null;
gmSet("tfs_travel", { t: _nowSec(), state: last });
return last;
}
return _fetchJson(TORN_TRAVEL_URL + encodeURIComponent(key)).then(function (json) {
if (json && json.error) return keepLast();
var state = parseTravelState(json);
gmSet("tfs_travel", { t: _nowSec(), state: state });
return state;
}).catch(keepLast);
}
// ─── DOM: settings, injector, observer ───────────────────
function getMode() { var m = gmGet("tfs_mode", "stock"); return (m === "profit") ? "profit" : "stock"; }
function setMode(m) { gmSet("tfs_mode", m); }
function getKey() { return gmGet("tfs_key", "") || ""; }
function setKey(k) { gmSet("tfs_key", String(k || "").trim()); }
function tfsMsg(s) { var m = document.querySelector("#tfs-bar .tfs-msg"); if (m) m.textContent = s ? (" " + s) : ""; }
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]; }); }
function injectCss() {
if (document.getElementById("tfs-css")) return;
var s = document.createElement("style");
s.id = "tfs-css";
s.textContent =
".tfs-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:7px 10px;margin:8px 0;background:#16181d;border:1px solid #262a33;border-radius:7px;box-shadow:0 1px 3px rgba(0,0,0,.35);font-size:12px;color:#cfd4dc;}" +
".tfs-bar .tfs-title{font-weight:700;color:#e8c44a;letter-spacing:.3px;margin-right:4px;}" +
".tfs-seg{display:inline-flex;border:1px solid #2e333d;border-radius:7px;overflow:hidden;background:#14161b;}" +
".tfs-toggle{background:transparent;color:#8a909a;border:0;border-radius:0;padding:4px 16px;cursor:pointer;font-weight:600;transition:background .12s,color .12s;}" +
".tfs-toggle:hover{background:#20242c;color:#cfd4dc;}" +
".tfs-toggle.on{background:#3b6dff;color:#fff;}" +
".tfs-toggle+.tfs-toggle{border-left:1px solid #2e333d;}" +
".tfs-refresh,.tfs-save,.tfs-filterbtn{background:#20242c;color:#aeb4bd;border:1px solid #2e333d;border-radius:7px;padding:4px 10px;cursor:pointer;transition:background .12s,color .12s;}" +
".tfs-refresh{width:27px;height:26px;padding:0;border-radius:50%;font-size:13px;display:inline-flex;align-items:center;justify-content:center;}" +
".tfs-refresh:hover,.tfs-save:hover,.tfs-filterbtn:hover{background:#262b34;color:#e6e9ee;border-color:#3a4150;}" +
".tfs-key{background:#0e0f12;border:1px solid #2e333d;color:#dde2e8;border-radius:6px;padding:3px 8px;width:150px;}" +
".tfs-msg{color:#e08a7a;}" +
".tfs-filters{display:none;flex-basis:100%;margin-top:7px;padding-top:8px;border-top:1px solid #262a33;}" +
".tfs-filters.open{display:block;}" +
".tfs-frow{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:5px 0;}" +
".tfs-frow.flags{flex-wrap:nowrap;gap:3px;}" +
".tfs-frow .lbl{color:#7a818c;margin-right:2px;min-width:54px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;}" +
".tfs-ricon{margin-right:5px;font-size:11px;opacity:.95;}" +
".tfs-chip{cursor:pointer;font-size:11px;transition:all .12s;}" +
".tfs-chip.cat{display:inline-flex;align-items:center;gap:3px;background:#202a40;color:#aec4ff;border:1px solid #2c3650;border-radius:11px;padding:2px 10px;}" +
".tfs-chip.cat:hover{border-color:#3b6dff;}" +
".tfs-chip.cat.off{background:transparent;color:#5b626d;border-color:#2a2e38;}" +
".tfs-chip.cat.off .tfs-cicon{filter:grayscale(1);opacity:.55;}" +
".tfs-chip.flag{background:transparent;border:0;border-radius:5px;font-size:15px;line-height:1;padding:1px 2px;flex:0 0 auto;}" +
".tfs-chip.flag:hover{background:#20242c;}" +
".tfs-chip.flag.off{filter:grayscale(1);opacity:.3;}" +
".tfs-ftog{background:#1a1e26;color:#8a909a;border:1px solid #2e333d;border-radius:7px;padding:3px 11px;cursor:pointer;transition:all .12s;}" +
".tfs-ftog:hover{border-color:#3a4150;color:#cfd4dc;}" +
".tfs-ftog.on{background:#3b6dff;color:#fff;border-color:#3b6dff;}" +
".tfs-ftog.on::before{content:'✓ ';}" +
".tfs-panel{margin:5px 0 10px;font-size:12px;max-width:540px;}" +
".tfs-head{display:flex;align-items:center;padding:2px 6px 3px;}" +
".tfs-age{margin-left:auto;font-size:10px;color:#6f7681;background:#20242c;padding:1px 7px;border-radius:8px;white-space:nowrap;}" +
".tfs-age.stale{opacity:.5;}" +
".tfs-row,.tfs-tr{display:grid;grid-template-columns:minmax(0,1fr) 50px 96px;gap:8px;align-items:baseline;padding:2px 6px;border-radius:4px;}" +
".tfs-row.mp,.tfs-tr.mp{grid-template-columns:minmax(0,1fr) 46px 92px 112px;}" +
".tfs-row.out,.tfs-tr.out{grid-template-columns:minmax(0,1fr) auto;opacity:.72;}" +
".tfs-row:hover,.tfs-tr:hover{background:#20242c;}" +
".tfs-name,.tfs-tn{min-width:0;color:#cfd4dc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
".tfs-qty,.tfs-tq{color:#7a818c;text-align:right;font-variant-numeric:tabular-nums;}" +
".tfs-cost,.tfs-tcost{color:#aeb4bd;text-align:right;font-variant-numeric:tabular-nums;}" +
".tfs-profit,.tfs-tp{text-align:right;font-variant-numeric:tabular-nums;}" +
".tfs-profit.pos,.tfs-tp.pos{color:#51c97a;}.tfs-profit.neg,.tfs-tp.neg{color:#b06a5a;}" +
".tfs-oos,.tfs-toos{text-align:right;color:#d8a463;font-style:italic;white-space:nowrap;}" +
"#tfs-desktop{margin:8px 0;}" +
".tfs-thost{background:#16181d;border:1px solid #262a33;border-radius:7px;box-shadow:0 1px 4px rgba(0,0,0,.4);overflow:hidden;font-size:12px;color:#cfd4dc;}" +
".tfs-thead{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#1c1f26;border-bottom:1px solid #262a33;}" +
".tfs-ttitle{font-weight:600;color:#8a909a;font-size:11px;text-transform:uppercase;letter-spacing:.6px;}" +
".tfs-bar .tfs-ico{font-size:13px;}" +
".tfs-tcollapse{background:none;border:0;color:#8a909a;cursor:pointer;font-size:13px;line-height:1;}" +
".tfs-tbody{padding:6px 12px 10px;max-height:64vh;overflow-y:auto;}" +
".tfs-tbody::-webkit-scrollbar{width:9px;}.tfs-tbody::-webkit-scrollbar-thumb{background:#2e333d;border-radius:5px;}" +
".tfs-tc{margin:0 0 12px;max-width:540px;}" +
".tfs-tch{display:flex;align-items:center;gap:7px;font-weight:600;color:#e6e9ee;padding:5px 6px 4px;border-bottom:1px solid #262a33;margin-bottom:3px;position:sticky;top:-1px;background:#16181d;}" +
".tfs-flag{font-size:14px;}" +
".tfs-tempty{color:#7a818c;padding:6px 6px;}" +
".tfs-verdict{font-size:11px;line-height:1.3;margin:0 0 4px;padding:2px 6px 4px;color:#aeb4bd;border-left:2px solid #2e333d;}" +
".tfs-verdict.safe{color:#51c97a;border-left-color:#2f6b45;}" +
".tfs-verdict.risky{color:#e0b35a;border-left-color:#7a5e22;}" +
".tfs-verdict.gone{color:#d8736a;border-left-color:#6b322c;}" +
".tfs-verdict.restock{color:#6aa6e0;border-left-color:#2c4d6b;}" +
".tfs-verdict.lowconf{opacity:.6;}" +
".tfs-verdict .tfs-lc{color:#7a818c;font-style:italic;}" +
"#tfs-travel{margin:8px 0 10px;font-size:12px;max-width:540px;background:#16181d;border:1px solid #262a33;border-radius:7px;box-shadow:0 1px 4px rgba(0,0,0,.4);overflow:hidden;color:#cfd4dc;}" +
".tfs-travel-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap;padding:8px 12px;background:#1c1f26;border-bottom:1px solid #262a33;font-weight:600;color:#e6e9ee;}" +
".tfs-travel-head .tfs-flag{font-size:16px;}" +
".tfs-travel-head .tfs-cn{color:#e6e9ee;}" +
".tfs-countdown{margin-left:auto;font-size:11px;color:#e8c44a;background:#20242c;padding:2px 9px;border-radius:9px;white-space:nowrap;font-variant-numeric:tabular-nums;}" +
"#tfs-travel .tfs-rows{padding:6px 12px 10px;}" +
"#tfs-travel .tfs-tempty{color:#7a818c;padding:6px 0;}";
document.head.appendChild(s);
}
var TFS_CATS = ["Plushie", "Flower", "Drug", "Temporary", "Weapon", "Armor", "Other"];
var TFS_COUNTRIES = [["mex", "Mexico"], ["cay", "Cayman"], ["can", "Canada"], ["haw", "Hawaii"], ["uni", "UK"], ["arg", "Argentina"], ["swi", "Switz"], ["jap", "Japan"], ["chi", "China"], ["uae", "UAE"], ["sou", "S.Africa"]];
var TFS_FLAGS = { mex: "🇲🇽", cay: "🇰🇾", can: "🇨🇦", haw: "🏝", uni: "🇬🇧", arg: "🇦🇷", swi: "🇨🇭", jap: "🇯🇵", chi: "🇨🇳", uae: "🇦🇪", sou: "🇿🇦" };
function tfsFlag(code) { return TFS_FLAGS[code] || "🏳"; }
var TFS_CATICON = { Plushie: "🧸", Flower: "🌸", Drug: "💊", Temporary: "⏳", Weapon: "⚔️", Armor: "🛡️", Other: "📦" };
function tfsRowIcon(id) { var ic = TFS_CATICON[itemCategory(id)]; return ic ? ('<span class="tfs-ricon">' + ic + '</span>') : ''; }
function buildFilterPanel(onChange) {
var f = getFilters();
var panel = document.createElement("div");
panel.className = "tfs-filters";
panel.id = "tfs-filters";
var html = '<div class="tfs-frow">' +
'<button class="tfs-ftog' + (f.hideOos ? " on" : "") + '" data-t="oos">Hide out-of-stock</button>' +
'<button class="tfs-ftog' + (f.hideNeg ? " on" : "") + '" data-t="neg">Hide -profit</button>' +
'</div><div class="tfs-frow"><span class="lbl">Items</span>';
for (var i = 0; i < TFS_CATS.length; i++) html += '<span class="tfs-chip cat' + (f.excludedCats.indexOf(TFS_CATS[i]) !== -1 ? " off" : "") + '" data-cat="' + TFS_CATS[i] + '" title="' + TFS_CATS[i] + '"><span class="tfs-cicon">' + (TFS_CATICON[TFS_CATS[i]] || "") + '</span>' + TFS_CATS[i] + '</span>';
html += '</div><div class="tfs-frow flags"><span class="lbl">Countries</span>';
for (var k = 0; k < TFS_COUNTRIES.length; k++) html += '<span class="tfs-chip ctry flag' + (f.hiddenCountries.indexOf(TFS_COUNTRIES[k][0]) !== -1 ? " off" : "") + '" data-code="' + TFS_COUNTRIES[k][0] + '" title="' + TFS_COUNTRIES[k][1] + '">' + tfsFlag(TFS_COUNTRIES[k][0]) + '</span>';
html += '</div>';
panel.innerHTML = html;
var togs = panel.querySelectorAll(".tfs-ftog");
for (var t = 0; t < togs.length; t++) {
(function (b) {
b.addEventListener("click", function () {
var which = b.getAttribute("data-t");
if (which === "oos") gmSet("tfs_hide_oos", !gmGet("tfs_hide_oos", false));
else gmSet("tfs_hide_negprofit", !gmGet("tfs_hide_negprofit", false));
b.classList.toggle("on"); onChange(false);
});
})(togs[t]);
}
var catChips = panel.querySelectorAll(".tfs-chip.cat");
for (var ci = 0; ci < catChips.length; ci++) {
(function (c) {
c.addEventListener("click", function () {
var cat = c.getAttribute("data-cat");
var ex = gmGet("tfs_cats", []); var idx = ex.indexOf(cat);
if (idx === -1) { ex.push(cat); c.classList.add("off"); } else { ex.splice(idx, 1); c.classList.remove("off"); }
gmSet("tfs_cats", ex); onChange(false);
});
})(catChips[ci]);
}
var ctryChips = panel.querySelectorAll(".tfs-chip.ctry");
for (var di = 0; di < ctryChips.length; di++) {
(function (c) {
c.addEventListener("click", function () {
var code = c.getAttribute("data-code");
var hc = gmGet("tfs_hidden_countries", []); var idx = hc.indexOf(code);
if (idx === -1) { hc.push(code); c.classList.add("off"); } else { hc.splice(idx, 1); c.classList.remove("off"); }
gmSet("tfs_hidden_countries", hc); onChange(false);
});
})(ctryChips[di]);
}
return panel;
}
function isMapLayout() {
return !!document.querySelector('[class*="worldMap___"]') && !document.querySelector('span[class*="country___"]');
}
function desktopHost() {
var h = document.getElementById("tfs-desktop");
if (h) return h;
h = document.createElement("div");
h.id = "tfs-desktop";
var map = document.querySelector('[class*="worldMap___"]');
var cont = (map && (map.closest('[class*="content-wrapper"]') || map.parentElement)) ||
document.querySelector('[class*="content-wrapper"]') || document.querySelector(".content") || document.body;
cont.appendChild(h);
return h;
}
function ensureTablePanel() {
var panel = document.getElementById("tfs-table-host");
if (panel) return panel;
panel = document.createElement("div");
panel.id = "tfs-table-host";
panel.className = "tfs-thost";
panel.innerHTML = '<div class="tfs-thead"><span class="tfs-ttitle">Stock by country</span><button class="tfs-tcollapse" title="Collapse">▾</button></div><div class="tfs-tbody"></div>';
panel.querySelector(".tfs-tcollapse").addEventListener("click", function () {
var b = panel.querySelector(".tfs-tbody"), btn = panel.querySelector(".tfs-tcollapse");
var show = (b.style.display === "none");
b.style.display = show ? "" : "none"; btn.textContent = show ? "▾" : "▸";
});
desktopHost().appendChild(panel);
return panel;
}
function paintTable(stock, mode, prices, model) {
var filters = getFilters();
var panel = ensureTablePanel();
var body = panel.querySelector(".tfs-tbody");
var nowMs = Date.now();
var html = "";
for (var ci = 0; ci < TFS_COUNTRIES.length; ci++) {
var code = TFS_COUNTRIES[ci][0], cname = TFS_COUNTRIES[ci][1];
if (!countryVisible(code, filters)) continue;
var country = stock[code];
if (!country) continue;
var rows = sortRows(buildRows(country.items, { mode: mode, getValue: function (id) { return prices[id]; } }), mode, nowMs);
rows = rows.filter(function (r) { return rowVisible(r, mode, filters); });
if (!rows.length) continue;
var age = formatAge(country.update, Math.floor(nowMs / 1000));
html += '<div class="tfs-tc"><div class="tfs-tch"><span class="tfs-flag">' + tfsFlag(code) + '</span><span class="tfs-cn">' + cname + '</span><span class="tfs-age' + (age.stale ? " stale" : "") + '">updated ' + age.text + '</span></div>';
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
if (r.qty === 0) {
var entry = (model && model[code]) ? model[code][String(r.id)] : null;
html += '<div class="tfs-tr out"><span class="tfs-tn">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span><span class="tfs-toos">' + restockDisplay(r.nextRestock, entry, nowMs, r.qty) + '</span></div>';
} else {
html += '<div class="tfs-tr' + (mode === "profit" ? " mp" : "") + '"><span class="tfs-tn">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span><span class="tfs-tq">×' + r.qty + '</span><span class="tfs-tcost">' + fmtMoney(r.cost) + '</span>' +
(mode === "profit" ? '<span class="tfs-tp ' + (r.profit != null && r.profit > 0 ? "pos" : "neg") + '">' + fmtProfit(r.profit) + ' ea</span>' : '') + '</div>';
}
}
html += '</div>';
}
body.innerHTML = html || '<div class="tfs-tempty">No items match your filters.</div>';
}
function fmtClock(sec) {
var s = Math.max(0, Math.floor(sec));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
function p(n) { return (n < 10 ? "0" : "") + n; }
return h > 0 ? (h + ":" + p(m) + ":" + p(ss)) : (p(m) + ":" + p(ss));
}
function travelHost() {
return document.querySelector('[class*="content-wrapper"]') || document.getElementById("mainContainer") || document.body;
}
// ── Live stock from Torn's own store grid (overrides the lagging API) ──
// Torn's foreign-store row = div[class*="row___"]; each column is a
// div[class*="cell___"] whose screen-reader span (class*="srOnly") names it
// ("type", "stock", …). The stock count is a bare text node after that span,
// so we read the whole cell's digits. Keyed on the label, not the hashed class.
function tfsCellLabel(cell) {
var sr = cell.querySelector('[class*="srOnly"]');
return sr ? String(sr.textContent || "").toLowerCase() : "";
}
function tfsStockFromRow(rowEl) {
var cells = rowEl.querySelectorAll('[class*="cell___"]');
var stockN = null, availN = null;
for (var c = 0; c < cells.length; c++) {
var label = tfsCellLabel(cells[c]);
var isStock = label.indexOf("stock") !== -1;
var isAvail = label.indexOf("available") !== -1;
if (!isStock && !isAvail) continue;
var digits = String(cells[c].textContent || "").replace(/[^\d]/g, "");
if (isStock) stockN = digits ? parseInt(digits, 10) : 0; // no digits = "Out of stock"
else if (digits) availN = parseInt(digits, 10);
}
return (stockN != null) ? stockN : availN;
}
function tfsStoreRowImgId(rowEl) {
var img = rowEl.querySelector('img[src*="/images/items/"]');
if (!img) return null;
var m = String(img.getAttribute("src") || "").match(/\/images\/items\/(\d+)/);
return m ? m[1] : null;
}
function tfsLiveStock(rows) {
var live = {}, byId = {};
try {
for (var i = 0; i < rows.length; i++) byId[String(rows[i].id)] = rows[i];
var storeRows = document.querySelectorAll('div[class*="row___"]');
for (var j = 0; j < storeRows.length; j++) {
var id = tfsStoreRowImgId(storeRows[j]);
if (!id || !byId[id]) continue;
var q = tfsStockFromRow(storeRows[j]);
if (q != null) live[id] = q;
}
} catch (e) {}
return live;
}
function travelRowsHtml(state, stock, model, prices, mode, nowMs) {
var country = stock && stock[state.code];
if (!country || !country.items || !country.items.length) return '<div class="tfs-tempty">no stock data</div>';
var built = buildRows(country.items, { mode: mode, getValue: function (id) { return prices[id]; } });
if (state.mode === "abroad") {
var live = tfsLiveStock(built);
for (var liv = 0; liv < built.length; liv++) { var lv = live[String(built[liv].id)]; if (lv != null) built[liv].qty = lv; }
}
var rows = sortRows(built, mode, nowMs);
rows = rows.filter(function (r) { return rowVisible(r, mode, getFilters()); });
if (!rows.length) return '<div class="tfs-tempty">no stock data</div>';
var flightMinutes = (state.mode === "flight" && state.timeLeftSec != null) ? (state.timeLeftSec / 60) : null;
var html = "";
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var entry = (model && model[state.code]) ? model[state.code][String(r.id)] : null;
if (r.qty === 0) {
html += '<div class="tfs-row out"><span class="tfs-name">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span>' +
'<span class="tfs-oos">' + restockDisplay(r.nextRestock, entry, nowMs, r.qty) + '</span></div>';
} else {
html += '<div class="tfs-row' + (mode === "profit" ? " mp" : "") + '"><span class="tfs-name">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span><span class="tfs-qty">×' + r.qty + '</span><span class="tfs-cost">' + fmtMoney(r.cost) + '</span>' +
(mode === "profit" ? '<span class="tfs-profit ' + (r.profit != null && r.profit > 0 ? "pos" : "neg") + '">' + fmtProfit(r.profit) + ' ea</span>' : '') + '</div>';
if (state.mode === "flight") {
var verdict = entry ? landVerdict({ qty: r.qty, sellRate: entry.sellRate, srel: entry.srel, sellReady: !!entry.sellReady, flightMinutes: flightMinutes, nextRestock: r.nextRestock, restockEntry: entry, nowMs: nowMs }) : null;
if (verdict) {
var cls = { SAFE: "safe", RISKY: "risky", GONE: "gone", GONE_THEN_RESTOCKED: "restock" }[verdict.state] || "";
html += '<div class="tfs-verdict ' + cls + (verdict.lowConf ? " lowconf" : "") + '">' + escapeHtml(verdict.text) + (verdict.lowConf ? ' <span class="tfs-lc">(low confidence)</span>' : '') + '</div>';
}
} else {
html += '<div class="tfs-verdict">' + restockDisplay(r.nextRestock, entry, nowMs, r.qty) + '</div>';
}
}
}
return html;
}
function renderTravelPanel(state, stock, model, prices, nowMs) {
if (!state) return;
prices = prices || {};
model = model || {};
var mode = (getMode() === "profit" && Object.keys(prices).length) ? "profit" : "stock";
var panel = document.getElementById("tfs-travel");
if (!panel) {
panel = document.createElement("div");
panel.id = "tfs-travel";
var host = travelHost();
host.insertBefore(panel, host.firstChild);
}
var head;
if (state.mode === "flight") {
var left = (state.timeLeftSec != null) ? state.timeLeftSec : ((state.arrivalSec || 0) - Math.floor(nowMs / 1000));
head = '<span class="tfs-flag">' + tfsFlag(state.code) + '</span><span class="tfs-cn">' + escapeHtml(state.countryName || "") + '</span>' +
'<span class="tfs-countdown">Landing in ' + fmtClock(left) + '</span>';
} else {
head = '<span class="tfs-flag">' + tfsFlag(state.code) + '</span><span class="tfs-cn">You\'re in ' + escapeHtml(state.countryName || "") + '</span>';
}
panel.innerHTML = '<div class="tfs-travel-head">' + head + '</div><div class="tfs-rows">' + travelRowsHtml(state, stock, model, prices, mode, nowMs) + '</div>';
}
var _travelTimer = null, _travelState = null, _travelLastMin = null, _travelCtx = null;
function updateCountdown() {
var panel = document.getElementById("tfs-travel");
if (!panel || !_travelState || _travelState.mode !== "flight") { clearTravelTicker(); return; }
if (!IS_PDA && typeof document !== "undefined" && document.hidden) return;
var left = (_travelState.arrivalSec || 0) - _nowSec();
if (left < 0) left = 0;
var cd = panel.querySelector(".tfs-countdown");
if (cd) cd.textContent = "Landing in " + fmtClock(left);
var min = Math.floor(left / 60);
if (min !== _travelLastMin) {
_travelLastMin = min;
if (_travelCtx) {
var rows = panel.querySelector(".tfs-rows");
if (rows) {
var st = { mode: "flight", code: _travelState.code, countryName: _travelState.countryName, arrivalSec: _travelState.arrivalSec, timeLeftSec: left };
var mode = (getMode() === "profit" && Object.keys(_travelCtx.prices).length) ? "profit" : "stock";
rows.innerHTML = travelRowsHtml(st, _travelCtx.stock, _travelCtx.model, _travelCtx.prices, mode, Date.now());
}
}
}
}
function startTravelTicker(state, ctx) {
_travelState = state;
if (state && state.mode === "flight") {
_travelCtx = ctx || null;
_travelLastMin = null;
if (!_travelTimer) _travelTimer = setInterval(updateCountdown, 1000);
} else {
clearTravelTicker();
}
}
function clearTravelTicker() {
if (_travelTimer) { clearInterval(_travelTimer); _travelTimer = null; }
_travelLastMin = null;
_travelCtx = null;
}
function removeTravelPanel() {
clearTravelTicker();
_travelState = null;
var panel = document.getElementById("tfs-travel");
if (panel && panel.parentNode) panel.parentNode.removeChild(panel);
}
function injectSettingsBar(onChange) {
if (document.getElementById("tfs-bar")) return;
var bar = document.createElement("div");
bar.id = "tfs-bar"; bar.className = "tfs-bar";
var mode = getMode();
bar.innerHTML =
'<span class="tfs-title"><span class="tfs-ico">✈</span> Foreign Stocks</span>' +
'<span class="tfs-seg"><button class="tfs-toggle" data-mode="stock">Stock</button><button class="tfs-toggle" data-mode="profit">Profit</button></span>' +
'<button class="tfs-refresh" title="Refresh stock">↻</button>' +
'<button class="tfs-filterbtn">Filters ▾</button>' +
'<span class="tfs-keywrap" style="display:' + (mode === "profit" ? "inline-flex" : "none") + '">' +
'<input class="tfs-key" type="password" placeholder="Torn API key for profit" value="' + getKey().replace(/"/g, "") + '">' +
'<button class="tfs-save">Save</button></span>' +
'<span class="tfs-msg"></span>';
function paint() {
var m = getMode();
var tg = bar.querySelectorAll(".tfs-toggle");
for (var i = 0; i < tg.length; i++) { tg[i].classList.toggle("on", tg[i].getAttribute("data-mode") === m); }
bar.querySelector(".tfs-keywrap").style.display = (m === "profit") ? "inline-flex" : "none";
}
var toggles = bar.querySelectorAll(".tfs-toggle");
for (var t = 0; t < toggles.length; t++) {
(function (btn) { btn.addEventListener("click", function () { setMode(btn.getAttribute("data-mode")); paint(); onChange(false); }); })(toggles[t]);
}
bar.querySelector(".tfs-refresh").addEventListener("click", function () { onChange(true); });
bar.querySelector(".tfs-save").addEventListener("click", function () {
var v = bar.querySelector(".tfs-key").value.trim();
if (!v) { tfsMsg("enter a key"); return; }
setKey(v); tfsMsg("saved"); onChange(true);
});
paint();
var fpanel = buildFilterPanel(onChange);
bar.appendChild(fpanel);
bar.querySelector(".tfs-filterbtn").addEventListener("click", function () { fpanel.classList.toggle("open"); });
var anchor = document.querySelector('[class*="destinationList___"]');
if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(bar, anchor); }
else if (isMapLayout()) { var dh = desktopHost(); dh.insertBefore(bar, dh.firstChild); }
else { var c = document.querySelector(".content") || document.body; c.insertBefore(bar, c.firstChild); }
}
function findDestinations() {
var out = [], seen = [];
var spans = document.querySelectorAll('span[class*="country___"]');
for (var i = 0; i < spans.length; i++) {
var code = normalizeCountryName((spans[i].textContent || "").trim());
if (!code) continue;
var row = spans[i];
for (var up = 0; up < 6 && row; up++) {
var cn = (row.getAttribute && row.getAttribute("class")) || "";
if (/destination___/.test(cn) && !/destinationList___|destinationDetails___/.test(cn)) break;
row = row.parentElement;
}
if (!row || !/destination___/.test((row.getAttribute && row.getAttribute("class")) || "")) continue;
if (seen.indexOf(row) !== -1) continue;
seen.push(row); out.push({ el: row, code: code });
}
return out;
}
function getTravelMethod() {
var el = document.querySelector('input[name="travelType"][aria-checked="true"]');
var v = el && el.value;
return (v && METHOD_MULT[v] != null) ? v : "standard";
}
function readFlightMinutes(destEl, code) {
if (destEl) {
var t = destEl.querySelector('[class*="duration___"] time[datetime]') || destEl.querySelector('time[datetime]');
if (t) {
var fromAttr = parseFlightMinutes(t.getAttribute("datetime"));
if (fromAttr != null) return fromAttr;
var vis = t.querySelector('span[aria-hidden="true"]');
var fromText = parseFlightMinutes(vis ? vis.textContent : t.textContent);
if (fromText != null) return fromText;
}
}
if (BASE_MIN[code] != null) return Math.round(BASE_MIN[code] * METHOD_MULT[getTravelMethod()]);
return null;
}
function renderPanel(destEl, code, stock, mode, prices, model, filters) {
if (!filters) filters = {};
var country = stock[code];
if (!country) return;
var rows = sortRows(buildRows(country.items, { mode: mode, getValue: function (id) { return prices[id]; } }), mode);
rows = rows.filter(function (r) { return rowVisible(r, mode, filters); });
if (!rows.length) { var gone = destEl.querySelector(".tfs-panel"); if (gone) gone.parentNode.removeChild(gone); return; }
var age = formatAge(country.update, Math.floor(Date.now() / 1000));
var html = '<div class="tfs-head"><span class="tfs-age' + (age.stale ? " stale" : "") + '">updated ' + age.text + '</span></div><div class="tfs-rows">';
var nowMs = Date.now();
var flightMinutes = readFlightMinutes(destEl, code);
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var entry = (model && model[code]) ? model[code][String(r.id)] : null;
if (r.qty === 0) {
html += '<div class="tfs-row out"><span class="tfs-name">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span>' +
'<span class="tfs-oos">' + restockDisplay(r.nextRestock, entry, nowMs, r.qty) + '</span></div>';
} else {
html += '<div class="tfs-row' + (mode === "profit" ? " mp" : "") + '"><span class="tfs-name">' + tfsRowIcon(r.id) + escapeHtml(r.name) + '</span><span class="tfs-qty">×' + r.qty + '</span><span class="tfs-cost">' + fmtMoney(r.cost) + '</span>' +
(mode === "profit" ? '<span class="tfs-profit ' + (r.profit != null && r.profit > 0 ? "pos" : "neg") + '">' + fmtProfit(r.profit) + ' ea</span>' : '') + '</div>';
var verdict = entry ? landVerdict({ qty: r.qty, sellRate: entry.sellRate, srel: entry.srel, sellReady: !!entry.sellReady, flightMinutes: flightMinutes, nextRestock: r.nextRestock, restockEntry: entry, nowMs: nowMs }) : null;
if (verdict) {
var cls = { SAFE: "safe", RISKY: "risky", GONE: "gone", GONE_THEN_RESTOCKED: "restock" }[verdict.state] || "";
html += '<div class="tfs-verdict ' + cls + (verdict.lowConf ? " lowconf" : "") + '">' + escapeHtml(verdict.text) + (verdict.lowConf ? ' <span class="tfs-lc">(low confidence)</span>' : '') + '</div>';
}
}
}
html += '</div>';
var existing = destEl.querySelector(".tfs-panel");
if (existing) { existing.innerHTML = html; }
else { var p = document.createElement("div"); p.className = "tfs-panel"; p.innerHTML = html; destEl.appendChild(p); }
}
var _applyTimer = null;
function paintPanels(stock, mode, prices, model) {
var filters = getFilters();
var dests = findDestinations();
for (var i = 0; i < dests.length; i++) {
if (!countryVisible(dests[i].code, filters)) {
var hidden = dests[i].el.querySelector(".tfs-panel");
if (hidden) hidden.parentNode.removeChild(hidden);
continue;
}
renderPanel(dests[i].el, dests[i].code, stock, mode, prices, model || {}, filters);
}
}
function applyTravel(stock, model, prices) {
getTravelState().then(function (state) {
if (!state) { removeTravelPanel(); return; }
startTravelTicker(state, { stock: stock, model: model || {}, prices: prices || {} });
renderTravelPanel(state, stock, model, prices, Date.now());
}).catch(function () { removeTravelPanel(); });
}
function applyAll(force) {
var mode = getMode(), key = getKey();
Promise.all([getStock(force), getModel()]).then(function (res) {
var stock = res[0], model = res[1] || {};
if (!stock) { tfsMsg("stock unavailable"); return; }
function render(prices, m) {
if (isMapLayout()) paintTable(stock, m, prices, model);
else paintPanels(stock, m, prices, model);
applyTravel(stock, model, prices);
}
if (mode === "profit" && key) {
return getPrices(key).then(function (mp) { tfsMsg(""); render(mp, "profit"); })
.catch(function () { tfsMsg("key error"); render({}, "stock"); });
}
if (mode === "profit" && !key) tfsMsg("add a key for profit");
render({}, "stock");
});
}
function scheduleApply() {
if (_applyTimer) clearTimeout(_applyTimer);
_applyTimer = setTimeout(function () { applyAll(false); }, 200);
}
function isOurNode(node) {
var el = node && node.nodeType === 1 ? node : (node && node.parentElement);
return !!(el && el.closest && el.closest('[id^="tfs"], [class*="tfs-"]'));
}
function startObserver() {
var root = document.querySelector('[class*="destinationList___"]') || document.querySelector(".content") || document.body;
var obs = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
if (!isOurNode(muts[i].target)) { scheduleApply(); return; }
}
});
obs.observe(root, { childList: true, subtree: true });
}
function main() {
injectCss();
injectSettingsBar(function (force) { applyAll(!!force); });
applyAll(false);
startObserver();
setInterval(function () { applyAll(false); }, 30000);
try { if (typeof GM_registerMenuCommand === "function") GM_registerMenuCommand("Foreign Stock: refresh", function () { applyAll(true); }); } catch (e) {}
}
if (typeof window !== "undefined" && typeof location !== "undefined" && /\/page\.php/.test(location.pathname) && /sid=travel/.test(location.search + location.hash)) {
main();
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
normalizeCountryName: normalizeCountryName, COUNTRY_MAP: COUNTRY_MAP,
parseYataExport: parseYataExport, fmtMoney: fmtMoney, fmtProfit: fmtProfit, formatAge: formatAge,
buildRows: buildRows, sortRows: sortRows, restockEta: restockEta,
fmtDuration: fmtDuration, modelEstimate: modelEstimate, restockDisplay: restockDisplay,
itemCategory: itemCategory, rowVisible: rowVisible, countryVisible: countryVisible,
parseFlightMinutes: parseFlightMinutes, landVerdict: landVerdict,
parseTravelState: parseTravelState,
getTravelMethod: getTravelMethod, readFlightMinutes: readFlightMinutes,
travelRowsHtml: travelRowsHtml, getFilters: getFilters,
tfsStockFromRow: tfsStockFromRow, tfsCellLabel: tfsCellLabel, tfsStoreRowImgId: tfsStoreRowImgId
};
module.exports.getStock = getStock;
module.exports.getPrices = getPrices;
module.exports.getModel = getModel;
module.exports.getTravelState = getTravelState;
module.exports.__setFetch = function (fn) { _fetchJson = fn; };
module.exports.__setClock = function (fn) { _nowSec = fn; };
}
})();