/* 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 `