/* pricewatch.js — Price Watch logic (local JSON version) - Enter in item input => search + focus target - Enter in target input => add to watch (if enabled) - Unified "Data last updated" from PvP/PvE files - 5-min cron countdown & polling */ (() => { 'use strict'; /***** Small: hamburger toggle *****/ const navBtn = document.querySelector('.nav-toggle'); const navEl = document.getElementById('primary-nav'); if (navBtn && navEl) { navBtn.addEventListener('click', () => { const open = navBtn.getAttribute('aria-expanded') === 'true'; navBtn.setAttribute('aria-expanded', String(!open)); navEl.classList.toggle('is-open', !open); document.body.classList.toggle('nav-open', !open); }); } /***** DOM *****/ const $ = (sel) => document.querySelector(sel); const resultsEl = $('#pc-results'); const selectedEl = $('#pc-selected'); const pricesEl = $('#pc-prices'); const statusEl = $('#pc-status'); const tbodyEl = $('#pc-tbody'); const addBtn = $('#pc-add'); const countdownEl = $('#pc-countdown'); const updatedEl = $('#pc-updated'); /***** Config *****/ const CRON_INTERVAL_MS = 5 * 60 * 1000; // every 5 minutes const CRON_GRACE_MS = 7 * 1000; // allow cron to finish before we poll const CRON_POLL_STEP_MS = 2000; // poll every 2s const CRON_POLL_MAX_MS = 45 * 1000; // poll up to 45s after expected tick /***** State *****/ let searchPick = null; // { id, name, shortName, mode, ... } let state = { cronIntervalMs: CRON_INTERVAL_MS, cronGraceMs: CRON_GRACE_MS, cronPollStepMs: CRON_POLL_STEP_MS, cronPollMaxMs: CRON_POLL_MAX_MS, serverOffsetMs: 0, // serverTime - clientTime (ms) lastKnownUpdatedMs: 0, // newest Last-Modified we've seen (ms epoch) nextTickAt: 0, // ms epoch of next expected cron run (LM + cron interval) cronAlarmId: null, countdownTimer: null, isRefreshing: false, watch: [] // { id, name, mode, direction, target, triggered, last:{curr,low,high} } }; /***** Audio *****/ function playBeep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = 'sine'; o.frequency.value = 700; o.connect(g); g.connect(ctx.destination); g.gain.setValueAtTime(0.25, ctx.currentTime); o.start(); o.stop(ctx.currentTime + 0.25); } catch {} } /***** Utils *****/ function getMode() { const el = document.querySelector('input[name="pc-mode"]:checked'); return el ? el.value : 'regular'; } function fmtRubles(n) { if (n === null || n === undefined) return 'n/a'; return Number(n).toLocaleString(undefined) + ' Rubles'; } function resolveCurrent(it) { // Prefer lastLowPrice; then low24h; then avg24h return it?.lastLowPrice ?? it?.low24hPrice ?? it?.avg24hPrice ?? null; } function keyOf(entry) { return `${entry.mode}:${entry.id}`; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /***** Local data helpers *****/ async function getLocalData(mode) { const url = mode === 'pve' ? '/items_pve.json' : '/items_pvp.json'; // Per-minute cache-bust const v = Math.floor(Date.now() / 60000); const res = await fetch(`${url}?v=${v}`, { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to load ${url}`); return res.json(); // returns an array } // ---- HTTP header helpers (Last-Modified + Date for server clock) ---- 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; } async function fetchServerDate(urlForHeaders = '/items_pvp.json') { try { const res = await fetch(urlForHeaders, { method: 'HEAD' }); const srv = res.headers.get('Date'); return srv ? new Date(srv) : null; } catch { return null; } } async function syncServerClock(urlForHeaders = '/items_pvp.json') { const serverDate = await fetchServerDate(urlForHeaders); if (serverDate) { state.serverOffsetMs = serverDate.getTime() - Date.now(); } } function serverNowMs() { return Date.now() + state.serverOffsetMs; } // Newest LM across PvP/PvE and render the badge; return ms epoch or 0 if unknown async function readAndRenderLastUpdated() { const [pvpDate, pveDate] = await Promise.all([ fetchLastModified('/items_pvp.json'), fetchLastModified('/items_pve.json') ]); const newest = (!pvpDate && !pveDate) ? null : (pvpDate && pveDate ? (pvpDate > pveDate ? pvpDate : pveDate) : (pvpDate || pveDate)); const lmText = newest ? newest.toLocaleString() : '—'; updatedEl.textContent = `Data last updated: ${lmText}`; updatedEl.title = newest ? newest.toISOString() : ''; return newest ? newest.getTime() : 0; } function scheduleNextCronAlarm() { if (!state.lastKnownUpdatedMs) { // Fallback: align to next 5-min boundary from server time const now = serverNowMs(); const nextBoundary = Math.ceil(now / state.cronIntervalMs) * state.cronIntervalMs; state.nextTickAt = nextBoundary; } else { state.nextTickAt = state.lastKnownUpdatedMs + state.cronIntervalMs; } // Alarm just past expected tick to give cron time to run const fireAt = state.nextTickAt + state.cronGraceMs; const delay = Math.max(0, fireAt - serverNowMs()); if (state.cronAlarmId) clearTimeout(state.cronAlarmId); state.cronAlarmId = setTimeout(handleCronWindow, delay); updateCountdown(); } async function handleCronWindow() { // Poll for Last-Modified to advance beyond lastKnownUpdatedMs const prev = state.lastKnownUpdatedMs || 0; const start = serverNowMs(); let advanced = false; while (serverNowMs() - start <= state.cronPollMaxMs) { const nowLm = await readAndRenderLastUpdated(); // also updates the badge if (nowLm && nowLm > prev) { state.lastKnownUpdatedMs = nowLm; advanced = true; break; } await sleep(state.cronPollStepMs); } // Whether we saw the change or timed out, refresh table once. await refreshAll().catch(() => {}); // Recompute nextTick and schedule again if (!advanced && state.lastKnownUpdatedMs < prev) state.lastKnownUpdatedMs = prev; if (!state.lastKnownUpdatedMs) state.lastKnownUpdatedMs = await readAndRenderLastUpdated(); scheduleNextCronAlarm(); } /***** Countdown helpers (to next cron run) *****/ function updateCountdown() { const now = serverNowMs(); if (!state.nextTickAt) { countdownEl.textContent = ''; return; } let ms = state.nextTickAt - now; if (ms <= 0) { const missed = Math.ceil((-ms) / state.cronIntervalMs); state.nextTickAt += missed * state.cronIntervalMs; ms = state.nextTickAt - now; } const secs = Math.max(0, Math.ceil(ms / 1000)); countdownEl.textContent = `Next refresh in: ${secs}s`; } function startCountdown() { if (state.countdownTimer) clearInterval(state.countdownTimer); updateCountdown(); state.countdownTimer = setInterval(updateCountdown, 1000); } /***** Search *****/ async function doSearch() { const name = $('#pc-search').value.trim().toLowerCase(); if (!name) return; const mode = getMode(); selectedEl.textContent = 'Searching...'; pricesEl.textContent = ''; resultsEl.innerHTML = ''; searchPick = null; addBtn.disabled = true; try { const items = await getLocalData(mode); const results = items.filter(it => (it.name || '').toLowerCase().includes(name) || (it.shortName || '').toLowerCase().includes(name) ); showSearchResults(results, mode); } catch (e) { selectedEl.textContent = 'Error: local data not found. Make sure cron has generated the JSON files.'; } } function showSearchResults(items, mode) { if (!items.length) { selectedEl.textContent = 'No items found.'; return; } if (items.length === 1) { pickItem(items[0], mode); return; } selectedEl.textContent = `Multiple results (${items.length}). Click one below.`; resultsEl.innerHTML = items.map(it => { const cur = resolveCurrent(it); return `
  • ${it.name} (${it.shortName ?? '—'}) — current: ${fmtRubles(cur)}, 24h L/H: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}
  • `; }).join(''); } function pickItem(it, mode) { resultsEl.innerHTML = ''; searchPick = { ...it, mode }; selectedEl.innerHTML = `Selected: ${it.name} (${it.id}) [${mode}]`; const cur = resolveCurrent(it); pricesEl.innerHTML = `Current: ${fmtRubles(cur)}
    ` + `24h Low / High: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}`; addBtn.disabled = false; } /***** Watch list table *****/ function renderWatchTable() { if (!state.watch.length) { tbodyEl.innerHTML = `No items being watched.`; return; } tbodyEl.innerHTML = state.watch.map(w => { const currTxt = w.last?.curr != null ? fmtRubles(w.last.curr) : '—'; const lowTxt = w.last?.low != null ? fmtRubles(w.last.low ) : '—'; const highTxt = w.last?.high != null ? fmtRubles(w.last.high) : '—'; const status = w.triggered ? 'Triggered' : 'Watching'; const key = keyOf(w); const cond = `${w.direction} ${fmtRubles(w.target)}`; return ` ${w.name} ${w.mode} ${currTxt} ${lowTxt} ${highTxt} ${cond} ${status} `; }).join(''); } /***** Refresh helpers using local JSON *****/ async function refreshSpecific(entries) { const byMode = new Set(entries.map(e => e.mode)); const snapshots = {}; for (const mode of byMode) snapshots[mode] = await getLocalData(mode); const maps = {}; for (const mode of Object.keys(snapshots)) { maps[mode] = new Map(snapshots[mode].map(it => [it.id, it])); } for (const w of entries) { const it = maps[w.mode].get(w.id); if (it) applyMetrics(w, it); } } async function refreshAll() { if (state.isRefreshing) return; state.isRefreshing = true; try { const active = state.watch.filter(w => !w.triggered); const neededModes = new Set(active.map(w => w.mode)); if (searchPick) neededModes.add(searchPick.mode); const snapshots = {}; for (const mode of neededModes) snapshots[mode] = await getLocalData(mode); const maps = {}; for (const mode of Object.keys(snapshots)) { maps[mode] = new Map(snapshots[mode].map(it => [it.id, it])); } for (const w of active) { const it = maps[w.mode].get(w.id); if (it) applyMetrics(w, it); } if (searchPick) { const it = maps[searchPick.mode]?.get(searchPick.id); if (it) { const curr = resolveCurrent(it); const low = it.low24hPrice ?? null; const high = it.high24hPrice ?? null; pricesEl.innerHTML = `Current: ${fmtRubles(curr)}
    ` + `24h Low / High: ${fmtRubles(low)} / ${fmtRubles(high)}`; } } renderWatchTable(); } catch (e) { statusEl.textContent = 'Error: ' + e.message; } finally { state.isRefreshing = false; } } function applyMetrics(w, it) { const curr = resolveCurrent(it); const low = it.low24hPrice ?? null; const high = it.high24hPrice ?? null; w.last = { curr, low, high }; const hit = (w.direction === 'above' && curr >= w.target) || (w.direction === 'below' && curr <= w.target); if (hit && !w.triggered) { w.triggered = true; playBeep(); alert(`TARGET HIT!\n\n${w.name}\nCurrent: ${fmtRubles(curr)}\nTarget: ${fmtRubles(w.target)}\nMode: ${w.mode.toUpperCase()}`); } if (searchPick && searchPick.id === w.id && searchPick.mode === w.mode) { pricesEl.innerHTML = `Current: ${fmtRubles(curr)}
    ` + `24h Low / High: ${fmtRubles(low)} / ${fmtRubles(high)}`; } const ts = new Date().toLocaleTimeString(); statusEl.textContent = `[${ts}] Updated ${w.name}: ${fmtRubles(curr)} (24h L/H ${fmtRubles(low)} / ${fmtRubles(high)})`; } /***** Add to watch *****/ async function addToWatch() { if (!searchPick) return; const dir = $('#pc-direction').value; const tVal = Number($('#pc-target').value); if (!Number.isFinite(tVal) || tVal < 0) { alert('Enter a valid target (Rubles).'); return; } const entry = { id: searchPick.id, name: searchPick.name, mode: searchPick.mode, direction: dir, target: tVal, triggered: false, last: {} }; const exists = state.watch.some(w => w.id === entry.id && w.mode === entry.mode); if (exists) { alert('Already watching this item in this mode.'); return; } state.watch.push(entry); renderWatchTable(); try { await refreshSpecific([entry]); } finally { renderWatchTable(); } } /***** Events *****/ // Buttons $('#pc-btnSearch')?.addEventListener('click', doSearch); addBtn?.addEventListener('click', addToWatch); // Search results (pick one) resultsEl?.addEventListener('click', ev => { const li = ev.target.closest('li'); if (!li) return; const it = { id: li.dataset.id, name: li.dataset.name, lastLowPrice: li.dataset.lp ? Number(li.dataset.lp) : null, low24hPrice: li.dataset.low ? Number(li.dataset.low) : null, high24hPrice: li.dataset.high ? Number(li.dataset.high) : null }; const mode = li.dataset.mode; pickItem(it, mode); }); // Watch table actions tbodyEl?.addEventListener('click', ev => { const btn = ev.target.closest('button'); if (!btn) return; const action = btn.dataset.action; const key = btn.dataset.key; const idx = state.watch.findIndex(w => keyOf(w) === key); if (idx === -1) return; if (action === 'remove') { state.watch.splice(idx, 1); renderWatchTable(); } else if (action === 'resume') { state.watch[idx].triggered = false; refreshSpecific([state.watch[idx]]).then(() => renderWatchTable()); } }); // Mode flip => refresh LM badge schedule document.querySelectorAll('input[name="pc-mode"]').forEach(radio => { radio.addEventListener('change', async () => { const lm = await readAndRenderLastUpdated(); if (lm) state.lastKnownUpdatedMs = lm; scheduleNextCronAlarm(); }); }); // ===== Enter key behaviors you requested ===== // 1) Enter in item input => search, then focus target const pcSearchInput = document.getElementById('pc-search'); pcSearchInput?.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { e.preventDefault(); try { await doSearch(); document.getElementById('pc-target')?.focus(); } catch {} } }); // 2) Enter in target input => add (if enabled) const pcTargetInput = document.getElementById('pc-target'); pcTargetInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const add = document.getElementById('pc-add'); if (!add?.disabled) addToWatch(); } }); // ===== Kickoff ===== (async function init() { await syncServerClock('/items_pvp.json').catch(()=>{}); state.lastKnownUpdatedMs = await readAndRenderLastUpdated(); scheduleNextCronAlarm(); startCountdown(); renderWatchTable(); })(); })();