Track plushie & flower sets, missing items, profit calculator with MV% thresholds and lifetime trade tracking
// ==UserScript==
// @name Torn Set Trader
// @namespace torn_set_trader
// @version 1.8.3
// @description Track plushie & flower sets, missing items, profit calculator with MV% thresholds and lifetime trade tracking
// @author TheOddSod (2640064)
// @match https://www.torn.com/item.php*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-idle
// ==/UserScript==
// Changelog:
// v1.8.3 — Full design system integration. All hardcoded hex values replaced
// with --ocm-* CSS custom properties. Full 8-theme system added
// (Default, Torn Classic, High Contrast, Deuteranopia, Protanopia,
// Tritanopia, Low Vision, Light Mode). Theme selector in Settings
// with live preview. Matches OC Manager design system exactly.
// v1.6.7 — Added Fring [3714844] credit in Settings (original concept author).
// v1.6.6 — Replaced browser confirm() dialogs with styled in-app modals.
// v1.6.5 — Record Exchange button dimmed and disabled when completeSets = 0.
// v1.6.4 — Renamed "Trade" → "Exchange" throughout UI and history log.
// v1.6.3 — MV Threshold repurposed: buy cost now uses threshold% of market
// price as assumed buy price. Profit shows best-case scenario.
// Settings label updated to "Buy Discount %".
// v1.6.2 — Fixed item doubling on refresh. Scraping now uses data-category
// attribute to only count items for the relevant set type.
// v1.6.1 — Fixed item doubling: filter by visibility when scraping DOM.
// v1.6.0 — Switched inventory scraping to programmatically click Plushie/Flower
// category tabs to force full render before scraping. Fixes missing
// items caused by Torn's virtual scroll / lazy rendering.
// v1.5.5 — Removed all debug logs. Confirmed correct price parsing.
// v1.5.4 — Fixed itemmarket price path: listings are at itemmarket.listings[],
// not itemmarket.item.listings[]. Prices now correct.
// v1.5.1 — Removed 30s cache guard — prices always re-fetched on refresh.
// v1.5.0 — Use listings[0].price (API returns listings sorted cheapest first).
// v1.4.9 — Added targeted debug logging for price diagnosis.
// v1.4.7 — Prices only re-fetched after 30s (Torn server-side cache window).
// Footer shows price fetch timestamp.
// v1.4.6 — Fixed itemmarket URL to v2. MutationObserver for lazy DOM loading.
// v1.4.3 — Confirmed itemmarket/pointsmarket are v2-only (error 23 on v1).
// v1.4.2 — $ signs on all money values. Fixed-width item row columns.
// v1.4.1 — Removed debug logs. Label cleanup.
// v1.4.0 — Fixed itemmarket array/object parsing. Added v1 market_value fallback.
// v1.3.9 — Header badge shows total items to buy. Buy cost and sell value
// calculations corrected. Record Trade uses completeSets.
// v1.3.8 — Switched API calls back to GM_xmlhttpRequest (CORS fix).
// v1.3.7 — Target sets defaults to 0 (auto = maxOwned per set type).
// v1.3.6 — Fixed all profit calculations: need/buyCost/sellValue/profit.
// v1.3.5 — Fixed -1 display for unowned items (Torn uses data-qty=-1).
// v1.3.4 — Fixed points price and item market prices showing 0.
// v1.3.3 — Fixed "apiFetch is not defined".
// v1.3.2 — Fixed "resolvedIds is not defined" — moved to module scope.
// v1.3.1 — Fixed DOM scraper selectors using actual item.php HTML structure.
// v1.3.0 — Switched to DOM scraping for inventory (Torn API endpoint removed).
// v1.2.1 — Switched all API calls to fetch() with full explicit URLs.
// v1.2.0 — Fixed all API endpoints to v2. Inventory deprecated on v1.
// v1.1.9 — Raw response logging to diagnose inventory API response structure.
// v1.1.8 — Improved error visibility. Clarified API key requirements.
// v1.1.7 — Force string keys throughout. Added targeted debug logging.
// v1.1.6 — Switched inventory & bazaar fetches to API v1.
// v1.1.4 — Item IDs resolved dynamically from torn/items API. Cached in GM storage.
// v1.1.3 — Corrected base points to 10 per set (11 was Museum Day active).
// v1.1.2 — Corrected all item IDs and names. Plushie set: 13 items. Flower
// set: 11 items. Points corrected to 11 per set (not 3).
// v1.1.1 — Fixed tabs and settings panel. Event listeners re-attached after
// every render. Fixed API parsing for v2 response structure.
// v1.0.0 — Initial release. Plushie & flower set tracker, profit calculator,
// MV% threshold, Museum Day toggle, lifetime trade log, export/import.
(function () {
'use strict';
// ─── ITEM DATA ───────────────────────────────────────────────────────────────
// Torn item IDs for each set piece — verified from Torn API item list & forum data.
// Plushie set: 13 items → 11 points at museum (Museum Day: +10% = 12 pts)
const PLUSHIE_SET = {
name: 'Plushies',
icon: '🧸',
items: {
186: 'Sheep Plushie',
187: 'Teddy Bear Plushie',
215: 'Kitten Plushie',
258: 'Jaguar Plushie',
266: 'Nessie Plushie',
268: 'Red Fox Plushie',
269: 'Monkey Plushie',
270: 'Wolverine Plushie',
273: 'Chamois Plushie',
274: 'Panda Plushie',
281: 'Lion Plushie',
384: 'Camel Plushie',
618: 'Stingray Plushie',
},
};
// Flower set: 11 items → 11 points at museum (Museum Day: +10% = 12 pts)
const FLOWER_SET = {
name: 'Flowers',
icon: '🌸',
items: {
260: 'Dahlia',
263: 'Crocus',
264: 'Orchid',
267: 'Heather',
271: 'Ceibo Flower',
272: 'Edelweiss',
276: 'Peony',
277: 'Cherry Blossom',
282: 'African Violet',
385: 'Tribulus Omanense',
617: 'Banana Orchid',
},
};
// Base points per completed set trade (without any bonuses).
// Museum Day (+10%) is handled separately via the MUSEUM_BONUS multiplier.
const PLUSHIE_POINTS = 10;
const FLOWER_POINTS = 10;
// Museum Day bonus: +10% points on exchange at the museum
const MUSEUM_BONUS = 1.10;
// ─── STORAGE ─────────────────────────────────────────────────────────────────
const S = {
get: (k, d) => { const v = GM_getValue(k, null); return v === null ? d : v; },
set: (k, v) => GM_setValue(k, v),
};
// ─── STATE ───────────────────────────────────────────────────────────────────
// Persisted config — loaded once, saved on settings save
const cfg = {
apiKey: S.get('tst_apiKey', ''),
pointsOverride: S.get('tst_pointsOver', ''), // empty = use API
mvThreshold: S.get('tst_mvThresh', 100), // buy if price <= X% of MV
targetSets: S.get('tst_targetSets', 0),
museumDay: S.get('tst_museumDay', false),
showPlushies: S.get('tst_showPlushies', true),
showFlowers: S.get('tst_showFlowers', true),
autoRefresh: S.get('tst_autoRefresh', 60), // seconds; 0 = off
theme: S.get('tst_theme', 'default'),
targetPrices: S.get('tst_targetPrices', {}), // keyed by item ID, value = max buy price
};
// Runtime state — not persisted
const rt = {
inventory: {}, bazaar: {}, marketPrices: {},
pointsPrice: 0, loading: false, lastRefresh: null,
tab: 'tracker', timer: null, error: '',
};
let collapsed = S.get('tst_collapsed', false);
// ─── ITEM ID RESOLUTION ──────────────────────────────────────────────────────
// Cached name→ID map — populated from DOM on first render, persisted in GM storage
let resolvedIds = S.get('tst_resolvedIds', null);
const ALL_SET_NAMES = [
...Object.values(PLUSHIE_SET.items),
...Object.values(FLOWER_SET.items),
].map(n => n.toLowerCase());
function itemId(hardcodedId, name) {
if (!resolvedIds) return hardcodedId;
return resolvedIds[name.toLowerCase()] || hardcodedId;
}
function resolvedSet(setDef) {
const items = {};
Object.entries(setDef.items).forEach(([hid, name]) => {
items[itemId(hid, name)] = name;
});
return { ...setDef, items };
}
// ─── DOM INVENTORY SCRAPING ──────────────────────────────────────────────────
// Torn's item page uses virtual scroll — not all items render on load.
// Fix: programmatically click each set's category filter tab, wait for DOM
// to settle, scrape quantities, then restore the original tab.
// Category filter anchors use data-info attribute: "Plushie" / "Flower"
function scrapeByCategory(category) {
// Use data-category attribute on each li — Torn keeps all items in DOM always
const inv = {};
document.querySelectorAll(`ul.items-cont li[data-item][data-qty][data-category="${category}"]`).forEach(li => {
const id = String(li.dataset.item || '').trim();
const qty = parseInt(li.dataset.qty) || 0;
if (!id || id === '0' || qty <= 0) return;
inv[id] = (inv[id] || 0) + qty;
});
return inv;
}
function clickCategoryAndWait(dataInfo) {
return new Promise(resolve => {
const anchor = document.querySelector(`a[data-info="${dataInfo}"]`);
if (!anchor) { resolve({}); return; }
anchor.click();
// Wait for Torn to render all items in this category
setTimeout(() => resolve(scrapeByCategory(dataInfo)), 600);
});
}
async function readInventoryFromDOM() {
// Remember what tab was active so we can restore it
const activeAnchor = document.querySelector('ul.ui-tabs-nav li.ui-tabs-active a, ul.cat-wrap li.active a');
const activeInfo = activeAnchor?.dataset?.info || null;
// Scrape plushies
const plushieInv = await clickCategoryAndWait('Plushie');
// Scrape flowers
const flowerInv = await clickCategoryAndWait('Flower');
// Restore original tab
if (activeInfo && activeInfo !== 'Flower') {
const restoreAnchor = document.querySelector(`a[data-info="${activeInfo}"]`);
if (restoreAnchor) restoreAnchor.click();
}
// Merge both
const inv = { ...plushieInv };
Object.entries(flowerInv).forEach(([id, qty]) => {
inv[id] = (inv[id] || 0) + qty;
});
return inv;
}
// Resolve item IDs from DOM — call after clicking a category tab
function resolveIdsFromDOM() {
const map = {};
document.querySelectorAll('ul.items-cont li[data-item]').forEach(li => {
const id = parseInt(li.dataset.item);
const nameEl = li.querySelector('span.name');
if (!nameEl || !id) return;
const name = nameEl.textContent.trim().toLowerCase();
if (ALL_SET_NAMES.includes(name)) map[name] = id;
});
if (Object.keys(map).length > 0) {
resolvedIds = map;
S.set('tst_resolvedIds', map);
}
}
// ─── API ─────────────────────────────────────────────────────────────────────
// Uses GM_xmlhttpRequest which bypasses CORS — required for Tampermonkey
// scripts calling external domains. fetch() triggers CORS errors here.
// market endpoints (itemmarket, pointsmarket) are v2-only (error 23 on v1)
const API_BASE = 'https://api.torn.com/v2';
function apiFetch(path) {
const sep = path.includes('?') ? '&' : '?';
const url = `${API_BASE}${path}${sep}key=${cfg.apiKey}&comment=TornSetTrader`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload(r) {
try {
const data = JSON.parse(r.responseText);
if (data.error) reject(new Error(`API ${data.error.code}: ${data.error.error}`));
else resolve(data);
} catch (e) { reject(e); }
},
onerror() { reject(new Error('Network error')); },
});
});
}
async function fetchAll() {
if (!cfg.apiKey) return;
rt.loading = true;
rt.error = '';
render();
try {
// Step 1: click Plushie + Flower category tabs to force full render, then scrape
const inv = await readInventoryFromDOM();
resolveIdsFromDOM();
rt.inventory = inv;
rt.bazaar = {};
// Step 2: market prices — fetch on every refresh
const rPlushies = resolvedSet(PLUSHIE_SET);
const rFlowers = resolvedSet(FLOWER_SET);
const allIds = [...Object.keys(rPlushies.items), ...Object.keys(rFlowers.items)];
async function fetchItemPrice(id) {
return new Promise(resolve => {
const url = `https://api.torn.com/v2/market/${id}?selections=itemmarket&key=${cfg.apiKey}&comment=TornSetTrader`;
GM_xmlhttpRequest({
method: 'GET', url,
onload(r) {
try {
const resp = JSON.parse(r.responseText);
if (resp.error) { resolve(0); return; }
// Structure: { itemmarket: { item: {...}, listings: [{price, amount}] } }
const listings = resp.itemmarket?.listings || [];
if (listings.length) {
const price = Number(listings[0].price ?? 0);
if (price > 0) { resolve(price); return; }
}
} catch { /* fall through */ }
resolve(0);
},
onerror() { resolve(0); },
});
});
}
const results = await Promise.allSettled(allIds.map(id => fetchItemPrice(id)));
const prices = {};
results.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value > 0) prices[String(allIds[i])] = r.value;
});
// Fallback: v1 torn/items market_value if itemmarket returned nothing
if (Object.keys(prices).length === 0) {
try {
const tornResp = await new Promise((res, rej) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.torn.com/torn?selections=items&key=${cfg.apiKey}&comment=TornSetTrader`,
onload: r => { try { res(JSON.parse(r.responseText)); } catch { rej(); } },
onerror: rej,
});
});
Object.entries(tornResp.items || {}).forEach(([id, item]) => {
if (allIds.includes(String(id)) && item.market_value) prices[String(id)] = item.market_value;
});
} catch { /* ignore */ }
}
rt.marketPrices = prices;
// Step 3: points market price
const ptResp = await apiFetch('/market?selections=pointsmarket');
const pmListings = Array.isArray(ptResp.pointsmarket)
? ptResp.pointsmarket
: Object.values(ptResp.pointsmarket || {});
if (pmListings.length) {
rt.pointsPrice = Math.min(...pmListings.map(l => Number(l.cost ?? l.price ?? 0)).filter(v => v > 0));
}
} catch (err) {
rt.error = String(err);
console.error('[TornSetTrader]', err);
}
rt.loading = false;
rt.lastRefresh = new Date();
render();
}
function startAutoRefresh() {
if (rt.timer) clearInterval(rt.timer);
if (cfg.autoRefresh > 0) rt.timer = setInterval(fetchAll, cfg.autoRefresh * 1000);
}
// ─── CALCULATIONS ─────────────────────────────────────────────────────────────
// Total owned across inventory + bazaar — all keys are strings
function owned(id) {
const k = String(id);
return (rt.inventory[k] || 0) + (rt.bazaar[k]?.qty || 0);
}
function analyseSet(setDef, basePoints) {
const rSet = resolvedSet(setDef);
const items = Object.entries(rSet.items);
const counts = items.map(([id]) => owned(id));
const maxSets = Math.max(...counts);
const completeSets = Math.min(...counts);
// target: use user-specified value, or auto = maxSets (the most you could complete)
const target = cfg.targetSets > 0 ? cfg.targetSets : maxSets;
const ptPrice = cfg.pointsOverride ? parseFloat(cfg.pointsOverride) : rt.pointsPrice;
const pts = cfg.museumDay ? Math.floor(basePoints * MUSEUM_BONUS) : basePoints;
const rows = items.map(([id, name]) => {
const have = owned(id);
const need = Math.max(0, target - have);
const price = rt.marketPrices[id] || 0;
const targetPrice = price * (cfg.mvThreshold / 100);
const maxBuy = cfg.targetPrices[id] ? parseFloat(cfg.targetPrices[id]) : 0;
const atTarget = maxBuy > 0 && price > 0 && price <= maxBuy;
return { id, name, have, need, price, targetPrice, atTarget, maxBuy };
});
const totalItemsNeeded = rows.reduce((s, r) => s + r.need, 0);
// Buy cost at threshold% of market price — e.g. 90% means you assume buying at 10% discount
const buyCost = rows.reduce((s, r) => s + r.targetPrice * r.need, 0);
// Full market buy cost (at 100%) for reference
const buyCostFull = rows.reduce((s, r) => s + r.price * r.need, 0);
const sellValue = target * pts * ptPrice;
const currentValue = completeSets * pts * ptPrice;
// Progress bar: items owned vs total items needed for full target
const totalNeededFull = target * items.length;
const totalOwned = rows.reduce((s, r) => s + Math.min(r.have, target), 0);
const pct = totalNeededFull > 0 ? Math.min(100, (totalOwned / totalNeededFull) * 100) : 0;
const barColour = pct >= 66 ? 'var(--ocm-status-ok)' : pct >= 33 ? 'var(--ocm-status-warn)' : 'var(--ocm-status-crit)';
return { completeSets, maxSets, target, rows, totalItemsNeeded, buyCost, buyCostFull, sellValue, currentValue, profit: sellValue - buyCost, pts, pct, totalOwned, totalNeededFull, barColour };
}
// ─── TRADE LOG ────────────────────────────────────────────────────────────────
function recordTrade(type, sets, profitPerSet) {
const log = S.get('tst_tradeLog', []);
log.unshift({ date: new Date().toISOString(), type, sets, profit: profitPerSet * sets });
S.set('tst_tradeLog', log.slice(0, 500)); // cap at 500 entries
S.set('tst_lifetimeProfit', S.get('tst_lifetimeProfit', 0) + profitPerSet * sets);
S.set('tst_lifetimeSets', S.get('tst_lifetimeSets', 0) + sets);
render();
}
function exportData() {
const data = {
lifetimeProfit: S.get('tst_lifetimeProfit', 0),
lifetimeSets: S.get('tst_lifetimeSets', 0),
tradeLog: S.get('tst_tradeLog', []),
exported: new Date().toISOString(),
};
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
a.download = `torn-set-trader-${Date.now()}.json`;
a.click();
}
function importData(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const d = JSON.parse(e.target.result);
if (d.tradeLog) {
S.set('tst_tradeLog', d.tradeLog);
S.set('tst_lifetimeProfit', d.lifetimeProfit || 0);
S.set('tst_lifetimeSets', d.lifetimeSets || 0);
render();
alert('Import successful!');
}
} catch { alert('Invalid export file.'); }
};
reader.readAsText(file);
}
// ─── HELPERS ─────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—';
const abs = Math.abs(n);
if (abs >= 1e9) return (n / 1e9).toFixed(2) + 'b';
if (abs >= 1e6) return (n / 1e6).toFixed(2) + 'm';
if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return Math.round(n).toLocaleString();
}
// fmt with leading $ sign
function fmtMoney(n) {
if (n == null || n === 0) return '—';
return '$' + fmt(n);
}
function fmtDate(iso) {
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' +
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
// ─── STYLES ──────────────────────────────────────────────────────────────────
GM_addStyle(`
/* ── Root — CSS variables applied by applyTheme() ── */
#tst-root {
font-family: Arial, sans-serif;
font-size: 13px;
color: var(--ocm-text-primary);
margin: 10px 0;
}
#tst-root * { box-sizing: border-box; }
/* ── Header ── */
#tst-header {
display: flex;
align-items: center;
gap: 10px;
background: var(--ocm-bg-header);
padding: 8px 12px;
border-radius: 6px 6px 0 0;
border-bottom: 2px solid var(--ocm-border-accent);
cursor: pointer;
user-select: none;
}
#tst-header h2 { margin: 0; font-size: 15px; color: var(--ocm-accent); flex: 1; }
#tst-header small { color: var(--ocm-text-label); font-size: 11px; }
#tst-root.tst-collapsed #tst-header { border-radius: 6px; }
#tst-root.tst-collapsed #tst-tabs,
#tst-root.tst-collapsed #tst-body { display: none; }
/* ── Tab bar ── */
#tst-tabs {
background: var(--ocm-bg-base);
padding: 4px 12px 0;
display: flex;
gap: 2px;
border-bottom: 1px solid var(--ocm-border-strip);
}
.tst-tab {
padding: 4px 12px;
font-size: 11px;
font-weight: bold;
color: var(--ocm-text-secondary);
background: var(--ocm-bg-deep);
border: 1px solid var(--ocm-border-strip);
border-bottom: none;
border-radius: 3px 3px 0 0;
cursor: pointer;
transition: background .15s, color .15s;
}
.tst-tab:hover { background: var(--ocm-border-strip); color: var(--ocm-text-primary); }
.tst-tab.active { background: var(--ocm-bg-base); color: var(--ocm-accent); border-color: var(--ocm-border-input); }
/* ── Body ── */
#tst-body {
background: var(--ocm-bg-base);
border-radius: 0 0 6px 6px;
padding: 10px;
}
/* ── Stats bar ── */
#tst-stats-bar {
display: flex;
gap: 16px;
background: var(--ocm-bg-input);
padding: 6px 12px;
flex-wrap: wrap;
margin-bottom: 10px;
border-radius: 3px;
}
.tst-stat { display: flex; flex-direction: column; }
.tst-stat-label { font-size: 10px; color: var(--ocm-text-secondary); text-transform: uppercase; letter-spacing: .5px; }
.tst-stat-value { font-size: 14px; font-weight: bold; color: var(--ocm-accent); }
.tst-stat-value.green { color: var(--ocm-status-ok); }
.tst-stat-value.red { color: var(--ocm-status-crit); }
.tst-stat-value.blue { color: var(--ocm-status-travel); }
.tst-stat-value.orange { color: var(--ocm-status-warn); }
.tst-stat-value.gold { color: var(--ocm-status-respect); }
/* ── Section title ── */
.tst-section-title {
color: var(--ocm-accent);
font-size: 9px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: .5px;
margin: 8px 0 4px;
border-bottom: 1px solid var(--ocm-border-section);
padding-bottom: 3px;
}
/* ── Set card ── */
.tst-set-card {
background: var(--ocm-bg-card);
border: 1px solid var(--ocm-border-card);
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
}
.tst-set-card.ready { border-color: var(--ocm-status-ok-border); }
.tst-set-card.missing { border-color: var(--ocm-status-crit-border); }
.tst-set-head {
background: var(--ocm-bg-deep);
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
border-bottom: 1px solid var(--ocm-border-card);
}
.tst-set-name { font-size: 13px; font-weight: bold; color: var(--ocm-text-primary); display: flex; align-items: center; gap: 8px; }
/* ── Badges ── */
.tst-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; }
.tst-badge.ready { background: var(--ocm-status-ok-bg); color: var(--ocm-status-ok); border: 1px solid var(--ocm-status-ok-border); }
.tst-badge.missing { background: var(--ocm-status-crit-bg); color: var(--ocm-status-crit); border: 1px solid var(--ocm-status-crit-border); }
.tst-badge.museum { background: var(--ocm-phase-rec-bg); color: var(--ocm-phase-rec-text); border: 1px solid var(--ocm-phase-rec-border); }
.tst-set-meta { display: flex; gap: 14px; flex-wrap: wrap; }
.tst-set-meta span { font-size: 10px; color: var(--ocm-text-label); }
.tst-set-meta b { color: var(--ocm-text-card); }
/* ── Item grid ── */
.tst-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.tst-item {
border-right: 1px solid var(--ocm-border-faint);
border-bottom: 1px solid var(--ocm-border-faint);
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 11px;
}
.tst-item:hover { background: var(--ocm-bg-hover); }
.tst-item-name { color: var(--ocm-text-card); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.tst-item-r { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
.tst-item-qty { font-size: 12px; font-weight: bold; min-width: 24px; text-align: right; }
.tst-item-qty.ok { color: var(--ocm-status-ok); }
.tst-item-qty.low { color: var(--ocm-status-crit); }
.tst-item-price { font-size: 10px; color: var(--ocm-text-muted); min-width: 50px; text-align: right; text-decoration: none; }
.tst-item-price:hover { color: var(--ocm-accent); text-decoration: underline; }
.tst-item-price.target { color: var(--ocm-status-ok); font-weight: bold; }
.tst-need-tag {
background: var(--ocm-status-crit-bg);
color: var(--ocm-status-crit);
font-size: 9px;
font-weight: bold;
padding: 1px 5px;
border-radius: 3px;
min-width: 52px;
text-align: center;
white-space: nowrap;
}
/* ── Profit bar ── */
.tst-profit-bar {
background: var(--ocm-bg-deep);
border-top: 1px solid var(--ocm-border-card);
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
}
.tst-profit-nums { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
.tst-pn span { color: var(--ocm-text-label); margin-right: 3px; }
.tst-pn b.orange { color: var(--ocm-status-warn); }
.tst-pn b.blue { color: var(--ocm-status-travel); }
.tst-pn b.green { color: var(--ocm-status-ok); }
.tst-pn b.red { color: var(--ocm-status-crit); }
/* ── Buttons ── */
.tst-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: Arial, sans-serif;
line-height: 1.4;
transition: opacity .15s;
white-space: nowrap;
border: none;
}
.tst-btn:hover { opacity: .85; }
.tst-btn:active { opacity: .70; }
.tst-btn-primary { background: var(--ocm-accent-hover); color: #fff; }
.tst-btn-primary:hover { background: var(--ocm-accent); opacity: 1; }
.tst-btn-secondary { background: var(--ocm-bg-input); border: 1px solid var(--ocm-border-input); color: var(--ocm-text-secondary); }
.tst-btn-secondary:hover { background: var(--ocm-bg-hover); color: var(--ocm-text-primary); opacity: 1; }
.tst-btn-green { background: var(--ocm-status-ok-bg); border: 1px solid var(--ocm-status-ok-border); color: var(--ocm-status-ok); }
.tst-btn-green:hover { background: var(--ocm-status-ok-border); color: #fff; opacity: 1; }
.tst-btn-red { background: var(--ocm-status-crit-bg); border: 1px solid var(--ocm-status-crit-border); color: var(--ocm-status-crit); }
.tst-btn-red:hover { background: var(--ocm-status-crit-border); color: #fff; opacity: 1; }
/* ── Footer ── */
.tst-foot { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; }
.tst-refresh-info { font-size: 10px; color: var(--ocm-text-muted); }
/* ── Settings form ── */
.tst-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin-bottom: 10px; }
.tst-form-full { grid-column: 1 / -1; }
.tst-form-label {
display: block;
font-size: 9px;
font-weight: bold;
color: var(--ocm-text-muted);
text-transform: uppercase;
margin-bottom: 3px;
letter-spacing: .5px;
}
.tst-form-input {
width: 100%;
background: var(--ocm-bg-deep);
border: 1px solid var(--ocm-border-input);
border-radius: 4px;
color: var(--ocm-text-primary);
padding: 5px 8px;
font-size: 11px;
font-family: Arial, sans-serif;
}
.tst-form-input::placeholder { color: var(--ocm-text-disabled); }
.tst-form-input:focus { outline: none; border-color: var(--ocm-accent); }
.tst-divider { height: 1px; background: var(--ocm-border-strip); margin: 10px 0; }
.tst-toggle-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
cursor: pointer;
font-size: 11px;
color: var(--ocm-text-card);
user-select: none;
}
.tst-toggle-row input[type=checkbox] { accent-color: var(--ocm-accent); width: 13px; height: 13px; }
.tst-api-note { font-size: 10px; color: var(--ocm-text-muted); margin-top: 4px; }
.tst-api-note b { color: var(--ocm-status-warn); }
/* ── History table ── */
.tst-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.tst-table th {
text-align: left;
padding: 3px 6px;
font-size: 10px;
color: var(--ocm-text-disabled);
font-weight: normal;
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid var(--ocm-border-section);
}
.tst-table td { padding: 3px 6px; border-bottom: 1px solid var(--ocm-border-faint); color: var(--ocm-text-card); }
.tst-table tr:hover td { background: var(--ocm-bg-hover); }
.tst-empty { text-align: center; color: var(--ocm-text-muted); padding: 24px 0; font-size: 12px; }
/* ── Modal ── */
#tst-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.75);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
}
#tst-modal {
position: relative;
z-index: 1000000;
background: var(--ocm-bg-card);
border: 1px solid var(--ocm-border-card);
border-top: 2px solid var(--ocm-border-accent);
border-radius: 6px;
padding: 16px 20px;
min-width: 280px;
max-width: 400px;
font-family: Arial, sans-serif;
box-shadow: 0 8px 32px rgba(0,0,0,.9);
}
.tst-modal-title { font-size: 14px; font-weight: bold; color: var(--ocm-accent); margin-bottom: 12px; }
.tst-modal-body { margin-bottom: 16px; }
.tst-modal-line { font-size: 12px; color: var(--ocm-text-card); margin-bottom: 6px; }
.tst-modal-line b { color: var(--ocm-text-primary); }
.tst-modal-line b.blue { color: var(--ocm-status-travel); }
.tst-modal-line b.green { color: var(--ocm-status-ok); }
.tst-modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
/* ── Progress bar ── */
.tst-progress-wrap {
height: 5px;
background: var(--ocm-bg-deep);
border-radius: 0 0 6px 6px;
overflow: hidden;
cursor: default;
}
.tst-progress-bar {
height: 100%;
border-radius: 0 0 6px 6px;
transition: width 0.4s ease;
}
/* ── Error banner ── */
#tst-error {
background: var(--ocm-status-crit-bg);
border: 1px solid var(--ocm-status-crit-border);
border-radius: 4px;
padding: 6px 10px;
font-size: 11px;
color: var(--ocm-status-crit);
margin-bottom: 8px;
}
`);
// ─── THEME SYSTEM ────────────────────────────────────────────────────────────
// Matches the OCM design system exactly. Theme is applied to #tst-root via
// CSS custom properties so all var(--ocm-*) references update automatically.
const THEMES = {
default: {
'--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
'--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
'--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
'--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
'--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#e05a00','--ocm-border-section':'#333',
'--ocm-accent':'#ff7700','--ocm-accent-hover':'#e05a00','--ocm-accent-dim':'#cc5500',
'--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
'--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
'--ocm-status-jail':'#ff8800','--ocm-status-hospital':'#ff4444','--ocm-status-travel':'#88aaff',
'--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#dd44dd','--ocm-status-respect':'#ffcc44',
'--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#7aadff','--ocm-phase-plan-border':'#3a7acc',
'--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffaa33','--ocm-phase-rec-border':'#cc7700',
'--ocm-stuck-bg':'#2a0000','--ocm-stuck-border':'#ff2200',
'--ocm-font-scale':'1',
},
torn: {
'--ocm-bg-deep':'#111','--ocm-bg-dark':'#1a1a1a','--ocm-bg-base':'#222',
'--ocm-bg-card':'#1c1c1c','--ocm-bg-header':'#1c1c1c','--ocm-bg-hover':'#2a2a2a',
'--ocm-bg-input':'#2a2a2a','--ocm-bg-dropdown':'#111','--ocm-bg-row':'#1a1a1a',
'--ocm-border-faint':'#2a2a2a','--ocm-border-card':'#3a3a3a','--ocm-border-strip':'#333',
'--ocm-border-input':'#555','--ocm-border-accent':'#c03020','--ocm-border-section':'#444',
'--ocm-accent':'#e04030','--ocm-accent-hover':'#c03020','--ocm-accent-dim':'#aa2010',
'--ocm-text-primary':'#ddd','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#333',
'--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
'--ocm-status-jail':'#ff8800','--ocm-status-hospital':'#ff4444','--ocm-status-travel':'#88aaff',
'--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#dd44dd','--ocm-status-respect':'#ffcc44',
'--ocm-phase-plan-bg':'#1a2a1a','--ocm-phase-plan-text':'#88cc88','--ocm-phase-plan-border':'#4a8a4a',
'--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffaa33','--ocm-phase-rec-border':'#cc7700',
'--ocm-stuck-bg':'#2a0000','--ocm-stuck-border':'#cc2200',
'--ocm-font-scale':'1',
},
highcontrast: {
'--ocm-bg-deep':'#000','--ocm-bg-dark':'#000','--ocm-bg-base':'#000',
'--ocm-bg-card':'#0a0a0a','--ocm-bg-header':'#000','--ocm-bg-hover':'#1a1a1a',
'--ocm-bg-input':'#111','--ocm-bg-dropdown':'#000','--ocm-bg-row':'#000',
'--ocm-border-faint':'#444','--ocm-border-card':'#fff','--ocm-border-strip':'#888',
'--ocm-border-input':'#fff','--ocm-border-accent':'#ffff00','--ocm-border-section':'#888',
'--ocm-accent':'#ffff00','--ocm-accent-hover':'#ffee00','--ocm-accent-dim':'#cccc00',
'--ocm-text-primary':'#fff','--ocm-text-card':'#fff','--ocm-text-secondary':'#eee',
'--ocm-text-label':'#ddd','--ocm-text-muted':'#bbb','--ocm-text-disabled':'#888','--ocm-text-dead':'#666',
'--ocm-status-ok':'#00ff88','--ocm-status-warn':'#ffdd00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003318','--ocm-status-warn-bg':'#332200','--ocm-status-crit-bg':'#330000',
'--ocm-status-ok-border':'#00ff88','--ocm-status-warn-border':'#ffdd00','--ocm-status-crit-border':'#ff4444',
'--ocm-status-jail':'#ffaa00','--ocm-status-hospital':'#ff6666','--ocm-status-travel':'#aaccff',
'--ocm-status-abroad':'#ccddff','--ocm-status-blocked':'#ff88ff','--ocm-status-respect':'#ffff88',
'--ocm-phase-plan-bg':'#001a33','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#88ccff',
'--ocm-phase-rec-bg':'#331a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#ffcc44',
'--ocm-stuck-bg':'#330000','--ocm-stuck-border':'#ff4444',
'--ocm-font-scale':'1',
},
deuteranopia: {
'--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
'--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
'--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
'--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
'--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#0088cc','--ocm-border-section':'#333',
'--ocm-accent':'#0099ee','--ocm-accent-hover':'#0077cc','--ocm-accent-dim':'#005599',
'--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
'--ocm-status-ok':'#4499ff','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff6600',
'--ocm-status-ok-bg':'#001833','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#331500',
'--ocm-status-ok-border':'#2266cc','--ocm-status-warn-border':'#886600','--ocm-status-crit-border':'#994400',
'--ocm-status-jail':'#ffcc00','--ocm-status-hospital':'#ff6600','--ocm-status-travel':'#88ccff',
'--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#cc88ff','--ocm-status-respect':'#ffffff',
'--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#3a7acc',
'--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#aa8800',
'--ocm-stuck-bg':'#2a1500','--ocm-stuck-border':'#ff6600',
'--ocm-font-scale':'1',
},
protanopia: {
'--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
'--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
'--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
'--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
'--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#00aacc','--ocm-border-section':'#333',
'--ocm-accent':'#00bbdd','--ocm-accent-hover':'#0099bb','--ocm-accent-dim':'#007799',
'--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
'--ocm-status-ok':'#00ddcc','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ffffff',
'--ocm-status-ok-bg':'#002a28','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#333333',
'--ocm-status-ok-border':'#009988','--ocm-status-warn-border':'#886600','--ocm-status-crit-border':'#aaaaaa',
'--ocm-status-jail':'#ffcc00','--ocm-status-hospital':'#ffffff','--ocm-status-travel':'#88ccff',
'--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#cc88ff','--ocm-status-respect':'#ffff88',
'--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#3a7acc',
'--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#aa8800',
'--ocm-stuck-bg':'#2a2a2a','--ocm-stuck-border':'#ffffff',
'--ocm-font-scale':'1',
},
tritanopia: {
'--ocm-bg-deep':'#1a0f1a','--ocm-bg-dark':'#1a111a','--ocm-bg-base':'#221522',
'--ocm-bg-card':'#1e121e','--ocm-bg-header':'#1e121e','--ocm-bg-hover':'#281828',
'--ocm-bg-input':'#2a0a2a','--ocm-bg-dropdown':'#1a0f1a','--ocm-bg-row':'#1a111a',
'--ocm-border-faint':'#2a1a2a','--ocm-border-card':'#3a2a3a','--ocm-border-strip':'#2a1a2a',
'--ocm-border-input':'#6a3a6a','--ocm-border-accent':'#cc0066','--ocm-border-section':'#442244',
'--ocm-accent':'#ff1177','--ocm-accent-hover':'#cc0055','--ocm-accent-dim':'#990033',
'--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#bbb',
'--ocm-text-label':'#999','--ocm-text-muted':'#777','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
'--ocm-status-ok':'#00ddaa','--ocm-status-warn':'#ff88cc','--ocm-status-crit':'#ff2255',
'--ocm-status-ok-bg':'#002a22','--ocm-status-warn-bg':'#2a0a1a','--ocm-status-crit-bg':'#2a001a',
'--ocm-status-ok-border':'#009977','--ocm-status-warn-border':'#884466','--ocm-status-crit-border':'#880033',
'--ocm-status-jail':'#ff88cc','--ocm-status-hospital':'#ff2255','--ocm-status-travel':'#ff99dd',
'--ocm-status-abroad':'#ffbbee','--ocm-status-blocked':'#aa44ff','--ocm-status-respect':'#ffffff',
'--ocm-phase-plan-bg':'#1a0a2a','--ocm-phase-plan-text':'#ff99dd','--ocm-phase-plan-border':'#882266',
'--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ff88cc','--ocm-phase-rec-border':'#884455',
'--ocm-stuck-bg':'#2a0011','--ocm-stuck-border':'#ff2255',
'--ocm-font-scale':'1',
},
lowvision: {
'--ocm-bg-deep':'#080d18','--ocm-bg-dark':'#0a1020','--ocm-bg-base':'#0d1628',
'--ocm-bg-card':'#111828','--ocm-bg-header':'#111828','--ocm-bg-hover':'#161c30',
'--ocm-bg-input':'#0a2a50','--ocm-bg-dropdown':'#080d18','--ocm-bg-row':'#0a1020',
'--ocm-border-faint':'#333','--ocm-border-card':'#4a4a7a','--ocm-border-strip':'#2a3a6a',
'--ocm-border-input':'#4a6a9a','--ocm-border-accent':'#ff8800','--ocm-border-section':'#555',
'--ocm-accent':'#ff9900','--ocm-accent-hover':'#ff7700','--ocm-accent-dim':'#dd6600',
'--ocm-text-primary':'#ffffff','--ocm-text-card':'#eee','--ocm-text-secondary':'#ccc',
'--ocm-text-label':'#aaa','--ocm-text-muted':'#888','--ocm-text-disabled':'#666','--ocm-text-dead':'#555',
'--ocm-status-ok':'#66ffaa','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff5555',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#33cc77','--ocm-status-warn-border':'#998800','--ocm-status-crit-border':'#cc2200',
'--ocm-status-jail':'#ffaa00','--ocm-status-hospital':'#ff5555','--ocm-status-travel':'#99bbff',
'--ocm-status-abroad':'#bbddff','--ocm-status-blocked':'#ee55ee','--ocm-status-respect':'#ffee55',
'--ocm-phase-plan-bg':'#0a2040','--ocm-phase-plan-text':'#99ccff','--ocm-phase-plan-border':'#4488cc',
'--ocm-phase-rec-bg':'#201400','--ocm-phase-rec-text':'#ffbb44','--ocm-phase-rec-border':'#cc8800',
'--ocm-stuck-bg':'#200000','--ocm-stuck-border':'#ff3300',
'--ocm-font-scale':'1.1',
},
light: {
'--ocm-bg-deep':'#dde4f0','--ocm-bg-dark':'#e8eef8','--ocm-bg-base':'#eef2fa',
'--ocm-bg-card':'#f4f6fc','--ocm-bg-header':'#f4f6fc','--ocm-bg-hover':'#e8ecf8',
'--ocm-bg-input':'#dde4f0','--ocm-bg-dropdown':'#dde4f0','--ocm-bg-row':'#e8eef8',
'--ocm-border-faint':'#ccd4e8','--ocm-border-card':'#b8c4dc','--ocm-border-strip':'#c8d4e8',
'--ocm-border-input':'#9aaac8','--ocm-border-accent':'#cc5500','--ocm-border-section':'#b0bcd8',
'--ocm-accent':'#cc5500','--ocm-accent-hover':'#aa4400','--ocm-accent-dim':'#993300',
'--ocm-text-primary':'#1a1a2e','--ocm-text-card':'#222','--ocm-text-secondary':'#444',
'--ocm-text-label':'#666','--ocm-text-muted':'#777','--ocm-text-disabled':'#999','--ocm-text-dead':'#aaa',
'--ocm-status-ok':'#006622','--ocm-status-warn':'#885500','--ocm-status-crit':'#cc1111',
'--ocm-status-ok-bg':'#d4f0dd','--ocm-status-warn-bg':'#fff0cc','--ocm-status-crit-bg':'#ffe0dd',
'--ocm-status-ok-border':'#44aa66','--ocm-status-warn-border':'#cc8800','--ocm-status-crit-border':'#dd4444',
'--ocm-status-jail':'#885500','--ocm-status-hospital':'#cc1111','--ocm-status-travel':'#2255aa',
'--ocm-status-abroad':'#1144aa','--ocm-status-blocked':'#882288','--ocm-status-respect':'#775500',
'--ocm-phase-plan-bg':'#d8e8f8','--ocm-phase-plan-text':'#1a4a8a','--ocm-phase-plan-border':'#3a7acc',
'--ocm-phase-rec-bg':'#fff4dd','--ocm-phase-rec-text':'#774400','--ocm-phase-rec-border':'#cc8800',
'--ocm-stuck-bg':'#ffe8e8','--ocm-stuck-border':'#cc1111',
'--ocm-font-scale':'1',
},
};
function applyTheme(key) {
const theme = THEMES[key] || THEMES.default;
const root = document.getElementById('tst-root');
if (!root) return;
for (const [prop, val] of Object.entries(theme)) {
if (prop.startsWith('--')) root.style.setProperty(prop, val);
}
root.style.fontSize = `${13 * parseFloat(theme['--ocm-font-scale'] || '1')}px`;
S.set('tst_theme', key);
cfg.theme = key;
}
// ─── HTML BUILDERS ────────────────────────────────────────────────────────────
function buildTrackerTab() {
if (!cfg.apiKey) {
return `<div class="tst-empty">🔑 Enter your Torn API key in the Settings tab to get started.</div>`;
}
if (rt.loading) {
return `<div class="tst-empty">Loading market data…</div>`;
}
const domItemCount = Object.keys(rt.inventory).length;
const domNote = domItemCount === 0
? `<div id="tst-error">⚠ No inventory items found on this page. Make sure you are on <b>torn.com/item.php</b> with your items loaded, then hit Refresh.</div>`
: '';
const pa = cfg.showPlushies ? analyseSet(PLUSHIE_SET, PLUSHIE_POINTS) : null;
const fa = cfg.showFlowers ? analyseSet(FLOWER_SET, FLOWER_POINTS) : null;
const totalProfit = (pa?.profit || 0) + (fa?.profit || 0);
const ptsPrice = cfg.pointsOverride ? parseFloat(cfg.pointsOverride) : rt.pointsPrice;
return `
${rt.error ? `<div id="tst-error">⚠ ${rt.error}</div>` : ''}
${domNote}
<div id="tst-stats-bar">
<div class="tst-stat">
<span class="tst-stat-label">Points price</span>
<span class="tst-stat-value blue">${fmtMoney(ptsPrice)}${cfg.pointsOverride ? ' ✏' : ''}</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Buy discount</span>
<span class="tst-stat-value orange">${cfg.mvThreshold}%</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Complete sets</span>
<span class="tst-stat-value">${(pa?.completeSets || 0) + (fa?.completeSets || 0)}</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Combined profit</span>
<span class="tst-stat-value ${totalProfit >= 0 ? 'green' : 'red'}">${totalProfit >= 0 ? '+' : ''}${fmtMoney(totalProfit)}</span>
</div>
${cfg.museumDay ? `<div class="tst-stat"><span class="tst-stat-label">Mode</span><span class="tst-stat-value orange">🏛 Museum Day</span></div>` : ''}
</div>
${pa ? buildSetCard(PLUSHIE_SET, pa) : ''}
${fa ? buildSetCard(FLOWER_SET, fa) : ''}
<div class="tst-foot">
${rt.lastRefresh ? `<span class="tst-refresh-info">Refreshed: ${rt.lastRefresh.toLocaleTimeString('en-GB')}</span>` : '<span></span>'}
<button class="tst-btn tst-btn-primary" id="tst-btn-refresh">↻ Refresh now</button>
</div>`;
}
function buildSetCard(setDef, a) {
const allGood = a.totalItemsNeeded === 0;
return `
<div class="tst-set-card ${allGood ? 'ready' : 'missing'}">
<div class="tst-set-head">
<div class="tst-set-name">
${setDef.icon} ${setDef.name}
<span class="tst-badge ${allGood ? 'ready' : 'missing'}">${allGood ? '✓ Ready' : `need ${a.totalItemsNeeded} items`}</span>
${cfg.museumDay ? '<span class="tst-badge museum">🏛 Museum Day</span>' : ''}
</div>
<div class="tst-set-meta">
<span>Complete: <b>${a.completeSets}</b></span>
<span>Target: <b>${a.target === a.maxSets ? `${a.target} (auto)` : a.target}</b></span>
<span>Pts/set: <b>${a.pts}</b></span>
</div>
</div>
<div class="tst-items">
${a.rows.map(r => `
<div class="tst-item">
<span class="tst-item-name" title="${r.name}">${r.name}</span>
<div class="tst-item-r">
${r.need > 0 ? `<span class="tst-need-tag">need ${r.need}</span>` : `<span class="tst-need-tag" style="background:var(--ocm-status-ok-bg);color:var(--ocm-status-ok);border:1px solid var(--ocm-status-ok-border)">✓</span>`}
<span class="tst-item-qty ${r.have >= a.target ? 'ok' : 'low'}">${Math.max(0, r.have)}</span>
${r.price > 0
? `<a href="https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${r.id}&itemName=${encodeURIComponent(r.name)}&itemType=${encodeURIComponent(setDef.name === 'Plushies' ? 'Plushie' : 'Flower')}"
target="_blank"
class="tst-item-price${r.atTarget ? ' target' : ''}"
title="${r.maxBuy > 0 ? `Target: ${fmtMoney(r.maxBuy)}` : 'Open in Item Market'}"
>${fmtMoney(r.price)}${r.atTarget ? ' 🎯' : ''}</a>`
: `<span class="tst-item-price">—</span>`}
</div>
</div>`).join('')}
</div>
<div class="tst-profit-bar">
<div class="tst-profit-nums">
<div class="tst-pn">
<span>Buy cost (${a.totalItemsNeeded} items${cfg.mvThreshold !== 100 ? ` @ ${cfg.mvThreshold}%` : ''}):</span>
<b class="orange">${fmtMoney(a.buyCost)}</b>
${cfg.mvThreshold !== 100 ? `<span style="color:#555;font-size:9px;margin-left:4px">full: ${fmtMoney(a.buyCostFull)}</span>` : ''}
</div>
<div class="tst-pn"><span>Sell value (${a.target} sets × ${a.pts}pts):</span><b class="blue">${fmtMoney(a.sellValue)}</b></div>
<div class="tst-pn"><span>Profit:</span><b class="${a.profit >= 0 ? 'green' : 'red'}">${a.profit >= 0 ? '+' : ''}${fmtMoney(a.profit)}</b></div>
</div>
<button class="tst-btn tst-btn-green tst-record-btn"
data-type="${setDef.name}"
data-sets="${a.completeSets}"
data-profit="${a.currentValue}"
${a.completeSets === 0 ? 'disabled style="opacity:.35;cursor:not-allowed"' : ''}>
✓ Record Exchange
</button>
</div>
<div class="tst-progress-wrap" title="${a.pct.toFixed(1)}% complete (${a.totalOwned.toLocaleString()} of ${a.totalNeededFull.toLocaleString()} items)">
<div class="tst-progress-bar" style="width:${a.pct}%;background:${a.barColour}"></div>
</div>
</div>`;
}
function buildHistoryTab() {
const log = S.get('tst_tradeLog', []);
const lifePft = S.get('tst_lifetimeProfit', 0);
const lifeSets = S.get('tst_lifetimeSets', 0);
return `
<div id="tst-stats-bar">
<div class="tst-stat">
<span class="tst-stat-label">Lifetime profit</span>
<span class="tst-stat-value green">+${fmt(lifePft)}</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Sets traded</span>
<span class="tst-stat-value blue">${lifeSets}</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Avg per set</span>
<span class="tst-stat-value gold">${lifeSets > 0 ? fmt(Math.round(lifePft / lifeSets)) : '—'}</span>
</div>
<div class="tst-stat">
<span class="tst-stat-label">Log entries</span>
<span class="tst-stat-value">${log.length}</span>
</div>
</div>
<div class="tst-foot" style="margin-top:0;margin-bottom:8px">
<div style="display:flex;gap:6px">
<button class="tst-btn tst-btn-secondary" id="tst-btn-export">⬇ Export</button>
<label class="tst-btn tst-btn-secondary" style="cursor:pointer">
⬆ Import
<input type="file" accept=".json" id="tst-import-input" style="display:none">
</label>
</div>
<button class="tst-btn tst-btn-red" id="tst-btn-clearlog">✕ Clear log</button>
</div>
${log.length === 0
? `<div class="tst-empty">No exchanges recorded yet.<br>Use "Record Exchange" in the Tracker tab before heading to the museum.</div>`
: `<table class="tst-table">
<thead><tr><th>Date</th><th>Type</th><th>Sets</th><th>Profit</th></tr></thead>
<tbody>
${log.slice(0, 100).map(e => `
<tr>
<td>${fmtDate(e.date)}</td>
<td>${e.type}</td>
<td>${e.sets}</td>
<td style="color:${e.profit >= 0 ? '#44ee88' : '#ff4444'};font-weight:bold">${e.profit >= 0 ? '+' : ''}${fmt(e.profit)}</td>
</tr>`).join('')}
</tbody>
</table>`}`;
}
function buildSettingsTab() {
return `
<div class="tst-section-title">Appearance</div>
<div class="tst-form-grid">
<div class="tst-form-full">
<label class="tst-form-label">Theme</label>
<select class="tst-form-input" id="tst-f-theme">
<option value="default" ${cfg.theme==='default'?'selected':''}>Default (Dark Blue)</option>
<option value="torn" ${cfg.theme==='torn'?'selected':''}>Torn Classic</option>
<option value="highcontrast" ${cfg.theme==='highcontrast'?'selected':''}>High Contrast (WCAG AAA)</option>
<option value="deuteranopia" ${cfg.theme==='deuteranopia'?'selected':''}>Deuteranopia (Red/Green CB)</option>
<option value="protanopia" ${cfg.theme==='protanopia'?'selected':''}>Protanopia (Red Deficiency)</option>
<option value="tritanopia" ${cfg.theme==='tritanopia'?'selected':''}>Tritanopia (Blue/Yellow CB)</option>
<option value="lowvision" ${cfg.theme==='lowvision'?'selected':''}>Low Vision</option>
<option value="light" ${cfg.theme==='light'?'selected':''}>Light Mode</option>
</select>
</div>
</div>
<div class="tst-divider"></div>
<div class="tst-section-title">API Access</div>
<div class="tst-api-note" style="margin-bottom:8px">
Requires <b>Minimal access</b> or higher (to read inventory & bazaar) plus <b>Public access</b> (for market prices).<br>
At <b>torn.com → Settings → API Keys</b>, create a key with at minimum: <b>Items</b> checked under Minimal access.
</div>
<div class="tst-form-grid">
<div class="tst-form-full">
<label class="tst-form-label">Torn API Key</label>
<input class="tst-form-input" type="password" id="tst-f-apikey" value="${cfg.apiKey}" placeholder="Enter your Torn API key…" autocomplete="off">
</div>
</div>
<div class="tst-section-title" style="margin-top:10px">Exchange Settings</div>
<div class="tst-form-grid">
<div>
<label class="tst-form-label">Target Sets (0 = auto, uses max owned)</label>
<input class="tst-form-input" type="number" id="tst-f-target" value="${cfg.targetSets}" min="0" max="999">
</div>
<div>
<label class="tst-form-label">Buy Discount % (100 = full price, 90 = assume 10% off)</label>
<input class="tst-form-input" type="number" id="tst-f-mvthresh" value="${cfg.mvThreshold}" min="1" max="100">
</div>
<div>
<label class="tst-form-label">Points Price Override (blank = live API)</label>
<input class="tst-form-input" type="number" id="tst-f-ptoverride" value="${cfg.pointsOverride}" placeholder="e.g. 50000">
</div>
<div>
<label class="tst-form-label">Auto-Refresh (seconds, 0 = off)</label>
<input class="tst-form-input" type="number" id="tst-f-autorefresh" value="${cfg.autoRefresh}" min="0" step="10">
</div>
</div>
<div class="tst-divider"></div>
<div class="tst-section-title">Display</div>
<label class="tst-toggle-row">
<input type="checkbox" id="tst-f-plushies" ${cfg.showPlushies ? 'checked' : ''}> Show Plushies 🧸
</label>
<label class="tst-toggle-row">
<input type="checkbox" id="tst-f-flowers" ${cfg.showFlowers ? 'checked' : ''}> Show Flowers 🌸
</label>
<label class="tst-toggle-row">
<input type="checkbox" id="tst-f-museum" ${cfg.museumDay ? 'checked' : ''}> Museum Day active (+10% points on exchange) 🏛
</label>
<div class="tst-divider"></div>
<div class="tst-section-title">Target Prices</div>
<div class="tst-api-note" style="margin-bottom:8px">Set the maximum price you're willing to pay per item. Price highlights 🎯 green on the tracker when at or below your target.</div>
<div class="tst-section-title" style="margin-top:6px">🧸 Plushies</div>
<div class="tst-form-grid">
${Object.entries(PLUSHIE_SET.items).map(([id, name]) => `
<div>
<label class="tst-form-label">${name}</label>
<input class="tst-form-input" type="number" id="tst-tp-${id}" value="${cfg.targetPrices[id] || ''}" placeholder="No target">
</div>`).join('')}
</div>
<div class="tst-section-title" style="margin-top:6px">🌸 Flowers</div>
<div class="tst-form-grid">
${Object.entries(FLOWER_SET.items).map(([id, name]) => `
<div>
<label class="tst-form-label">${name}</label>
<input class="tst-form-input" type="number" id="tst-tp-${id}" value="${cfg.targetPrices[id] || ''}" placeholder="No target">
</div>`).join('')}
</div>
<div class="tst-divider"></div>
<div class="tst-divider"></div>
<button class="tst-btn tst-btn-primary" id="tst-btn-save">✓ Save Settings</button>
💡 Original concept by <a href="https://www.torn.com/profiles.php?XID=3714844" target="_blank" style="color:#666;text-decoration:none">Fring [3714844]</a>
</div>`;
}
// ─── MODAL ───────────────────────────────────────────────────────────────────
function showModal({ title, lines, confirm: confirmLabel, confirmClass, onConfirm }) {
const existing = document.getElementById('tst-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'tst-modal-overlay';
// Copy --ocm-* CSS variables from #tst-root so the modal inherits the theme
const root = document.getElementById('tst-root');
if (root) {
const computed = getComputedStyle(root);
const vars = ['--ocm-bg-card','--ocm-border-card','--ocm-border-accent',
'--ocm-accent','--ocm-text-primary','--ocm-text-card',
'--ocm-status-travel','--ocm-status-ok','--ocm-status-ok-bg',
'--ocm-status-ok-border','--ocm-status-crit-bg','--ocm-status-crit-border','--ocm-status-crit'];
vars.forEach(v => {
const val = computed.getPropertyValue(v).trim();
if (val) overlay.style.setProperty(v, val);
});
}
overlay.innerHTML = `
<div id="tst-modal">
<div class="tst-modal-title">${title}</div>
<div class="tst-modal-body">
${lines.map(l => `<div class="tst-modal-line">${l}</div>`).join('')}
</div>
<div class="tst-modal-footer">
<button class="tst-btn tst-btn-secondary" id="tst-modal-cancel">Cancel</button>
<button class="tst-btn ${confirmClass}" id="tst-modal-confirm">${confirmLabel}</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.getElementById('tst-modal-cancel').addEventListener('click', () => overlay.remove());
document.getElementById('tst-modal-confirm').addEventListener('click', () => {
overlay.remove();
onConfirm();
});
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
}
// ─── RENDER ───────────────────────────────────────────────────────────────────
function render() {
const root = document.getElementById('tst-root');
if (!root) return;
const tabHTML =
rt.tab === 'tracker' ? buildTrackerTab() :
rt.tab === 'history' ? buildHistoryTab() :
rt.tab === 'settings' ? buildSettingsTab() : '';
root.className = collapsed ? 'tst-collapsed' : '';
root.innerHTML = `
<div id="tst-header">
<h2>🎯 Set Trader <span style="font-size:10px;font-weight:normal;opacity:.5">v1.8.3</span></h2>
${rt.lastRefresh ? `<small>Updated: ${rt.lastRefresh.toLocaleTimeString('en-GB')}</small>` : ''}
<button class="tst-btn tst-btn-secondary" id="tst-collapse-btn">${collapsed ? '▼' : '▲'}</button>
</div>
<div id="tst-tabs">
<div class="tst-tab ${rt.tab === 'tracker' ? 'active' : ''}" data-tab="tracker">Tracker</div>
<div class="tst-tab ${rt.tab === 'history' ? 'active' : ''}" data-tab="history">History</div>
<div class="tst-tab ${rt.tab === 'settings' ? 'active' : ''}" data-tab="settings">Settings</div>
</div>
<div id="tst-body">${tabHTML}</div>`;
bindEvents();
}
// ─── EVENT BINDING ────────────────────────────────────────────────────────────
// Re-runs after every render() so listeners always point at current DOM nodes.
function bindEvents() {
const el = id => document.getElementById(id);
// Collapse toggle (header click or dedicated button)
el('tst-collapse-btn')?.addEventListener('click', e => {
e.stopPropagation();
collapsed = !collapsed;
S.set('tst_collapsed', collapsed);
render();
});
el('tst-header')?.addEventListener('click', () => {
collapsed = !collapsed;
S.set('tst_collapsed', collapsed);
render();
});
// Tab switching — stop propagation so header click doesn't also fire
document.querySelectorAll('#tst-root .tst-tab').forEach(tab => {
tab.addEventListener('click', e => {
e.stopPropagation();
rt.tab = tab.dataset.tab;
render();
});
});
// ── Tracker ──────────────────────────────────────────────────────────────
el('tst-btn-refresh')?.addEventListener('click', fetchAll);
document.querySelectorAll('#tst-root .tst-record-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.disabled) return;
const { type, sets, profit } = btn.dataset;
const n = parseInt(sets), p = parseFloat(profit);
showModal({
title: `Record ${type} Exchange`,
lines: [
`Sets exchanged: <b>${n}</b>`,
`Points value: <b class="blue">${fmtMoney(p)}</b>`,
],
confirm: 'Record Exchange',
confirmClass: 'tst-btn-green',
onConfirm: () => recordTrade(type, n, p / n),
});
});
});
// ── History ──────────────────────────────────────────────────────────────
el('tst-btn-export')?.addEventListener('click', exportData);
el('tst-import-input')?.addEventListener('change', e => {
if (e.target.files[0]) importData(e.target.files[0]);
});
el('tst-btn-clearlog')?.addEventListener('click', () => {
showModal({
title: 'Clear Exchange History',
lines: ['This will permanently delete all recorded exchanges and lifetime stats.'],
confirm: 'Clear History',
confirmClass: 'tst-btn-red',
onConfirm: () => {
S.set('tst_tradeLog', []);
S.set('tst_lifetimeProfit', 0);
S.set('tst_lifetimeSets', 0);
render();
},
});
});
// Theme live preview
el('tst-f-theme')?.addEventListener('change', () => applyTheme(el('tst-f-theme').value));
el('tst-btn-save')?.addEventListener('click', () => {
cfg.apiKey = el('tst-f-apikey').value.trim();
cfg.targetSets = Math.max(0, parseInt(el('tst-f-target').value) || 0);
cfg.mvThreshold = parseFloat(el('tst-f-mvthresh').value) || 100;
cfg.pointsOverride = el('tst-f-ptoverride').value.trim();
cfg.autoRefresh = Math.max(0, parseInt(el('tst-f-autorefresh').value) || 0);
cfg.showPlushies = el('tst-f-plushies').checked;
cfg.showFlowers = el('tst-f-flowers').checked;
cfg.museumDay = el('tst-f-museum').checked;
cfg.theme = el('tst-f-theme')?.value || 'default';
// Save target prices for all set items
const tp = {};
[...Object.keys(PLUSHIE_SET.items), ...Object.keys(FLOWER_SET.items)].forEach(id => {
const val = el(`tst-tp-${id}`)?.value.trim();
if (val && parseFloat(val) > 0) tp[id] = parseFloat(val);
});
cfg.targetPrices = tp;
S.set('tst_targetPrices', tp);
S.set('tst_apiKey', cfg.apiKey);
S.set('tst_targetSets', cfg.targetSets);
S.set('tst_mvThresh', cfg.mvThreshold);
S.set('tst_pointsOver', cfg.pointsOverride);
S.set('tst_autoRefresh', cfg.autoRefresh);
S.set('tst_showPlushies', cfg.showPlushies);
S.set('tst_showFlowers', cfg.showFlowers);
S.set('tst_museumDay', cfg.museumDay);
// theme is already saved inside applyTheme()
startAutoRefresh();
rt.tab = 'tracker';
render();
if (cfg.apiKey) fetchAll();
});
}
// ─── INIT ─────────────────────────────────────────────────────────────────────
function init() {
const root = document.createElement('div');
root.id = 'tst-root';
const anchor =
document.querySelector('.content-title') ||
document.querySelector('#mainContainer') ||
document.querySelector('.cont-gray') ||
document.body;
if (anchor.parentNode) anchor.parentNode.insertBefore(root, anchor);
else document.body.prepend(root);
render();
applyTheme(cfg.theme);
if (cfg.apiKey) { fetchAll(); startAutoRefresh(); }
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();