Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down Expand Up @@ -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

Expand Down
52 changes: 45 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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());
Expand All @@ -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]}`;
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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", `<button class="show-more-btn" onclick="displayedActiveCount+=25;applySourceFilter()">Show more (${filteredActive.length - displayedActiveCount} remaining)</button>`);
}
if (filteredSold.length > displayedSoldCount) {
soldList.insertAdjacentHTML("beforeend", `<button class="show-more-btn" onclick="displayedSoldCount+=25;applySourceFilter()">Show more (${filteredSold.length - displayedSoldCount} remaining)</button>`);
}

document.querySelector('.list-tab[data-tab="active"]').textContent = `Active (${filteredActive.length})`;
document.querySelector('.list-tab[data-tab="sold"]').textContent = `Sold (${filteredSold.length})`;
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ <h1 class="fade-up">Research any Pokemon card<br>in seconds</h1>
<select id="sort-select" class="sort-select">
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="grade-desc">Grade: High to Low</option>
</select>
</div>
<div id="active-list" class="card-list"></div>
Expand Down
3 changes: 3 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,9 @@ footer {
.filter-select { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; padding: 5px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: var(--inset); color: #fff; cursor: pointer; -webkit-appearance: menulist; appearance: menulist; position: relative; z-index: 50; }
@media (max-width: 768px) { .search-filters { gap: 8px; } .filter-group { flex-wrap: wrap; } }

.show-more-btn { display: block; width: 100%; padding: 10px; margin-top: 8px; background: transparent; border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; color: var(--gold); font-family: 'Inter Tight', sans-serif; font-size: 0.85rem; cursor: pointer; transition: background 0.15s; }
.show-more-btn:hover { background: rgba(217,182,118,0.08); }

/* Autocomplete */
.search-bar { position: relative; }
.autocomplete-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--panel); border: 1px solid rgba(255,255,255,0.08); border-top: none; border-radius: 0 0 10px 10px; max-height: 360px; overflow-y: auto; z-index: 100; }
Expand Down
22 changes: 22 additions & 0 deletions test/smoke-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,28 @@ async function run() {

await acPage.close();

// --- Search filters ---
console.log("\n[Search filters]");
const filterPage = await browser.newPage();
await filterPage.goto(BASE);
const filters = filterPage.locator("#search-filters");
assert(await filters.isVisible(), "search filters visible on landing");
const formatPills = await filterPage.locator('.filter-pill[data-format]').count();
assert(formatPills === 2, "2 format pills (Raw/Slab)");
const sourcePills = await filterPage.locator('.source-pill').count();
assert(sourcePills === 5, "5 source pills (All + 4 sources)");
const condSelect = filterPage.locator('#condition-filter');
assert(await condSelect.isVisible(), "condition dropdown visible");
const sortSelect = filterPage.locator('#sort-select');
const sortOptions = await sortSelect.locator('option').count();
assert(sortOptions === 3, "3 sort options (price asc/desc + grade)");

const slabOpts = filterPage.locator('#slab-options');
assert(await slabOpts.evaluate(el => el.classList.contains('hidden')), "slab options hidden by default");
await filterPage.locator('.filter-pill[data-format="slab"]').click();
assert(!(await slabOpts.evaluate(el => el.classList.contains('hidden'))), "slab options visible after clicking Slab");
await filterPage.close();

// --- Static assets ---
console.log("\n[Static assets]");
const page2 = await browser.newPage();
Expand Down
Loading