630 lines
21 KiB
JavaScript
630 lines
21 KiB
JavaScript
/* 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 = `
|
||
<tr>
|
||
<th scope="col">#</th>
|
||
<th scope="col">Item</th>
|
||
<th scope="col" id="sort-col" data-key="gap" style="cursor:pointer">Gap ₽</th>
|
||
<th scope="col">Low24h ₽</th>
|
||
<th scope="col">High24h ₽</th>
|
||
<th scope="col">Avg24h ₽</th>
|
||
<th scope="col">Under Avg ₽</th>
|
||
<th scope="col">Best Trader Buy ₽</th>
|
||
<th scope="col">Best Trader Sell ₽</th>
|
||
<th scope="col">Offers</th>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
thead.innerHTML = `
|
||
<tr>
|
||
<th scope="col">#</th>
|
||
<th scope="col">Item</th>
|
||
<th scope="col" id="sort-col" data-key="profit" style="cursor:pointer">Profit ₽</th>
|
||
<th scope="col">Trader Buy ₽</th>
|
||
<th scope="col">Trader (LL)</th>
|
||
<th scope="col">Flea Sell (after fee) ₽</th>
|
||
<th scope="col">Avg24h ₽</th>
|
||
<th scope="col">Under Avg ₽</th>
|
||
<th scope="col">Offers</th>
|
||
</tr>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderBody(mode, rows) {
|
||
const tbody = byId('spread-tbody');
|
||
tbody.innerHTML = '';
|
||
rows.forEach((r, i) => {
|
||
let inner = '';
|
||
if (mode === 'spread') {
|
||
inner = `
|
||
<td>${i + 1}</td>
|
||
<td>${r.name}</td>
|
||
<td data-value="${r.gap}">${fmtRUB(r.gap)}</td>
|
||
<td>${fmtRUB(r.low24h)}</td>
|
||
<td>${fmtRUB(r.high24h)}</td>
|
||
<td>${fmtRUB(r.avg24h)}</td>
|
||
<td>${fmtRUB(r.underAvg)}</td>
|
||
<td>${fmtRUB(r.traderBuy)}</td>
|
||
<td>${fmtRUB(r.traderSell)}</td>
|
||
<td>${r.offers ?? '–'}</td>
|
||
`;
|
||
} else {
|
||
const traderLLText = `${r.traderName}${Number.isFinite(r.traderLL) ? ` (LL${r.traderLL})` : ' (LL–)'}`;
|
||
inner = `
|
||
<td>${i + 1}</td>
|
||
<td>${r.name}</td>
|
||
<td data-value="${r.profit}">${fmtRUB(r.profit)}</td>
|
||
<td>${fmtRUB(r.traderBuy)}</td>
|
||
<td>${traderLLText}</td>
|
||
<td>${fmtRUB(r.fleaSell)}</td>
|
||
<td>${fmtRUB(r.avg24h)}</td>
|
||
<td>${fmtRUB(r.underAvg)}</td>
|
||
<td>${r.offers ?? '–'}</td>
|
||
`;
|
||
}
|
||
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 = `
|
||
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;">
|
||
<strong style="font-size:1.05rem;">Price Watch Settings</strong>
|
||
<span style="margin-left:auto;opacity:.7;">PvE/PvP + filters</span>
|
||
<button id="btn-close-settings" class="mode-btn" style="padding:.25rem .5rem;">✕</button>
|
||
</div>
|
||
|
||
<div style="display:flex;flex-direction:column;gap:.5rem;">
|
||
<label>
|
||
<span>Game Mode</span><br/>
|
||
<label style="margin-right:1rem;">
|
||
<input type="radio" name="gm" value="regular"> PvP (Regular)
|
||
</label>
|
||
<label>
|
||
<input type="radio" name="gm" value="pve"> PvE
|
||
</label>
|
||
</label>
|
||
|
||
<label>
|
||
<span>Min Offers (liquidity gate)</span><br/>
|
||
<input id="inp-min-offers" type="number" min="0" step="1" style="width:6rem;">
|
||
</label>
|
||
|
||
<label>
|
||
<span>Max Spread Ratio (high/low cap)</span><br/>
|
||
<input id="inp-max-spread" type="number" min="0" step="0.05" style="width:6rem;">
|
||
</label>
|
||
|
||
<label>
|
||
<span>Max Avg/Low Ratio (skew guard)</span><br/>
|
||
<input id="inp-max-skew" type="number" min="0" step="0.01" style="width:6rem;">
|
||
</label>
|
||
|
||
<label>
|
||
<input id="chk-fee" type="checkbox"> Apply Flea Fee
|
||
</label>
|
||
|
||
<label>
|
||
<span>Flea Fee % (if applied)</span><br/>
|
||
<input id="inp-fee-pct" type="number" min="0" step="0.5" style="width:6rem;"> %
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem;">
|
||
<button id="btn-reset-settings" class="mode-btn">Reset</button>
|
||
<button id="btn-apply-settings" class="mode-btn is-active">Apply & Refresh</button>
|
||
</div>
|
||
`;
|
||
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();
|
||
});
|