diff --git a/agent.md b/agent.md index f022f3c..a2bd01d 100644 --- a/agent.md +++ b/agent.md @@ -132,7 +132,7 @@ On mobile (< 620 px): scan input goes full-width on its own row; user dropdown a ## Key behaviours - **On load**: cookie credentials restored → GET webhook fires → dropdown populated → scan input auto-focused -- **Enter key**: trims input → **duplicate check** → POSTs to webhook, prepends result row, clears + re-focuses input +- **Enter key**: trims input → duplicate check → clears input **immediately** → adds pending row → pushed onto FIFO queue → `drainQueue()` starts if idle - **Clear button**: empties the table, clears the scan input, resets the scan counter - **No user selected**: red toast + highlighted dropdown; does not submit - **401 on any request**: modal prompts for credentials (pre-filled from cookie), saves to cookie, retries original call @@ -140,7 +140,28 @@ On mobile (< 620 px): scan input goes full-width on its own row; user dropdown a - **Network error**: dismissing error banner; input selected so user can retry - **Dropdown error state**: tapping/focusing the dropdown while it shows an error re-triggers `loadUsers()` - **NDJSON/array/object**: `parseJson()` normalises all response formats from n8n -- **Duplicate scan**: `isDuplicate(code)` queries `.col-code` cells in the live table before every POST; if found, shows a warning toast and skips the webhook call entirely +- **Duplicate scan**: `isDuplicate(code)` queries `.col-code` cells (present on pending, resolved, and error rows) before enqueuing +- **Scan queue**: `scanQueue[]` is a FIFO array of `{ code, user, rowEl }`. `drainQueue()` runs sequentially in the background — one `processItem()` at a time. Scans can be added faster than the webhook responds; each gets its own pending row immediately +- **Pending row**: shows a spinner + `Processing… ` while the item is in-flight; replaced in-place by the resolved row or an error row +- **Error row**: red background, shows the error message and the raw code; code is still in `.col-code` so it cannot be re-scanned +- **Queue pill**: teal badge next to the scan counter showing `N pending`; hidden when queue is empty +- **Auth mid-queue**: if `processItem` gets 401, it returns `"auth"` to `drainQueue`, which pauses (`queueBusy = false`), shows the auth modal with `drainQueue` as the retry callback — queue resumes from the same item after login +- **Clear**: also drains `scanQueue` and resets `queueBusy` + +## Scan flow (sequence) +``` +Enter key + └─ enqueue(code) + ├─ validation (user selected? duplicate?) + ├─ scanInput.value = "" ← immediate clear + ├─ addPendingRow(code) ← spinner row in table + └─ scanQueue.push(...) ← add to FIFO + └─ drainQueue() if idle + └─ processItem() loop + ├─ fetch POST webhook + ├─ resolveRow() ← replace spinner with result + └─ rowError() ← replace spinner with error +``` ## Table columns (DOM) | Visible | Header | CSS class | Content | diff --git a/index.html b/index.html index 275adff..a2a3d0a 100644 --- a/index.html +++ b/index.html @@ -401,6 +401,90 @@ display: none; } + /* Queue status pill */ + .queue-pill { + background: var(--primary-light); + color: var(--primary); + font-size: 12px; + font-weight: 700; + padding: 2px 11px; + border-radius: 99px; + letter-spacing: 0.2px; + display: flex; + align-items: center; + gap: 5px; + } + .queue-pill.hidden { + display: none; + } + + /* Pending row */ + .row-pending td { + padding: 0; + } + + .pending-cell { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 18px; + font-size: 13px; + color: var(--text-muted); + } + + .pending-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(0, 95, 90, 0.2); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; + } + + .pending-label { + font-style: italic; + } + + .pending-code { + font-family: "Courier New", monospace; + font-size: 12px; + color: var(--text); + opacity: 0.6; + } + + /* Error row */ + .row-error td { + padding: 0; + } + + .error-cell { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 18px; + background: var(--error-bg); + font-size: 13px; + } + + .error-icon { + color: var(--error); + font-weight: 700; + flex-shrink: 0; + } + + .error-msg { + color: var(--error); + flex: 1; + } + + .error-code { + font-family: "Courier New", monospace; + font-size: 11px; + color: var(--error); + opacity: 0.55; + } + tbody tr { border-bottom: 1px solid var(--card); animation: rowIn 0.28s ease-out; @@ -876,7 +960,20 @@
Scan Log - 0 scans +
+ + 0 scans +
@@ -981,10 +1078,12 @@ const WEBHOOK = "https://brandize.app.n8n.cloud/webhook/e0268b0f-4935-49ba-bfdf-1c0ee01d3b9d"; - // ── State ─────────────────────────────────────────────────────────────── + // ── State ─────────────────────────────────────────────────────────────────── let creds = null; // { user, pass } let pendingAuthCb = null; // function to retry after auth let scanCount = 0; + const scanQueue = []; // { code, user, rowEl } — FIFO + let queueBusy = false; // true while drainQueue is running // ── DOM refs ───────────────────────────────────────────────────────────── const scanInput = document.getElementById("scanInput"); @@ -992,6 +1091,8 @@ const tableBody = document.getElementById("tableBody"); const emptyState = document.getElementById("emptyState"); const countPill = document.getElementById("countPill"); + const queuePill = document.getElementById("queuePill"); + const queuePillText = document.getElementById("queuePillText"); const banner = document.getElementById("statusBanner"); const authModal = document.getElementById("authModal"); const authUser = document.getElementById("authUser"); @@ -1208,22 +1309,34 @@ } // ── Duplicate check ────────────────────────────────────────────────────── + // Checks both resolved rows and pending rows (all have .col-code) function isDuplicate(code) { - const cells = tableBody.querySelectorAll(".col-code"); - for (const cell of cells) { - if (cell.textContent === code.trim()) return true; + for (const cell of tableBody.querySelectorAll(".col-code")) { + if (cell.textContent === code) return true; } return false; } - // ── Submit Scan ────────────────────────────────────────────────────────── - async function submitScan(code) { + // ── Queue pill ────────────────────────────────────────────────────────── + function updateQueuePill() { + const n = scanQueue.length; + if (n > 0) { + queuePillText.textContent = + n + (n === 1 ? " pending" : " pending"); + queuePill.classList.remove("hidden"); + } else { + queuePill.classList.add("hidden"); + } + } + + // ── Enqueue (called immediately on Enter) ────────────────────────────── + function enqueue(raw) { + const code = raw.trim(); + if (!code) return; + const user = userSelect.value; if (!user) { showToast("Please select a user first!", "fail"); - userSelect.focus(); - // Short shake animation on the select - userSelect.style.transition = "none"; userSelect.style.outline = "2px solid rgba(255,80,80,0.8)"; setTimeout(() => { userSelect.style.outline = ""; @@ -1231,96 +1344,138 @@ return; } - // Duplicate check against already-scanned codes in the table if (isDuplicate(code)) { - showToast( - "⚠️ Already scanned: " + code.trim(), - "fail", - 4000, - ); + showToast("⚠️ Already scanned: " + code, "fail", 4000); scanInput.select(); return; } - showBanner("Looking up package…", "loading"); + // Clear input immediately so the next scan can happen right away + scanInput.value = ""; + scanInput.focus(); + hideBanner(); + // Add a pending row to the table (also registers code for dupe detection) + const rowEl = addPendingRow(code); + + // Push onto queue + scanQueue.push({ code, user, rowEl }); + updateQueuePill(); + + // Kick off the queue processor if it isn’t running already + if (!queueBusy) drainQueue(); + } + + // ── Queue drain (processes items sequentially in the background) ──────── + async function drainQueue() { + if (queueBusy) return; + queueBusy = true; + while (scanQueue.length > 0) { + const item = scanQueue[0]; // peek — only shift after handling + const result = await processItem(item); + if (result === "auth") { + // Credentials needed — pause queue, resume after login + queueBusy = false; + needAuth(drainQueue); + return; + } + scanQueue.shift(); + updateQueuePill(); + } + queueBusy = false; + updateQueuePill(); + } + + // ── Process one queued item ──────────────────────────────────────────── + async function processItem({ code, user, rowEl }) { try { const res = await fetch(WEBHOOK, { method: "POST", headers: getHeaders(true), - body: JSON.stringify({ user, code: code.trim() }), + body: JSON.stringify({ user, code }), }); - if (res.status === 401) { - hideBanner(); - // Restore input so it's not lost - scanInput.value = code; - needAuth(() => submitScan(code)); - return; - } + if (res.status === 401) return "auth"; // signal caller to pause if (!res.ok) { - // Try to extract a descriptive error from the response body - let errMsg = `Scan failed (HTTP ${res.status}).`; + let errMsg = `Scan failed (HTTP ${res.status})`; try { const errData = await parseJson(res); if (errData?.error) errMsg = errData.error; } catch (_) {} - showBanner(errMsg, "error", 0); // persistent until next action - scanInput.select(); - return; + rowError(rowEl, errMsg); + return "ok"; } const item = await parseJson(res); - // Webhook may return 200 but carry an error field if (item?.error) { - showBanner(item.error, "error", 0); - scanInput.select(); - return; + rowError(rowEl, item.error); + return "ok"; } - hideBanner(); - addRow(item, code); - showToast( - "✓ Added: " + (item.name || item.producttag || code), - "ok", - ); - // Clear input and keep focus for next scan - scanInput.value = ""; - scanInput.focus(); + resolveRow(rowEl, item); + return "ok"; } catch (err) { console.error(err); - showBanner( - "Network error – scan not recorded.", - "error", - 5000, - ); - scanInput.select(); + rowError(rowEl, "Network error – could not reach server"); + return "ok"; } } - // ── Table Row ─────────────────────────────────────────────────────────────────── - function addRow(item, code) { + // ── Table rows ──────────────────────────────────────────────────────────────── + function addPendingRow(code) { emptyState.style.display = "none"; + const tr = document.createElement("tr"); + tr.className = "row-pending"; + tr.innerHTML = ` + + + Processing… + ${esc(code)} + + ${esc(code)} + `; + tableBody.insertBefore(tr, tableBody.firstChild); + return tr; + } + + function resolveRow(rowEl, item) { + const code = rowEl.querySelector(".col-code").textContent; + rowEl.className = ""; + rowEl.innerHTML = ` + ${esc(item.country || "—")} + ${esc(item.name || "—")} + ${esc(item.producttag || "—")} + ${esc(code)} + `; scanCount++; countPill.textContent = scanCount + (scanCount === 1 ? " scan" : " scans"); countPill.classList.remove("zero"); - - const tr = document.createElement("tr"); - tr.innerHTML = ` - ${esc(item.country || "—")} - ${esc(item.name || "—")} - ${esc(item.producttag || "—")} - ${esc(code.trim())} - `; - // Newest row at the top - tableBody.insertBefore(tr, tableBody.firstChild); } - // ── Clear ──────────────────────────────────────────────────────────────── + function rowError(rowEl, msg) { + const code = rowEl.querySelector(".col-code").textContent; + rowEl.className = "row-error"; + rowEl.innerHTML = ` + + + ${esc(msg)} + ${esc(code)} + + ${esc(code)} + `; + } + + // ── Clear ────────────────────────────────────────────────────────────────────── function clearTable() { - if (scanCount === 0 && !scanInput.value) return; + const hasContent = + scanCount > 0 || scanQueue.length > 0 || scanInput.value; + if (!hasContent) return; + // Cancel any pending queue items + scanQueue.length = 0; + queueBusy = false; + updateQueuePill(); tableBody.innerHTML = ""; scanInput.value = ""; scanCount = 0; @@ -1331,11 +1486,11 @@ showToast("Table cleared", "", 2000); } - // ── Scan Input Enter ───────────────────────────────────────────────────── + // ── Scan Input Enter ────────────────────────────────────────────────────────── scanInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { const v = scanInput.value.trim(); - if (v) submitScan(v); + if (v) enqueue(v); } });