initial commit

This commit is contained in:
2026-06-25 21:26:53 +00:00
commit e5a1511098
68 changed files with 186669 additions and 0 deletions
+1
View File
File diff suppressed because one or more lines are too long
Executable
+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ESCAPE FROM TARKOV COMPANION — Ammo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Site-wide styles -->
<link rel="stylesheet" href="styles.css" />
<!-- Page-scoped visuals only -->
<style>
/* ====== Layout helpers (match your theme) ====== */
.ammo-section {
border: 1px solid rgba(0, 255, 102, .25);
padding: 1rem;
margin: 1rem 0;
box-shadow: 0 0 14px rgba(0, 255, 102, .08);
}
.row {
display: flex;
flex-wrap: wrap;
gap: .75rem;
align-items: center;
}
.muted {
color: #9aa0a6;
}
.badge {
padding: .25rem .5rem;
border-radius: 999px;
background: rgba(0, 255, 102, .08);
border: 1px solid rgba(0, 255, 102, .25);
color: #a7f3d0;
font-size: .85rem;
white-space: nowrap;
}
.input,
select,
input[type="number"],
input[type="text"],
button {
padding: .45rem .6rem;
background: #151924;
color: var(--fg);
border: 1px solid #2a3246;
border-radius: 8px;
}
.btn {
background: #1f2636;
color: var(--fg);
border: 1px solid #2a3246;
border-radius: 8px;
padding: .5rem .8rem;
cursor: pointer;
}
.btn:hover {
background: #273049;
}
/* ====== Table ====== */
.table-wrap {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: .5rem;
}
th,
td {
padding: .5rem .6rem;
border-bottom: 1px solid rgba(0, 255, 102, .15);
text-align: left;
}
th.right,
td.right {
text-align: right;
}
th.sticky {
position: sticky;
top: 0;
background: #0b0f0c;
z-index: 1;
}
.ammo-name {
display: flex;
align-items: center;
gap: .5rem;
}
.ammo-icon {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 4px;
border: 1px solid rgba(0, 255, 102, .18);
}
/* ====== Mobile card view (<= 680px) ====== */
@media (max-width: 680px) {
table thead {
position: absolute;
left: -9999px;
height: 0;
overflow: hidden;
}
table,
tbody,
tr,
td {
display: block;
width: 100%;
}
tbody tr {
border: 1px solid rgba(0, 255, 102, .25);
border-radius: 8px;
padding: .5rem .75rem;
margin: .75rem 0;
box-shadow: 0 0 10px rgba(0, 255, 102, .06);
background: rgba(0, 0, 0, .2);
}
td {
border: none;
border-bottom: 1px solid rgba(0, 255, 102, .15);
padding: .4rem 0;
text-align: left !important;
}
td:last-child {
border-bottom: none;
padding-bottom: 0;
}
td::before {
content: attr(data-label);
display: block;
font-size: .8rem;
color: #9aa0a6;
margin-bottom: .15rem;
}
td[data-label="Actions"] {
display: flex;
gap: .5rem;
justify-content: flex-end;
padding-top: .5rem;
}
td[data-label="Ammo"] {
font-weight: 600;
color: #e5ffe5;
font-size: 1rem;
padding-top: 0;
}
}
/* Use a clean UI stack only for the clickable ammo name */
.ammo-name a {
font-family:
system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
"Helvetica Neue", Arial, "Liberation Sans", sans-serif;
color: #283f75 !important;
font-weight: 700;
font-size: 1.06rem;
line-height: 1.25;
color: var(--fg);
text-shadow: none;
/* remove glow completely */
}
.ammo-name a:hover,
.ammo-name a:focus-visible {
color: #b0ffcf;
text-decoration: underline;
}
/* Subtle chip styles for flags */
.chip {
padding: .1rem .4rem;
border-radius: 6px;
border: 1px solid rgba(0, 255, 102, .25);
background: rgba(0, 255, 102, .06);
font-size: .8rem;
display: inline-block;
}
</style>
</head>
<body>
<!-- ===== TOP BAR ===== -->
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html" aria-current="page">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<!-- ===== PAGE CONTENT ===== -->
<main class="content">
<h1 class="hero">Ammo & Ballistics</h1>
<!--<p class="page-title">Filter by caliber, sort by penetration, and quickly spot the right round for the job.</p>-->
<!-- (1) Toolbar -->
<section class="ammo-section" aria-labelledby="ammo-h2-tools">
<h2 id="ammo-h2-tools">1) Filters & Sorting</h2>
<div class="row">
<input id="ammoSearch" type="text" placeholder="Search ammo..." aria-label="Search ammo by name" />
<label>
Caliber:
<select id="caliberSelect" aria-label="Filter by caliber">
<option value="">All calibers</option>
<!-- populated by JS -->
</select>
</label>
<label>
Min Pen:
<input id="minPen" type="number" inputmode="numeric" pattern="[0-9]*" min="0" step="1"
placeholder="e.g., 35" aria-label="Minimum penetration value" />
</label>
<label>
Sort:
<select id="sortSelect" aria-label="Sort ammo">
<option value="pen-desc">Penetration ⟱</option>
<option value="dmg-desc">Damage ⟱</option>
<option value="vel-desc">Velocity ⟱</option>
<option value="name-asc">Name ⟰</option>
</select>
</label>
<button id="clearFilters" class="btn" type="button">Reset</button>
<span class="badge" id="ammoUpdated">Loading…</span>
</div>
<div class="muted" style="margin-top:.35rem">
<!--Data refreshes about every 5 minutes on the API; client results are cached by your browser for this
session. API cache note -->
</div>
</section>
<!-- (2) Results -->
<section class="ammo-section" aria-labelledby="ammo-h2-results">
<h2 id="ammo-h2-results">2) Results</h2>
<div class="table-wrap">
<table id="ammoTable" aria-label="Ammo list">
<thead>
<tr>
<th class="sticky">Ammo</th>
<th class="sticky">Caliber</th>
<th class="sticky right">Pen</th>
<th class="sticky right">Dmg</th>
<th class="sticky right">Armor&nbsp;Dmg&nbsp;%</th>
<th class="sticky right">Frag&nbsp;%</th>
<th class="sticky right">Acc&nbsp;%</th>
<th class="sticky right">Recoil&nbsp;%</th>
<th class="sticky right">Vel&nbsp;(m/s)</th>
<th class="sticky">Flags</th>
</tr>
</thead>
<tbody id="ammoTbody">
<tr>
<td colspan="10" class="muted">Loading ammo…</td>
</tr>
</tbody>
</table>
</div>
<div id="ammoStatus" class="muted" style="margin-top:.5rem;"></div>
</section>
</main>
<!-- HAMBURGER MENU SCRIPT -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
<!-- Page logic -->
<script src="ammo.js" defer></script>
</body>
</html>
Executable
+324
View File
@@ -0,0 +1,324 @@
/* eslint-disable no-console */
(function () {
'use strict';
// ===== DOM =====
const tbody = document.getElementById('ammoTbody');
const searchEl = document.getElementById('ammoSearch');
const caliberEl = document.getElementById('caliberSelect');
const minPenEl = document.getElementById('minPen');
const sortEl = document.getElementById('sortSelect'); // toolbar dropdown (kept)
const clearBtn = document.getElementById('clearFilters');
const updatedBadge = document.getElementById('ammoUpdated');
const statusEl = document.getElementById('ammoStatus');
const thead = document.querySelector('#ammoTable thead');
// ===== State =====
let ammo = []; // normalized rows
let calibers = []; // distinct list
let lastUpdated = '';
// Default: Name ascending
const sortState = { key: 'name', dir: 'asc' };
// Column mapping (by header index) -> sort key
// 0 Ammo, 1 Caliber, 2 Pen, 3 Dmg, 4 Armor Dmg %, 5 Frag %, 6 Acc %, 7 Recoil %, 8 Vel, 9 Flags
const headerKeys = [
'name', // 0
'caliber', // 1
'pen', // 2
'dmg', // 3
'armorDmgVal', // 4 (numeric)
'fragVal', // 5 (numeric)
'accVal', // 6 (numeric)
'recoilVal', // 7 (numeric)
'vel', // 8
null // 9 (Flags: not sortable)
];
// ===== Helpers =====
function guessCaliberFromName(name = '') {
// e.g., "5.45x39mm BP gzh" -> "5.45x39mm"
const m = name.match(/^([0-9.]+x[0-9.]+mm|[.][0-9]+[\w.]*)/i);
return m ? m[1] : '';
}
function toPct(x) {
if (x === null || x === undefined) return null;
return Math.round(x * 100);
}
function fmtSignedPct(x) {
if (x === null || x === undefined) return '';
const v = Math.round(x * 100);
return (v > 0 ? `+${v}` : `${v}`) + '%';
}
function asArmorPct(raw) {
// API often returns 0..100; if we see 0..1, convert to %
if (raw === null || raw === undefined) return null;
return raw > 1 ? Math.round(raw) : Math.round(raw * 100);
}
function cmpValues(a, b) {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
// ===== Normalization =====
/**
* Expected shape of /ammo-data.json:
* { fetchedAt, source, items: [ { id, name, shortName, iconLink, wikiLink, updated, properties: { __typename:"ItemPropertiesAmmo", ... } } ] }
*/
function normalize(raw) {
const items = Array.isArray(raw.items) ? raw.items : [];
const rows = items
.filter((it) => it && it.properties && it.properties.__typename === 'ItemPropertiesAmmo')
.map((it) => {
const p = it.properties || {};
const caliber = p.caliber || guessCaliberFromName(it.name);
const pen = p.penetrationPower ?? null;
const dmg = p.damage ?? null;
const armorDmgVal = asArmorPct(p.armorDamage); // numeric for sorting
const armorDmgPct = armorDmgVal; // display
const fragVal = p.fragmentationChance != null ? toPct(p.fragmentationChance) : null;
const fragPct = fragVal; // display
const accVal = p.accuracyModifier != null ? Math.round(p.accuracyModifier * 100) : null;
const accPct = p.accuracyModifier != null ? fmtSignedPct(p.accuracyModifier) : '';
const recoilVal = p.recoilModifier != null ? Math.round(p.recoilModifier * 100) : null;
const recoilPct = p.recoilModifier != null ? fmtSignedPct(p.recoilModifier) : '';
const vel = p.initialSpeed ?? null;
return {
id: it.id,
name: it.name,
short: it.shortName || it.name,
icon: it.iconLink,
wiki: it.wikiLink,
updated: it.updated || '',
caliber,
pen,
dmg,
armorDmgVal, armorDmgPct,
fragVal, fragPct,
accVal, accPct,
recoilVal, recoilPct,
vel,
tracer: p.tracer ? (p.tracerColor || 'Tracer') : '',
bleedLight: p.lightBleedModifier != null ? toPct(p.lightBleedModifier) : null,
bleedHeavy: p.heavyBleedModifier != null ? toPct(p.heavyBleedModifier) : null,
proj: p.projectileCount ?? 1
};
});
const cals = Array.from(new Set(rows.map((r) => r.caliber).filter(Boolean)))
.sort((a, b) => a.localeCompare(b));
lastUpdated = raw.fetchedAt || '';
return { rows, calibers: cals };
}
// ===== Rendering =====
function renderCalibers(list) {
caliberEl.innerHTML =
'<option value="">All calibers</option>' +
list.map((c) => `<option value="${c}">${c}</option>`).join('');
}
function renderTable(rows) {
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="10" class="muted">No results. Try clearing filters.</td></tr>`;
return;
}
tbody.innerHTML = rows.map((r) => {
const flags = [r.tracer].filter(Boolean)
.map((s) => `<span class="chip">${s}</span>`).join(' ');
return `
<tr>
<td data-label="Ammo">
<div class="ammo-name">
${r.icon ? `<img class="ammo-icon" src="${r.icon}" alt="">` : ''}
${r.wiki
? `<a href="${r.wiki}" target="_blank" rel="noopener">${r.name}</a>`
: `${r.name}`
}
</div>
</td>
<td data-label="Caliber">${r.caliber || ''}</td>
<td class="right" data-label="Pen">${r.pen ?? ''}</td>
<td class="right" data-label="Dmg">${r.dmg ?? ''}</td>
<td class="right" data-label="Armor Dmg %">${r.armorDmgPct ?? ''}</td>
<td class="right" data-label="Frag %">${r.fragPct ?? ''}</td>
<td class="right" data-label="Acc %">${r.accPct}</td>
<td class="right" data-label="Recoil %">${r.recoilPct}</td>
<td class="right" data-label="Vel (m/s)">${r.vel ?? ''}</td>
<td data-label="Flags">${flags}</td>
</tr>
`;
}).join('');
}
// ===== Sorting =====
function sortRows(rows) {
const key = sortState.key;
const dir = sortState.dir === 'asc' ? 1 : -1;
return rows.slice().sort((a, b) => dir * cmpValues(a[key], b[key]));
}
function setSort(key, dir) {
sortState.key = key;
sortState.dir = dir;
syncSortDropdown();
applyFilters(); // re-renders
}
function toggleSortForKey(key) {
if (sortState.key === key) {
setSort(key, sortState.dir === 'asc' ? 'desc' : 'asc');
} else {
// Default direction: strings asc, numbers desc
const stringKeys = new Set(['name', 'caliber']);
setSort(key, stringKeys.has(key) ? 'asc' : 'desc');
}
}
function syncSortDropdown() {
// Map state -> dropdown value
const map = {
'name:asc': 'name-asc',
'pen:desc': 'pen-desc',
'dmg:desc': 'dmg-desc',
'vel:desc': 'vel-desc'
};
// If key not in dropdown, leave as-is (user can still change via header)
const k = `${sortState.key}:${sortState.dir}`;
if (map[k]) sortEl.value = map[k];
// Update header indicators
updateHeaderIndicators();
}
function updateHeaderIndicators() {
const ths = Array.from(thead.querySelectorAll('th'));
ths.forEach((th, i) => {
const key = headerKeys[i];
th.classList.remove('is-sorted-asc', 'is-sorted-desc');
th.setAttribute('aria-sort', 'none');
if (!key) return;
th.classList.add('is-sortable');
if (key === sortState.key) {
const cls = sortState.dir === 'asc' ? 'is-sorted-asc' : 'is-sorted-desc';
th.classList.add(cls);
th.setAttribute('aria-sort', sortState.dir === 'asc' ? 'ascending' : 'descending');
}
});
}
function wireHeaderSorting() {
const ths = Array.from(thead.querySelectorAll('th'));
ths.forEach((th, i) => {
const key = headerKeys[i];
if (!key) return; // skip Flags
th.classList.add('is-sortable');
th.title = 'Click to sort';
th.addEventListener('click', () => toggleSortForKey(key));
th.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSortForKey(key);
}
});
th.tabIndex = 0; // keyboard focusable
});
updateHeaderIndicators();
}
// ===== Filtering + render pipeline =====
function applyFilters() {
const q = (searchEl.value || '').trim().toLowerCase();
const cal = (caliberEl.value || '').trim();
const minPen = parseInt(minPenEl.value || '0', 10);
let out = ammo.slice();
if (q) {
out = out.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.short.toLowerCase().includes(q)
);
}
if (cal) out = out.filter((r) => r.caliber === cal);
if (!Number.isNaN(minPen) && minPen > 0) out = out.filter((r) => (r.pen ?? 0) >= minPen);
out = sortRows(out);
renderTable(out);
statusEl.textContent = `${out.length} of ${ammo.length} rounds shown`;
}
function wireUI() {
searchEl.addEventListener('input', applyFilters);
caliberEl.addEventListener('change', applyFilters);
minPenEl.addEventListener('input', applyFilters);
// Keep dropdown sorter, but make default "name-asc"
sortEl.value = 'name-asc';
sortEl.addEventListener('change', () => {
switch (sortEl.value) {
case 'name-asc': setSort('name', 'asc'); break;
case 'pen-desc': setSort('pen', 'desc'); break;
case 'dmg-desc': setSort('dmg', 'desc'); break;
case 'vel-desc': setSort('vel', 'desc'); break;
default: setSort('name', 'asc'); break;
}
});
clearBtn.addEventListener('click', () => {
searchEl.value = '';
caliberEl.value = '';
minPenEl.value = '';
sortEl.value = 'name-asc';
setSort('name', 'asc');
});
}
async function loadLocal() {
const res = await fetch('/ammo-data.json', { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to load /ammo-data.json (HTTP ${res.status})`);
return res.json();
}
async function init() {
try {
updatedBadge.textContent = 'Loading…';
const data = await loadLocal();
const { rows, calibers: cals } = normalize(data);
ammo = rows;
calibers = cals;
renderCalibers(calibers);
wireHeaderSorting();
applyFilters();
updatedBadge.textContent = `Updated: ${lastUpdated || '—'}`;
} catch (err) {
console.error(err);
tbody.innerHTML =
`<tr><td colspan="10" class="muted">Failed to load ammo-data.json (${String(err)}).</td></tr>`;
updatedBadge.textContent = 'Update failed';
}
}
wireUI();
// Ensure default dropdown reflects our default sort
sortEl.value = 'name-asc';
init();
})();
+171
View File
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ESCAPE FROM TARKOV COMPANION — Hideout Crafts</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Site-wide styles -->
<link rel="stylesheet" href="styles.css">
<!-- Page-scoped cosmetics (kept light; mirrors your pc-* look) -->
<style>
.pc-section { border: 1px solid rgba(0,255,102,.25); padding: 1rem; margin: 1rem 0; box-shadow: 0 0 14px rgba(0,255,102,.08); }
.pc-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.pc-muted { color: #9aa0a6; }
.pc-badge { padding: .25rem .5rem; border-radius: 999px; background: rgba(0,255,102,.08); border: 1px solid rgba(0,255,102,.25); color: #a7f3d0; font-size: .85rem; white-space: nowrap; }
input[type="text"], input[type="number"], select, button { padding: .45rem .6rem; }
button { cursor: pointer; }
.pc-table-wrap { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
th, td { padding: .5rem .6rem; border-bottom: 1px solid rgba(0,255,102,.15); text-align: left; }
.right { text-align: right; }
.good { color: #22c55e; } .bad { color: #ef4444; }
/* Mobile card view (<= 680px), consistent with your pattern */
@media (max-width: 680px) {
#hc-table thead { position: absolute; left: -9999px; height: 0; overflow: hidden; }
#hc-table, #hc-table tbody, #hc-table tr, #hc-table td { display: block; width: 100%; }
#hc-table tbody tr { border: 1px solid rgba(0,255,102,.25); border-radius: 8px; padding: .5rem .75rem; margin: .75rem 0; box-shadow: 0 0 10px rgba(0,255,102,.06); background: rgba(0,0,0,.2); }
#hc-table td { border: none; border-bottom: 1px solid rgba(0,255,102,.15); padding: .4rem 0; text-align: left !important; }
#hc-table td:last-child { border-bottom: none; padding-bottom: 0; }
#hc-table td::before { content: attr(data-label); display: block; font-size: .8rem; color: #9aa0a6; margin-bottom: .15rem; }
#hc-table td[data-label="Craft"] { font-weight: 600; color: #e5ffe5; font-size: 1rem; padding-top: 0; }
button { padding: .55rem .7rem; }
}
</style>
<!-- Chart.js for grouped bar chart -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script>
</head>
<body>
<!-- ======= TOP BAR ======= -->
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!-- Hamburger -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<!-- Primary nav -->
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<!-- ======= PAGE CONTENT ======= -->
<main class="content">
<h1>Hideout Crafts — Cost, Sell Value & Profit</h1>
<div class="pc-muted" style="margin:.25rem 0 1rem;">
Data from the public tarkov.dev GraphQL API (updates frequently).
</div>
<!-- 1) Mode & Options -->
<section class="pc-section" aria-labelledby="hc-h2-mode">
<h2 id="hc-h2-mode">1) Mode &amp; Options</h2>
<!-- Mode toggle (same pattern as your Price Watch) -->
<div class="pc-row" role="group" aria-label="Game mode">
<label><input type="radio" name="hc-mode" value="regular" checked> PvP (regular)</label>
<label><input type="radio" name="hc-mode" value="pve"> PvE</label>
</div>
<div class="pc-row">
<label>
Price metric:
<select id="hc-metric" aria-label="Price metric">
<option value="flea">Flea Market (sellFor)</option>
<option value="avg24hPrice">Avg 24h price</option>
</select>
</label>
<label>
Top N to chart:
<input id="hc-topn" type="number" min="5" max="200" value="30" aria-label="Top N by profit">
</label>
<span class="pc-muted">Refreshes every ≈5 minutes.</span>
<button id="hc-refresh" class="btn" type="button">Refresh</button>
<span id="hc-updated" class="pc-badge">Data last updated: —</span>
</div>
</section>
<!-- 2) Flea fee controls -->
<section class="pc-section" aria-labelledby="hc-h2-fee">
<h2 id="hc-h2-fee">2) Flea Fee (Approximation)</h2>
<div class="pc-row">
<label><input type="checkbox" id="hc-applyfee"> Apply flea fee to sell value</label>
<label>
Fee percentage:
<input id="hc-fee" type="number" min="0" max="30" step="0.5" value="10" aria-label="Flea fee percentage"> %
</label>
<span class="pc-muted">Tip: Real flea fees vary; this is a simple % of gross sell.</span>
</div>
</section>
<!-- 3) Chart -->
<section class="pc-section" aria-labelledby="hc-h2-chart">
<h2 id="hc-h2-chart">3) Cost vs. Sell Value</h2>
<div class="pc-row">
<canvas id="hc-chart" height="160" aria-label="Craft costs and sell values chart"></canvas>
</div>
<div id="hc-status" class="pc-muted" role="status" aria-live="polite">Loading…</div>
</section>
<!-- 4) Table -->
<section class="pc-section" aria-labelledby="hc-h2-table">
<h2 id="hc-h2-table">4) Details</h2>
<div class="pc-table-wrap">
<table id="hc-table" aria-label="Craft profitability">
<thead>
<tr>
<th>Craft</th>
<th>Station</th>
<th class="right">Cost</th>
<th class="right">Sell (gross)</th>
<th class="right">Sell (net)</th>
<th class="right">Profit</th>
<th>Inputs</th>
</tr>
</thead>
<tbody id="hc-tbody">
<tr><td class="pc-muted" colspan="7">Loading…</td></tr>
</tbody>
</table>
</div>
<div class="pc-muted" style="margin-top:.5rem;">
<!--Prices per mode via <code>items(gameMode: …)</code>; flea price from <code>sellFor { source: "Flea Market" }</code> or fallback to <code>avg24hPrice</code>. [2](https://www.eft-ammo.com/flea-market-prices)-->
</div>
</section>
</main>
<!-- ===== Hamburger MENU SCRIPT ===== -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
<!-- Page logic -->
<script src="crafts.js"></script>
</body>
</html>
+263
View File
@@ -0,0 +1,263 @@
// crafts.js
(() => {
const API = 'https://api.tarkov.dev/graphql'; // official GraphQL endpoint (playground at /)
// ---------- GraphQL ----------
// 1) All crafts: structure only (station, required items, rewards).
// NOTE: No variables here—keeps it compatible and avoids "unused variable" errors.
const CRAFTS_Q = `
query AllCrafts {
crafts {
id
duration
station { name } # station.level is not in the public schema we saw—just show the name.
requiredItems {
count
item { id name }
}
rewardItems {
count
item { id name }
}
}
}
`;
// 2) Items by IDs with prices for a specific mode (regular/pve)
// Official examples show avg24hPrice and sellFor { price, source } on items.
const ITEMS_BY_IDS_Q = `
query ItemsByIds($ids: [ID!], $mode: GameMode, $lang: LanguageCode) {
items(ids: $ids, gameMode: $mode, lang: $lang) {
id
name
avg24hPrice
sellFor { price source }
}
}
`;
// ---------- DOM ----------
const modeRadios = Array.from(document.querySelectorAll('input[name="hc-mode"]'));
const metricSel = document.getElementById('hc-metric');
const topNInput = document.getElementById('hc-topn');
const refreshBtn = document.getElementById('hc-refresh');
const updatedEl = document.getElementById('hc-updated');
const statusEl = document.getElementById('hc-status');
const tbody = document.getElementById('hc-tbody');
const chartCanvas = document.getElementById('hc-chart');
const applyFeeChk = document.getElementById('hc-applyfee');
const feePctInput = document.getElementById('hc-fee');
let chart;
let craftsCache = [];
let pricesCache = new Map(); // id -> item pricing record
// ---------- Helpers ----------
const fmt = n => '₽' + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(Math.round(n || 0));
const nowStr = () => new Date().toLocaleTimeString();
const getMode = () => (modeRadios.find(r => r.checked)?.value) || 'regular';
function fleaPriceFromSellFor(sellFor) {
if (!Array.isArray(sellFor)) return 0;
const flea = sellFor.find(x => x?.source === 'Flea Market');
return Number(flea?.price || 0);
}
function getUnitPrice(itemRec, metric) {
if (!itemRec) return 0;
if (metric === 'avg24hPrice') return Number(itemRec.avg24hPrice || 0);
// default flea: try sellFor(Flea Market), fallback to avg24h
return fleaPriceFromSellFor(itemRec.sellFor) || Number(itemRec.avg24hPrice || 0);
}
function netAfterFee(gross, applyFee, feePct) {
if (!applyFee) return gross;
const pct = Math.max(0, Math.min(100, Number(feePct || 0)));
return gross * (1 - pct / 100);
}
function collectIds(crafts) {
const ids = new Set();
for (const c of crafts) {
for (const ri of (c.requiredItems || [])) ids.add(ri.item?.id);
for (const ro of (c.rewardItems || [])) ids.add(ro.item?.id);
}
ids.delete(undefined);
return Array.from(ids);
}
async function gql(query, variables) {
const resp = await fetch(API, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
if (json.errors) throw new Error(json.errors[0]?.message || 'GraphQL error');
return json.data;
}
async function fetchCrafts() {
// Fix: no variables passed—prevents "Variable $lang is never used" error.
const data = await gql(CRAFTS_Q);
return data?.crafts ?? [];
}
async function fetchItemsByIds(ids, mode) {
// batch to avoid giant payloads
const out = new Map();
const BATCH = 80;
for (let i = 0; i < ids.length; i += BATCH) {
const slice = ids.slice(i, i + BATCH);
const data = await gql(ITEMS_BY_IDS_Q, { ids: slice, mode, lang: 'en' });
for (const it of data?.items || []) out.set(it.id, it);
}
return out;
}
function summarize(crafts, metric, applyFee, feePct) {
const rows = [];
for (const c of crafts) {
// Inputs (cost)
const inputs = (c.requiredItems || []).map(ri => {
const rec = pricesCache.get(ri.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ri.item?.name || 'Unknown', count: ri.count || 0, unit, total: unit * (ri.count || 0) };
});
// Outputs (sell)
const outputs = (c.rewardItems || []).map(ro => {
const rec = pricesCache.get(ro.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ro.item?.name || 'Unknown', count: ro.count || 0, unit, total: unit * (ro.count || 0) };
});
const cost = inputs.reduce((s, x) => s + x.total, 0);
const sellGross = outputs.reduce((s, x) => s + x.total, 0);
const sellNet = netAfterFee(sellGross, applyFee, feePct);
const profit = sellNet - cost;
// Only station name (no level), because station.level isn't public in the docs/examples we referenced.
const stationLabel = c.station?.name || 'Station';
let craftName = 'Unknown craft';
if (outputs.length === 1) craftName = `${outputs[0].name} ×${outputs[0].count}`;
else if (outputs.length > 1) craftName = `${outputs[0].name} ×${outputs[0].count} (+${outputs.length - 1} other)`;
rows.push({
id: c.id,
craft: craftName,
station: stationLabel,
cost, sellGross, sellNet, profit,
inputs
});
}
rows.sort((a, b) => b.profit - a.profit);
return rows;
}
function renderTable(rows) {
tbody.innerHTML = '';
if (!rows.length) {
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">No data</td></tr>`;
return;
}
const frag = document.createDocumentFragment();
for (const r of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td data-label="Craft">${r.craft}</td>
<td data-label="Station">${r.station}</td>
<td data-label="Cost" class="right">${fmt(r.cost)}</td>
<td data-label="Sell (gross)" class="right">${fmt(r.sellGross)}</td>
<td data-label="Sell (net)" class="right">${fmt(r.sellNet)}</td>
<td data-label="Profit" class="right ${r.profit >= 0 ? 'good' : 'bad'}">${fmt(r.profit)}</td>
<td data-label="Inputs">${r.inputs.map(i => `${i.name} ×${i.count} (${fmt(i.unit)})`).join(', ')}</td>
`;
frag.appendChild(tr);
}
tbody.appendChild(frag);
}
function renderChart(rows, applyFee) {
const labels = rows.map(r => r.craft);
const costs = rows.map(r => r.cost);
const sells = rows.map(r => applyFee ? r.sellNet : r.sellGross);
const ctx = chartCanvas.getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Cost to craft', data: costs, backgroundColor: 'rgba(255,99,132,.6)' },
{ label: applyFee ? 'Sell (net)' : 'Sell (gross)', data: sells, backgroundColor: 'rgba(75,192,192,.7)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
afterBody(ctx) {
const i = ctx[0].dataIndex;
const r = rows[i];
return [`Station: ${r.station}`, `Profit: ${fmt(r.profit)}`];
}
}
}
},
scales: {
y: { title: { display: true, text: 'Rubles (₽)' } }
}
}
});
}
async function refresh() {
const mode = getMode(); // 'regular' | 'pve' (items supports gameMode)
const metric = metricSel.value; // 'flea' | 'avg24hPrice'
const topN = Math.max(5, Math.min(200, parseInt(topNInput.value || '30', 10)));
const applyFee = applyFeeChk.checked;
const feePct = parseFloat(feePctInput.value || '0');
statusEl.textContent = 'Loading latest data…';
try {
// Step 1: crafts (structure)
const crafts = await fetchCrafts();
craftsCache = crafts;
// Step 2: price lookup by IDs in selected mode
const ids = collectIds(crafts);
pricesCache = await fetchItemsByIds(ids, mode);
const allRows = summarize(craftsCache, metric, applyFee, feePct);
const rows = allRows.slice(0, topN);
renderTable(rows);
renderChart(rows, applyFee);
updatedEl.textContent = `Data last updated: ${nowStr()}`;
statusEl.textContent = `Showing top ${rows.length} crafts by profit — Mode: ${mode.toUpperCase()}, Metric: ${metric}, Fee: ${applyFee ? (feePct + '%') : 'off'}.`;
} catch (err) {
console.error(err);
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">Error: ${String(err.message || err)}</td></tr>`;
statusEl.textContent = 'Failed to load data. Try again.';
}
}
// Events & timers
modeRadios.forEach(r => r.addEventListener('change', refresh));
metricSel.addEventListener('change', refresh);
topNInput.addEventListener('input', refresh);
refreshBtn.addEventListener('click', refresh);
applyFeeChk.addEventListener('change', refresh);
feePctInput.addEventListener('input', refresh);
refresh();
setInterval(refresh, 5 * 60 * 1000); // every 5 minutes
})();
+1
View File
@@ -0,0 +1 @@
{"fetchedAt":"2026-06-25T21:25:02+00:00","pvp":{"map":"Customs","timeText":"","lastSeenText":"","source":"https://www.goon-tracker.com/","debug":""},"pve":{"map":"Customs","timeText":"","lastSeenText":"","source":"https://www.goon-tracker.com/pvetracker","debug":""}}
Executable
+72
View File
@@ -0,0 +1,72 @@
/* ===== Goons Status page-scoped visuals only ===== */
/* Match your “card” look from other pages */
.gs-section {
border: 1px solid rgba(0,255,102,.25);
padding: 1rem;
margin: 1rem 0;
box-shadow: 0 0 14px rgba(0,255,102,.08);
}
.gs-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.muted { color: #9aa0a6; }
/* Pill/badge styles */
.chip {
padding: .2rem .5rem;
border-radius: 999px;
border: 1px solid rgba(0,255,102,.25);
background: rgba(0,255,102,.08);
color: #a7f3d0;
font-size: .85rem;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: .35rem;
}
.chip.stale {
border-color: rgba(255,194,0,.45);
background: rgba(255,194,0,.08);
color: #ffd889;
}
.badge {
padding: .25rem .5rem;
border-radius: 999px;
background: rgba(0,255,102,.08);
border: 1px solid rgba(0,255,102,.25);
color: #a7f3d0;
font-size: .85rem;
white-space: nowrap;
}
/* Map name */
.map-pill {
display: inline-block;
padding: .2rem .5rem;
border-radius: 6px;
border: 1px solid rgba(0,255,102,.25);
background: rgba(0,255,102,.08);
color: #283f75; /* your chosen accent color */
font-weight: 700;
text-shadow: none;
}
.pill-row { margin-bottom: .35rem; }
/* Cards & layout */
.gs-card { margin-bottom: .75rem; }
.gs-card h3 { margin: 0 0 .35rem 0; font-size: 1.05rem; }
.gs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
/* Header meta row */
.page-meta { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
.page-meta .spacer { flex: 1 1 auto; }
/* Tighten glow around updated badge for clearer text */
#gs-updated { text-shadow: none; }
Executable
+176
View File
@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ESCAPE FROM TARKOV COMPANION — Goons Status</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Global site styles -->
<link rel="stylesheet" href="styles.css" />
<!-- Page-scoped styles for Goons page -->
<link rel="stylesheet" href="goons.css" />
</head>
<body>
<!-- ======= TOP BAR ======= -->
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!-- Hamburger -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<!-- Primary nav -->
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<!-- This page -->
<li><a href="goons.html" aria-current="page">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<!-- ======= PAGE CONTENT ======= -->
<main class="content">
<h1 class="hero">Goons Locations</h1>
<p class="page-title">Locations are gathered from public trackers at <a href="https://www.goon-tracker.com" target="_blank" rel="noopener">PVP</a> and <a href="https://www.goon-tracker.com/pvetracker" target="_blank" rel="noopener">PVE</a> and refreshed every 5 minutes.</p>
<!-- Page meta row: stale badge + last updated -->
<div class="page-meta" style="margin-bottom:.5rem;">
<span id="gs-stale" class="chip" title="Data health badge" aria-live="polite">Checking freshness…</span>
<span class="spacer"></span>
<span id="gs-updated" class="badge" title="From local /goons-status.json">Last updated: —</span>
</div>
<!-- PvP + PvE cards -->
<section class="gs-section" aria-labelledby="gs-h2">
<h2 id="gs-h2">Live Snapshot</h2>
<div class="gs-grid">
<div class="gs-card" id="card-pvp">
<h3>Last Seen (PvP)</h3>
<div id="pvp-body" class="muted">Loading PvP last-seen…</div>
</div>
<div class="gs-card" id="card-pve">
<h3>Last Seen (PvE)</h3>
<div id="pve-body" class="muted">Loading PvE last-seen…</div>
</div>
</div>
<div class="muted" style="margin-top:.5rem;">
Sources: community tracker pages. Verify in-game; this is for convenience only.
</div>
</section>
</main>
<!-- HAMBURGER MENU SCRIPT -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
<!-- PAGE LOGIC -->
<script>
(function() {
'use strict';
const UPDATED = document.getElementById('gs-updated');
const STALE = document.getElementById('gs-stale');
const PVP = document.getElementById('pvp-body');
const PVE = document.getElementById('pve-body');
// Consider data stale after N minutes
const STALE_MINUTES = 30;
function fmtDateLocal(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString();
} catch { return '—'; }
}
function minutesDiffFromNow(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return Infinity;
return (Date.now() - d.getTime()) / 60000;
} catch { return Infinity; }
}
function setStaleBadge(fetchedAtIso) {
const mins = minutesDiffFromNow(fetchedAtIso);
if (!isFinite(mins)) {
STALE.textContent = 'STALE (invalid time)';
STALE.classList.add('stale');
return;
}
if (mins > STALE_MINUTES) {
STALE.textContent = `STALE (${Math.round(mins)} min old)`;
STALE.classList.add('stale');
} else {
STALE.textContent = 'Fresh';
STALE.classList.remove('stale');
}
}
function mapPill(map) {
if (!map) return '<span class="muted">—</span>';
return `<span class="map-pill">${map}</span>`;
}
function renderBlock(el, node, label) {
if (!node || !node.map) {
el.innerHTML = `<div class="muted">No recent ${label} data.</div>`;
return;
}
el.innerHTML = `
<div>
<div class="pill-row">${mapPill(node.map)}</div>
${node.timeText ? `<div>Time: ${node.timeText}</div>` : ''}
${node.lastSeenText ? `<div class="muted" style="margin-top:.15rem;">Last seen: ${node.lastSeenText}</div>` : ''}
<div class="muted" style="margin-top:.35rem;">Source: ${node.source ? node.source.replace('https://','') : '—'}</div>
</div>
`;
}
async function init() {
try {
const res = await fetch('/goons-status.json', { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
// Last updated + stale badge
UPDATED.textContent = 'Last updated: ' + fmtDateLocal(data.fetchedAt || '');
setStaleBadge(data.fetchedAt || '');
// Cards
renderBlock(PVP, data.pvp, 'PvP');
renderBlock(PVE, data.pve, 'PvE');
} catch (e) {
console.error(e);
UPDATED.textContent = 'Last updated: —';
STALE.textContent = 'STALE (fetch failed)';
STALE.classList.add('stale');
PVP.textContent = 'Failed to load PvP last-seen.';
PVE.textContent = 'Failed to load PvE last-seen.';
}
}
init();
})();
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Executable
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ESCAPE FROM TARKOV COMPANION</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!--HAMBURGER BUTTON-->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<main class="content">
<section id="Home">
<h1 class="hero" align="center">WELCOME TO THE ESCAPE FROM TARKOV COMPANION</h1>
<p align="center">Here you can find Goons locations, maps, and other tools for Escape From Tarkov.</p>
</section>
</main>
<img src="https://i.redd.it/7shkp0qdaq8y.png" alt="Escape From Tarkov Companion Logo"
style="height: auto; max-width: 100%; display: block; margin-left: auto; margin-right: auto; border: 1px solid rgba(0,255,102,.25); box-shadow: 0 0 14px rgba(0,255,102,.25);">
<!--HAMBURGER MENU SCRIPT-->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
</body>
</html>
Executable
+45389
View File
File diff suppressed because it is too large Load Diff
Executable
+45389
View File
File diff suppressed because it is too large Load Diff
Executable
+126
View File
@@ -0,0 +1,126 @@
/* =========================================================
maps.css — Page-specific styles for the Maps page
========================================================= */
/* Inputs: match your theme, scoped to this page */
.input {
background: #151924;
color: var(--fg);
border: 1px solid #2a3246;
border-radius: 8px;
padding: 8px 12px;
min-width: 220px;
}
.input::placeholder { color: #8fd8b3; }
/* Toolbar layout and small-screen tweaks */
.map-toolbar { gap: 10px; }
@media (max-width: 560px) {
/* Slightly trim the global content padding on phones so the
viewer sits closer to the title/tools. This overrides the
top padding defined in styles.css for small screens only. */
.content {
padding: 84px 14px 24px; /* was 96px top in styles.css */
}
.map-toolbar .spacer { display: none; }
#mapSelect { width: 100%; }
.zoom-controls {
width: 100%;
display: flex;
gap: 8px;
justify-content: space-between;
}
}
/* =========================================================
Map Viewer Container
- Desktop/tablet: comfortable bounded height
- Phone: fixed height using svh to ignore URL bar growth
========================================================= */
.map-viewer {
position: relative;
background: #0e131a;
border: 1px solid rgba(0, 255, 102, 0.18);
border-radius: 12px;
overflow: hidden;
/* Desktop/tablet defaults */
min-height: min(78vh, 900px);
max-height: 82vh;
/* Enable smooth gestures */
touch-action: none; /* allow custom pinch/pan */
user-select: none;
-webkit-user-drag: none;
}
/* Phone-specific viewer height:
Use the "small viewport" to avoid oversized containers
when the mobile URL bar is visible. Provide fallbacks. */
@media (max-width: 560px) {
.map-viewer {
min-height: 0;
max-height: none;
height: 65svh; /* preferred: stable with URL bar shown */
height: 65dvh; /* dynamic viewport fallback */
height: 65vh; /* legacy fallback */
}
}
/* =========================================================
Map Image
- Centered by default using translate(-50%, -50%)
- JS appends pan (x,y) + scale() after this base transform
========================================================= */
#mapImage {
position: absolute;
top: 50%;
left: 50%;
/* Base centering; JS keeps this and adds pan/zoom */
transform: translate3d(-50%, -50%, 0) scale(1);
transform-origin: 50% 50%;
will-change: transform;
image-rendering: auto;
/* Allow zooming beyond container bounds */
max-width: none;
height: auto;
}
/* Optional: subtle grid overlay for orientation while zoomed */
.map-grid-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(0,255,102,0.06), rgba(0,255,102,0.06)),
repeating-linear-gradient(0deg, transparent, transparent 29px, rgba(0,255,102,0.06) 30px),
repeating-linear-gradient(90deg, transparent, transparent 29px, rgba(0,255,102,0.06) 30px);
opacity: .2;
}
/* Zoom control button states */
.zoom-controls .btn[disabled] {
opacity: .5;
cursor: not-allowed;
}
/* Accessibility helper (scoped copy in case the global one isn't loaded here) */
.visually-hidden {
position: absolute !important;
height: 1px; width: 1px; overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; clip-path: inset(50%);
}
/* Desktop cursor cues */
@media (hover: hover) and (pointer: fine) {
.map-viewer { cursor: grab; }
.map-viewer.is-panning { cursor: grabbing; }
}
Executable
+358
View File
@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ESCAPE FROM TARKOV COMPANION</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Global site styles -->
<link rel="stylesheet" href="styles.css" />
<!-- Page-specific styles for Maps -->
<link rel="stylesheet" href="maps.css" />
<!-- Preload default map for faster first paint -->
<link rel="preload" as="image" href="/images/TARKOV.webp" fetchpriority="high" />
</head>
<body>
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!-- HAMBURGER BUTTON -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<main class="content">
<h1 class="hero">Maps</h1>
<p class="page-title">Pinch or scroll to zoom • Drag to pan</p>
<!-- Toolbar -->
<div class="trader-toolbar toolbar-flex map-toolbar" role="region" aria-label="Map tools">
<label for="mapSelect" class="visually-hidden">Select map</label>
<select id="mapSelect" class="input" aria-label="Select map">
<option value="TARKOV" selected>Tarkov (Global)</option>
<option value="CUSTOMS">Customs</option>
<option value="FACTORY">Factory</option>
<option value="GROUND_ZERO">Ground Zero</option>
<option value="INTERCHANGE">Interchange</option>
<option value="LABS">The Lab</option>
<option value="LABYRINTH">Labyrinth</option>
<option value="LIGHTHOUSE">Lighthouse</option>
<option value="RESERVE">Reserve</option>
<option value="SHORELINE">Shoreline</option>
<option value="STREETS">Streets</option>
<option value="WOODS">Woods</option>
</select>
<div class="spacer"></div>
<!-- Zoom controls -->
<div class="zoom-controls" role="group" aria-label="Zoom controls">
<button id="zoomOut" class="btn" type="button" aria-label="Zoom out"></button>
<button id="zoomReset" class="btn" type="button" aria-label="Reset zoom">100%</button>
<button id="zoomIn" class="btn" type="button" aria-label="Zoom in">+</button>
</div>
</div>
<!-- Viewer -->
<section class="map-viewer" id="mapViewer" aria-label="Map viewer">
<img
id="mapImage"
src="/images/TARKOV.webp"
alt="Tarkov (Global) map"
loading="eager"
decoding="async"
fetchpriority="high"
draggable="false"
/>
<div class="map-grid-overlay" aria-hidden="true"></div>
</section>
</main>
<!-- HAMBURGER MENU SCRIPT -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
<!-- MAP SWITCH + ZOOM/PAN SCRIPT (desktop pan fix + no double-tap/dblclick) -->
<script>
(function () {
// ---------- Available maps ----------
const MAPS = [
{ id: 'TARKOV', label: 'Tarkov (Global)', file: '/images/TARKOV.webp' },
{ id: 'CUSTOMS', label: 'Customs', file: '/images/CUSTOMS.webp' },
{ id: 'FACTORY', label: 'Factory', file: '/images/FACTORY.webp' },
{ id: 'GROUND_ZERO', label: 'Ground Zero', file: '/images/GROUND_ZERO.webp' },
{ id: 'INTERCHANGE', label: 'Interchange', file: '/images/INTERCHANGE.webp' },
{ id: 'LABS', label: 'The Lab', file: '/images/LABS.webp' },
{ id: 'LABYRINTH', label: 'Labyrinth', file: '/images/LABYRINTH.webp' },
{ id: 'LIGHTHOUSE', label: 'Lighthouse', file: '/images/LIGHTHOUSE.webp' },
{ id: 'RESERVE', label: 'Reserve', file: '/images/RESERVE.webp' },
{ id: 'SHORELINE', label: 'Shoreline', file: '/images/SHORELINE.webp' },
{ id: 'STREETS', label: 'Streets', file: '/images/STREETS.webp' },
{ id: 'WOODS', label: 'Woods', file: '/images/WOODS.webp' }
];
// ---------- Elements ----------
const select = document.getElementById('mapSelect');
const viewer = document.getElementById('mapViewer');
const img = document.getElementById('mapImage');
const zoomInBtn = document.getElementById('zoomIn');
const zoomOutBtn = document.getElementById('zoomOut');
const zoomResetBtn = document.getElementById('zoomReset');
// Block native image drag on all browsers
img.addEventListener('dragstart', (e) => e.preventDefault());
viewer.addEventListener('dragstart', (e) => e.preventDefault());
// ---------- Zoom/Pan state ----------
let baseScale = 1; // fit-to-viewer "contain" scale (recomputed per image)
let scale = 1; // current scale
let minScale = 1; // lower bound = baseScale
const maxScale = 5; // adjust if you want more/less max zoom
let x = 0, y = 0; // pan offsets, in CSS px relative to center
let isPanning = false;
let lastX = 0, lastY = 0;
// Pointer tracking for pinch
const pointers = new Map();
let initialPinchDistance = 0;
let pinchStart = null; // { x, y, scale }
// Keep transform origin centered
img.style.transformOrigin = '50% 50%';
// Center + pan/zoom
function applyTransform() {
img.style.transform =
`translate3d(-50%, -50%, 0) translate3d(${x}px, ${y}px, 0) scale(${scale})`;
}
function clampPan() {
const rect = viewer.getBoundingClientRect();
const naturalW = img.naturalWidth || 1;
const naturalH = img.naturalHeight || 1;
const displayW = naturalW * scale;
const displayH = naturalH * scale;
const maxOffsetX = Math.max(0, (displayW - rect.width) / 2);
const maxOffsetY = Math.max(0, (displayH - rect.height) / 2);
x = Math.min(maxOffsetX, Math.max(-maxOffsetX, x));
y = Math.min(maxOffsetY, Math.max(-maxOffsetY, y));
}
// Fit image to viewer ("contain")
function computeBaseScale() {
const rect = viewer.getBoundingClientRect();
const iw = img.naturalWidth || 1;
const ih = img.naturalHeight || 1;
baseScale = Math.min(rect.width / iw, rect.height / ih);
if (!isFinite(baseScale) || baseScale <= 0) baseScale = 1;
minScale = baseScale;
}
function resetView() {
computeBaseScale();
scale = baseScale;
x = 0; y = 0;
clampPan();
applyTransform();
updateZoomUI();
}
function updateZoomUI() {
const pct = Math.round((scale / baseScale) * 100); // relative to fit
zoomResetBtn.textContent = `${pct}%`;
zoomOutBtn.disabled = scale <= minScale + 0.0001;
zoomInBtn.disabled = scale >= maxScale - 0.0001;
}
function zoomAtPoint(factor, clientX, clientY) {
const rect = viewer.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const target = Math.min(maxScale, Math.max(minScale, scale * factor));
const scaleFactor = target / scale;
x = cx - (cx - x) * scaleFactor;
y = cy - (cy - y) * scaleFactor;
scale = target;
clampPan();
applyTransform();
updateZoomUI();
}
function setMap(id) {
const m = MAPS.find(m => m.id === id) || MAPS[0];
img.src = m.file;
img.alt = `${m.label} map`;
const doReset = () => resetView();
if ('decode' in img && typeof img.decode === 'function') {
img.decode().then(doReset).catch(doReset);
} else if (img.complete) {
doReset();
} else {
img.onload = doReset;
}
}
// Map selection
select.addEventListener('change', () => setMap(select.value));
// Initial load
setMap(select.value || 'TARKOV');
// Handle viewer resize/rotation (keep relative zoom)
let resizeTimer = 0;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const prevRatio = scale / baseScale;
computeBaseScale();
scale = Math.max(minScale, Math.min(maxScale, baseScale * prevRatio));
clampPan();
applyTransform();
updateZoomUI();
}, 120);
});
// ----- Mouse/trackpad zoom -----
viewer.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
zoomAtPoint(factor, e.clientX, e.clientY);
}, { passive: false });
// ----- Buttons (zoom around center) -----
zoomInBtn.addEventListener('click', () => {
const rect = viewer.getBoundingClientRect();
zoomAtPoint(1.15, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
zoomOutBtn.addEventListener('click', () => {
const rect = viewer.getBoundingClientRect();
zoomAtPoint(1 / 1.15, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
zoomResetBtn.addEventListener('click', resetView);
// ====== POINTER EVENTS (pan + pinch) ======
// POINTER DOWN
viewer.addEventListener('pointerdown', (e) => {
// On desktop, only pan with primary (left) mouse button
if (e.pointerType === 'mouse' && e.button !== 0) return;
// Prevent native drag/select on desktop immediately
if (e.pointerType === 'mouse') e.preventDefault();
viewer.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) {
// Single pointer → begin pan
isPanning = true;
viewer.classList.add('is-panning'); // for grab/grabbing cursor
lastX = e.clientX; lastY = e.clientY;
} else if (pointers.size === 2) {
// Two pointers → pinch start
const pts = [...pointers.values()];
initialPinchDistance = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
pinchStart = { x, y, scale };
}
});
// POINTER MOVE
viewer.addEventListener('pointermove', (e) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1 && isPanning) {
// Prevent default drag/select/scroll on desktop while panning
if (e.pointerType === 'mouse') e.preventDefault();
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
x += dx; y += dy;
clampPan();
applyTransform();
} else if (pointers.size === 2 && pinchStart) {
// Pinch zoom
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
const centerX = (pts[0].x + pts[1].x) / 2;
const centerY = (pts[0].y + pts[1].y) / 2;
const newScale = Math.min(maxScale, Math.max(minScale, pinchStart.scale * (dist / initialPinchDistance)));
const rect = viewer.getBoundingClientRect();
const cx = centerX - rect.left - rect.width / 2;
const cy = centerY - rect.top - rect.height / 2;
const scaleFactor = newScale / scale;
x = cx - (cx - x) * scaleFactor;
y = cy - (cy - y) * scaleFactor;
scale = newScale;
clampPan();
applyTransform();
updateZoomUI();
}
});
// POINTER UP / CANCEL / LEAVE
function endPointer(e) {
if (pointers.has(e.pointerId)) pointers.delete(e.pointerId);
if (pointers.size < 2) {
initialPinchDistance = 0;
pinchStart = null;
}
if (pointers.size === 0) {
isPanning = false;
viewer.classList.remove('is-panning'); // restore cursor
}
viewer.releasePointerCapture?.(e.pointerId);
}
viewer.addEventListener('pointerup', endPointer);
viewer.addEventListener('pointercancel', endPointer);
viewer.addEventListener('pointerleave', endPointer);
// Prevent native gesture zoom (iOS Safari)
viewer.addEventListener('gesturestart', (e) => e.preventDefault());
viewer.addEventListener('gesturechange', (e) => e.preventDefault());
viewer.addEventListener('gestureend', (e) => e.preventDefault());
})();
</script>
</body>
</html>
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ESCAPE FROM TARKOV COMPANION — Price Check</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Site-wide styles -->
<link rel="stylesheet" href="styles.css">
<!-- Page-scoped styles kept inline (visuals only) -->
<style>
/* ===== Base Helpers ===== */
.pc-section { border: 1px solid rgba(0,255,102,.25); padding: 1rem; margin: 1rem 0; box-shadow: 0 0 14px rgba(0,255,102,.08); }
.pc-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.pc-muted { color: #9aa0a6; }
.pc-list { margin: .5rem 0; padding-left: 1rem; }
.pc-list li { cursor: pointer; padding: .25rem 0; }
.pc-list li:hover { background: rgba(0,255,102,.08); }
.pc-good { color: #22c55e; }
.pc-warn { color: #f59e0b; }
.pc-bad { color: #ef4444; }
input[type="text"], input[type="number"], select, button { padding: .45rem .6rem; }
button { cursor: pointer; }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
th, td { padding: .5rem .6rem; border-bottom: 1px solid rgba(0,255,102,.15); text-align: left; }
.right { text-align: right; }
#pc-selected strong { color: #00ff66; }
/* Prevent table from blowing out layout on small screens */
.pc-table-wrap { width: 100%; overflow-x: auto; }
/* ===== Mobile Card View (<= 680px) ===== */
@media (max-width: 680px) {
/* Hide header; well show labels via data-label on cells */
#pc-table thead {
position: absolute;
left: -9999px;
height: 0;
overflow: hidden;
}
#pc-table, #pc-table tbody, #pc-table tr, #pc-table td {
display: block;
width: 100%;
}
/* Each row becomes a card */
#pc-table tbody tr {
border: 1px solid rgba(0,255,102,.25);
border-radius: 8px;
padding: .5rem .75rem;
margin: .75rem 0;
box-shadow: 0 0 10px rgba(0,255,102,.06);
background: rgba(0,0,0,.2);
}
/* Stacked cells with inlined labels */
#pc-table td {
border: none;
border-bottom: 1px solid rgba(0,255,102,.15);
padding: .4rem 0;
text-align: left !important; /* override .right in stacked view */
}
#pc-table td:last-child { border-bottom: none; padding-bottom: 0; }
#pc-table td::before {
content: attr(data-label);
display: block;
font-size: .8rem;
color: #9aa0a6;
margin-bottom: .15rem;
}
/* Actions: keep buttons on one line and right-aligned */
#pc-table td[data-label="Actions"] {
display: flex;
gap: .5rem;
justify-content: flex-end;
padding-top: .5rem;
}
/* Emphasize item name at the top of card */
#pc-table td[data-label="Item"] {
font-weight: 600;
color: #e5ffe5;
font-size: 1rem;
padding-top: 0;
}
/* Larger tap targets */
button { padding: .55rem .7rem; }
}
/* ===== Ultra-narrow phones (<= 420px) ===== */
@media (max-width: 420px) {
#pc-table td[data-label="24h Low"],
#pc-table td[data-label="24h High"] {
display: none;
}
}
/* Small badge style */
.pc-badge {
padding: .25rem .5rem;
border-radius: 999px;
background: rgba(0,255,102,.08);
border: 1px solid rgba(0,255,102,.25);
color: #a7f3d0;
font-size: .85rem;
white-space: nowrap;
}
/* Layout hardening */
.pc-row > * { min-width: 0; }
#pc-updated { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
@media (max-width: 640px) {
#pc-countdown, #pc-updated { flex-basis: 100%; order: 10; margin-top: .25rem; }
}
</style>
</head>
<body>
<!-- ======= TOP BAR ======= -->
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!-- Hamburger -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<!-- Primary nav -->
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<!-- ======= PAGE CONTENT ======= -->
<main class="content">
<h1>Price Check &amp; Alerts</h1>
<br>
Get an alert when an item's price crosses your target!
<!-- 1) Mode & Search -->
<section class="pc-section" aria-labelledby="pc-h2-search">
<h2 id="pc-h2-search">1) Mode &amp; Search</h2>
<div class="pc-row" role="group" aria-label="Game mode">
<label><input type="radio" name="pc-mode" value="regular" checked> PvP (regular)</label>
<label><input type="radio" name="pc-mode" value="pve"> PvE</label>
</div>
<div class="pc-row">
<input id="pc-search" type="text" placeholder="Item name..." size="28" aria-label="Item name">
<!-- Styled like Quests buttons via .btn from styles.css -->
<button id="pc-btnSearch" class="btn" type="button">Search</button>
<span class="pc-muted">Local data updates every ≈5 minutes (cron).</span>
</div>
<ul id="pc-results" class="pc-list" aria-live="polite"></ul>
</section>
<!-- 2) Add to watch list -->
<section class="pc-section" aria-labelledby="pc-h2-add">
<h2 id="pc-h2-add">2) Add to Watch List</h2>
<div id="pc-selected" class="pc-muted">No item selected.</div>
<div id="pc-prices" class="pc-muted"></div>
<div class="pc-row" style="margin-top:.75rem;">
<label>
Notify when
<select id="pc-direction" aria-label="Notify direction">
<option value="above">at or above</option>
<option value="below">at or below</option>
</select>
</label>
<label>
Target:
<input id="pc-target" type="number" min="0" step="1" placeholder="e.g., 125000" aria-label="Target price (Rubles)">
</label>
<!-- Keep the consistent button look -->
<button id="pc-add" class="btn" disabled>Add to watch list</button>
</div>
<div class="pc-muted" style="margin-top:.5rem;">
Tip: You can add multiple items. Each entry will alert with a sound + popup and then pause itself.
</div>
</section>
<!-- 3) Watch list -->
<section class="pc-section" aria-labelledby="pc-h2-watch">
<h2 id="pc-h2-watch">3) Watch List</h2>
<div class="pc-row" style="gap:.75rem; align-items:center;">
<span style="flex:1 1 auto;"></span>
<span id="pc-countdown" class="pc-muted" aria-live="polite"></span>
<span id="pc-updated" class="pc-badge" title="From static file Last-Modified headers">
Data last updated: —
</span>
</div>
<div class="pc-table-wrap">
<table id="pc-table" aria-label="Watch list">
<thead>
<tr>
<th>Item</th>
<th>Mode</th>
<th class="right">Current</th>
<th class="right">24h Low</th>
<th class="right">24h High</th>
<th>Condition</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="pc-tbody">
<tr><td colspan="8" class="pc-muted">No items being watched.</td></tr>
</tbody>
</table>
</div>
<div id="pc-status" class="pc-muted" style="margin-top:.5rem;"></div>
</section>
<!-- ===== Divider before Top Spreads ===== -->
<hr style="margin:2.5rem 0; border-color: rgba(0,255,102,.25);">
<!-- ===== Top Spreads & Trader → Flea Flips ===== -->
<section class="pc-section" aria-labelledby="ts-h2">
<h2 id="ts-h2">Top Spreads &amp; Trader → Flea Flips</h2>
<div class="pc-row controls" style="margin-bottom:.5rem;">
<button id="toggle-spread" class="btn is-active" aria-pressed="true">
Flea Spreads (High Low)
</button>
<button id="toggle-trader" class="btn" aria-pressed="false">
Trader → Flea Flips
</button>
<span id="last-updated" class="pc-muted" style="margin-left:auto;"></span>
</div>
<div id="status" class="pc-muted" role="status" aria-live="polite">
Loading price data…
</div>
<div class="pc-table-wrap">
<table id="spread-table" aria-label="Top spreads and flips" hidden>
<thead id="spread-thead"></thead>
<tbody id="spread-tbody"></tbody>
<caption id="spread-caption" class="pc-muted" style="caption-side: bottom; padding-top:.5rem;">
Data: tarkov.dev
</caption>
</table>
</div>
</section>
</main>
<!-- ===== Hamburger MENU SCRIPT ===== -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
<!-- ===== Page Logic ===== -->
<!-- Your existing Price Check logic -->
<script src="pricewatch.js"></script>
<!-- Top Spreads & Trader Flips logic (the file we built together) -->
<script src="top_spreads.js"></script>
</body>
</html>
Executable
+458
View File
@@ -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();
})();
})();
+398
View File
@@ -0,0 +1,398 @@
(() => {
'use strict';
const els = {
input: document.getElementById('questInput'),
button: document.getElementById('questBtn'),
status: document.getElementById('questStatus'),
results: document.getElementById('questResults'),
toolbar: document.querySelector('.quest-toolbar')
};
function showStatus(msg, ok = false) {
if (!els.status) return;
els.status.textContent = msg;
els.status.style.color = ok ? 'var(--fg)' : 'var(--fg-soft)';
}
function escapeHTML(s = '') {
return s.toString()
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
async function fetchJSON(url) {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
let allTasks = null;
async function loadLocalData() {
if (Array.isArray(allTasks)) return allTasks;
els.results?.setAttribute('aria-busy', 'true');
try {
const json = await fetchJSON('/quests.json');
const tasks =
(Array.isArray(json?.tasks) && json.tasks) ||
(Array.isArray(json?.data?.tasks) && json.data.tasks) ||
[];
// ✅ FIX: assign the cache
allTasks = tasks;
return allTasks;
} finally {
els.results?.removeAttribute('aria-busy');
}
}
function renderQuest(task) {
const wrap = document.createElement('article');
wrap.className = 'quest-card trader-card';
const h2 = document.createElement('h2');
h2.className = 'quest-name trader-name';
h2.textContent = task.name || 'Unknown Quest';
wrap.appendChild(h2);
if (task.trader || task.wikiLink) {
const meta = document.createElement('div');
meta.className = 'quest-meta';
if (task.trader) {
const box = document.createElement('div');
box.className = 'quest-trader';
box.style.display = 'flex';
box.style.alignItems = 'center';
if (task.trader.imageLink) {
const img = document.createElement('img');
img.src = task.trader.imageLink;
img.alt = task.trader.name || 'Trader';
img.loading = 'lazy';
img.width = 36;
img.height = 36;
img.style.borderRadius = '6px';
img.style.marginRight = '8px';
img.style.border = '1px solid rgba(0,255,102,0.2)';
box.appendChild(img);
}
const nm = document.createElement('span');
nm.textContent = task.trader.name || 'Trader';
box.appendChild(nm);
meta.appendChild(box);
}
if (task.wikiLink) {
const wiki = document.createElement('a');
wiki.href = task.wikiLink;
wiki.target = '_blank';
wiki.rel = 'noopener noreferrer';
wiki.className = 'btn';
wiki.style.marginLeft = 'auto';
wiki.textContent = 'Open Wiki';
meta.appendChild(wiki);
}
wrap.appendChild(meta);
}
const objectives = Array.isArray(task.objectives) ? task.objectives : [];
const ol = document.createElement('ol');
ol.className = 'quest-objectives';
if (!objectives.length) {
const li = document.createElement('li');
li.className = 'quest-objective';
li.textContent = 'No objectives listed.';
ol.appendChild(li);
} else {
for (const obj of objectives) {
const li = document.createElement('li');
li.className = 'quest-objective';
const lines = [];
const type = obj.type ? `<strong>${escapeHTML(obj.type)}</strong>` : '<strong>Objective</strong>';
const desc = escapeHTML(obj.description || '—');
const maps = (obj.maps && obj.maps.length)
? ` — Map: ${escapeHTML(obj.maps.map(m => m.normalizedName).join(', '))}`
: '';
lines.push(`${type}: ${desc}${maps}`);
if (typeof obj.count === 'number' && (obj.item || (obj.items && obj.items.length))) {
const baseName = obj.item?.name || obj.items?.[0]?.name || 'item';
const fir = obj.foundInRaid ? ' (FIR)' : '';
lines.push(`Hand in: ${escapeHTML(baseName)} × ${obj.count}${escapeHTML(fir)}`);
}
if (obj.targetNames && obj.targetNames.length && typeof obj.count === 'number') {
lines.push(`Eliminate: ${escapeHTML(obj.targetNames.join(', '))} × ${obj.count}`);
}
li.innerHTML = lines.map(l => `<div>${l}</div>`).join('');
ol.appendChild(li);
}
}
wrap.appendChild(ol);
const fr = task.finishRewards;
if (fr) {
const box = document.createElement('div');
box.className = 'quest-rewards';
function addRow(label, valueHTML) {
const row = document.createElement('div');
row.innerHTML = `<strong>${escapeHTML(label)}:</strong> ${valueHTML}`;
box.appendChild(row);
}
if (Array.isArray(fr.items) && fr.items.length) {
const grid = document.createElement('div');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(180px, 1fr))';
grid.style.gap = '8px';
for (const g of fr.items) {
const c = document.createElement('div');
c.style.display = 'flex';
c.style.alignItems = 'center';
c.style.gap = '8px';
if (g.item?.iconLink) {
const img = document.createElement('img');
img.src = g.item.iconLink;
img.alt = g.item.name || '';
img.width = 24;
img.height = 24;
img.style.border = '1px solid rgba(0,255,102,0.2)';
img.style.borderRadius = '4px';
c.appendChild(img);
}
const span = document.createElement('span');
const nm = g.item?.name || 'Item';
const ct = (typeof g.count === 'number') ? ` × ${g.count}` : '';
span.textContent = `${nm}${ct}`;
c.appendChild(span);
grid.appendChild(c);
}
const row = document.createElement('div');
row.innerHTML = `<strong>Items:</strong>`;
row.appendChild(grid);
box.appendChild(row);
}
if (Array.isArray(fr.traderStanding) && fr.traderStanding.length) {
const parts = fr.traderStanding.map(s => {
const t = s.trader?.name || 'Trader';
const v = typeof s.standing === 'number'
? `${s.standing > 0 ? '+' : ''}${s.standing}`
: (s.standing ?? '');
return `${t}: ${v}`;
});
addRow('Standing', escapeHTML(parts.join(', ')));
}
if (fr.offerUnlock) {
const list = Array.isArray(fr.offerUnlock) ? fr.offerUnlock : [fr.offerUnlock];
const ul = document.createElement('ul');
for (const u of list) {
const li = document.createElement('li');
const itemName = u?.item?.name || 'Item';
const traderName = u?.trader?.name || 'Trader';
li.textContent = `${itemName} @ ${traderName}`;
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `<strong>Offer unlocks:</strong>`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.skillLevelReward) {
const list = Array.isArray(fr.skillLevelReward) ? fr.skillLevelReward : [fr.skillLevelReward];
const ul = document.createElement('ul');
for (const s of list) {
const li = document.createElement('li');
const skillObj = s?.skill;
const skillName = skillObj?.name || skillObj?.id || 'Skill';
const lvl = typeof s?.level === 'number' ? ` +${s.level}` : '';
li.textContent = `${skillName}${lvl}`;
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `<strong>Skill rewards:</strong>`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.traderUnlock) {
const list = Array.isArray(fr.traderUnlock) ? fr.traderUnlock : [fr.traderUnlock];
const ul = document.createElement('ul');
for (const t of list) {
const li = document.createElement('li');
li.textContent = t?.name || t?.id || 'Trader unlock';
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `<strong>Trader unlocks:</strong>`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.craftUnlock) {
const list = Array.isArray(fr.craftUnlock) ? fr.craftUnlock : [fr.craftUnlock];
const ul = document.createElement('ul');
for (const p of list) {
const li = document.createElement('li');
li.textContent = p?.name || p?.id || 'Craft unlock';
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `<strong>Craft unlocks:</strong>`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.achievement) {
const list = Array.isArray(fr.achievement) ? fr.achievement : [fr.achievement];
const line = list.map(a => a?.name || a?.id || 'Achievement').join(', ');
addRow('Achievement', escapeHTML(line));
}
if (fr.customization) {
const list = Array.isArray(fr.customization) ? fr.customization : [fr.customization];
const line = list.map(c => c?.name || c?.id || 'Customization').join(', ');
addRow('Customization', escapeHTML(line));
}
if (box.children.length) wrap.appendChild(box);
}
return wrap;
}
function getOrCreateAltMatchesContainer() {
const id = 'questAltMatches';
let box = document.getElementById(id);
if (box) {
box.hidden = false;
return box;
}
const results = document.getElementById('questResults');
if (!results || !results.parentNode) return null;
box = document.createElement('div');
box.id = id;
box.className = 'quest-alt-matches';
results.parentNode.insertBefore(box, results);
return box;
}
function renderMatchList(matches) {
const altBox = getOrCreateAltMatchesContainer();
if (!altBox) return;
altBox.innerHTML = '';
if (!matches.length) {
altBox.hidden = true;
return;
}
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = `Show ${matches.length} match(es)`;
details.appendChild(summary);
const ul = document.createElement('ul');
ul.style.listStyle = 'disc';
ul.style.paddingLeft = '1.25rem';
for (const m of matches) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'linklike';
btn.textContent = m.trader?.name ? `${m.name}${m.trader.name}` : m.name;
btn.addEventListener('click', () => openTaskDetails(m.id));
li.appendChild(btn);
ul.appendChild(li);
}
details.appendChild(ul);
altBox.appendChild(details);
}
async function openTaskDetails(id) {
try {
showStatus('Loading task…');
els.results.innerHTML = '';
const list = await loadLocalData();
const task = list.find(t => t?.id === id);
if (!task) {
showStatus('Task not found', false);
return;
}
const card = renderQuest(task);
els.results.appendChild(card);
showStatus('Ready', true);
} catch (err) {
console.error('[QuestSearch] open details failed:', err);
showStatus('Failed to load task details.', false);
}
}
async function search() {
const q = (els.input?.value || '').trim();
const alt = getOrCreateAltMatchesContainer();
els.results.innerHTML = '';
if (alt) alt.innerHTML = '';
if (!q) {
if (alt) alt.hidden = true;
showStatus('Type a quest name to search.');
return;
}
try {
showStatus('Searching…');
const list = await loadLocalData();
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(safe, 'i');
const matches = (Array.isArray(list) ? list : []).filter(t => re.test(t.name || ''));
renderMatchList(matches);
showStatus(`Found ${matches.length} match(es)`, true);
if (matches.length === 1) await openTaskDetails(matches[0].id);
} catch (err) {
console.error('[QuestSearch] search failed:', err);
showStatus('Search failed. Check console.', false);
}
}
let lastEnterTs = 0;
const ENTER_DEBOUNCE_MS = 250;
els.button?.addEventListener('click', search);
els.input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const now = Date.now();
if (now - lastEnterTs < ENTER_DEBOUNCE_MS) return;
lastEnterTs = now;
e.preventDefault();
search();
}
});
showStatus('Waiting to search…');
})();
Executable
+162
View File
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ESCAPE FROM TARKOV COMPANION — Quests</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Correct stylesheet include -->
<link rel="stylesheet" href="styles.css" />
<!-- Page-specific overrides (only affect this page) -->
<style>
/* Make the quest card a full-width panel on this page only */
.quests .quest-card.trader-card { max-width: 100%; }
/* Title spacing (center is already in .page-title) */
.quests .page-title { margin-bottom: 1rem; }
/* Objectives: compact, no default list indentation */
.quests .quest-objectives {
list-style: none;
padding-left: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.quests .quest-objective {
padding: 4px 0;
border-left: 2px solid rgba(0,255,102,0.18);
padding-left: 10px;
}
/* Meta row (title + wiki button) visual rhythm */
.quests .quest-meta {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 6px;
}
.quests .quest-meta .btn { margin-left: auto; }
/* Two-column layout on desktop (objectives left, rewards right) */
@media (min-width: 900px) {
.quests .quest-card.trader-card {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 18px;
}
.quests .quest-meta { grid-column: 1 / -1; }
.quests .quest-objectives { grid-column: 1; }
.quests .quest-rewards { grid-column: 2; align-self: start; }
}
/* Optional: ensure the alt-matches block shows as a rounded panel in your theme */
.quest-alt-matches[hidden] { display: none !important; }
/* ====== NEW: Match Price Watch input look (padding/width) ====== */
.quests .quest-toolbar input[type="search"] {
padding: .45rem .6rem; /* same feel as pricewatch inputs */
}
/* Center the search input and button on the Quests page */
.quests .quest-toolbar {
justify-content: center; /* centers children in the flex row from styles.css */
}
/* Optional: keep the status from crowding the button a bit */
.quests #questStatus {
margin-left: 8px;
}
</style>
</head>
<body>
<!-- ======= TOP BAR ======= -->
<header class="topbar">
<a class="logo" href="index.html">ESCAPE FROM TARKOV COMPANION</a>
<!-- Hamburger (mobile) -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<!-- Primary nav -->
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html" aria-current="page">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener noreferrer">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki"
target="_blank" rel="noopener noreferrer">Wiki</a></li>
</ul>
</nav>
</header>
<!-- ======= PAGE CONTENT ======= -->
<main class="content">
<section class="quests" aria-labelledby="quest-title">
<h1 id="quest-title" class="page-title">Quest Search</h1>
<!-- Search toolbar -->
<div class="quest-toolbar trader-toolbar">
<input
id="questInput"
type="search"
size="28"
placeholder="Type a quest name (e.g., Living High is Not a Crime)"
aria-label="Quest name">
<button id="questBtn" class="btn" type="button">Search</button>
<span id="questStatus" class="status">Waiting to search…</span>
</div>
<!-- “Other matches” list (populated by JS when there are multiple hits) -->
<div id="questAltMatches" class="quest-alt-matches" hidden></div>
<!-- Main results (the quest card is rendered here) -->
<div id="questResults"
class="quest-results grid-root"
role="region"
aria-live="polite"></div>
<p class="small-muted" style="text-align:center; margin-top:1rem;">
<!--Quest data is served from a local cache file (<code>/var/www/html/quests.json</code>).-->
</p>
<noscript>
<p class="error">JavaScript is required to search and display quest details.</p>
</noscript>
</section>
</main>
<!-- ======= HAMBURGER MENU SCRIPT ======= -->
<script>
(function () {
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
if (btn && nav) {
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
nav.classList.toggle('is-open', !open);
document.body.classList.toggle('nav-open', !open);
});
}
})();
</script>
<!-- Clean quest search (no guide-images code) -->
<script src="quest-search-local.js?v=clean-1.0.3"></script>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
Executable
+186
View File
@@ -0,0 +1,186 @@
/* =======================
Console Green Theme
======================= */
:root {
--bg: #0b0f0c;
--fg: #00ff66;
--fg-soft: #66ff99;
--bar: #1a1a1a;
--bar-border: #242424;
}
/* Reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background-color: var(--bg); }
html, body { overflow-x: hidden; } /* prevent sideways scroll */
body {
background: var(--bg); color: var(--fg);
font-family: Consolas, "Liberation Mono", Menlo, "DejaVu Sans Mono", "Source Code Pro", "Courier New", monospace;
line-height: 1.6; letter-spacing: 0.02em;
text-shadow: 0 0 6px rgba(0, 255, 102, 0.3);
min-height: 100vh;
}
/* =======================
Top Bar
======================= */
.topbar {
position: fixed; top: 0; left: 0; right: 0; height: 56px;
display: flex; align-items: center; justify-content: space-between;
background: var(--bar); border-bottom: 1px solid var(--bar-border);
padding: 0 20px; z-index: 1000;
}
.topbar, .topbar a { color: var(--fg); text-shadow: none; }
.logo { font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
/* Nav */
.main-nav ul { list-style: none; display: flex; gap: 20px; }
.main-nav a { text-decoration: none; border-bottom: 2px solid transparent; }
.main-nav a:hover { color: var(--fg-soft); }
/* =======================
Content
======================= */
.content {
max-width: 1400px; margin: 0 auto; padding: 96px 20px 40px;
}
.page-title { text-align: center; margin-bottom: 1rem; }
/* =======================
Toolbar
======================= */
.trader-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.toolbar-flex { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; min-width: 0; }
.toolbar-flex .spacer { flex: 1 1 auto; min-width: 0; }
.btn {
background: #1f2636; color: var(--fg);
border: 1px solid #2a3246; border-radius: 8px;
padding: 8px 14px; cursor: pointer;
}
.btn:hover { background: #273049; }
/* Toggle */
.mode-toggle {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 10px; border: 1px solid #2a3246; border-radius: 8px;
background: #1a2030; user-select: none;
}
.mode-toggle input[type="checkbox"] { width: 18px; height: 18px; accent-color: #00ff66; cursor: pointer; }
.mode-toggle span { color: var(--fg); font-size: 0.95rem; }
.mode-toggle:hover { background: #20273a; }
/* =======================
Unified update bubble
======================= */
.update-bar { width: 100%; margin: 4px 0 8px; }
.badge {
padding: .25rem .5rem; border-radius: 999px;
background: rgba(0,255,102,.08); border: 1px solid rgba(0,255,102,.25);
color: #a7f3d0; font-size: .85rem; display: inline-flex; align-items: center;
max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.update-pill {
display: block; width: 100%; text-align: left;
padding: .35rem .65rem;
}
/* =======================
Trader Grid & Cards
======================= */
.trader-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 28px; justify-items: center; align-items: start; width: 100%;
}
.trader-card {
width: 100%; max-width: 320px;
background: #151924; border-radius: 12px; padding: 14px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
display: flex; flex-direction: column; box-sizing: border-box;
transition: transform .18s ease, box-shadow .18s ease;
}
/* Hover animation on devices that support hover */
@media (hover: hover) and (pointer: fine) {
.trader-card:hover { transform: translateY(-6px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45); }
}
.trader-figure {
width: 100%; aspect-ratio: 4 / 3; margin-bottom: 10px;
overflow: hidden; border-radius: 8px; border: 1px solid rgba(0, 255, 102, 0.18);
}
.trader-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.trader-name { font-weight: 700; margin-bottom: 6px; color: var(--fg); }
.trader-countdown { font-size: 1.25rem; font-variant-numeric: tabular-nums; color: var(--fg); }
.trader-meta { font-size: 0.9rem; margin-top: 6px; color: var(--fg-soft); white-space: nowrap; }
/* Single column on small phones */
@media (max-width: 560px) {
.trader-grid { grid-template-columns: 1fr; gap: 20px; }
.trader-card { max-width: 100%; }
}
/* =======================
CRT overlay
======================= */
html::before {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 9999;
background-image: repeating-linear-gradient(to bottom,
rgba(0, 255, 102, 0.035), rgba(0, 255, 102, 0.035) 1px,
rgba(0, 0, 0, 0) 3px, rgba(0, 0, 0, 0) 8px);
opacity: 0.5;
}
/* =======================
Hamburger menu (mobile)
======================= */
.nav-toggle {
display: inline-grid; grid-auto-rows: 3px; gap: 4px;
align-items: center; justify-content: center; width: 40px; height: 40px;
padding: 0; background: transparent; border: 1px solid transparent; border-radius: 8px;
color: var(--fg); cursor: pointer; transform: translateY(10px);
}
.nav-toggle:focus-visible { outline: 2px solid rgba(0, 255, 102, .7); outline-offset: 2px; }
.nav-toggle__bar { width: 22px; height: 2px; background: var(--fg); border-radius: 2px; box-shadow: 0 0 6px rgba(0, 255, 102, .45); }
/* Mobile dropdown nav */
.main-nav {
position: absolute; top: 56px; left: 0; right: 0; display: none;
background: var(--bar); border-bottom: 1px solid var(--bar-border); padding: 12px 16px 16px;
}
.main-nav.is-open { display: block; animation: menuDrop 160ms ease-out; }
.main-nav.is-open ul { display: grid; gap: 10px; }
.main-nav a { display: block; padding: 10px 12px; color: var(--fg); }
@keyframes menuDrop { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
/* Desktop nav */
@media (min-width: 900px) {
.nav-toggle { display: none; }
.main-nav { position: static; display: block; background: transparent; border: 0; padding: 0; }
.main-nav ul { display: flex; gap: 20px; }
.main-nav a { display: inline-block; padding: 6px 8px; }
}
body.nav-open { overflow: hidden; }
/* Optional: styling for the matches block injected above results */
.quest-alt-matches {
background: #151924;
border-radius: 12px;
padding: 10px 12px;
border: 1px solid rgba(0, 255, 102, 0.18);
margin: 8px 0 10px;
}
.quest-alt-matches details > summary {
cursor: pointer;
list-style: none;
color: var(--fg);
}
.quest-alt-matches .linklike {
background: none;
color: var(--fg);
border: 0;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
/* General hero title used across pages */
.hero {
font-size: 44px;
letter-spacing: .02em;
text-align: center;
}
/* Sortable header styles */
#ammoTable thead th.is-sortable { cursor: pointer; user-select: none; }
#ammoTable thead th.is-sorted-asc::after { content: " ▲"; color: #8fd8b3; }
#ammoTable thead th.is-sorted-desc::after { content: " ▼"; color: #8fd8b3; }
.table-scroll { overflow:auto; border:1px solid rgba(0,255,102,.25); }
.spread-table { width:100%; border-collapse:collapse; }
.spread-table th, .spread-table td { padding:.5rem .75rem; border-bottom:1px solid rgba(0,255,102,.15); }
.spread-table thead th { position:sticky; top:0; background:#0b0b0b; }
.spread-table tbody tr:hover { background:rgba(0,255,102,.05); }
+90781
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
OUT="/var/www/EFT_COMPANION/public_html/quests.json"
ENDPOINT="https://api.tarkov.dev/graphql"
# Use a single-line GraphQL query to avoid escaping headaches
# (Also removed fields that caused schema errors: Skill.normalizedName, craftUnlock.name)
QUERY='query AllTasks { tasks { id name trader { id name imageLink } wikiLink objectives { id type description maps { normalizedName } ... on TaskObjectiveItem { count foundInRaid item { id name shortName iconLink } items { id name shortName iconLink } } ... on TaskObjectiveShoot { targetNames count } } finishRewards { items { count item { id name shortName iconLink } } traderStanding { standing trader { id name } } offerUnlock { item { id name } trader { id name } } skillLevelReward { level skill { id name } } traderUnlock { id name } craftUnlock { id } achievement { id name } customization { id name } } } }'
# Build minimal JSON payload without external tools (query has no double quotes)
PAYLOAD=$(printf '{"query":"%s"}' "$QUERY")
# Write to a temp file in the same directory, then atomically mv into place
TMP="$(mktemp -p /var/www/EFT_COMPANION/public_html/quests.json.tmp.XXXXXX)"
echo "[update-quests] Posting to $ENDPOINT ..."
HTTP_STATUS="$(curl -sS -o "$TMP" -w '%{http_code}' \
-X POST "$ENDPOINT" \
-H 'Content-Type: application/json' \
--data-binary "$PAYLOAD")"
if [ "$HTTP_STATUS" != "200" ]; then
echo "[update-quests] ERROR: HTTP $HTTP_STATUS"
echo "[update-quests] Response body (first 2000 bytes):"
head -c 2000 "$TMP"; echo
rm -f "$TMP"
exit 1
fi
chmod 0644 "$TMP"
mv -f "$TMP" "$OUT"
echo "[update-quests] Wrote $OUT at $(date -Is)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+629
View File
@@ -0,0 +1,629 @@
/* top_spreads.js
* Price Watch with Settings Drawer + PvE/PvP game mode switch
* Trader → Flea view includes Trader name + required Loyalty Level (LL)
* Adds “Under Avg ₽” column based on current (lastLow → low24h) vs avg24h
* Flea Market is NEVER treated as a trader. Unknown sources are skipped.
*/
/* =========================
Configuration & defaults
========================= */
const ENDPOINT = 'https://api.tarkov.dev/graphql'; // Tarkov.dev GraphQL
const CACHE_KEY = 'tarkov_items_cache_v1';
const CACHE_MS = 5 * 60 * 1000; // 5 minutes
// Recognized traders (strict allow-list)
const TRADERS = new Set([
'Prapor', 'Therapist', 'Fence', 'Skier',
'Peacekeeper', 'Mechanic', 'Ragman', 'Jaeger'
]);
// Persisted settings
const SETTINGS_KEY = 'tarkov_spread_settings_v1';
const DEFAULT_SETTINGS = {
// Denoising / realism gates
minOffers: 30, // gate thin markets (0 disables; try 3080)
maxSpreadRatio: 2.0, // reject if high24h / low24h > X
maxAvgToLow: 1.25, // reject if avg24h / lastLow > X
// Flea fee approximation (rough % cut to avoid overstating profit)
applyFleaFee: true,
feePercent: 9, // displayed as percent; converted to 0.09
// Game mode: 'regular' (PvP economy) or 'pve'
gameMode: 'regular'
};
// UI state (not persisted)
let itemsCache = null; // raw items from GraphQL
let currentMode = 'spread'; // 'spread' | 'trader'
let currentRows = [];
/* =========================
DOM helpers / formatting
========================= */
const $ = (sel) => document.querySelector(sel);
function byId(id) { return document.getElementById(id); }
function asInt(n) { return Number.isFinite(n) ? Math.trunc(n) : null; }
function fmtRUB(n) { return Number.isFinite(n) ? n.toLocaleString('en-US') : ''; }
function nowFmt(d = new Date()) {
return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
}
function safeDiv(a, b) { return (Number.isFinite(a) && Number.isFinite(b) && b !== 0) ? (a / b) : null; }
/* =========================
Settings load/save
========================= */
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const saved = JSON.parse(raw);
return { ...DEFAULT_SETTINGS, ...saved };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {}
}
let SETTINGS = loadSettings();
/* =========================
GraphQL query & fetch
========================= */
const QUERY = `
query ItemsWithPrices($mode: GameMode) {
items(lang: en, gameMode: $mode) {
id
name
shortName
basePrice
lastLowPrice
low24hPrice
high24hPrice
avg24hPrice
lastOfferCount
buyFor {
source
currency
price
priceRUB
vendor { name }
requirements { type value } # parsed for loyalty level
}
sellFor {
source
currency
price
priceRUB
vendor { name }
}
}
}
`;
async function fetchItems(gameMode = 'regular') {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: QUERY, variables: { mode: gameMode } })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const items = json?.data?.items ?? [];
writeCache(gameMode, items);
return items;
}
/* =========================
Cache (per game mode)
========================= */
function readCache(gameMode) {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const pack = parsed[gameMode];
if (!pack) return null;
if (Date.now() - (pack.savedAt || 0) > CACHE_MS) return null;
return pack.items;
} catch { return null; }
}
function writeCache(gameMode, items) {
try {
const raw = localStorage.getItem(CACHE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
parsed[gameMode] = { savedAt: Date.now(), items };
localStorage.setItem(CACHE_KEY, JSON.stringify(parsed));
} catch {}
}
/* =========================
Price helpers
========================= */
function priceRUB(p) {
if (!p) return null;
if (Number.isFinite(p.priceRUB)) return p.priceRUB;
if (p.currency === 'RUB' && Number.isFinite(p.price)) return p.price;
return null;
}
function normalizedTraderName(offer) {
const n = offer?.vendor?.name || offer?.source || null;
if (!n || n === 'Flea Market') return null;
return TRADERS.has(n) ? n : null;
}
// Only real traders: exclude Flea and unknown sources; require a usable RUB price
function bestTraderBuyOffer(item) {
const offers = (item.buyFor || []).filter(o => {
if (!o || o.source === 'Flea Market') return false;
const name = normalizedTraderName(o);
const rub = priceRUB(o);
return name && Number.isFinite(rub);
});
let best = null;
for (const o of offers) {
const rub = priceRUB(o);
if (!best || rub < priceRUB(best)) best = o;
}
return best; // may be null if no acceptable trader offer exists
}
function bestTraderSellRUB(item) {
const arr = (item.sellFor || [])
.filter(o => o.source !== 'Flea Market' && normalizedTraderName(o))
.map(priceRUB)
.filter(Number.isFinite);
return arr.length ? Math.max(...arr) : null;
}
function conservativeFleaSell(item) {
// Very conservative: lastLowPrice -> low24hPrice -> avg24hPrice
return item.lastLowPrice ?? item.low24hPrice ?? item.avg24hPrice ?? null;
}
// Extract name + loyalty (LL). If unknown or flea, return nulls so the row can be skipped.
function extractTraderInfo(offer) {
const name = normalizedTraderName(offer);
if (!name) return { name: null, loyalty: null };
const req = (offer.requirements || []).find(
r => String(r.type || '').toLowerCase().includes('loyalty')
);
const loyaltyNum = req ? Number.parseInt(req.value, 10) : null;
return { name, loyalty: Number.isFinite(loyaltyNum) ? loyaltyNum : null };
}
function estimateFleaFee(listPriceRUB) {
if (!SETTINGS.applyFleaFee || !Number.isFinite(listPriceRUB)) return 0;
const factor = Math.max(0, (SETTINGS.feePercent || 0)) / 100;
return Math.floor(listPriceRUB * factor);
}
// --- New: Under-Avg helper
function underAvgDiff(item) {
const avg = asInt(item.avg24hPrice ?? null);
const current = asInt(item.lastLowPrice ?? item.low24hPrice ?? null);
if (!Number.isFinite(avg) || !Number.isFinite(current)) return null;
if (current >= avg) return null;
return avg - current; // positive number meaning “current is X under average”
}
/* =========================
Computations
========================= */
function computeSpreadRows(items) {
const rows = [];
const { minOffers, maxSpreadRatio, maxAvgToLow } = SETTINGS;
for (const it of items) {
const offers = it.lastOfferCount ?? 0;
if (offers < minOffers) continue;
const low = asInt(it.low24hPrice ?? it.lastLowPrice ?? null);
const high = asInt(it.high24hPrice ?? it.avg24hPrice ?? null);
if (!Number.isFinite(low) || !Number.isFinite(high)) continue;
// Outlier guards
const spreadRatio = safeDiv(high, low);
if (spreadRatio !== null && spreadRatio > maxSpreadRatio) continue;
const consLow = it.lastLowPrice ?? it.low24hPrice ?? null;
const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null);
if (avgToLow !== null && avgToLow > maxAvgToLow) continue;
const gap = high - low;
if (gap <= 0) continue;
rows.push({
id: it.id,
name: it.name,
shortName: it.shortName,
gap,
low24h: low,
high24h: high,
avg24h: asInt(it.avg24hPrice ?? null),
underAvg: underAvgDiff(it), // NEW
traderBuy: asInt(priceRUB(bestTraderBuyOffer(it))),
traderSell: asInt(bestTraderSellRUB(it)),
offers
});
}
rows.sort((a, b) => b.gap - a.gap);
return rows.slice(0, 100);
}
function computeTraderFlipRows(items) {
const rows = [];
const { minOffers, maxAvgToLow } = SETTINGS;
for (const it of items) {
const offers = it.lastOfferCount ?? 0;
if (offers < minOffers) continue;
const bestOffer = bestTraderBuyOffer(it); // Strict trader-only offer
const traderBuy = asInt(priceRUB(bestOffer)); // Buy from trader (RUB)
const fleaSellRaw = asInt(conservativeFleaSell(it));// Conservative flea sell target
if (!Number.isFinite(traderBuy) || !Number.isFinite(fleaSellRaw)) continue;
// Derive trader name + LL, and skip if we can't identify a known trader
const tInfo = extractTraderInfo(bestOffer);
if (!tInfo.name) continue;
// Apply fee estimate to flea sell side
const fee = estimateFleaFee(fleaSellRaw);
const netFleaSell = fleaSellRaw - fee;
// Guard skew (inflated averages)
const consLow = it.lastLowPrice ?? it.low24hPrice ?? null;
const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null);
if (avgToLow !== null && avgToLow > maxAvgToLow) continue;
const profit = netFleaSell - traderBuy;
if (profit <= 0) continue;
rows.push({
id: it.id,
name: it.name,
shortName: it.shortName,
profit,
traderBuy,
traderName: tInfo.name,
traderLL: tInfo.loyalty,
fleaSell: netFleaSell, // net after fee
avg24h: asInt(it.avg24hPrice ?? null),
underAvg: underAvgDiff(it), // NEW
offers
});
}
rows.sort((a, b) => b.profit - a.profit);
return rows.slice(0, 100);
}
/* =========================
Rendering (table)
========================= */
function renderHead(mode) {
const thead = byId('spread-thead');
if (mode === 'spread') {
thead.innerHTML = `
<tr>
<th scope="col">#</th>
<th scope="col">Item</th>
<th scope="col" id="sort-col" data-key="gap" style="cursor:pointer">Gap ₽</th>
<th scope="col">Low24h ₽</th>
<th scope="col">High24h ₽</th>
<th scope="col">Avg24h ₽</th>
<th scope="col">Under Avg ₽</th>
<th scope="col">Best Trader Buy ₽</th>
<th scope="col">Best Trader Sell ₽</th>
<th scope="col">Offers</th>
</tr>
`;
} else {
thead.innerHTML = `
<tr>
<th scope="col">#</th>
<th scope="col">Item</th>
<th scope="col" id="sort-col" data-key="profit" style="cursor:pointer">Profit ₽</th>
<th scope="col">Trader Buy ₽</th>
<th scope="col">Trader (LL)</th>
<th scope="col">Flea Sell (after fee) ₽</th>
<th scope="col">Avg24h ₽</th>
<th scope="col">Under Avg ₽</th>
<th scope="col">Offers</th>
</tr>
`;
}
}
function renderBody(mode, rows) {
const tbody = byId('spread-tbody');
tbody.innerHTML = '';
rows.forEach((r, i) => {
let inner = '';
if (mode === 'spread') {
inner = `
<td>${i + 1}</td>
<td>${r.name}</td>
<td data-value="${r.gap}">${fmtRUB(r.gap)}</td>
<td>${fmtRUB(r.low24h)}</td>
<td>${fmtRUB(r.high24h)}</td>
<td>${fmtRUB(r.avg24h)}</td>
<td>${fmtRUB(r.underAvg)}</td>
<td>${fmtRUB(r.traderBuy)}</td>
<td>${fmtRUB(r.traderSell)}</td>
<td>${r.offers ?? ''}</td>
`;
} else {
const traderLLText = `${r.traderName}${Number.isFinite(r.traderLL) ? ` (LL${r.traderLL})` : ' (LL)'}`;
inner = `
<td>${i + 1}</td>
<td>${r.name}</td>
<td data-value="${r.profit}">${fmtRUB(r.profit)}</td>
<td>${fmtRUB(r.traderBuy)}</td>
<td>${traderLLText}</td>
<td>${fmtRUB(r.fleaSell)}</td>
<td>${fmtRUB(r.avg24h)}</td>
<td>${fmtRUB(r.underAvg)}</td>
<td>${r.offers ?? ''}</td>
`;
}
const tr = document.createElement('tr');
tr.innerHTML = inner.trim();
tbody.appendChild(tr);
});
}
function wireSort(mode, rows) {
const sortCol = byId('spread-thead').querySelector('#sort-col');
if (!sortCol) return;
let desc = true;
sortCol.addEventListener('click', () => {
desc = !desc;
const key = sortCol.dataset.key;
rows.sort((a, b) => desc ? (b[key] - a[key]) : (a[key] - b[key]));
renderBody(mode, rows);
});
}
/* =========================
Settings Drawer (injected)
========================= */
function injectSettingsUI() {
// Add "Settings" button to the existing .controls row (created in your HTML)
const controls = document.querySelector('.controls');
if (controls && !byId('btn-settings')) {
const btn = document.createElement('button');
btn.id = 'btn-settings';
btn.className = 'mode-btn';
btn.type = 'button';
btn.textContent = 'Settings ⚙️';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const drawer = byId('settings-drawer');
const open = drawer?.classList.contains('open');
if (open) closeSettings(); else openSettings();
});
controls.appendChild(btn);
// Add a small badge to show active game mode
const badge = document.createElement('span');
badge.id = 'mode-badge';
badge.style.marginLeft = '0.5rem';
badge.style.opacity = '0.8';
badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
controls.appendChild(badge);
}
// Inject drawer container once
if (!byId('settings-drawer')) {
const drawer = document.createElement('div');
drawer.id = 'settings-drawer';
drawer.style.position = 'fixed';
drawer.style.right = '1rem';
drawer.style.top = '5rem';
drawer.style.width = '340px';
drawer.style.maxWidth = '95vw';
drawer.style.border = '1px solid rgba(0,255,102,.35)';
drawer.style.background = '#0b0b0b';
drawer.style.boxShadow = '0 8px 24px rgba(0,0,0,.6), 0 0 14px rgba(0,255,102,.25)';
drawer.style.borderRadius = '8px';
drawer.style.padding = '12px 14px';
drawer.style.zIndex = '9999';
drawer.style.display = 'none';
drawer.innerHTML = `
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;">
<strong style="font-size:1.05rem;">Price Watch Settings</strong>
<span style="margin-left:auto;opacity:.7;">PvE/PvP + filters</span>
<button id="btn-close-settings" class="mode-btn" style="padding:.25rem .5rem;">✕</button>
</div>
<div style="display:flex;flex-direction:column;gap:.5rem;">
<label>
<span>Game Mode</span><br/>
<label style="margin-right:1rem;">
<input type="radio" name="gm" value="regular"> PvP (Regular)
</label>
<label>
<input type="radio" name="gm" value="pve"> PvE
</label>
</label>
<label>
<span>Min Offers (liquidity gate)</span><br/>
<input id="inp-min-offers" type="number" min="0" step="1" style="width:6rem;">
</label>
<label>
<span>Max Spread Ratio (high/low cap)</span><br/>
<input id="inp-max-spread" type="number" min="0" step="0.05" style="width:6rem;">
</label>
<label>
<span>Max Avg/Low Ratio (skew guard)</span><br/>
<input id="inp-max-skew" type="number" min="0" step="0.01" style="width:6rem;">
</label>
<label>
<input id="chk-fee" type="checkbox"> Apply Flea Fee
</label>
<label>
<span>Flea Fee % (if applied)</span><br/>
<input id="inp-fee-pct" type="number" min="0" step="0.5" style="width:6rem;"> %
</label>
</div>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem;">
<button id="btn-reset-settings" class="mode-btn">Reset</button>
<button id="btn-apply-settings" class="mode-btn is-active">Apply & Refresh</button>
</div>
`;
document.body.appendChild(drawer);
// Wire close button
byId('btn-close-settings').addEventListener('click', () => closeSettings());
// Initialize control values from SETTINGS
const gmRadios = drawer.querySelectorAll('input[name="gm"]');
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
byId('inp-min-offers').value = SETTINGS.minOffers;
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
byId('inp-fee-pct').value = SETTINGS.feePercent;
// Wire Apply
byId('btn-apply-settings').addEventListener('click', async () => {
const newSettings = {
...SETTINGS,
gameMode: [...gmRadios].find(r => r.checked)?.value || SETTINGS.gameMode,
minOffers: Math.max(0, parseInt(byId('inp-min-offers').value || DEFAULT_SETTINGS.minOffers, 10)),
maxSpreadRatio: Math.max(0, parseFloat(byId('inp-max-spread').value || DEFAULT_SETTINGS.maxSpreadRatio)),
maxAvgToLow: Math.max(0, parseFloat(byId('inp-max-skew').value || DEFAULT_SETTINGS.maxAvgToLow)),
applyFleaFee: byId('chk-fee').checked,
feePercent: Math.max(0, parseFloat(byId('inp-fee-pct').value || DEFAULT_SETTINGS.feePercent))
};
SETTINGS = newSettings;
saveSettings(SETTINGS);
updateModeBadge();
closeSettings();
await refreshData(); // refetch if game mode changed; otherwise re-render
});
// Wire Reset
byId('btn-reset-settings').addEventListener('click', () => {
SETTINGS = { ...DEFAULT_SETTINGS };
saveSettings(SETTINGS);
// Reset UI fields
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
byId('inp-min-offers').value = SETTINGS.minOffers;
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
byId('inp-fee-pct').value = SETTINGS.feePercent;
updateModeBadge();
});
}
}
function openSettings() {
const drawer = byId('settings-drawer');
const btn = byId('btn-settings');
if (!drawer) return;
drawer.style.display = 'block';
drawer.classList.add('open');
btn?.setAttribute('aria-expanded', 'true');
}
function closeSettings() {
const drawer = byId('settings-drawer');
const btn = byId('btn-settings');
if (!drawer) return;
drawer.style.display = 'none';
drawer.classList.remove('open');
btn?.setAttribute('aria-expanded', 'false');
}
function updateModeBadge() {
const badge = byId('mode-badge');
if (badge) badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
}
/* =========================
Controller: render & refresh
========================= */
function setMode(viewMode) {
if (viewMode !== 'spread' && viewMode !== 'trader') return;
currentMode = viewMode;
// Toggle buttons in your existing controls
$('#toggle-spread')?.classList.toggle('is-active', viewMode === 'spread');
$('#toggle-trader')?.classList.toggle('is-active', viewMode === 'trader');
$('#toggle-spread')?.setAttribute('aria-pressed', String(viewMode === 'spread'));
$('#toggle-trader')?.setAttribute('aria-pressed', String(viewMode === 'trader'));
byId('spread-caption').textContent = viewMode === 'spread'
? 'Top 100 flea spreads (High Low). Data: tarkov.dev'
: 'Top 100 Trader → Flea flips (net flea after fee best trader buy). Data: tarkov.dev';
if (!itemsCache) return;
const rows = (viewMode === 'spread') ? computeSpreadRows(itemsCache) : computeTraderFlipRows(itemsCache);
currentRows = rows;
renderHead(viewMode);
renderBody(viewMode, rows);
wireSort(viewMode, rows);
byId('spread-table').hidden = rows.length === 0;
}
async function refreshData() {
const status = byId('status');
const table = byId('spread-table');
const lastUpdated = byId('last-updated');
// Try cache first for this game mode
const cached = readCache(SETTINGS.gameMode);
if (cached && cached.length) {
itemsCache = cached;
setMode(currentMode);
table.hidden = false;
status.textContent = 'Loaded from cache. Refreshing…';
}
// Always attempt live refresh for the selected mode
try {
const items = await fetchItems(SETTINGS.gameMode);
itemsCache = items;
setMode(currentMode);
status.textContent = `Showing top ${currentRows.length} items. Updated just now.`;
lastUpdated.textContent = `Updated: ${nowFmt(new Date())}`;
table.hidden = false;
} catch (err) {
console.error(err);
if (!itemsCache) {
status.textContent = 'Failed to load price data. Please try again.';
table.hidden = true;
} else {
status.textContent = 'Using cached data (latest refresh failed).';
}
}
}
/* =========================
Boot
========================= */
document.addEventListener('DOMContentLoaded', () => {
// Wire existing view toggles from your HTML
$('#toggle-spread')?.addEventListener('click', () => setMode('spread'));
$('#toggle-trader')?.addEventListener('click', () => setMode('trader'));
// Inject settings UI and show current mode badge
injectSettingsUI();
updateModeBadge();
// Initial load
refreshData();
});
+83
View File
@@ -0,0 +1,83 @@
# top_spreads.py
import requests
import csv
ENDPOINT = "https://api.tarkov.dev/graphql"
QUERY = """
query ItemsWithPrices {
items(lang: en) {
id
name
shortName
basePrice
lastLowPrice
low24hPrice
high24hPrice
avg24hPrice
lastOfferCount
buyFor { source currency price priceRUB vendor { name } }
sellFor { source currency price priceRUB vendor { name } }
}
}
"""
def pick_rub(p):
pr = p.get("priceRUB")
if pr is not None:
return pr
if p.get("currency") == "RUB":
return p.get("price")
return None
def best_trader_buy_rub(item):
offers = [pick_rub(o) for o in (item.get("buyFor") or []) if o.get("source") != "Flea Market"]
offers = [x for x in offers if isinstance(x, (int, float))]
return min(offers) if offers else None
def best_trader_sell_rub(item):
offers = [pick_rub(o) for o in (item.get("sellFor") or []) if o.get("source") != "Flea Market"]
offers = [x for x in offers if isinstance(x, (int, float))]
return max(offers) if offers else None
resp = requests.post(ENDPOINT, json={"query": QUERY}, timeout=60)
resp.raise_for_status()
data = resp.json()["data"]["items"]
rows = []
for it in data:
flea_low = it.get("low24hPrice") or it.get("lastLowPrice")
flea_high = it.get("high24hPrice") or it.get("avg24hPrice")
if isinstance(flea_low, (int, float)) and isinstance(flea_high, (int, float)):
gap = flea_high - flea_low
if gap > 0:
rows.append({
"id": it["id"],
"name": it["name"],
"shortName": it["shortName"],
"gap": gap,
"fleaLow": flea_low,
"fleaHigh": flea_high,
"avg24h": it.get("avg24hPrice"),
"bestTraderBuy": best_trader_buy_rub(it),
"bestTraderSell": best_trader_sell_rub(it),
"lastOfferCount": it.get("lastOfferCount"),
})
rows.sort(key=lambda r: r["gap"], reverse=True)
top = rows[:100]
# Print a quick top list
for i, r in enumerate(top, 1):
print(f"{i:>2}. {r['name']:<50} gap ₽{r['gap']:,} (low {r['fleaLow']:,} → high {r['fleaHigh']:,})")
# Save CSV
with open("top100_spread.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=[
"id","name","shortName","gap","fleaLow","fleaHigh","avg24h",
"bestTraderBuy","bestTraderSell","lastOfferCount"
])
w.writeheader()
w.writerows(top)
print("\nSaved: top100_spread.csv")
+228
View File
@@ -0,0 +1,228 @@
// trader-resets.js (v2.2.0) — Local JSON version with ONE unified "data last updated" bubble
(() => {
'use strict';
// ===== Config =====
const REFRESH_MS = 5 * 60 * 1000; // auto-refresh every 5 minutes
const STORAGE_KEY = 'eft.traders.mode'; // 'regular' or 'pve'
const DEBUG_TS = false;
// Local JSON files (served from /var/www/html via cron)
const DATA_URL = {
regular: '/traders_pvp.json',
pve: '/traders_pve.json',
};
// ===== DOM refs =====
const els = {
grid: document.getElementById('traderGrid'),
refresh: document.getElementById('refreshBtn'),
modeToggle: document.getElementById('modeToggle'),
dataUpdated: document.getElementById('dataUpdated'),
};
if (!els.grid) {
console.warn('[trader-resets] Missing #traderGrid.');
return;
}
// ===== Utilities =====
// Normalize resetTime (seconds, ms, ISO) -> ms epoch or null
function toMsEpoch(resetTime) {
if (resetTime == null) return null;
if (typeof resetTime === 'number') return resetTime < 1e12 ? resetTime * 1000 : resetTime;
if (typeof resetTime === 'string') {
const parsed = Date.parse(resetTime);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function currentMode() {
return els.modeToggle && els.modeToggle.checked ? 'pve' : 'regular';
}
function fmtHMS(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '00:00:00';
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
async function getLocalTraders(mode) {
const url = DATA_URL[mode] || DATA_URL.regular;
const v = Math.floor(Date.now() / 60000); // per-minute cache-bust
const res = await fetch(`${url}?v=${v}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status} ${res.statusText}`);
return res.json(); // array of traders
}
// ===== Last-updated (from static file Last-Modified headers) =====
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;
}
// Set the *single* bubble to the newest (max) of PvP/PvE file timestamps
async function refreshUnifiedUpdatedBubble() {
const [pvp, pve] = await Promise.all([
fetchLastModified(DATA_URL.regular),
fetchLastModified(DATA_URL.pve),
]);
const newest = (!pvp && !pve)
? null
: (pvp && pve ? (pvp > pve ? pvp : pve) : (pvp || pve));
if (els.dataUpdated) {
els.dataUpdated.textContent =
`Data last updated: ${newest ? newest.toLocaleString() : '—'}`;
els.dataUpdated.title = newest ? newest.toISOString() : '';
}
}
// ===== Rendering =====
function renderUnknownCard(trader) {
const card = document.createElement('article');
card.className = 'trader-card';
card.setAttribute('role', 'listitem');
const figure = document.createElement('figure');
figure.className = 'trader-figure';
const img = document.createElement('img');
img.className = 'trader-img';
img.alt = `${trader.name || 'Trader'}`;
img.loading = 'lazy';
img.src = trader.imageLink || 'https://tarkov.dev/images/traders/default.jpg';
figure.appendChild(img);
const name = document.createElement('div');
name.className = 'trader-name';
name.textContent = trader.name || 'Unknown trader';
const timer = document.createElement('div');
timer.className = 'trader-countdown';
timer.textContent = 'Unknown';
const meta = document.createElement('div');
meta.className = 'trader-meta';
meta.textContent = 'Next reset: —';
card.appendChild(figure);
card.appendChild(name);
card.appendChild(timer);
card.appendChild(meta);
els.grid.appendChild(card);
}
function render(traders) {
els.grid.innerHTML = '';
const now = Date.now();
traders.forEach(t => {
const nextReset = toMsEpoch(t.resetTime);
if (DEBUG_TS) {
console.debug('[trader-resets] raw resetTime:', t.name, t.resetTime, '→', nextReset && new Date(nextReset).toISOString());
}
if (!Number.isFinite(nextReset)) {
renderUnknownCard(t);
return;
}
const msLeft = nextReset - now;
const card = document.createElement('article');
card.className = 'trader-card';
card.setAttribute('role', 'listitem');
const figure = document.createElement('figure');
figure.className = 'trader-figure';
const img = document.createElement('img');
img.className = 'trader-img';
img.alt = t.name || 'Trader';
img.loading = 'lazy';
img.src = t.imageLink || 'https://tarkov.dev/images/traders/default.jpg';
figure.appendChild(img);
const name = document.createElement('div');
name.className = 'trader-name';
name.textContent = t.name || 'Unknown trader';
const timer = document.createElement('div');
timer.className = 'trader-countdown';
timer.textContent = fmtHMS(msLeft);
const meta = document.createElement('div');
meta.className = 'trader-meta';
meta.textContent = `Next reset: ${new Date(nextReset).toLocaleTimeString()}`;
card.appendChild(figure);
card.appendChild(name);
card.appendChild(timer);
card.appendChild(meta);
els.grid.appendChild(card);
// Live ticking
let tickId = setInterval(() => {
const left = nextReset - Date.now();
if (left <= 0) {
timer.textContent = '00:00:00';
setTimeout(() => { timer.textContent = 'Restocking…'; }, 500);
clearInterval(tickId);
return;
}
timer.textContent = fmtHMS(left);
}, 1000);
});
}
// ===== Load from local JSON =====
async function load() {
try {
const mode = currentMode(); // 'regular' or 'pve'
const traders = await getLocalTraders(mode);
render(traders);
// Update the unified "data last updated" bubble
refreshUnifiedUpdatedBubble().catch(() => {});
} catch (err) {
console.error('[trader-resets] load failed:', err);
if (els.dataUpdated) {
els.dataUpdated.textContent = 'Data last updated: — (failed to read local JSON)';
}
}
}
// ===== Persistence: restore & save mode =====
if (els.modeToggle) {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'pve') els.modeToggle.checked = true;
if (saved === 'regular') els.modeToggle.checked = false;
els.modeToggle.addEventListener('change', () => {
localStorage.setItem(STORAGE_KEY, currentMode());
load(); // re-render immediately when mode flips
});
}
// ===== Events =====
els.refresh?.addEventListener('click', load);
// ===== Kickoff =====
load();
setInterval(load, REFRESH_MS);
})();
Executable
+91
View File
@@ -0,0 +1,91 @@
<!-- traders.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ESCAPE FROM TARKOV COMPANION — Trader Resets</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Site stylesheet -->
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
<!-- HAMBURGER BUTTON -->
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
aria-label="Toggle navigation">
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
<span class="nav-toggle__bar" aria-hidden="true"></span>
</button>
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="goons.html">Goons</a></li>
<li><a href="maps.html">Maps</a></li>
<li><a href="quests.html">Quests</a></li>
<li><a href="ammo.html">Ammo</a></li>
<li><a href="traders.html">Traders</a></li>
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
<li><a href="crafts.html">Craft Calculator</a></li>
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
rel="noopener">Wiki</a></li>
</ul>
</nav>
</header>
<main class="content">
<section id="Home" aria-labelledby="home-title">
<h1 id="home-title" class="page-title" align="center">Trader Inventory Resets</h1>
<!-- Toolbar: only the controls live here -->
<div class="trader-toolbar toolbar-flex">
<button id="refreshBtn" type="button" class="btn">Refresh now</button>
<!-- PvP / PvE toggle -->
<label class="mode-toggle" title="Switch between PvP (regular) and PvE data">
<input id="modeToggle" type="checkbox" />
<span>Use PvE data</span>
</label>
<span class="spacer"></span>
</div>
<!-- Single unified "data last updated" bubble (from JSON Last-Modified) -->
<div class="update-bar">
<span id="dataUpdated" class="badge update-pill">Data last updated: —</span>
</div>
<!-- Trader cards -->
<div id="traderGrid" class="trader-grid grid-root" role="list"></div>
<p class="small-muted">
<!--Data is cached locally and refreshed by cron every ~5 minutes.-->
</p>
<noscript>
<p class="error">JavaScript is required to load trader reset times.</p>
</noscript>
</section>
</main>
<!-- App logic -->
<script src="trader-resets.js?v=2.2.0"></script>
<!-- HAMBURGER MENU SCRIPT -->
<script>
const btn = document.querySelector('.nav-toggle');
const nav = document.getElementById('primary-nav');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !open);
nav.classList.toggle('is-open', !open);
});
</script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
[
{
"id": "54cb50c76803fa8b248b4571",
"name": "Prapor",
"resetTime": "2026-06-25T22:24:58.000Z",
"imageLink": "https://assets.tarkov.dev/54cb50c76803fa8b248b4571.webp"
},
{
"id": "54cb57776803fa99248b456e",
"name": "Therapist",
"resetTime": "2026-06-25T22:21:07.000Z",
"imageLink": "https://assets.tarkov.dev/54cb57776803fa99248b456e.webp"
},
{
"id": "579dc571d53a0658a154fbec",
"name": "Fence",
"resetTime": "2026-06-25T21:28:29.000Z",
"imageLink": "https://assets.tarkov.dev/579dc571d53a0658a154fbec.webp"
},
{
"id": "58330581ace78e27b8b10cee",
"name": "Skier",
"resetTime": "2026-06-25T21:44:20.000Z",
"imageLink": "https://assets.tarkov.dev/58330581ace78e27b8b10cee.webp"
},
{
"id": "5935c25fb3acc3127c3d8cd9",
"name": "Peacekeeper",
"resetTime": "2026-06-25T22:43:25.000Z",
"imageLink": "https://assets.tarkov.dev/5935c25fb3acc3127c3d8cd9.webp"
},
{
"id": "5a7c2eca46aef81a7ca2145d",
"name": "Mechanic",
"resetTime": "2026-06-25T23:44:09.000Z",
"imageLink": "https://assets.tarkov.dev/5a7c2eca46aef81a7ca2145d.webp"
},
{
"id": "5ac3b934156ae10c4430e83c",
"name": "Ragman",
"resetTime": "2026-06-25T22:14:54.000Z",
"imageLink": "https://assets.tarkov.dev/5ac3b934156ae10c4430e83c.webp"
},
{
"id": "5c0647fdd443bc2504c2d371",
"name": "Jaeger",
"resetTime": "2026-06-25T21:23:53.000Z",
"imageLink": "https://assets.tarkov.dev/5c0647fdd443bc2504c2d371.webp"
},
{
"id": "638f541a29ffd1183d187f57",
"name": "Lightkeeper",
"resetTime": "2026-06-25T22:00:55.000Z",
"imageLink": "https://assets.tarkov.dev/638f541a29ffd1183d187f57.webp"
},
{
"id": "68fe15910f29ba3fdbba9d54",
"name": "Taran",
"resetTime": "2026-06-25T21:29:54.000Z",
"imageLink": "https://assets.tarkov.dev/68fe15910f29ba3fdbba9d54.webp"
},
{
"id": "656f0f98d80a697f855d34b1",
"name": "BTR Driver",
"resetTime": "2026-06-25T21:09:54.000Z",
"imageLink": "https://assets.tarkov.dev/656f0f98d80a697f855d34b1.webp"
},
{
"id": "68fe15990f29ba3fdbba9d55",
"name": "Radio station",
"resetTime": "2026-06-25T21:10:55.000Z",
"imageLink": "https://assets.tarkov.dev/68fe15990f29ba3fdbba9d55.webp"
},
{
"id": "6617beeaa9cfa777ca915b7c",
"name": "Ref",
"resetTime": "2026-06-25T22:48:37.000Z",
"imageLink": "https://assets.tarkov.dev/6617beeaa9cfa777ca915b7c.webp"
},
{
"id": "688246518448b05efd61d461",
"name": "Mr. Kerman",
"resetTime": "2026-06-25T21:51:54.000Z",
"imageLink": "https://assets.tarkov.dev/688246518448b05efd61d461.webp"
},
{
"id": "688246958448b05efd61d462",
"name": "Voevoda",
"resetTime": "2026-06-25T22:00:55.000Z",
"imageLink": "https://assets.tarkov.dev/688246958448b05efd61d462.webp"
},
{
"id": "69e0d6cc77b63940375b9173",
"name": "Survivor",
"resetTime": "2026-06-25T21:34:54.000Z",
"imageLink": "https://assets.tarkov.dev/unknown-trader.webp"
}
]
+98
View File
@@ -0,0 +1,98 @@
[
{
"id": "54cb50c76803fa8b248b4571",
"name": "Prapor",
"resetTime": "2026-06-25T21:20:55.000Z",
"imageLink": "https://assets.tarkov.dev/54cb50c76803fa8b248b4571.webp"
},
{
"id": "54cb57776803fa99248b456e",
"name": "Therapist",
"resetTime": "2026-06-25T23:28:01.000Z",
"imageLink": "https://assets.tarkov.dev/54cb57776803fa99248b456e.webp"
},
{
"id": "579dc571d53a0658a154fbec",
"name": "Fence",
"resetTime": "2026-06-25T21:32:00.000Z",
"imageLink": "https://assets.tarkov.dev/579dc571d53a0658a154fbec.webp"
},
{
"id": "58330581ace78e27b8b10cee",
"name": "Skier",
"resetTime": "2026-06-25T22:43:48.000Z",
"imageLink": "https://assets.tarkov.dev/58330581ace78e27b8b10cee.webp"
},
{
"id": "5935c25fb3acc3127c3d8cd9",
"name": "Peacekeeper",
"resetTime": "2026-06-25T23:20:08.000Z",
"imageLink": "https://assets.tarkov.dev/5935c25fb3acc3127c3d8cd9.webp"
},
{
"id": "5a7c2eca46aef81a7ca2145d",
"name": "Mechanic",
"resetTime": "2026-06-25T23:30:56.000Z",
"imageLink": "https://assets.tarkov.dev/5a7c2eca46aef81a7ca2145d.webp"
},
{
"id": "5ac3b934156ae10c4430e83c",
"name": "Ragman",
"resetTime": "2026-06-25T22:15:58.000Z",
"imageLink": "https://assets.tarkov.dev/5ac3b934156ae10c4430e83c.webp"
},
{
"id": "5c0647fdd443bc2504c2d371",
"name": "Jaeger",
"resetTime": "2026-06-25T22:06:12.000Z",
"imageLink": "https://assets.tarkov.dev/5c0647fdd443bc2504c2d371.webp"
},
{
"id": "638f541a29ffd1183d187f57",
"name": "Lightkeeper",
"resetTime": "2026-06-25T21:13:54.000Z",
"imageLink": "https://assets.tarkov.dev/638f541a29ffd1183d187f57.webp"
},
{
"id": "68fe15910f29ba3fdbba9d54",
"name": "Taran",
"resetTime": "2026-06-25T21:52:54.000Z",
"imageLink": "https://assets.tarkov.dev/68fe15910f29ba3fdbba9d54.webp"
},
{
"id": "656f0f98d80a697f855d34b1",
"name": "BTR Driver",
"resetTime": "2026-06-25T21:15:54.000Z",
"imageLink": "https://assets.tarkov.dev/656f0f98d80a697f855d34b1.webp"
},
{
"id": "68fe15990f29ba3fdbba9d55",
"name": "Radio station",
"resetTime": "2026-06-25T21:16:54.000Z",
"imageLink": "https://assets.tarkov.dev/68fe15990f29ba3fdbba9d55.webp"
},
{
"id": "6617beeaa9cfa777ca915b7c",
"name": "Ref",
"resetTime": "2026-06-25T23:06:36.000Z",
"imageLink": "https://assets.tarkov.dev/6617beeaa9cfa777ca915b7c.webp"
},
{
"id": "688246518448b05efd61d461",
"name": "Mr. Kerman",
"resetTime": "2026-06-25T21:38:54.000Z",
"imageLink": "https://assets.tarkov.dev/688246518448b05efd61d461.webp"
},
{
"id": "688246958448b05efd61d462",
"name": "Voevoda",
"resetTime": "2026-06-25T22:05:54.000Z",
"imageLink": "https://assets.tarkov.dev/688246958448b05efd61d462.webp"
},
{
"id": "69e0d6cc77b63940375b9173",
"name": "Survivor",
"resetTime": "2026-06-25T21:40:54.000Z",
"imageLink": "https://assets.tarkov.dev/unknown-trader.webp"
}
]
Executable
+109
View File
@@ -0,0 +1,109 @@
#!/bin/sh
# POSIX-safe (dash/sh compatible)
set -eu
OUT="/var/www/EFT_COMPANION/public_html/ammo-data.json"
API="https://api.tarkov.dev/graphql"
# --- Preferred: ammo-only (note lowercase enum value 'ammo') ---
GQL_AMMO_ONLY='{
items(lang: en, types: [ammo]) {
id
name
shortName
iconLink
wikiLink
updated
properties {
__typename
... on ItemPropertiesAmmo {
caliber
tracer
tracerColor
damage
armorDamage
fragmentationChance
penetrationPower
accuracyModifier
recoilModifier
initialSpeed
lightBleedModifier
heavyBleedModifier
projectileCount
}
}
}
}'
# --- Fallback: all items then filter to ItemPropertiesAmmo ---
GQL_ALL_MIN='{
items(lang: en) {
id
name
shortName
iconLink
wikiLink
updated
properties {
__typename
... on ItemPropertiesAmmo {
caliber
tracer
tracerColor
damage
armorDamage
fragmentationChance
penetrationPower
accuracyModifier
recoilModifier
initialSpeed
lightBleedModifier
heavyBleedModifier
projectileCount
}
}
}
}'
# Build JSON payloads safely via jq (read from stdin, -R = raw, -s = slurp)
PAYLOAD_AMMO_ONLY=$(printf '%s' "$GQL_AMMO_ONLY" | jq -Rs '{query: .}')
PAYLOAD_ALL_MIN=$(printf '%s' "$GQL_ALL_MIN" | jq -Rs '{query: .}')
fetch_graphql () {
payload="$1"
curl -sS -X POST "$API" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
--data-binary "$payload"
}
# Try ammo-only first
JSON=$(fetch_graphql "$PAYLOAD_AMMO_ONLY")
# If .data is missing, fall back to broad query
if ! printf '%s' "$JSON" | jq -e '.data' >/dev/null 2>&1 ; then
echo "Ammo-only query failed, falling back to broad items query…" >&2
JSON=$(fetch_graphql "$PAYLOAD_ALL_MIN")
if ! printf '%s' "$JSON" | jq -e '.data' >/dev/null 2>&1 ; then
echo "GraphQL error on fallback:" >&2
printf '%s\n' "$JSON" | jq . >&2 || printf '%s\n' "$JSON" >&2
exit 1
fi
fi
# Normalize to a compact JSON your site can consume
TMP="$OUT.tmp"
printf '%s' "$JSON" | jq -c '
{
fetchedAt: (now | todate),
source: "api.tarkov.dev/graphql",
items: (
.data.items
| map(select(.properties != null and .properties.__typename == "ItemPropertiesAmmo"))
)
}
' > "$TMP"
mv "$TMP" "$OUT"
COUNT=$(jq '.items | length' "$OUT")
echo "Wrote $COUNT ammo rows to $OUT at $(date -Is)"
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/python3
"""
update-goons-status.py
Fetch "Last Seen" blocks from goon-tracker.com (PvP + PvE) and write a local JSON.
Pages & markers (as of today):
- PvP page: https://www.goon-tracker.com/
Shows "Last Seen: <Map>" and a "Time:" line. (wording: "Last Seen:") [ref]
- PvE page: https://www.goon-tracker.com/pvetracker
Shows "Last Seen on PvE Mode:" then the map + "Time:" + "Last seen:". [ref] [1](https://github.com/the-hideout/tarkov-api/issues/133)
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
import requests
from bs4 import BeautifulSoup
UA = "EFTC-CompanionBot/1.0 (+yourdomain; admin@yourdomain)"
TIMEOUT = 15
OUT = "/var/www/EFT_COMPANION/public_html/goons-status.json"
PVP_URL = "https://www.goon-tracker.com/"
PVE_URL = "https://www.goon-tracker.com/pvetracker"
# Common EFT map names (helps with fallback parsing)
KNOWN_MAPS = {
"Customs","Shoreline","Lighthouse","Woods","Streets","Reserve",
"Interchange","Factory","Labs","Ground","GroundZero","GroundZero","The","TheLab","Lab","TheLab"
}
def fetch_html(url: str) -> str:
r = requests.get(url, headers={"User-Agent": UA}, timeout=TIMEOUT)
r.raise_for_status()
return r.text
def text_nodes(soup):
for el in soup.find_all(string=True):
txt = (el or "").strip()
if txt:
yield txt
def clean_token(s: str) -> str:
return re.sub(r"\s+", " ", s).strip()
def parse_time_and_ago(all_text):
time_text, last_seen_text = "", ""
for t in all_text:
if t.startswith("Time:"):
time_text = t.split("Time:", 1)[-1].strip()
elif t.startswith("Last seen:"):
last_seen_text = t.split("Last seen:", 1)[-1].strip()
return time_text, last_seen_text
def parse_pvp(html: str):
"""
Strategy (PvP):
1) Find a text line that starts with "Last Seen".
2) Try to capture the map on the same line or the next nearby text node(s).
3) Also collect "Time:" and "Last seen:" if present.
The PvP homepage wording is "Last Seen: <Map>" (without 'PvE'). [ref]
"""
soup = BeautifulSoup(html, "html.parser")
all_text = list(text_nodes(soup))
last_seen_map = None
time_text, last_seen_text = "", ""
# 1) Direct regex on any line containing "Last Seen"
for idx, t in enumerate(all_text):
if t.lower().startswith("last seen"):
# Try "Last Seen: Lighthouse" pattern
m = re.search(r"Last\s*Seen\s*:?\s*([A-Za-z][A-Za-z ]{2,30})", t, flags=re.I)
if m:
candidate = clean_token(m.group(1))
# If the candidate includes extra words, trim to first known map token
for tok in candidate.replace("-", " ").split():
if tok in KNOWN_MAPS:
last_seen_map = tok
break
if not last_seen_map:
# As a fallback, keep first word (often it's already just 'Customs', etc.)
last_seen_map = candidate.split()[0]
# 2) If not found on same line, scan a small window ahead
if not last_seen_map:
window = " ".join(all_text[idx: idx+6])
m2 = re.search(r"\b(Customs|Shoreline|Lighthouse|Woods|Streets|Reserve|Interchange|Factory|Labs)\b", window, flags=re.I)
if m2:
last_seen_map = clean_token(m2.group(1))
break # Stop after first marker
# Times and "last seen:" (ago)
time_text, last_seen_text = parse_time_and_ago(all_text)
debug = ""
if not last_seen_map:
# Provide a small debug snippet for troubleshooting
sample = [t for t in all_text if t.lower().startswith("last seen")][:2]
debug = f"pvp_debug: marker_lines={sample}"
return {
"map": last_seen_map,
"timeText": time_text,
"lastSeenText": last_seen_text,
"source": PVP_URL,
"debug": debug
}
def parse_pve(html: str):
"""
Strategy (PvE):
- Find the text "Last Seen on PvE Mode:" then read the nearby span/inline with the map token.
- Also capture "Time:" and "Last seen:" lines if present.
The PvE page explicitly uses the "Last Seen on PvE Mode" header. [ref] [1](https://github.com/the-hideout/tarkov-api/issues/133)
"""
soup = BeautifulSoup(html, "html.parser")
all_text = list(text_nodes(soup))
last_seen_map = None
marker = soup.find(string=lambda s: isinstance(s, str) and "Last Seen on PvE Mode" in s)
if marker:
# Try closest siblings/children for a short map token
siblings_text = []
for sib in marker.parent.next_siblings:
if hasattr(sib, "get_text"):
txt = sib.get_text(strip=True)
else:
txt = str(sib).strip()
if txt:
siblings_text.append(txt)
if len(siblings_text) > 5:
break
# First sibling chunk is often the map (e.g., "Lighthouse")
if siblings_text:
# If contains multiple words, try to pick a known map token
words = siblings_text[0].replace("-", " ").split()
for w in words:
if w in KNOWN_MAPS:
last_seen_map = w
break
if not last_seen_map:
last_seen_map = words[0]
time_text, last_seen_text = parse_time_and_ago(all_text)
debug = ""
if not last_seen_map:
sample = [t for t in all_text if "Last Seen on PvE Mode" in t][:2]
debug = f"pve_debug: marker_lines={sample}"
return {
"map": last_seen_map,
"timeText": time_text,
"lastSeenText": last_seen_text,
"source": PVE_URL,
"debug": debug
}
def main():
pvp_html = fetch_html(PVP_URL)
pve_html = fetch_html(PVE_URL)
pvp = parse_pvp(pvp_html)
pve = parse_pve(pve_html)
now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
data = {
"fetchedAt": now_iso,
"pvp": pvp,
"pve": pve
}
tmp = OUT + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, OUT)
print(f"Wrote {OUT}: PvP={pvp.get('map')} PvE={pve.get('map')} at {now_iso}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# update-quests.sh — fetch EFT quests and write atomically to quests.json
set -euo pipefail
umask 022
# ---- Environment safe for cron ----
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
OUT="/var/www/EFT_COMPANION/public_html/quests.json"
OUT_DIR="$(dirname "$OUT")"
ENDPOINT="https://api.tarkov.dev/graphql"
# ---- Helpers ----
die() { echo "[update-quests] ERROR: $*" >&2; exit 1; }
# Confirm required tools exist (cron PATH can be minimal)
for bin in curl mktemp mv chmod head date; do
command -v "$bin" >/dev/null 2>&1 || die "Required binary '$bin' not found in PATH"
done
# Ensure output directory exists
mkdir -p "$OUT_DIR"
# GraphQL query — single line to avoid escaping double quotes
# (Removed fields that caused schema errors: Skill.normalizedName, craftUnlock.name)
QUERY='query AllTasks { tasks { id name trader { id name imageLink } wikiLink objectives { id type description maps { normalizedName } ... on TaskObjectiveItem { count foundInRaid item { id name shortName iconLink } items { id name shortName iconLink } } ... on TaskObjectiveShoot { targetNames count } } finishRewards { items { count item { id name shortName iconLink } } traderStanding { standing trader { id name } } offerUnlock { item { id name } trader { id name } } skillLevelReward { level skill { id name } } traderUnlock { id name } craftUnlock { id } achievement { id name } customization { id name } } } }'
# Minimal JSON payload (no double quotes inside QUERY)
PAYLOAD=$(printf '{"query":"%s"}' "$QUERY")
# Create temp file in the same directory, then atomically mv into place
TMP="$(mktemp -p "$OUT_DIR" 'quests.json.tmp.XXXXXX')" || die "mktemp failed"
# Clean up the temp file on any exit path (cleared later after mv)
cleanup() { [ -n "${TMP:-}" ] && [ -f "$TMP" ] && rm -f "$TMP" || true; }
trap cleanup EXIT
echo "[update-quests] Posting to $ENDPOINT ..."
HTTP_STATUS="$(
curl -sS -o "$TMP" -w '%{http_code}' \
-X POST "$ENDPOINT" \
-H 'Content-Type: application/json' \
--data-binary "$PAYLOAD"
)"
if [ "$HTTP_STATUS" != "200" ]; then
echo "[update-quests] ERROR: HTTP $HTTP_STATUS"
echo "[update-quests] Response body (first 2000 bytes):"
head -c 2000 "$TMP" || true; echo
exit 1
fi
# Ensure readable by web server
chmod 0644 "$TMP"
# Atomic replace
mv -f "$TMP" "$OUT"
# Prevent trap from trying to remove a moved file
TMP=""
trap - EXIT
echo "[update-quests] Wrote $OUT at $(date -Is)"
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Directory where JSON will be saved
OUTDIR="/var/www/EFT_COMPANION/public_html/"
# Tarkov.dev endpoint
URL="https://api.tarkov.dev/graphql"
# PvP query
read -r -d '' Q_PVP << 'EOF'
{
items {
id
name
shortName
avg24hPrice
lastLowPrice
low24hPrice
high24hPrice
}
}
EOF
# PvE query
read -r -d '' Q_PVE << 'EOF'
{
items(gameMode: pve) {
id
name
shortName
avg24hPrice
lastLowPrice
low24hPrice
high24hPrice
}
}
EOF
# Pull PvP data
curl -s -X POST -H "Content-Type: application/json" \
--data "{\"query\":\"${Q_PVP//$'\n'/ }\"}" \
"$URL" | jq '.data.items' > "$OUTDIR/items_pvp.json"
# Pull PvE data
curl -s -X POST -H "Content-Type: application/json" \
--data "{\"query\":\"${Q_PVE//$'\n'/ }\"}" \
"$URL" | jq '.data.items' > "$OUTDIR/items_pve.json"
# Optional combined file
jq -n \
--slurpfile pvp "$OUTDIR/items_pvp.json" \
--slurpfile pve "$OUTDIR/items_pve.json" \
'{updatedAt: now, pvp: $pvp[0], pve: $pve[0]}' \
> "$OUTDIR/tarkov_all.json"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Pull trader reset times (PvP & PvE) from Tarkov.dev and save as local JSON.
# Writes: /var/www/EFT_COMPANION/public_html/traders_pvp.json, /var/www/EFT_COMPANION/public_html/traders_pve.json
# Logs: /var/log/eft_traders_pull.log
set -euo pipefail
umask 022
OUTDIR="/var/www/EFT_COMPANION/public_html/"
URL="https://api.tarkov.dev/graphql"
LOG="/var/log/eft_traders_pull.log"
# Ensure log file exists and is writable (no sudo; assume root or correct perms)
mkdir -p /var/log
touch "$LOG"
log() { echo "[$(date -Is)] $*" | tee -a "$LOG"; }
# Compact, single-line GraphQL bodies (avoids quoting/heredoc pitfalls)
Q_PVP='{"query":"{ traders(gameMode: regular) { id name resetTime imageLink } }"}'
Q_PVE='{"query":"{ traders(gameMode: pve) { id name resetTime imageLink } }"}'
mkdir -p "$OUTDIR"
tmp_pvp="$(mktemp)"
tmp_pve="$(mktemp)"
raw_pvp="$(mktemp)"
raw_pve="$(mktemp)"
cleanup() {
rm -f "$tmp_pvp" "$tmp_pve" "$raw_pvp" "$raw_pve"
}
trap cleanup EXIT
log "Starting trader pulls…"
# ---- PvP ----
if curl -fSs -X POST "$URL" \
-H "Content-Type: application/json" \
--data "$Q_PVP" -o "$raw_pvp"
then
if jq -e '.errors' "$raw_pvp" >/dev/null 2>&1; then
log "ERROR: PvP GraphQL returned errors:"
jq '.errors' "$raw_pvp" | tee -a "$LOG"
exit 1
fi
jq '.data.traders' "$raw_pvp" > "$tmp_pvp"
else
rc=$?
log "ERROR: curl PvP request failed (exit $rc). Raw (if any):"
cat "$raw_pvp" | tee -a "$LOG" || true
exit 1
fi
# ---- PvE ----
if curl -fSs -X POST "$URL" \
-H "Content-Type: application/json" \
--data "$Q_PVE" -o "$raw_pve"
then
if jq -e '.errors' "$raw_pve" >/dev/null 2>&1; then
log "ERROR: PvE GraphQL returned errors:"
jq '.errors' "$raw_pve" | tee -a "$LOG"
exit 1
fi
jq '.data.traders' "$raw_pve" > "$tmp_pve"
else
rc=$?
log "ERROR: curl PvE request failed (exit $rc). Raw (if any):"
cat "$raw_pve" | tee -a "$LOG" || true
exit 1
fi
# Atomic move into place
mv "$tmp_pvp" "$OUTDIR/traders_pvp.json"
mv "$tmp_pve" "$OUTDIR/traders_pve.json"
chmod 644 "$OUTDIR/traders_pvp.json" "$OUTDIR/traders_pve.json"
log "Success: wrote $OUTDIR/traders_pvp.json and $OUTDIR/traders_pve.json"