459 lines
16 KiB
JavaScript
Executable File
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();
|
|
})();
|
|
})();
|