Files
2026-06-25 21:26:53 +00:00

459 lines
16 KiB
JavaScript
Executable File

/* 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 `<li data-id="${it.id}" data-name="${it.name}" data-mode="${mode}"
data-lp="${it.lastLowPrice ?? ''}" data-low="${it.low24hPrice ?? ''}" data-high="${it.high24hPrice ?? ''}">
<strong>${it.name}</strong> (${it.shortName ?? '—'}) —
current: ${fmtRubles(cur)},
24h L/H: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}
</li>`;
}).join('');
}
function pickItem(it, mode) {
resultsEl.innerHTML = '';
searchPick = { ...it, mode };
selectedEl.innerHTML = `Selected: <strong>${it.name}</strong> (${it.id}) [${mode}]`;
const cur = resolveCurrent(it);
pricesEl.innerHTML =
`Current: <strong>${fmtRubles(cur)}</strong><br>` +
`24h Low / High: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}`;
addBtn.disabled = false;
}
/***** Watch list table *****/
function renderWatchTable() {
if (!state.watch.length) {
tbodyEl.innerHTML = `<tr><td colspan="8" class="pc-muted">No items being watched.</td></tr>`;
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 `
<tr id="row-${w.mode}-${w.id}">
<td data-label="Item">${w.name}</td>
<td data-label="Mode">${w.mode}</td>
<td data-label="Current" class="right">${currTxt}</td>
<td data-label="24h Low" class="right">${lowTxt}</td>
<td data-label="24h High" class="right">${highTxt}</td>
<td data-label="Condition">${cond}</td>
<td data-label="Status">${status}</td>
<td data-label="Actions">
<button data-action="resume" data-key="${key}" class="btn">Resume</button>
<button data-action="remove" data-key="${key}" class="btn pc-bad">Remove</button>
</td>
</tr>`;
}).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: <strong>${fmtRubles(curr)}</strong><br>` +
`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: <strong>${fmtRubles(curr)}</strong><br>` +
`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();
})();
})();