/* top_spreads.js * Price Watch with Settings Drawer + PvE/PvP game mode switch * Trader → Flea view includes Trader name + required Loyalty Level (LL) * Adds “Under Avg ₽” column based on current (lastLow → low24h) vs avg24h * Flea Market is NEVER treated as a trader. Unknown sources are skipped. */ /* ========================= Configuration & defaults ========================= */ const ENDPOINT = 'https://api.tarkov.dev/graphql'; // Tarkov.dev GraphQL const CACHE_KEY = 'tarkov_items_cache_v1'; const CACHE_MS = 5 * 60 * 1000; // 5 minutes // Recognized traders (strict allow-list) const TRADERS = new Set([ 'Prapor', 'Therapist', 'Fence', 'Skier', 'Peacekeeper', 'Mechanic', 'Ragman', 'Jaeger' ]); // Persisted settings const SETTINGS_KEY = 'tarkov_spread_settings_v1'; const DEFAULT_SETTINGS = { // Denoising / realism gates minOffers: 30, // gate thin markets (0 disables; try 30–80) maxSpreadRatio: 2.0, // reject if high24h / low24h > X maxAvgToLow: 1.25, // reject if avg24h / lastLow > X // Flea fee approximation (rough % cut to avoid overstating profit) applyFleaFee: true, feePercent: 9, // displayed as percent; converted to 0.09 // Game mode: 'regular' (PvP economy) or 'pve' gameMode: 'regular' }; // UI state (not persisted) let itemsCache = null; // raw items from GraphQL let currentMode = 'spread'; // 'spread' | 'trader' let currentRows = []; /* ========================= DOM helpers / formatting ========================= */ const $ = (sel) => document.querySelector(sel); function byId(id) { return document.getElementById(id); } function asInt(n) { return Number.isFinite(n) ? Math.trunc(n) : null; } function fmtRUB(n) { return Number.isFinite(n) ? n.toLocaleString('en-US') : '–'; } function nowFmt(d = new Date()) { return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }); } function safeDiv(a, b) { return (Number.isFinite(a) && Number.isFinite(b) && b !== 0) ? (a / b) : null; } /* ========================= Settings load/save ========================= */ function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return { ...DEFAULT_SETTINGS }; const saved = JSON.parse(raw); return { ...DEFAULT_SETTINGS, ...saved }; } catch { return { ...DEFAULT_SETTINGS }; } } function saveSettings(settings) { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {} } let SETTINGS = loadSettings(); /* ========================= GraphQL query & fetch ========================= */ const QUERY = ` query ItemsWithPrices($mode: GameMode) { items(lang: en, gameMode: $mode) { id name shortName basePrice lastLowPrice low24hPrice high24hPrice avg24hPrice lastOfferCount buyFor { source currency price priceRUB vendor { name } requirements { type value } # parsed for loyalty level } sellFor { source currency price priceRUB vendor { name } } } } `; async function fetchItems(gameMode = 'regular') { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: QUERY, variables: { mode: gameMode } }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const items = json?.data?.items ?? []; writeCache(gameMode, items); return items; } /* ========================= Cache (per game mode) ========================= */ function readCache(gameMode) { try { const raw = localStorage.getItem(CACHE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); const pack = parsed[gameMode]; if (!pack) return null; if (Date.now() - (pack.savedAt || 0) > CACHE_MS) return null; return pack.items; } catch { return null; } } function writeCache(gameMode, items) { try { const raw = localStorage.getItem(CACHE_KEY); const parsed = raw ? JSON.parse(raw) : {}; parsed[gameMode] = { savedAt: Date.now(), items }; localStorage.setItem(CACHE_KEY, JSON.stringify(parsed)); } catch {} } /* ========================= Price helpers ========================= */ function priceRUB(p) { if (!p) return null; if (Number.isFinite(p.priceRUB)) return p.priceRUB; if (p.currency === 'RUB' && Number.isFinite(p.price)) return p.price; return null; } function normalizedTraderName(offer) { const n = offer?.vendor?.name || offer?.source || null; if (!n || n === 'Flea Market') return null; return TRADERS.has(n) ? n : null; } // Only real traders: exclude Flea and unknown sources; require a usable RUB price function bestTraderBuyOffer(item) { const offers = (item.buyFor || []).filter(o => { if (!o || o.source === 'Flea Market') return false; const name = normalizedTraderName(o); const rub = priceRUB(o); return name && Number.isFinite(rub); }); let best = null; for (const o of offers) { const rub = priceRUB(o); if (!best || rub < priceRUB(best)) best = o; } return best; // may be null if no acceptable trader offer exists } function bestTraderSellRUB(item) { const arr = (item.sellFor || []) .filter(o => o.source !== 'Flea Market' && normalizedTraderName(o)) .map(priceRUB) .filter(Number.isFinite); return arr.length ? Math.max(...arr) : null; } function conservativeFleaSell(item) { // Very conservative: lastLowPrice -> low24hPrice -> avg24hPrice return item.lastLowPrice ?? item.low24hPrice ?? item.avg24hPrice ?? null; } // Extract name + loyalty (LL). If unknown or flea, return nulls so the row can be skipped. function extractTraderInfo(offer) { const name = normalizedTraderName(offer); if (!name) return { name: null, loyalty: null }; const req = (offer.requirements || []).find( r => String(r.type || '').toLowerCase().includes('loyalty') ); const loyaltyNum = req ? Number.parseInt(req.value, 10) : null; return { name, loyalty: Number.isFinite(loyaltyNum) ? loyaltyNum : null }; } function estimateFleaFee(listPriceRUB) { if (!SETTINGS.applyFleaFee || !Number.isFinite(listPriceRUB)) return 0; const factor = Math.max(0, (SETTINGS.feePercent || 0)) / 100; return Math.floor(listPriceRUB * factor); } // --- New: Under-Avg helper function underAvgDiff(item) { const avg = asInt(item.avg24hPrice ?? null); const current = asInt(item.lastLowPrice ?? item.low24hPrice ?? null); if (!Number.isFinite(avg) || !Number.isFinite(current)) return null; if (current >= avg) return null; return avg - current; // positive number meaning “current is X under average” } /* ========================= Computations ========================= */ function computeSpreadRows(items) { const rows = []; const { minOffers, maxSpreadRatio, maxAvgToLow } = SETTINGS; for (const it of items) { const offers = it.lastOfferCount ?? 0; if (offers < minOffers) continue; const low = asInt(it.low24hPrice ?? it.lastLowPrice ?? null); const high = asInt(it.high24hPrice ?? it.avg24hPrice ?? null); if (!Number.isFinite(low) || !Number.isFinite(high)) continue; // Outlier guards const spreadRatio = safeDiv(high, low); if (spreadRatio !== null && spreadRatio > maxSpreadRatio) continue; const consLow = it.lastLowPrice ?? it.low24hPrice ?? null; const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null); if (avgToLow !== null && avgToLow > maxAvgToLow) continue; const gap = high - low; if (gap <= 0) continue; rows.push({ id: it.id, name: it.name, shortName: it.shortName, gap, low24h: low, high24h: high, avg24h: asInt(it.avg24hPrice ?? null), underAvg: underAvgDiff(it), // NEW traderBuy: asInt(priceRUB(bestTraderBuyOffer(it))), traderSell: asInt(bestTraderSellRUB(it)), offers }); } rows.sort((a, b) => b.gap - a.gap); return rows.slice(0, 100); } function computeTraderFlipRows(items) { const rows = []; const { minOffers, maxAvgToLow } = SETTINGS; for (const it of items) { const offers = it.lastOfferCount ?? 0; if (offers < minOffers) continue; const bestOffer = bestTraderBuyOffer(it); // Strict trader-only offer const traderBuy = asInt(priceRUB(bestOffer)); // Buy from trader (RUB) const fleaSellRaw = asInt(conservativeFleaSell(it));// Conservative flea sell target if (!Number.isFinite(traderBuy) || !Number.isFinite(fleaSellRaw)) continue; // Derive trader name + LL, and skip if we can't identify a known trader const tInfo = extractTraderInfo(bestOffer); if (!tInfo.name) continue; // Apply fee estimate to flea sell side const fee = estimateFleaFee(fleaSellRaw); const netFleaSell = fleaSellRaw - fee; // Guard skew (inflated averages) const consLow = it.lastLowPrice ?? it.low24hPrice ?? null; const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null); if (avgToLow !== null && avgToLow > maxAvgToLow) continue; const profit = netFleaSell - traderBuy; if (profit <= 0) continue; rows.push({ id: it.id, name: it.name, shortName: it.shortName, profit, traderBuy, traderName: tInfo.name, traderLL: tInfo.loyalty, fleaSell: netFleaSell, // net after fee avg24h: asInt(it.avg24hPrice ?? null), underAvg: underAvgDiff(it), // NEW offers }); } rows.sort((a, b) => b.profit - a.profit); return rows.slice(0, 100); } /* ========================= Rendering (table) ========================= */ function renderHead(mode) { const thead = byId('spread-thead'); if (mode === 'spread') { thead.innerHTML = ` # Item Gap ₽ Low24h ₽ High24h ₽ Avg24h ₽ Under Avg ₽ Best Trader Buy ₽ Best Trader Sell ₽ Offers `; } else { thead.innerHTML = ` # Item Profit ₽ Trader Buy ₽ Trader (LL) Flea Sell (after fee) ₽ Avg24h ₽ Under Avg ₽ Offers `; } } function renderBody(mode, rows) { const tbody = byId('spread-tbody'); tbody.innerHTML = ''; rows.forEach((r, i) => { let inner = ''; if (mode === 'spread') { inner = ` ${i + 1} ${r.name} ${fmtRUB(r.gap)} ${fmtRUB(r.low24h)} ${fmtRUB(r.high24h)} ${fmtRUB(r.avg24h)} ${fmtRUB(r.underAvg)} ${fmtRUB(r.traderBuy)} ${fmtRUB(r.traderSell)} ${r.offers ?? '–'} `; } else { const traderLLText = `${r.traderName}${Number.isFinite(r.traderLL) ? ` (LL${r.traderLL})` : ' (LL–)'}`; inner = ` ${i + 1} ${r.name} ${fmtRUB(r.profit)} ${fmtRUB(r.traderBuy)} ${traderLLText} ${fmtRUB(r.fleaSell)} ${fmtRUB(r.avg24h)} ${fmtRUB(r.underAvg)} ${r.offers ?? '–'} `; } const tr = document.createElement('tr'); tr.innerHTML = inner.trim(); tbody.appendChild(tr); }); } function wireSort(mode, rows) { const sortCol = byId('spread-thead').querySelector('#sort-col'); if (!sortCol) return; let desc = true; sortCol.addEventListener('click', () => { desc = !desc; const key = sortCol.dataset.key; rows.sort((a, b) => desc ? (b[key] - a[key]) : (a[key] - b[key])); renderBody(mode, rows); }); } /* ========================= Settings Drawer (injected) ========================= */ function injectSettingsUI() { // Add "Settings" button to the existing .controls row (created in your HTML) const controls = document.querySelector('.controls'); if (controls && !byId('btn-settings')) { const btn = document.createElement('button'); btn.id = 'btn-settings'; btn.className = 'mode-btn'; btn.type = 'button'; btn.textContent = 'Settings ⚙️'; btn.setAttribute('aria-expanded', 'false'); btn.addEventListener('click', () => { const drawer = byId('settings-drawer'); const open = drawer?.classList.contains('open'); if (open) closeSettings(); else openSettings(); }); controls.appendChild(btn); // Add a small badge to show active game mode const badge = document.createElement('span'); badge.id = 'mode-badge'; badge.style.marginLeft = '0.5rem'; badge.style.opacity = '0.8'; badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`; controls.appendChild(badge); } // Inject drawer container once if (!byId('settings-drawer')) { const drawer = document.createElement('div'); drawer.id = 'settings-drawer'; drawer.style.position = 'fixed'; drawer.style.right = '1rem'; drawer.style.top = '5rem'; drawer.style.width = '340px'; drawer.style.maxWidth = '95vw'; drawer.style.border = '1px solid rgba(0,255,102,.35)'; drawer.style.background = '#0b0b0b'; drawer.style.boxShadow = '0 8px 24px rgba(0,0,0,.6), 0 0 14px rgba(0,255,102,.25)'; drawer.style.borderRadius = '8px'; drawer.style.padding = '12px 14px'; drawer.style.zIndex = '9999'; drawer.style.display = 'none'; drawer.innerHTML = `
Price Watch Settings PvE/PvP + filters
`; document.body.appendChild(drawer); // Wire close button byId('btn-close-settings').addEventListener('click', () => closeSettings()); // Initialize control values from SETTINGS const gmRadios = drawer.querySelectorAll('input[name="gm"]'); gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode)); byId('inp-min-offers').value = SETTINGS.minOffers; byId('inp-max-spread').value = SETTINGS.maxSpreadRatio; byId('inp-max-skew').value = SETTINGS.maxAvgToLow; byId('chk-fee').checked = !!SETTINGS.applyFleaFee; byId('inp-fee-pct').value = SETTINGS.feePercent; // Wire Apply byId('btn-apply-settings').addEventListener('click', async () => { const newSettings = { ...SETTINGS, gameMode: [...gmRadios].find(r => r.checked)?.value || SETTINGS.gameMode, minOffers: Math.max(0, parseInt(byId('inp-min-offers').value || DEFAULT_SETTINGS.minOffers, 10)), maxSpreadRatio: Math.max(0, parseFloat(byId('inp-max-spread').value || DEFAULT_SETTINGS.maxSpreadRatio)), maxAvgToLow: Math.max(0, parseFloat(byId('inp-max-skew').value || DEFAULT_SETTINGS.maxAvgToLow)), applyFleaFee: byId('chk-fee').checked, feePercent: Math.max(0, parseFloat(byId('inp-fee-pct').value || DEFAULT_SETTINGS.feePercent)) }; SETTINGS = newSettings; saveSettings(SETTINGS); updateModeBadge(); closeSettings(); await refreshData(); // refetch if game mode changed; otherwise re-render }); // Wire Reset byId('btn-reset-settings').addEventListener('click', () => { SETTINGS = { ...DEFAULT_SETTINGS }; saveSettings(SETTINGS); // Reset UI fields gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode)); byId('inp-min-offers').value = SETTINGS.minOffers; byId('inp-max-spread').value = SETTINGS.maxSpreadRatio; byId('inp-max-skew').value = SETTINGS.maxAvgToLow; byId('chk-fee').checked = !!SETTINGS.applyFleaFee; byId('inp-fee-pct').value = SETTINGS.feePercent; updateModeBadge(); }); } } function openSettings() { const drawer = byId('settings-drawer'); const btn = byId('btn-settings'); if (!drawer) return; drawer.style.display = 'block'; drawer.classList.add('open'); btn?.setAttribute('aria-expanded', 'true'); } function closeSettings() { const drawer = byId('settings-drawer'); const btn = byId('btn-settings'); if (!drawer) return; drawer.style.display = 'none'; drawer.classList.remove('open'); btn?.setAttribute('aria-expanded', 'false'); } function updateModeBadge() { const badge = byId('mode-badge'); if (badge) badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`; } /* ========================= Controller: render & refresh ========================= */ function setMode(viewMode) { if (viewMode !== 'spread' && viewMode !== 'trader') return; currentMode = viewMode; // Toggle buttons in your existing controls $('#toggle-spread')?.classList.toggle('is-active', viewMode === 'spread'); $('#toggle-trader')?.classList.toggle('is-active', viewMode === 'trader'); $('#toggle-spread')?.setAttribute('aria-pressed', String(viewMode === 'spread')); $('#toggle-trader')?.setAttribute('aria-pressed', String(viewMode === 'trader')); byId('spread-caption').textContent = viewMode === 'spread' ? 'Top 100 flea spreads (High − Low). Data: tarkov.dev' : 'Top 100 Trader → Flea flips (net flea after fee − best trader buy). Data: tarkov.dev'; if (!itemsCache) return; const rows = (viewMode === 'spread') ? computeSpreadRows(itemsCache) : computeTraderFlipRows(itemsCache); currentRows = rows; renderHead(viewMode); renderBody(viewMode, rows); wireSort(viewMode, rows); byId('spread-table').hidden = rows.length === 0; } async function refreshData() { const status = byId('status'); const table = byId('spread-table'); const lastUpdated = byId('last-updated'); // Try cache first for this game mode const cached = readCache(SETTINGS.gameMode); if (cached && cached.length) { itemsCache = cached; setMode(currentMode); table.hidden = false; status.textContent = 'Loaded from cache. Refreshing…'; } // Always attempt live refresh for the selected mode try { const items = await fetchItems(SETTINGS.gameMode); itemsCache = items; setMode(currentMode); status.textContent = `Showing top ${currentRows.length} items. Updated just now.`; lastUpdated.textContent = `Updated: ${nowFmt(new Date())}`; table.hidden = false; } catch (err) { console.error(err); if (!itemsCache) { status.textContent = 'Failed to load price data. Please try again.'; table.hidden = true; } else { status.textContent = 'Using cached data (latest refresh failed).'; } } } /* ========================= Boot ========================= */ document.addEventListener('DOMContentLoaded', () => { // Wire existing view toggles from your HTML $('#toggle-spread')?.addEventListener('click', () => setMode('spread')); $('#toggle-trader')?.addEventListener('click', () => setMode('trader')); // Inject settings UI and show current mode badge injectSettingsUI(); updateModeBadge(); // Initial load refreshData(); });