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
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
57 changes: 56 additions & 1 deletion api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions lib/data/card-identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down