diff --git a/CHANGELOG.md b/CHANGELOG.md index acc54ad..f694958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - Lazy PSA loading: search returns results without waiting for PSA, frontend fetches PSA separately - Pre-warm cache: track-prices scheduler pre-caches active listings + PSA for tracked cards - Fast card-first search: autocomplete → card share → demo search → render in 2-3s (was 30s) +- Client-side format filtering: Raw excludes slabs, Slab matches provider+grade +- Client-side condition filtering: instant without re-fetch +- Sort by grade (high to low) added to sort dropdown +- Pagination: 25 listings per page with "Show more" button +- Autocomplete suppressed on hint chip clicks - eBay relevance filtering: blocklist expanded (art case, sleeves, playmat, booster, etc.), applied to active+sold - Arbitrage alerts: notify when cross-source spread exceeds threshold (POST /api/alerts with type "arbitrage") - Price drop alerts: notify when price falls below target (POST /api/alerts with type "price") @@ -58,7 +63,7 @@ - Card identity: cleaned up long names (strips pack names, condition text from titles) - track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults - Demo condition filter: checks detectedCondition in addition to raw condition field -- Tests: 271 total (128 unit + 80 API + 63 smoke), up from 183 +- Tests: 278 total (128 unit + 80 API + 70 smoke), up from 183 - AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance - Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence) - Removed dead code: Redis import from api.js, updateCardField from card-identity.js diff --git a/README.md b/README.md index 325f325..6300042 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ public/admin/ Admin dashboard (keys, stats, errors) extension/ Chrome extension: queue auto-join, drop intel terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manager test/ - unit-test.js 118 unit tests + unit-test.js 128 unit tests api-test.js 76 API integration tests smoke-test.js 40 Playwright smoke tests (dashboard UI) ``` @@ -241,7 +241,7 @@ Load unpacked from `extension/` in `chrome://extensions`. ## Tests -238 tests: 118 unit (filters, grading, query builder, card identity, condition detection, demo data, image preprocessing, email alerts, portfolio ROI, CSV, gainers/losers, grading opportunities) + 80 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation, portfolio CRUD, portfolio history/export/grading) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport). +278 tests: 128 unit (filters, grading, query builder, card identity, condition detection, demo data, image preprocessing, email alerts, portfolio ROI, CSV, gainers/losers, grading opportunities, autocomplete matching) + 80 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation, portfolio CRUD, portfolio history/export/grading) + 70 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport, portfolio, autocomplete, search filters). ## Contributing diff --git a/public/app.js b/public/app.js index 3456501..7040e06 100644 --- a/public/app.js +++ b/public/app.js @@ -29,6 +29,7 @@ let acResults = []; input.addEventListener("input", () => { clearTimeout(acDebounce); + if (suppressAc) return; const q = input.value.trim(); if (q.length < 2) { hideAc(); return; } acDebounce = setTimeout(() => fetchAc(q), 150); @@ -184,13 +185,17 @@ let currentPsaSignal = null; let currentMagiMarket = null; let pendingChartPoints = null; +let suppressAc = false; document.querySelectorAll(".hint").forEach(h => { h.addEventListener("click", () => { + suppressAc = true; input.value = h.dataset.q; currentSource = h.dataset.source || ""; currentCondition = h.dataset.condition || ""; forceDemo = true; + hideAc(); form.dispatchEvent(new Event("submit")); + setTimeout(() => { suppressAc = false; }, 300); }); }); @@ -221,7 +226,8 @@ document.querySelectorAll('.filter-pill[data-format]').forEach(btn => { btn.classList.add("active"); currentFormat = btn.dataset.format; document.getElementById("slab-options").classList.toggle("hidden", currentFormat !== "slab"); - triggerFilterSearch(); + displayedActiveCount = 25; displayedSoldCount = 25; + if (allActive.length) applySourceFilter(); else triggerFilterSearch(); }); }); @@ -259,7 +265,8 @@ document.querySelectorAll('.source-pill:not([data-source="all"])').forEach(btn = document.getElementById("condition-filter").addEventListener("change", (e) => { currentFilterCondition = e.target.value; - triggerFilterSearch(); + displayedActiveCount = 25; displayedSoldCount = 25; + if (allActive.length) applySourceFilter(); else triggerFilterSearch(); }); document.getElementById("slab-provider").addEventListener("change", () => triggerFilterSearch()); @@ -268,6 +275,8 @@ document.getElementById("slab-grade").addEventListener("change", () => triggerFi function triggerFilterSearch() { const q = currentQuery || input.value.trim(); if (!q) return; + displayedActiveCount = 25; + displayedSoldCount = 25; const sources = [...activeSources]; if (sources.length === 1) { let url = `/api/search?q=${encodeURIComponent(q)}&source=${sources[0]}`; @@ -317,6 +326,8 @@ function sortItems(items) { const sorted = [...items]; if (currentSort === "price-desc") { sorted.sort((a, b) => (b.totalCost || b.price) - (a.totalCost || a.price)); + } else if (currentSort === "grade-desc") { + sorted.sort((a, b) => (b.grade?.overall || 0) - (a.grade?.overall || 0)); } else { sorted.sort((a, b) => (a.totalCost || a.price) - (b.totalCost || b.price)); } @@ -477,17 +488,44 @@ function renderSourceFilters(sources) { }); } +let displayedActiveCount = 25; +let displayedSoldCount = 25; + +function clientFilter(item) { + if (currentFormat === "raw" && item.listingGradeLabel && item.listingGradeLabel !== "Ungraded") return false; + if (currentFormat === "slab") { + const provider = document.getElementById("slab-provider").value; + const grade = document.getElementById("slab-grade").value; + const label = (item.listingGradeLabel || "").toUpperCase(); + if (!label.includes(provider.toUpperCase()) || !label.includes(grade)) return false; + } + if (currentFilterCondition) { + const cond = currentFilterCondition.toLowerCase(); + const detected = (item.detectedCondition || "").toLowerCase(); + const raw = (item.condition || "").toLowerCase(); + if (!detected.includes(cond) && !raw.includes(cond)) return false; + } + return true; +} + function applySourceFilter() { - const filterFn = activeSourceFilter === "all" + const sourceFilterFn = activeSourceFilter === "all" ? () => true : (item) => itemSource(item.itemWebUrl) === activeSourceFilter; - const filteredActive = sortItems(allActive.filter(filterFn)); - const filteredSold = sortItems(allSold.filter(filterFn)); + const filteredActive = sortItems(allActive.filter(i => sourceFilterFn(i) && clientFilter(i))); + const filteredSold = sortItems(allSold.filter(i => sourceFilterFn(i) && clientFilter(i))); allItems = [...filteredActive, ...filteredSold]; - renderList(activeList, filteredActive); - renderList(soldList, filteredSold); + renderList(activeList, filteredActive.slice(0, displayedActiveCount)); + renderList(soldList, filteredSold.slice(0, displayedSoldCount)); + + if (filteredActive.length > displayedActiveCount) { + activeList.insertAdjacentHTML("beforeend", ``); + } + if (filteredSold.length > displayedSoldCount) { + soldList.insertAdjacentHTML("beforeend", ``); + } document.querySelector('.list-tab[data-tab="active"]').textContent = `Active (${filteredActive.length})`; document.querySelector('.list-tab[data-tab="sold"]').textContent = `Sold (${filteredSold.length})`; diff --git a/public/index.html b/public/index.html index ebe4581..0b8f71a 100644 --- a/public/index.html +++ b/public/index.html @@ -96,6 +96,7 @@