diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a75667..8ae06ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,25 +3,39 @@ ## Unreleased ### Added -- Playwright smoke test suite (40 tests): dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport, static assets +- 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") +- check-alerts endpoint (owner-only): evaluates all active alerts against live data +- Live price tracking: track-prices fetches real eBay sold + magi comps (was demo-only) +- Cloud Scheduler: track-prices + check-alerts run every 6 hours +- Grading ROI card: "Grade This Card?" panel with raw price, grading cost, total, gem rate, verdict +- Population-aware expected outcome: maps AI pre-grade to likely PSA grade with scarcity indicator +- TCGPlayer market price reference in price chart (with wrong-card sanity filter) +- Ungraded listing indicators: dash chip on cards + "AI grading unavailable" note in detail panel +- Playwright smoke test suite (40 tests): dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport - Sort dropdown on listing tabs (price ascending/descending) - Result counts in tab labels: "Active (6)" / "Sold (3)" - Condition badges on raw listing cards using detectedCondition from API - Price outlier warnings (flagPriceOutliers applied in API pipeline) - GRADED badge for slab listings in detail panel - Inline PSA stats in Prices tab with gem progress bar -- Price chart x-axis date labels +- Price chart x-axis date labels, redraws on tab switch (fixes blank canvas) - Arbitrage "Best Price" chip and savings summary - Fade-up entrance animations, sticky frosted header, sticky search bar +- Alert form: toggle between Price Drop and Arbitrage Spread types +- Developers nav link in dashboard header ### Changed - Dashboard UI synced with casecomp.xyz frontend: Inter Tight + JetBrains Mono fonts, pill-style tabs/hints, ghost view button - Moved lib/demo.js to lib/data/, lib/output.js to lib/search/ -- Umbreon demo data: added detectedCondition (NM/LP) based on AI grades +- Umbreon demo data: now multi-source (eBay + magi + Yahoo) with detectedCondition NM/LP +- All demo sold data spans 30+ days with realistic date spreads - Detail panel: prefer detectedCondition over "Ungraded" - Consistent shipping display with green "Free shipping" -- CI: unit + smoke run in parallel, test gate job, removed duplicate dev push trigger +- CI: unit + smoke run in parallel, both required by branch protection +- TCGPlayer search: full query first, fallback to simplified, price sanity check - Demo rate limit shown correctly as 360/min +- PR template: added breaking changes + demo data check sections ## 1.0.0-beta.1 (2026-05-10) diff --git a/README.md b/README.md index 314d0ca..4c09f42 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ test/ Three sample cards work without API keys (`?demo=true`): - Pikachu ex SAR PSA 10 (multi-source slab: eBay + magi + Yahoo) - Mega Greninja ex SAR (SNKRDUNK + AI grade) -- Umbreon ex SAR 217/187 (eBay JP + AI grade) +- Umbreon ex SAR 217/187 (eBay + magi + Yahoo + AI grade) ## REST API @@ -133,6 +133,12 @@ curl -H "Authorization: Bearer $CASECOMP_KEY" \ # Error monitoring curl -H "Authorization: Bearer $CASECOMP_KEY" \ "https://api.casecomp.xyz/api/errors" + +# Set an arbitrage alert (notify when spread > 10%) +curl -X POST -H "Authorization: Bearer $CASECOMP_KEY" \ + -H "Content-Type: application/json" \ + -d '{"email":"you@email.com","query":"Umbreon ex SAR 217/187","type":"arbitrage","spreadThreshold":10}' \ + "https://api.casecomp.xyz/api/alerts" ``` ### Rate limits @@ -217,7 +223,7 @@ All caches use Firestore (shared across Cloud Run instances, persists across dep ## Infrastructure -GCP (Terraform managed): Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR with Cloud CDN), Firestore, HTTPS LB, Secret Manager, Cloud Monitoring. Cloudflare handles SSL + edge caching for `casecomp.xyz` (~85ms TTFB). GCP managed SSL for `api.casecomp.xyz`. Same LB IP routes by host. See `terraform/`. +GCP (Terraform managed): Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR with Cloud CDN), Firestore, HTTPS LB, Secret Manager, Cloud Monitoring, Cloud Scheduler. Cloudflare handles SSL + edge caching for `casecomp.xyz` (~85ms TTFB). GCP managed SSL for `api.casecomp.xyz`. Same LB IP routes by host. Cloud Scheduler runs `track-prices` and `check-alerts` every 6 hours. See `terraform/`. ## Chrome Extension diff --git a/api.js b/api.js index c610b31..f0528f3 100644 --- a/api.js +++ b/api.js @@ -21,7 +21,7 @@ import { getDemoSearchResult, listDemoCards } from "./lib/data/demo.js"; import { createApiKey, listApiKeys, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/data/api-keys.js"; import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; -import { getOrCreateCard, findCardByQuery, parseCardIdentity, SET_NAME_MAP } from "./lib/data/card-identity.js"; +import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js"; import { fileURLToPath } from "url"; import path from "path"; @@ -647,6 +647,61 @@ app.get("/api/card", apiAuthMiddleware, async (req, res) => { } }); +// GET /api/card/share/:setCode/:number — bundled card data for share pages +app.get("/api/card/share/:setCode/:number", async (req, res) => { + const cardId = `${req.params.setCode}/${req.params.number}`; + const searchQuery = resolveCardIdToQuery(cardId); + try { + const [card, search, priceData] = await Promise.all([ + findCardByQuery(searchQuery).catch(() => null), + (async () => { + const demo = getDemoSearchResult(searchQuery, {}); + return demo._demo ? demo : null; + })(), + (async () => { + const demo = getDemoSearchResult(searchQuery, {}); + const sold = (demo.sold || []).filter(s => s.soldDate && s.price); + const history = sold.map(s => ({ price: s.price, recordedAt: s.soldDate })); + const prices = history.map(h => h.price); + return { + history, + stats: prices.length ? { + min: Math.min(...prices), max: Math.max(...prices), + avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, + count: prices.length, + } : null, + }; + })(), + ]); + + const identity = card || parseCardIdentity(searchQuery); + if (identity.setCode) identity.setName = SET_NAME_MAP[identity.setCode] || identity.setCode; + + const active = search ? Object.values(search.activeByCountry || {}).flat() : []; + const lowestPrice = active.length ? Math.min(...active.map(i => i.totalCost || i.price)) : null; + const lowestSource = lowestPrice && active.length + ? active.find(i => (i.totalCost || i.price) === lowestPrice) + : null; + + res.json({ + cardId, + identity, + price: { + lowest: lowestPrice, + source: lowestSource?.itemWebUrl?.includes("magi") ? "magi" : lowestSource?.itemWebUrl?.includes("yahoo") ? "yahoo" : lowestSource?.itemWebUrl?.includes("snkrdunk") ? "snkrdunk" : "ebay", + listingCount: active.length, + currency: active[0]?.priceCurrency || "USD", + }, + psaSignal: search?.psaSignal || null, + priceHistory: priceData, + searchQuery, + }); + } catch (e) { + logError("card-share", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // GET /api/price-history — historical sold prices for a card app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { const { q } = req.query; diff --git a/lib/data/card-identity.js b/lib/data/card-identity.js index 6bcecba..d66e399 100644 --- a/lib/data/card-identity.js +++ b/lib/data/card-identity.js @@ -305,6 +305,15 @@ export async function getOrCreateCard(query, { source, lang } = {}) { } } +export function resolveCardIdToQuery(cardId) { + const m = cardId.match(/^([a-z0-9.]+)\/([\d]+-[\d]+)$/i); + if (!m) return cardId; + const setCode = m[1]; + const number = m[2].replace("-", "/"); + const setName = SET_NAME_MAP[setCode] || ""; + return `${number} ${setName}`.trim(); +} + export async function findCardByQuery(query) { const fs = getDb(); if (!fs) return null;