Zastępuje stronę DROP_OFF własnym dashboardem. Pobiera dane z Tantei GraphQL co 45s i wyświetla paczki w 4 sekcjach: DROP_OFF GA SLAM 1-10, Problem Solve (condtion 13, Hot Pick, PS to SHIP KO, Zwrot PS->SHIP), Vendor Returns oraz SLAM Stations (dwell time 24min-10h). Pokazuje Sort Code, Process Path z Rodeo, CPT i countdown. Paczki z CPT przekroczonym ponad 2h są ukrywane.
// ==UserScript==
// @name DROP_OFF Tracker
// @namespace http://tampermonkey.net/
// @version 3.2
// @description Zastępuje stronę DROP_OFF własnym dashboardem. Pobiera dane z Tantei GraphQL co 45s i wyświetla paczki w 4 sekcjach: DROP_OFF GA SLAM 1-10, Problem Solve (condtion 13, Hot Pick, PS to SHIP KO, Zwrot PS->SHIP), Vendor Returns oraz SLAM Stations (dwell time 24min-10h). Pokazuje Sort Code, Process Path z Rodeo, CPT i countdown. Paczki z CPT przekroczonym ponad 2h są ukrywane.
// @author @nowaratn
// @contributor stefakac
// @match https://trans-logistics-eu.amazon.com/sortcenter/tt?setNodeId=KTW1&dropoff
// @match https://trans-logistics-eu.amazon.com/sortcenter/tantei?setNodeId=KTW1&dropoff
// @icon https://www.google.com/s2/favicons?sz=64&domain=amazon.com
// @grant GM_xmlhttpRequest
// @connect rodeo-dub.amazon.com
// ==/UserScript==
(function () {
'use strict';
// ── CONFIG ──────────────────────────────────────────────
const NODE_ID = 'KTW1';
const REFRESH_SEC = 45;
const PAGE_SIZE = 100;
const TANTEI_GQL = 'https://trans-logistics-eu.amazon.com/sortcenter/tantei/graphql';
const TANTEI_URL = 'https://trans-logistics-eu.amazon.com/sortcenter/tantei';
const RODEO_URL = 'https://rodeo-dub.amazon.com';
const EXPIRED_HIDE_MS = 2 * 60 * 60 * 1000;
const SLAM_DWELL_THRESHOLD_MS = 0.40 * 60 * 60 * 1000;
const SLAM_DWELL_MAX_MS = 10 * 60 * 60 * 1000;
const SLAM_CPT_MAX_MS = 10 * 60 * 60 * 1000;
const LOCATIONS = [
'DROP_OFF GA SLAM 1',
'DROP_OFF GA SLAM 2',
'DROP_OFF GA SLAM 3',
'DROP_OFF GA SLAM 4',
'DROP_OFF GA SLAM 5',
'DROP_OFF GA SLAM 6',
'DROP_OFF GA SLAM 7',
'DROP_OFF GA SLAM 8',
'DROP_OFF GA SLAM 9',
'DROP_OFF GA SLAM 10',
];
const LOCATIONS_PS = [
'Problem Solve - condtion 13',
'Problem Solve Hot Pick',
'PS to SHIP KO',
'Zwrot _PS->SHIP',
];
const LOCATIONS_VR = [
'cefe6d1b-1aa6-4b5e-aae7-2d226adb8136',
];
const LOCATIONS_SLAM = [
'7ab91a76-bc8c-4552-b6ed-195815b2dd72',
'ff1b8043-b488-480d-84c4-4a11fdd513ee',
'd5f33711-d3a9-4233-9375-8caa0432fb44',
'eedc629b-bba9-4c2f-a8b6-b945c7c2c233',
'aab8cfff-ecac-4abb-bb24-048d8b940d5c',
'c4d55a40-e428-4060-9dd0-7a1f8396e294',
'f8fa4a79-71b4-49f2-a4e6-5b5a0a4a1850',
'4109e312-c842-4686-9697-b4fe62514e1e',
'c7c42551-d752-46fe-a4b5-87bb3b573d76',
'bd110212-f6fe-4d67-90ee-0356b86c9a83',
'758b5bcf-36a1-4b77-86e6-ecec12062cd1',
'07c23d0c-ad1a-417c-9b2f-631dd78301b8',
'ae1a8bb5-d6cd-4db2-a5ab-237be5a66e20',
'85e12053-d452-4dc4-acbb-7a47d4cd8ffd',
'ed34c603-74bc-432a-82e0-b06415c2091b',
'bae2c71a-3a83-4931-b677-7b118eb31391',
];
const DISPLAY_NAMES = {
'cefe6d1b-1aa6-4b5e-aae7-2d226adb8136': 'SLAM STATION-705',
'7ab91a76-bc8c-4552-b6ed-195815b2dd72': 'SLAM STATION-3011',
'ff1b8043-b488-480d-84c4-4a11fdd513ee': 'SLAM STATION-3012',
'd5f33711-d3a9-4233-9375-8caa0432fb44': 'SLAM STATION-3031',
'eedc629b-bba9-4c2f-a8b6-b945c7c2c233': 'SLAM STATION-3032',
'aab8cfff-ecac-4abb-bb24-048d8b940d5c': 'SLAM STATION-3041',
'c4d55a40-e428-4060-9dd0-7a1f8396e294': 'SLAM STATION-3042',
'f8fa4a79-71b4-49f2-a4e6-5b5a0a4a1850': 'SLAM STATION-3051',
'4109e312-c842-4686-9697-b4fe62514e1e': 'SLAM STATION-3052',
'c7c42551-d752-46fe-a4b5-87bb3b573d76': 'SLAM STATION-3061',
'bd110212-f6fe-4d67-90ee-0356b86c9a83': 'SLAM STATION-3062',
'758b5bcf-36a1-4b77-86e6-ecec12062cd1': 'SLAM STATION-906',
'07c23d0c-ad1a-417c-9b2f-631dd78301b8': 'SLAM STATION-912',
'ae1a8bb5-d6cd-4db2-a5ab-237be5a66e20': 'SLAM STATION-905',
'85e12053-d452-4dc4-acbb-7a47d4cd8ffd': 'SLAM STATION-913',
'ed34c603-74bc-432a-82e0-b06415c2091b': 'SLAM STATION-914',
'bae2c71a-3a83-4931-b677-7b118eb31391': 'SLAM STATION-915',
};
const ALL_LOCATIONS = [...LOCATIONS, ...LOCATIONS_PS, ...LOCATIONS_VR, ...LOCATIONS_SLAM];
const LEFT_LOCS = LOCATIONS.slice(0, 5);
const RIGHT_LOCS = LOCATIONS.slice(5, 10);
// ── PROCESS PATH CACHE ──────────────────────────────────
// label -> { path: string, ts: number }
const ppCache = {};
const PP_CACHE_TTL = 10 * 60 * 1000; // 10 minut
let ppLoaded = false;
// ── GRAPHQL QUERY ───────────────────────────────────────
const QUERY = `
query ($queryInput: [SearchTermInput!]!, $startIndex: String) {
searchEntities(searchTerms: $queryInput) {
searchTerm { nodeId searchId resolvedIdType }
contents(pageSize: ${PAGE_SIZE}, startIndex: $startIndex, forwardNavigate: true) {
contents {
containerId
containerLabel
containerType
stackingFilter
criticalPullTime
isEmpty
isClosed
timeOfAssociation
shipmentId
}
endToken
}
}
}`;
// ── CSRF ────────────────────────────────────────────────
let csrfToken = '';
async function initCsrf() {
const existing = document.querySelector("input[name='__token_']");
if (existing && existing.value) {
csrfToken = existing.value;
console.log('DROP_OFF Tracker: CSRF z DOM ✓');
return;
}
try {
const resp = await fetch(`${TANTEI_URL}?nodeId=${NODE_ID}`, {
credentials: 'include',
});
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const input = doc.querySelector("input[name='__token_']");
if (input && input.value) {
csrfToken = input.value;
console.log('DROP_OFF Tracker: CSRF z fetch ✓');
return;
}
} catch (e) {
console.error('DROP_OFF Tracker: błąd pobierania CSRF:', e);
}
console.error('DROP_OFF Tracker: brak CSRF tokena!');
}
function getCsrf() {
return csrfToken;
}
// ── FETCH PROCESS PATH Z RODEO ───────────────────────────
function gmFetch(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
withCredentials: true,
onload: (r) => resolve(r),
onerror: (e) => reject(e),
});
});
}
async function fetchProcessPath(shipmentId) {
if (!shipmentId) return '—';
const now = Date.now();
if (ppCache[shipmentId] && now - ppCache[shipmentId].ts < PP_CACHE_TTL) {
return ppCache[shipmentId].path;
}
try {
const url = `${RODEO_URL}/${NODE_ID}/Search?searchKey=${encodeURIComponent(shipmentId)}`;
const resp = await gmFetch(url);
if (resp.status < 200 || resp.status >= 300) {
ppCache[shipmentId] = { path: '—', ts: now };
return '—';
}
const doc = new DOMParser().parseFromString(resp.responseText, 'text/html');
// Szukaj tabeli z nagłówkiem "Process Path"
const allTables = doc.querySelectorAll('table');
for (const table of allTables) {
const ths = [...table.querySelectorAll('th')];
const ppIndex = ths.findIndex(th => th.textContent.trim() === 'Process Path');
if (ppIndex !== -1) {
const firstRow = table.querySelector('tbody tr');
if (firstRow) {
const cells = firstRow.querySelectorAll('td');
const path = cells[ppIndex] ? cells[ppIndex].textContent.trim() : '—';
ppCache[shipmentId] = { path: path || '—', ts: now };
return path || '—';
}
}
}
ppCache[shipmentId] = { path: '—', ts: now };
return '—';
} catch (e) {
ppCache[shipmentId] = { path: '—', ts: now };
return '—';
}
}
async function loadAllProcessPaths() {
const btn = document.getElementById('pp-btn');
const statusEl = document.getElementById('pp-status');
btn.disabled = true;
ppLoaded = false;
const shipments = new Set();
document.querySelectorAll('[data-pp-shipment]').forEach(el => {
if (el.dataset.ppShipment) shipments.add(el.dataset.ppShipment);
});
if (shipments.size === 0) {
statusEl.textContent = 'Brak paczek';
btn.disabled = false;
return;
}
// Ustaw wszystkie na "…"
document.querySelectorAll('[data-pp-shipment]').forEach(el => {
el.innerHTML = '<span class="pp-badge pp-loading">…</span>';
});
let done = 0;
statusEl.textContent = `0 / ${shipments.size}`;
const arr = [...shipments];
for (let i = 0; i < arr.length; i += 3) {
const batch = arr.slice(i, i + 3);
await Promise.all(batch.map(async sid => {
const path = await fetchProcessPath(sid);
document.querySelectorAll(`[data-pp-shipment="${CSS.escape(sid)}"]`).forEach(el => {
el.innerHTML = `<span class="pp-badge" title="${path}">${path}</span>`;
});
done++;
statusEl.textContent = `${done} / ${shipments.size}`;
}));
}
statusEl.textContent = `✓ ${done} załadowanych`;
btn.disabled = false;
ppLoaded = true;
}
// ── FETCH (1 BATCH) ─────────────────────────────────────
async function fetchAllLocations() {
const resp = await fetch(TANTEI_GQL, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': '*/*',
'anti-csrftoken-a2z': getCsrf(),
},
body: JSON.stringify({
query: QUERY,
variables: {
queryInput: ALL_LOCATIONS.map(loc => ({
nodeId: NODE_ID,
searchId: loc,
searchIdType: 'UNKNOWN',
})),
startIndex: '0',
},
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
return json.data.searchEntities;
}
// ── HELPERS ─────────────────────────────────────────────
const pad = n => String(n).padStart(2, '0');
function cptToDate(ms) { return ms ? new Date(parseInt(ms)) : null; }
function tsToDate(ms) { return ms ? new Date(parseInt(ms)) : null; }
function fmtDate(d) {
if (!d) return '—';
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtTime(d) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function fmtCountdown(ms) {
if (ms <= 0) {
const abs = Math.abs(ms);
const m = Math.floor(abs / 60000);
const s = Math.floor((abs % 60000) / 1000);
return `−${m}m ${String(s).padStart(2, '0')}s`;
}
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
return `${m}m ${String(s).padStart(2, '0')}s`;
}
function fmtDwell(ms) {
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${String(m).padStart(2, '0')}m`;
}
function urgencyClass(ms) {
if (ms === null) return '';
if (ms <= 0) return 'cpt-expired';
if (ms <= 20 * 60000) return 'cpt-critical';
if (ms <= 30 * 60000) return 'cpt-warning';
return 'cpt-ok';
}
function dwellClass(ms) {
if (ms >= 6 * 3600000) return 'cpt-expired';
if (ms >= 4 * 3600000) return 'cpt-critical';
return 'cpt-warning';
}
function getDisplayName(locId) {
return DISPLAY_NAMES[locId] || locId;
}
function tanteiLink(searchId, label) {
const url = `${TANTEI_URL}?nodeId=${NODE_ID}&searchType=Container&searchId=${encodeURIComponent(searchId)}`;
return `<a href="${url}" target="_blank">${label || searchId}</a>`;
}
function pkgLink(label) {
const url = `${TANTEI_URL}?nodeId=${NODE_ID}&searchId=${encodeURIComponent(label)}`;
return `<a href="${url}" target="_blank">${label}</a>`;
}
// ── BUILD UI ────────────────────────────────────────────
function buildUI() {
document.body.innerHTML = '';
document.title = 'DROP_OFF Tracker — ' + NODE_ID;
const style = document.createElement('style');
style.textContent = `
:root {
--bg: #0f1923;
--card: #1a2634;
--border: #2a3a4a;
--text: #c9d1d9;
--text-dim: #6e7e8e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--orange: #db6d28;
--red: #f85149;
--white: #e6edf3;
--row-h: 28px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
padding: 14px 18px;
min-height: 100vh;
}
#app-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 12px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}
#app-header h1 {
font-size: 20px;
font-weight: 600;
color: var(--white);
}
#app-header h1 span { color: var(--accent); }
.header-right {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-dim);
}
.header-sep {
color: var(--border);
margin: 0 4px;
}
.status-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.status-dot.ok { background: var(--green); }
.status-dot.loading { background: var(--yellow); animation: pulse 1s infinite; }
.status-dot.error { background: var(--red); }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
#tables-wrap {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
#bottom-wrap {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
#slam-wrap {
margin-top: 14px;
}
.section-label {
font-size: 15px;
font-weight: 600;
color: var(--text-dim);
margin: 18px 0 8px 0;
padding-left: 4px;
}
.section-label span { color: var(--accent); }
.half table, .bottom-half table, #slam-wrap table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
thead th {
text-align: left;
padding: 8px 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-dim);
background: rgba(0,0,0,0.25);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
tbody tr:hover { background: rgba(88,166,255,0.04); }
tbody tr:last-child { border-bottom: none; }
td {
padding: 5px 10px;
font-size: 14px;
vertical-align: top;
white-space: nowrap;
}
td.loc-name {
font-weight: 600;
color: var(--white);
}
td.loc-name a { color: var(--accent); text-decoration: none; font-size: 14px; }
td.loc-name a:hover { text-decoration: underline; }
.pkg-row {
height: var(--row-h);
line-height: var(--row-h);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pkg-row a {
color: var(--accent);
text-decoration: none;
font-family: 'Consolas', 'SF Mono', monospace;
font-size: 13px;
}
.pkg-row a:hover { text-decoration: underline; }
.sort-badge {
display: inline-block;
padding: 0 7px;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
line-height: var(--row-h);
background: rgba(88,166,255,0.1);
color: var(--accent);
white-space: nowrap;
}
.pp-badge {
display: inline-block;
padding: 0 7px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
line-height: var(--row-h);
background: rgba(63,185,80,0.1);
color: var(--green);
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.pp-loading {
color: var(--text-dim);
background: rgba(110,126,142,0.1);
animation: pulse 1s infinite;
}
#pp-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--accent);
background: rgba(88,166,255,0.1);
color: var(--accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
#pp-btn:hover { background: rgba(88,166,255,0.22); }
#pp-btn:disabled { opacity: 0.45; cursor: not-allowed; }
#pp-status {
font-size: 12px;
color: var(--text-dim);
}
.cpt-cell .pkg-row {
font-variant-numeric: tabular-nums;
font-size: 13px;
}
.cd-cell .pkg-row {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 16px;
}
.dwell-cell .pkg-row {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 16px;
}
.cpt-expired { color: var(--red); }
.cpt-critical { color: var(--red); }
.cpt-warning { color: var(--yellow); }
.cpt-ok { color: var(--green); }
.empty-label {
color: var(--text-dim);
font-style: italic;
font-size: 13px;
line-height: var(--row-h);
}
.pkg-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
margin-right: 8px;
padding: 0 6px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
line-height: 20px;
vertical-align: middle;
background: rgba(248,81,73,0.15);
color: var(--red);
}
.pkg-count-badge.zero {
background: rgba(63,185,80,0.1);
color: var(--green);
}
.pkg-count-badge.warn {
background: rgba(210,153,34,0.15);
color: var(--yellow);
}
.progress-bar {
height: 2px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar .fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 1s linear;
}
.author-badge {
position: fixed;
bottom: 14px;
right: 18px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
letter-spacing: 0.3px;
opacity: 0.5;
transition: opacity 0.3s, transform 0.3s;
cursor: default;
user-select: none;
z-index: 100;
}
.author-badge:hover {
opacity: 1;
transform: scale(1.05);
}
.author-badge span {
background: linear-gradient(90deg, var(--accent), #a78bfa, var(--green), var(--yellow), var(--accent));
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: author-shine 6s linear infinite;
}
@keyframes author-shine {
0% { background-position: 0% 50%; }
100% { background-position: 300% 50%; }
}
`;
document.head.appendChild(style);
const tableHTML = `
<table>
<thead>
<tr>
<th>Lokacja</th>
<th>Paczki</th>
<th>Sort Code</th>
<th>Process Path</th>
<th>CPT</th>
<th>Do CPT</th>
</tr>
</thead>
<tbody></tbody>
</table>`;
const slamTableHTML = `
<table>
<thead>
<tr>
<th>Lokacja</th>
<th>Paczki</th>
<th>Sort Code</th>
<th>Process Path</th>
<th>CPT</th>
<th>Do CPT</th>
<th>Dwell Time</th>
</tr>
</thead>
<tbody></tbody>
</table>`;
document.body.innerHTML = `
<div id="app-header">
<h1>\u{1F4E6} DROP_OFF Tracker — <span>${NODE_ID}</span></h1>
<div class="header-right">
<button id="pp-btn">\uD83D\uDD0D Process Path</button>
<span id="pp-status"></span>
<span class="header-sep">\u00B7</span>
<span id="status"><span class="status-dot loading"></span>\u0141adowanie...</span>
<span class="header-sep">\u00B7</span>
<span id="last-refresh">\u2014</span>
<span class="header-sep">\u00B7</span>
<span id="next-refresh">\u2014</span>
</div>
</div>
<div id="tables-wrap">
<div class="half" id="table-left">${tableHTML}</div>
<div class="half" id="table-right">${tableHTML}</div>
</div>
<div id="bottom-wrap">
<div class="bottom-half">
<div class="section-label">\u{1F527} <span>Problem Solve</span></div>
<div id="table-ps">${tableHTML}</div>
</div>
<div class="bottom-half">
<div class="section-label">\u{1F4E5} <span>Vendor Returns</span></div>
<div id="table-vr">${tableHTML}</div>
</div>
</div>
<div id="slam-wrap">
<div class="section-label">\u{1F6A8} <span>SLAM Stations</span> <span style="font-size:12px;font-weight:400;color:var(--text-dim);">(dwell time 1h\u201310h, CPT < 10h)</span></div>
<div id="table-slam">${slamTableHTML}</div>
</div>
<div class="progress-bar">
<div class="fill" id="refresh-bar" style="width:100%"></div>
</div>
<div class="author-badge">made by <span>@NOWARATN</span></div>
`;
document.getElementById('pp-btn').addEventListener('click', loadAllProcessPaths);
}
// ── RENDER (standard tables) ─────────────────────────────
function renderTable(tbodyEl, entities, now, excludeSortSuffix = null) {
let totalPkgs = 0, expiredCount = 0, criticalCount = 0;
const rows = [];
for (const entity of entities) {
const locId = entity.searchTerm.searchId;
const locLabel = getDisplayName(locId);
let contents = entity.contents?.contents || [];
contents = contents.filter(pkg => {
const cptDate = cptToDate(pkg.criticalPullTime);
if (!cptDate) return true;
return cptDate.getTime() - now > -EXPIRED_HIDE_MS;
});
if (excludeSortSuffix) {
contents = contents.filter(pkg =>
!(pkg.stackingFilter || '').endsWith(excludeSortSuffix)
);
}
const pkgCount = contents.length;
totalPkgs += pkgCount;
contents.sort((a, b) => {
const ca = parseInt(a.criticalPullTime) || Infinity;
const cb = parseInt(b.criticalPullTime) || Infinity;
return ca - cb;
});
const badgeClass = pkgCount === 0 ? 'zero' : pkgCount <= 7 ? 'warn' : '';
const countBadge = `<span class="pkg-count-badge ${badgeClass}">${pkgCount}</span>`;
let pkgHTML = '', sortHTML = '', ppHTML = '', cptHTML = '', countdownHTML = '';
if (pkgCount === 0) {
pkgHTML = '<span class="empty-label">\u2713 Pusto</span>';
} else {
const pkgParts = [], sortParts = [], ppParts = [], cptParts = [], cdParts = [];
for (const pkg of contents) {
const label = pkg.containerLabel || pkg.containerId;
const sid = pkg.shipmentId || '';
const cptDate = cptToDate(pkg.criticalPullTime);
const diff = cptDate ? cptDate.getTime() - now : null;
const urg = urgencyClass(diff);
const cached = sid ? ppCache[sid] : null;
const ppText = cached ? cached.path : '—';
if (diff !== null && diff <= 0) expiredCount++;
else if (diff !== null && diff <= 20 * 60000) criticalCount++;
pkgParts.push(`<div class="pkg-row">${pkgLink(label)}</div>`);
sortParts.push(`<div class="pkg-row"><span class="sort-badge">${pkg.stackingFilter || '\u2014'}</span></div>`);
ppParts.push(`<div class="pkg-row" data-pp-shipment="${sid}"><span class="pp-badge" title="${ppText}">${ppText}</span></div>`);
cptParts.push(`<div class="pkg-row ${urg}">${fmtDate(cptDate)}</div>`);
cdParts.push(`<div class="pkg-row ${urg}">${diff !== null ? fmtCountdown(diff) : '\u2014'}</div>`);
}
pkgHTML = pkgParts.join('');
sortHTML = sortParts.join('');
ppHTML = ppParts.join('');
cptHTML = cptParts.join('');
countdownHTML = cdParts.join('');
}
rows.push(`
<tr>
<td class="loc-name">${countBadge}${tanteiLink(locId, locLabel)}</td>
<td>${pkgHTML}</td>
<td>${sortHTML}</td>
<td>${ppHTML}</td>
<td class="cpt-cell">${cptHTML}</td>
<td class="cd-cell">${countdownHTML}</td>
</tr>`);
}
tbodyEl.innerHTML = rows.join('');
return { totalPkgs, expiredCount, criticalCount };
}
// ── RENDER (SLAM table with dwell time) ──────────────────
function renderSlamTable(tbodyEl, entities, now) {
let totalPkgs = 0;
const rows = [];
for (const entity of entities) {
const locId = entity.searchTerm.searchId;
const locLabel = getDisplayName(locId);
let contents = entity.contents?.contents || [];
// Filter: dwell 1h-10h AND remaining CPT <= 10h
contents = contents.filter(pkg => {
const assocDate = tsToDate(pkg.timeOfAssociation);
if (!assocDate) return false;
const dwell = now - assocDate.getTime();
if (dwell <= SLAM_DWELL_THRESHOLD_MS || dwell > SLAM_DWELL_MAX_MS) return false;
const cptDate = cptToDate(pkg.criticalPullTime);
if (!cptDate) return true;
const remaining = cptDate.getTime() - now;
if (remaining > SLAM_CPT_MAX_MS) return false;
return true;
});
const pkgCount = contents.length;
// Skip empty locations entirely
if (pkgCount === 0) continue;
totalPkgs += pkgCount;
// Sort by dwell time descending (earliest association first)
contents.sort((a, b) => {
const da = parseInt(a.timeOfAssociation) || Infinity;
const db = parseInt(b.timeOfAssociation) || Infinity;
return da - db;
});
const badgeClass = pkgCount <= 7 ? 'warn' : '';
const countBadge = `<span class="pkg-count-badge ${badgeClass}">${pkgCount}</span>`;
const pkgParts = [], sortParts = [], cptParts = [], cdParts = [], dwParts = [], ppParts = [];
for (const pkg of contents) {
const label = pkg.containerLabel || pkg.containerId;
const sid = pkg.shipmentId || '';
const cptDate = cptToDate(pkg.criticalPullTime);
const diff = cptDate ? cptDate.getTime() - now : null;
const urg = urgencyClass(diff);
const assocDate = tsToDate(pkg.timeOfAssociation);
const dwellMs = assocDate ? now - assocDate.getTime() : null;
const dwCls = dwellMs !== null ? dwellClass(dwellMs) : '';
const cached = sid ? ppCache[sid] : null;
const ppText = cached ? cached.path : '—';
pkgParts.push(`<div class="pkg-row">${pkgLink(label)}</div>`);
sortParts.push(`<div class="pkg-row"><span class="sort-badge">${pkg.stackingFilter || '\u2014'}</span></div>`);
ppParts.push(`<div class="pkg-row" data-pp-shipment="${sid}"><span class="pp-badge" title="${ppText}">${ppText}</span></div>`);
cptParts.push(`<div class="pkg-row ${urg}">${fmtDate(cptDate)}</div>`);
cdParts.push(`<div class="pkg-row ${urg}">${diff !== null ? fmtCountdown(diff) : '\u2014'}</div>`);
dwParts.push(`<div class="pkg-row ${dwCls}">${dwellMs !== null ? fmtDwell(dwellMs) : '\u2014'}</div>`);
}
rows.push(`
<tr>
<td class="loc-name">${countBadge}${tanteiLink(locId, locLabel)}</td>
<td>${pkgParts.join('')}</td>
<td>${sortParts.join('')}</td>
<td>${ppParts.join('')}</td>
<td class="cpt-cell">${cptParts.join('')}</td>
<td class="cd-cell">${cdParts.join('')}</td>
<td class="dwell-cell">${dwParts.join('')}</td>
</tr>`);
}
tbodyEl.innerHTML = rows.join('');
return { totalPkgs };
}
// ── MAIN RENDER ──────────────────────────────────────────
function render(allEntities) {
const now = Date.now();
const entitiesMap = {};
for (const e of allEntities) {
entitiesMap[e.searchTerm.searchId] = e;
}
const leftEntities = LEFT_LOCS.map(l => entitiesMap[l]).filter(Boolean);
const rightEntities = RIGHT_LOCS.map(l => entitiesMap[l]).filter(Boolean);
const psEntities = LOCATIONS_PS.map(l => entitiesMap[l]).filter(Boolean);
const vrEntities = LOCATIONS_VR.map(l => entitiesMap[l]).filter(Boolean);
const slamEntities = LOCATIONS_SLAM.map(l => entitiesMap[l]).filter(Boolean);
const tbodyLeft = document.querySelector('#table-left tbody');
const tbodyRight = document.querySelector('#table-right tbody');
const tbodyPS = document.querySelector('#table-ps tbody');
const tbodyVR = document.querySelector('#table-vr tbody');
const tbodySlam = document.querySelector('#table-slam tbody');
renderTable(tbodyLeft, leftEntities, now);
renderTable(tbodyRight, rightEntities, now);
renderTable(tbodyPS, psEntities, now);
renderTable(tbodyVR, vrEntities, now, '-VR');
renderSlamTable(tbodySlam, slamEntities, now);
const allPkgs = [...LOCATIONS, ...LOCATIONS_PS, ...LOCATIONS_VR].reduce((sum, loc) => {
const e = entitiesMap[loc];
if (!e) return sum;
const contents = (e.contents?.contents || []).filter(pkg => {
const cptDate = cptToDate(pkg.criticalPullTime);
if (!cptDate) return true;
return cptDate.getTime() - now > -EXPIRED_HIDE_MS;
});
return sum + contents.length;
}, 0);
document.title = allPkgs > 0
? `(${allPkgs}) DROP_OFF \u2014 ${NODE_ID}`
: `DROP_OFF \u2014 ${NODE_ID}`;
}
// ── STATUS ──────────────────────────────────────────────
function setStatus(type, text) {
document.getElementById('status').innerHTML =
`<span class="status-dot ${type}"></span>${text}`;
}
// ── REFRESH LOOP ────────────────────────────────────────
let countdown = REFRESH_SEC;
async function refresh() {
setStatus('loading', 'Pobieranie...');
try {
const entities = await fetchAllLocations();
render(entities);
const now = new Date();
document.getElementById('last-refresh').textContent = `\u{1F550} ${fmtTime(now)}`;
setStatus('ok', 'Live');
// ── AUTO PROCESS PATH ──────────────────────────────
// Automatycznie ładuj Process Path po każdym odświeżeniu
const btn = document.getElementById('pp-btn');
if (btn && !btn.disabled) {
loadAllProcessPaths();
}
// ───────────────────────────────────────────────────
} catch (err) {
console.error('DROP_OFF Tracker error:', err);
setStatus('error', err.message);
}
countdown = REFRESH_SEC;
}
function tick() {
countdown--;
if (countdown <= 0) { refresh(); }
document.getElementById('next-refresh').textContent = `\u27F3 ${countdown}s`;
const pct = (countdown / REFRESH_SEC) * 100;
document.getElementById('refresh-bar').style.width = pct + '%';
}
// ── INIT ────────────────────────────────────────────────
async function init() {
await initCsrf();
buildUI();
await refresh();
setInterval(tick, 1000);
}
init();
})();