initial commit
This commit is contained in:
Executable
+458
@@ -0,0 +1,458 @@
|
||||
|
||||
/* 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();
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user