// trader-resets.js (v2.2.0) — Local JSON version with ONE unified "data last updated" bubble (() => { 'use strict'; // ===== Config ===== const REFRESH_MS = 5 * 60 * 1000; // auto-refresh every 5 minutes const STORAGE_KEY = 'eft.traders.mode'; // 'regular' or 'pve' const DEBUG_TS = false; // Local JSON files (served from /var/www/html via cron) const DATA_URL = { regular: '/traders_pvp.json', pve: '/traders_pve.json', }; // ===== DOM refs ===== const els = { grid: document.getElementById('traderGrid'), refresh: document.getElementById('refreshBtn'), modeToggle: document.getElementById('modeToggle'), dataUpdated: document.getElementById('dataUpdated'), }; if (!els.grid) { console.warn('[trader-resets] Missing #traderGrid.'); return; } // ===== Utilities ===== // Normalize resetTime (seconds, ms, ISO) -> ms epoch or null function toMsEpoch(resetTime) { if (resetTime == null) return null; if (typeof resetTime === 'number') return resetTime < 1e12 ? resetTime * 1000 : resetTime; if (typeof resetTime === 'string') { const parsed = Date.parse(resetTime); return Number.isFinite(parsed) ? parsed : null; } return null; } function currentMode() { return els.modeToggle && els.modeToggle.checked ? 'pve' : 'regular'; } function fmtHMS(ms) { if (!Number.isFinite(ms) || ms <= 0) return '00:00:00'; const sec = Math.floor(ms / 1000); const h = String(Math.floor(sec / 3600)).padStart(2, '0'); const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0'); const s = String(sec % 60).padStart(2, '0'); return `${h}:${m}:${s}`; } async function getLocalTraders(mode) { const url = DATA_URL[mode] || DATA_URL.regular; const v = Math.floor(Date.now() / 60000); // per-minute cache-bust const res = await fetch(`${url}?v=${v}`, { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status} ${res.statusText}`); return res.json(); // array of traders } // ===== Last-updated (from static file Last-Modified headers) ===== async function fetchLastModified(url) { try { const head = await fetch(url, { method: 'HEAD' }); const lm = head.headers.get('Last-Modified'); if (lm) return new Date(lm); } catch {} try { const get = await fetch(url + '?stamp=' + Date.now(), { cache: 'no-store' }); const lm = get.headers.get('Last-Modified'); if (lm) return new Date(lm); } catch {} return null; } // Set the *single* bubble to the newest (max) of PvP/PvE file timestamps async function refreshUnifiedUpdatedBubble() { const [pvp, pve] = await Promise.all([ fetchLastModified(DATA_URL.regular), fetchLastModified(DATA_URL.pve), ]); const newest = (!pvp && !pve) ? null : (pvp && pve ? (pvp > pve ? pvp : pve) : (pvp || pve)); if (els.dataUpdated) { els.dataUpdated.textContent = `Data last updated: ${newest ? newest.toLocaleString() : '—'}`; els.dataUpdated.title = newest ? newest.toISOString() : ''; } } // ===== Rendering ===== function renderUnknownCard(trader) { const card = document.createElement('article'); card.className = 'trader-card'; card.setAttribute('role', 'listitem'); const figure = document.createElement('figure'); figure.className = 'trader-figure'; const img = document.createElement('img'); img.className = 'trader-img'; img.alt = `${trader.name || 'Trader'}`; img.loading = 'lazy'; img.src = trader.imageLink || 'https://tarkov.dev/images/traders/default.jpg'; figure.appendChild(img); const name = document.createElement('div'); name.className = 'trader-name'; name.textContent = trader.name || 'Unknown trader'; const timer = document.createElement('div'); timer.className = 'trader-countdown'; timer.textContent = 'Unknown'; const meta = document.createElement('div'); meta.className = 'trader-meta'; meta.textContent = 'Next reset: —'; card.appendChild(figure); card.appendChild(name); card.appendChild(timer); card.appendChild(meta); els.grid.appendChild(card); } function render(traders) { els.grid.innerHTML = ''; const now = Date.now(); traders.forEach(t => { const nextReset = toMsEpoch(t.resetTime); if (DEBUG_TS) { console.debug('[trader-resets] raw resetTime:', t.name, t.resetTime, '→', nextReset && new Date(nextReset).toISOString()); } if (!Number.isFinite(nextReset)) { renderUnknownCard(t); return; } const msLeft = nextReset - now; const card = document.createElement('article'); card.className = 'trader-card'; card.setAttribute('role', 'listitem'); const figure = document.createElement('figure'); figure.className = 'trader-figure'; const img = document.createElement('img'); img.className = 'trader-img'; img.alt = t.name || 'Trader'; img.loading = 'lazy'; img.src = t.imageLink || 'https://tarkov.dev/images/traders/default.jpg'; figure.appendChild(img); const name = document.createElement('div'); name.className = 'trader-name'; name.textContent = t.name || 'Unknown trader'; const timer = document.createElement('div'); timer.className = 'trader-countdown'; timer.textContent = fmtHMS(msLeft); const meta = document.createElement('div'); meta.className = 'trader-meta'; meta.textContent = `Next reset: ${new Date(nextReset).toLocaleTimeString()}`; card.appendChild(figure); card.appendChild(name); card.appendChild(timer); card.appendChild(meta); els.grid.appendChild(card); // Live ticking let tickId = setInterval(() => { const left = nextReset - Date.now(); if (left <= 0) { timer.textContent = '00:00:00'; setTimeout(() => { timer.textContent = 'Restocking…'; }, 500); clearInterval(tickId); return; } timer.textContent = fmtHMS(left); }, 1000); }); } // ===== Load from local JSON ===== async function load() { try { const mode = currentMode(); // 'regular' or 'pve' const traders = await getLocalTraders(mode); render(traders); // Update the unified "data last updated" bubble refreshUnifiedUpdatedBubble().catch(() => {}); } catch (err) { console.error('[trader-resets] load failed:', err); if (els.dataUpdated) { els.dataUpdated.textContent = 'Data last updated: — (failed to read local JSON)'; } } } // ===== Persistence: restore & save mode ===== if (els.modeToggle) { const saved = localStorage.getItem(STORAGE_KEY); if (saved === 'pve') els.modeToggle.checked = true; if (saved === 'regular') els.modeToggle.checked = false; els.modeToggle.addEventListener('change', () => { localStorage.setItem(STORAGE_KEY, currentMode()); load(); // re-render immediately when mode flips }); } // ===== Events ===== els.refresh?.addEventListener('click', load); // ===== Kickoff ===== load(); setInterval(load, REFRESH_MS); })();