diff --git a/.convoys/migrate-neon-to-homelab.md b/.convoys/migrate-neon-to-homelab.md
index 159ea05..448bb6b 100644
--- a/.convoys/migrate-neon-to-homelab.md
+++ b/.convoys/migrate-neon-to-homelab.md
@@ -46,9 +46,9 @@ Vercel cannot connect to `192.168.68.102`. Production cutover requires
| 3 | `docs/HOMELAB_DATABASE.md` runbook | code | done |
| 4 | Operator: provision DB, `npm run migrate up` | operator | done |
| 5 | MinIO + Redis wiring, Dockerfile, Dokploy docs | code | done |
-| 6 | `npm run migrate-neon-to-homelab` data copy | operator | pending |
-| 7 | Dokploy app + Traefik route | operator | in_progress |
-| 8 | Decommission Neon + Vercel | operator | pending |
+| 6 | `npm run migrate-neon-to-homelab` data copy | operator | **done** (homelab has prod data; ~69k cards) |
+| 7 | Dokploy app + Traefik route | operator | **done** (`deckhearth.stillwell.cloud`; `POSTGRES_URL` → CT 102) |
+| 8 | Decommission Neon + Vercel | operator | **pending** — safe after Dokploy soak + local `.env.local` switched |
## Env contract
diff --git a/.convoys/pricing-online-local.md b/.convoys/pricing-online-local.md
new file mode 100644
index 0000000..ab07709
--- /dev/null
+++ b/.convoys/pricing-online-local.md
@@ -0,0 +1,320 @@
+---
+name: pricing-online-local
+classification: feature
+success_metric: Users see honest online prices (MTG/Pokémon/Lorcana) with sync timestamps, can sort collections by price with correct foil-aware totals, and authenticated users with a saved zip see nearby game stores — all on $0 APIs.
+skip: []
+status: implementers-complete
+created: 2026-09-08
+branch: convoy/pricing-online-local
+design_direction:
+ version: 1
+ locked_at: 2026-09-08
+ extension: incremental
+ parent_system: liquid-glass
+ product_type: consumer TCG collection + pricing utility
+ pattern: authenticated app — card detail tabs + collection list + settings form
+ style: Liquid Glass — warm ember accent on glass; honesty-first pricing metadata
+ stack: nextjs-pages-router | react-18 | tailwind | css-vars
+ generator: skipped
+ generator_reason: Established Liquid Glass DS; incremental pricing surfaces only
+model_policy:
+ default_session: auto
+ roles:
+ role-conductor: composer-2.5-fast
+ role-architect: composer-2.5
+ role-ia-architect: composer-2.5-fast
+ role-ux-reviewer: composer-2.5-fast
+ role-ui-designer: composer-2.5-fast
+ role-implementer: composer-2.5-fast
+ role-reviewer: cursor-grok-4.5-high
+ role-security-auditor: gpt-5.6-terra-medium
+---
+
+# Convoy: Pricing — Online + Local Near Me (Free APIs Only)
+
+**Branch:** `convoy/pricing-online-local`
+**Constraint:** **$0 recurring** — no Google Places, no paid pricing tiers, no scraping.
+**Goal:** Wire honest online pricing + gentle local store discovery.
+
+---
+
+## Why
+
+Collection valuation, card detail, and purchase flows today show incomplete or wrong prices: Pokémon/Lorcana sync writes null prices, MTG totals use foil `market_price` for nonfoil rows, sort-by-price UI is inert, and Price History / Purchase tabs are stubs. Users need trustworthy online estimates and optional local store discovery without paid APIs.
+
+## Scope
+
+**In:** Scryfall (MTG), TCGdex (Pokémon), Foilio (Lorcana) ingest; foil-aware valuation; sort-by-price; pricing + history APIs; Purchase / Price History tabs; home zip + nearby stores (Nominatim + Overpass).
+
+**Out:** Paid aggregators, scraping, in-store inventory/prices, buylist data, affiliate links.
+
+## Roles invoked
+
+1. Conductor ✓
+2. IA Architect ✓
+3. UI Designer ✓ (incremental — skipped ui-ux-pro-max)
+4. UX Reviewer ✓
+5. Security Auditor ✓ (planning phase)
+6. Architect ✓ → **Human gate 1** ← you are here
+7. Implementers (13 briefs, `/multitask` where safe)
+8. Audit fan-out (reviewer + security + design-system + a11y)
+
+## Todos
+
+- [x] Planning roles (IA, UX, UI, Security, Architect)
+- [ ] Human gate 1 — plan approval
+- [ ] Phase 0 implementers (Briefs 1–7)
+- [ ] Phase 1 implementers (Briefs 8–9)
+- [ ] Phase 2 implementers (Briefs 10–13)
+- [ ] Audit fan-out + merge
+
+---
+
+## Free-only stack (recommended)
+
+### Online pricing by game
+
+| Game | Source | Cost | Key? | Notes |
+|------|--------|------|------|-------|
+| **MTG** | [Scryfall](https://scryfall.com/docs/api) bulk + card API | Free | No | Already integrated. `prices.usd` / `usd_foil`. Polite rate limits (~10 req/s). |
+| **Pokemon** | [TCGdex](https://tcgdex.dev) REST | Free | **No** | Catalog + embedded `pricing.tcgplayer` (USD) + `pricing.cardmarket` (EUR). Hourly/daily updates. |
+| **Lorcana** | [Foilio feed](https://foilio.net/developers) `GET /api/prices/feed` | Free | **No** | JSON, CORS, cached 1h. EUR/USD as reported; match on `card_id` / name+set. |
+| **Fallback links** | TCGPlayer + eBay search URLs | Free | No | Already on card detail — keep for buy-outbound. |
+
+**Optional signup (still $0):**
+- [TCG Price Lookup](https://tcgpricelookup.com/tcg-api) — **10,000 req/month free** with API key (multi-game). Good for on-demand card detail if TCGdex/Foilio miss a card.
+
+**Do NOT use for this convoy:**
+- tcgapi.dev beyond 100/day (too tight for bulk sync)
+- Google Places (paid after credits)
+- Scraping TCGPlayer / Cardmarket pages
+
+### Local “near me” (free)
+
+| Step | Source | Cost |
+|------|--------|------|
+| Zip → lat/lng | [Nominatim](https://nominatim.org/release-docs/develop/api/Search/) (OSM) | Free — **1 req/s max**, cache forever per zip |
+| Stores in radius | [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) `shop=games` | Free — cache 7 days per `(lat,lng,radius)` |
+
+Show: name, address, distance, `website` / `phone` tags, OpenStreetMap link. **No scraped in-store prices.**
+
+---
+
+## Decisions (planning round)
+
+| # | Question | Decision |
+| --- | --- | --- |
+| A | Nearby stores auth | **Auth required**; zip from saved `home_zip` only — no anonymous geocode relay (Security sev-3) |
+| B | Local stores placement | Subsection in **Purchase tab**, not a fourth tab |
+| C | Zip input | Manual zip in Preferences; geolocation **nice-to-have** later |
+| D | Online pricing visibility | Public (matches existing `/api/cards/[id]`) |
+| E | Price History empty state | Show tab with honest empty copy until snapshots accumulate |
+| F | Collection sort | **Server-side** `sort=price` on paginated APIs |
+| G | Settings placement | Home zip under **Preferences** panel |
+| H | Currency v1 | USD primary; respect `preferred_currency` where data exists |
+
+---
+
+## Design direction
+
+Incremental extension of Liquid Glass — no new token files. Prices use `.gradient-text-gold` / `var(--accent-flame)`; staleness uses neutral tertiary pills (not error red); glass on section containers only (never per-card grid). See full typography/spacing/badge spec in planning transcript; repo tokens win on conflict.
+
+**Surfaces:** Purchase tab price summary, Price History chart panel, staleness badge, collection sort control, settings zip field, nearby stores list.
+
+---
+
+## IA
+
+### Affected routes
+
+**Pages:** `/card/[id]` [modified], `/settings` [modified], `/my-cards` [modified], `/collection/[identifier]` [modified], `/collections` [impacted], `/profile` [impacted], `/cards` [impacted]
+
+**API:** `GET /api/cards/[id]/pricing` [new], `GET /api/cards/[id]/price-history` [new], `GET /api/stores/nearby` [new], `GET|PUT /api/user/settings` [modified], `GET /api/cards/owned` [modified], bulk import jobs [impacted]
+
+### User flow
+
+```mermaid
+flowchart LR
+ A[Settings: save zip] --> B[Card Detail]
+ B --> C[Purchase tab]
+ C --> D[Online NM price]
+ C --> E[Buy links]
+ C --> F[Stores near you]
+ G[My Collection] --> H[Sort by Price]
+```
+
+### Screen inventory
+
+| Screen | Path | New/modified | Notes |
+| --- | --- | --- | --- |
+| Card Detail — Purchase | `/card/[id]` | Modified | DB prices, staleness badge, outbound links, local stores subsection |
+| Card Detail — Price History | `/card/[id]` | Modified | Snapshot chart from sync-stored history |
+| Settings — zip | `/settings` | Modified | Home ZIP in Preferences |
+| My Collection | `/my-cards` | Modified | Sort by price + corrected estimated value |
+| List Detail | `/collection/[identifier]` | Modified | Wire existing sort dropdown |
+
+---
+
+## UX
+
+**Reuse:** `CardDetailView`, `useCardDetail`, `CollectionPageView`, `useCollectionView`, `StatCard`, `GlassSurface`, `Input`, `Button`, settings page patterns.
+
+**Key patterns:** DB-first pricing (no optimistic price UI); staleness badge with text date (not color-only); server-side sort; zip saves with explicit Settings Save button; local stores below purchase links.
+
+**A11y handoff:** Tab roles on card detail tabs; `aria-live` for pricing load; zip validation via `Input error`; 4.5:1 contrast on badge text; link purpose text on all outbound URLs.
+
+**Anti-patterns:** No “live” pricing copy; no per-card glass; no client-only sort across pages; no auto GPS on load.
+
+---
+
+## Security (planning)
+
+| Check | Status | Notes |
+| --- | --- | --- |
+| Auth boundary | ⚠️ | Pricing public OK; **nearby stores auth required** |
+| Authorization (IDOR) | ⚠️ | Zip settings owner-scoped; no arbitrary zip query |
+| Input / injection | ⚠️ | Overpass fixed-template only; validate zip + radius |
+| Secrets exposure | ✅ | Core path keyless; server-only optional key |
+| Dependencies | ⚠️ | Validate upstream JSON; native fetch preferred |
+
+**Top pre-merge fixes:** Overpass QL injection (sev 4), geocode relay without auth (sev 3), bulk sync admin gate (sev 3).
+
+Re-audit with file:line citations after implementer PR.
+
+---
+
+## Architecture
+
+### Decomposition
+
+| Brief # | Title | Phase | Depends on | Est. PR |
+| --- | --- | --- | --- | --- |
+| 1 | Pricing schema migration | 0 | — | ~120 LOC |
+| 2 | Pricing foundation lib | 0 | 1 | ~280 LOC |
+| 3 | MTG bulk sync + snapshots | 0 | 2 | ~180 LOC |
+| 4 | TCGdex Pokémon pricing | 0 | 2 | ~320 LOC |
+| 5 | Foilio Lorcana pricing | 0 | 2 | ~280 LOC |
+| 6 | Foil-aware valuation APIs | 0 | 2 | ~150 LOC |
+| 7 | Sort by price wiring | 0 | 2, 6 | ~220 LOC |
+| 8 | Card pricing + history APIs | 1 | 2 | ~350 LOC |
+| 9 | Card detail pricing UI | 1 | 8 | ~380 LOC |
+| 10 | Geo clients + DB cache | 2 | 1 | ~300 LOC |
+| 11 | Stores nearby API | 2 | 10 | ~200 LOC |
+| 12 | Settings home zip | 2 | 1 | ~120 LOC |
+| 13 | Local stores card detail UI | 2 | 9, 11, 12 | ~200 LOC |
+
+**Brief files:** `.convoys/pricing-online-local/brief-*.md`
+
+### Schema diff (migration `1783000000001_add-pricing-schema.js`)
+
+- `cards.prices_updated_at TIMESTAMPTZ`
+- `card_price_history` (daily snapshots, UNIQUE per card/day/source/type/currency)
+- `users.home_zip VARCHAR(10)`
+- `geo_zip_cache`, `geo_stores_cache` (DB-backed, 7d TTL on stores)
+
+### slice_dependencies (multitask-ready)
+
+```yaml
+slice_dependencies:
+ - brief: 1
+ depends_on: []
+ files: [migrations/1783000000001_add-pricing-schema.js, docs/SCHEMA_MAP.md]
+ - brief: 2
+ depends_on: [1]
+ files: [lib/pricing/card-price.js, lib/pricing/price-history.js, lib/pricing/collection-valuation.js, lib/pricing/outbound-links.js]
+ - brief: 3
+ depends_on: [2]
+ files: [lib/card-import/bulk-sync.js]
+ - brief: 4
+ depends_on: [2]
+ files: [lib/pricing/tcgdex-client.js, lib/card-import/pokemon-bulk.js]
+ - brief: 5
+ depends_on: [2]
+ files: [lib/pricing/foilio-client.js, lib/card-import/lorcana-bulk.js]
+ - brief: 6
+ depends_on: [2]
+ files: [pages/api/user/stats.js, pages/api/collections/[identifier].js]
+ - brief: 7
+ depends_on: [2, 6]
+ files: [pages/api/cards/owned.js, pages/api/collections/[identifier]/cards.js, lib/use-collection-view.js, components/CollectionPageView.js, pages/my-cards.js]
+ - brief: 8
+ depends_on: [2]
+ files: [lib/pricing/pricing-service.js, pages/api/cards/[id]/pricing.js, pages/api/cards/[id]/price-history.js]
+ - brief: 9
+ depends_on: [8]
+ files: [components/card-detail/CardDetailPurchaseTab.js, components/card-detail/CardDetailPriceHistoryTab.js, components/CardDetailView.js, lib/use-card-detail.js]
+ - brief: 10
+ depends_on: [1]
+ files: [lib/pricing/nominatim.js, lib/pricing/overpass.js, lib/pricing/geo-cache.js]
+ - brief: 11
+ depends_on: [10]
+ files: [pages/api/stores/nearby.js, lib/rate-limit.js]
+ - brief: 12
+ depends_on: [1]
+ files: [pages/settings.js, pages/api/user/settings.js]
+ - brief: 13
+ depends_on: [9, 11, 12]
+ files: [components/card-detail/CardDetailLocalStores.js, components/CardDetailView.js, lib/use-card-detail.js]
+```
+
+**Parallelization:** After Brief 1 → Brief 2 → **parallel** Briefs 3, 4, 5, 6 → Brief 7 → Brief 8 → Brief 9 → **parallel** Briefs 10, 12 → Brief 11 → Brief 13.
+
+### Risks
+
+| Risk | Mitigation |
+| --- | --- |
+| TCGdex ID mismatch | Match set_code + card_number; log unmatched |
+| Foilio card_id drift | Fallback name+set match |
+| Nominatim ToS | DB cache forever; 1 req/s; server-only |
+| Overpass load | 7d cache; auth + 1/hour/user |
+| Valuation on lists (no is_foil) | `COALESCE(current_price, market_price)` + tooltip |
+
+---
+
+## Phase checklist
+
+### Phase 0
+- [ ] Pokemon bulk → TCGdex prices
+- [ ] Lorcana bulk → Foilio feed
+- [ ] Fix MTG foil valuation bug
+- [ ] Sort by Price wired
+- [ ] Price snapshots on sync
+
+### Phase 1
+- [ ] `GET /api/cards/[id]/pricing`
+- [ ] Purchase tab + staleness badge
+- [ ] Price History tab
+
+### Phase 2
+- [ ] Settings home zip
+- [ ] `GET /api/stores/nearby` (auth-gated)
+- [ ] Local stores on card detail
+
+---
+
+## Env vars
+
+```bash
+TCGDEX_BASE_URL=https://api.tcgdex.net/v2/en
+FOILIO_PRICES_FEED_URL=https://foilio.net/api/prices/feed?game=lorcana
+NOMINATIM_USER_AGENT=DeckHearth/1.0 (contact@stillwell.cloud)
+OVERPASS_URL=https://overpass-api.de/api/interpreter
+TCG_PRICE_LOOKUP_API_KEY= # optional
+PRICING_LOCAL_ENABLED=true
+```
+
+---
+
+## Gentleness rules
+
+| API | Rule |
+|-----|------|
+| Scryfall bulk | One download per sync; User-Agent set |
+| TCGdex | Batch by set; sleep 100ms between pages |
+| Foilio | One feed fetch/hour |
+| Nominatim | Max 1 req/s; cache zip→coords forever |
+| Overpass | Max 1 query/user/hour; 7d cache |
+
+---
+
+**Architecture complete. 13 briefs created. Estimated PRs: 13. Awaiting human gate 1 (plan approval) before implementers run.**
diff --git a/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md b/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md
new file mode 100644
index 0000000..2cc8db8
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md
@@ -0,0 +1,39 @@
+---
+convoy: pricing-online-local
+brief_number: 1
+depends_on: []
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - migrations/1783000000001_add-pricing-schema.js
+ - docs/SCHEMA_MAP.md
+---
+
+# Brief 1: Pricing schema migration
+
+## Goal (1 sentence)
+
+Ship the node-pg-migrate migration for pricing timestamps, price history, user home zip, and geo caches.
+
+## Files in scope (do not edit anything else)
+- `migrations/1783000000001_add-pricing-schema.js`
+- `docs/SCHEMA_MAP.md`
+
+## Conventions to follow
+- Generate via `npm run migrate create add-pricing-schema -- -j js`; apply with `npm run migrate up`.
+- `down()` must hard-stub (throw) — see `migrations/1779853647564_initial-schema.js`.
+- Update `docs/SCHEMA_MAP.md` for every new column/table (AGENTS.md § Schema changes).
+- Use `ADD COLUMN IF NOT EXISTS` / `CREATE TABLE IF NOT EXISTS` for idempotency.
+
+## Acceptance criteria
+- [ ] Migration adds `cards.prices_updated_at TIMESTAMPTZ`
+- [ ] Migration creates `card_price_history` with UNIQUE `(card_id, source, price_type, currency, recorded_at)` and index on `(card_id, recorded_at DESC)`
+- [ ] Migration adds `users.home_zip VARCHAR(10)`
+- [ ] Migration creates `geo_zip_cache` and `geo_stores_cache` tables per Architecture §3
+- [ ] `docs/SCHEMA_MAP.md` updated
+- [ ] `npm run migrate up` succeeds on fresh + existing DB
+- [ ] No scope expansion (do not edit files outside `files:` above)
+
+## Rationale (≤3 sentences)
+
+All pricing and geo features depend on these columns/tables. Landing schema first unblocks parallel implementers for bulk ingest, APIs, and settings without merge conflicts on migration files.
diff --git a/.convoys/pricing-online-local/brief-10-geo-clients-cache.md b/.convoys/pricing-online-local/brief-10-geo-clients-cache.md
new file mode 100644
index 0000000..db9b3c1
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-10-geo-clients-cache.md
@@ -0,0 +1,47 @@
+---
+convoy: pricing-online-local
+brief_number: 10
+depends_on: [1]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/pricing/nominatim.js
+ - lib/pricing/overpass.js
+ - lib/pricing/geo-cache.js
+ - test/lib/pricing/nominatim.test.js
+ - test/lib/pricing/overpass.test.js
+ - test/lib/pricing/geo-cache.test.js
+---
+
+# Brief 10: Geo clients + DB cache
+
+## Goal (1 sentence)
+
+Implement Nominatim geocoding, Overpass store queries, and DB-backed caches per convoy gentleness rules.
+
+## Files in scope (do not edit anything else)
+- `lib/pricing/nominatim.js`
+- `lib/pricing/overpass.js`
+- `lib/pricing/geo-cache.js`
+- `test/lib/pricing/nominatim.test.js`
+- `test/lib/pricing/overpass.test.js`
+- `test/lib/pricing/geo-cache.test.js`
+
+## Conventions to follow
+- **Security:** Overpass QL uses fixed template only; inject parsed floats for lat/lng/radiusMeters — never user strings.
+- `nominatim.js`: User-Agent from `NOMINATIM_USER_AGENT`; min 1s between requests.
+- `overpass.js`: query `shop=games` and `shop=hobby`; radius capped 1–50 miles server-side.
+- `geo-cache.js`: zip coords cached forever; stores cache 7-day TTL.
+- Allowlist outbound hosts at module init.
+
+## Acceptance criteria
+- [ ] Zip cache hit skips Nominatim
+- [ ] Stores cache respects 7-day TTL
+- [ ] Overpass parser returns distance-ready lat/lng per store
+- [ ] Unit tests cover parser + TTL logic
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Isolates external geo APIs from the route handler for testability. DB cache avoids Redis dependency for long-lived zip coords. Satisfies convoy rate-limit requirements before Brief 11 wires the API.
diff --git a/.convoys/pricing-online-local/brief-11-stores-nearby-api.md b/.convoys/pricing-online-local/brief-11-stores-nearby-api.md
new file mode 100644
index 0000000..5fbb278
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-11-stores-nearby-api.md
@@ -0,0 +1,40 @@
+---
+convoy: pricing-online-local
+brief_number: 11
+depends_on: [10]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - pages/api/stores/nearby.js
+ - lib/rate-limit.js
+ - test/api/stores-nearby.test.js
+---
+
+# Brief 11: Stores nearby API
+
+## Goal (1 sentence)
+
+Ship `GET /api/stores/nearby` with geocode + Overpass lookup, caching, and per-user refresh rate limit.
+
+## Files in scope (do not edit anything else)
+- `pages/api/stores/nearby.js`
+- `lib/rate-limit.js`
+- `test/api/stores-nearby.test.js`
+
+## Conventions to follow
+- **Auth required** (`getUserFromRequest` → 401). Zip from user's saved `home_zip` only — ignore arbitrary `zip` query param (security planning decision A).
+- Add `stores` limiter: 1/hour/user; IP backstop for burst abuse.
+- Respect `PRICING_LOCAL_ENABLED` — return 503 when `false`.
+- Overpass fixed-template query only (see Brief 10).
+
+## Acceptance criteria
+- [ ] Endpoint returns store list for authenticated user with saved zip
+- [ ] 401 without Bearer token
+- [ ] Second call within 7 days serves from `geo_stores_cache`
+- [ ] Rate limit returns 429 with `Retry-After`
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Phase 2 API surface from convoy spec. Auth-gated zip prevents geocode relay abuse. Rate limiting protects Overpass.
diff --git a/.convoys/pricing-online-local/brief-12-settings-home-zip.md b/.convoys/pricing-online-local/brief-12-settings-home-zip.md
new file mode 100644
index 0000000..1d8903a
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-12-settings-home-zip.md
@@ -0,0 +1,39 @@
+---
+convoy: pricing-online-local
+brief_number: 12
+depends_on: [1]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - pages/settings.js
+ - pages/api/user/settings.js
+ - test/api/user-settings-home-zip.test.js
+---
+
+# Brief 12: Settings home zip
+
+## Goal (1 sentence)
+
+Let users save a home zip code in Settings for local store discovery.
+
+## Files in scope (do not edit anything else)
+- `pages/settings.js`
+- `pages/api/user/settings.js`
+- `test/api/user-settings-home-zip.test.js`
+
+## Conventions to follow
+- Auth: `getUserFromRequest` → 401 if null.
+- Validate zip: 5-digit US; store 5-digit string; allow `null` to clear.
+- Settings UI: field under **Preferences** — “Home ZIP code (for nearby game stores)”.
+- Owner-scoped read/write only (`user_id = session.userId`).
+
+## Acceptance criteria
+- [ ] GET settings includes `home_zip`
+- [ ] PUT persists valid zip; 400 on invalid
+- [ ] Settings page loads and saves zip
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Phase 2 requires a default location without GPS. Reuses existing settings flow. Independent of geo API briefs once migration lands.
diff --git a/.convoys/pricing-online-local/brief-13-local-stores-ui.md b/.convoys/pricing-online-local/brief-13-local-stores-ui.md
new file mode 100644
index 0000000..c368e57
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-13-local-stores-ui.md
@@ -0,0 +1,47 @@
+---
+convoy: pricing-online-local
+brief_number: 13
+depends_on: [9, 11, 12]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - components/card-detail/CardDetailLocalStores.js
+ - components/CardDetailView.js
+ - lib/use-card-detail.js
+ - test/components/CardDetailLocalStores.test.js
+cross_brief_commitments:
+ - brief: 9
+ description: |
+ Inserts local stores section into the purchase tab area using the
+ slot Brief 9 reserved. Does not rework purchase pricing UI.
+---
+
+# Brief 13: Local stores card detail UI
+
+## Goal (1 sentence)
+
+Show “Game stores near you” on card detail using saved home zip and the nearby stores API.
+
+## Files in scope (do not edit anything else)
+- `components/card-detail/CardDetailLocalStores.js`
+- `components/CardDetailView.js`
+- `lib/use-card-detail.js`
+- `test/components/CardDetailLocalStores.test.js`
+
+## Conventions to follow
+- Fetch `/api/stores/nearby` when purchase tab active (auth required).
+- List: name, address, distance, phone/website links, OSM link — no in-store prices.
+- Empty states: no zip → link to Settings; no stores → gentle copy.
+- Liquid Glass styling consistent with Brief 9 components.
+
+## Acceptance criteria
+- [ ] Stores render when user has home zip configured
+- [ ] Prompt to set zip when missing
+- [ ] No scraped in-store pricing shown
+- [ ] Component test covers empty + populated states
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Completes Phase 2 user-facing goal on card detail. Depends on pricing UI shell from Brief 9 and backend from Briefs 11–12.
diff --git a/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md b/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md
new file mode 100644
index 0000000..17cad78
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md
@@ -0,0 +1,56 @@
+---
+convoy: pricing-online-local
+brief_number: 2
+depends_on: [1]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/pricing/card-price.js
+ - lib/pricing/price-history.js
+ - lib/pricing/collection-valuation.js
+ - lib/pricing/outbound-links.js
+ - test/lib/pricing/card-price.test.js
+ - test/lib/pricing/price-history.test.js
+ - test/lib/pricing/collection-valuation.test.js
+cross_brief_commitments:
+ - brief: 3
+ description: |
+ Briefs 3–5 import `recordPriceSnapshots` from `lib/pricing/price-history.js`
+ and set `prices_updated_at` after successful price writes. They must call
+ the shared helpers rather than duplicating snapshot SQL.
+---
+
+# Brief 2: Pricing foundation lib
+
+## Goal (1 sentence)
+
+Create shared pricing helpers for foil-aware unit prices, daily history snapshots, collection valuation SQL, and outbound buy links.
+
+## Files in scope (do not edit anything else)
+- `lib/pricing/card-price.js`
+- `lib/pricing/price-history.js`
+- `lib/pricing/collection-valuation.js`
+- `lib/pricing/outbound-links.js`
+- `test/lib/pricing/card-price.test.js`
+- `test/lib/pricing/price-history.test.js`
+- `test/lib/pricing/collection-valuation.test.js`
+
+## Conventions to follow
+- ESM (`import`/`export`); DB via `import { sql } from '../sql.js'` (relative from `lib/pricing/`).
+- `card-price.js`: export `resolveUnitPrice({ currentPrice, marketPrice, isFoil })` — nonfoil prefers `current_price`, foil prefers `market_price`; export `PRICE_STALE_HOURS = 24`.
+- `collection-valuation.js`: export `effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' })` returning SQL `CASE WHEN uc.is_foil THEN c.market_price ELSE COALESCE(c.current_price, c.market_price) END`; export `listUnitPriceExpr({ cardAlias = 'c' })` → `COALESCE(c.current_price, c.market_price)`.
+- `price-history.js`: export `recordPriceSnapshots(rows)` where each row is `{ cardId, source, priceType, currency, amount }`; upsert on daily UNIQUE key.
+- `outbound-links.js`: extract TCGPlayer + eBay URL logic currently inline in `CardDetailView.js` (game-aware).
+- Mirror test style from `test/lib/collection-vocabulary.test.js`.
+
+## Acceptance criteria
+- [ ] `resolveUnitPrice` returns foil vs nonfoil correctly for MTG-shaped data
+- [ ] `recordPriceSnapshots` is idempotent for same card/day/source/type
+- [ ] `effectiveUnitPriceExpr` produces valid SQL fragment (unit-tested via known inputs or integration mock)
+- [ ] `buildTcgplayerUrl(card)` and `buildEbayUrl(card)` exported
+- [ ] All three test files pass (`npm run test:run`)
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Centralizes pricing semantics so bulk sync, valuation fix, and APIs cannot drift. The foil bug (`market_price` = Scryfall `usd_foil`) is fixed once at the helper layer. Snapshot writer gives Briefs 3–5 a single hook.
diff --git a/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md b/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md
new file mode 100644
index 0000000..d14c072
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md
@@ -0,0 +1,44 @@
+---
+convoy: pricing-online-local
+brief_number: 3
+depends_on: [2]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/card-import/bulk-sync.js
+ - test/lib/card-import/bulk-sync.test.js
+cross_brief_commitments:
+ - brief: 2
+ description: |
+ Uses `recordPriceSnapshots` and sets `prices_updated_at = CURRENT_TIMESTAMP`
+ on each upsert batch where price columns change.
+---
+
+# Brief 3: MTG bulk sync + snapshots
+
+## Goal (1 sentence)
+
+Update Scryfall bulk sync to stamp `prices_updated_at` and record daily price history snapshots.
+
+## Files in scope (do not edit anything else)
+- `lib/card-import/bulk-sync.js`
+- `test/lib/card-import/bulk-sync.test.js`
+
+## Conventions to follow
+- Keep existing `mapCard()` mapping: `usd` → `current_price`, `usd_foil` → `market_price`.
+- Add `prices_updated_at` to INSERT/UPDATE columns (set when either price is non-null).
+- After each successful `upsertBatch`, call `recordPriceSnapshots` for rows with prices (`source: 'scryfall'`, `priceType: 'market'` for nonfoil amount, optionally second snapshot for foil if present).
+- Export `mapCard` for unit tests (or test via named export already present).
+- Preserve Scryfall User-Agent header.
+
+## Acceptance criteria
+- [ ] `upsertBatch` writes `prices_updated_at`
+- [ ] Price snapshots recorded per card per sync day
+- [ ] `mapCard` unit tests cover price parsing
+- [ ] Existing `runBulkMtgSync` return shape unchanged (add optional `snapshotsWritten` count)
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+MTG already has price data from Scryfall; this brief wires timestamps and history without changing upstream source. Fixes the data pipeline for Phase 1 pricing API reads.
diff --git a/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md b/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md
new file mode 100644
index 0000000..ec0bad8
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md
@@ -0,0 +1,44 @@
+---
+convoy: pricing-online-local
+brief_number: 4
+depends_on: [2]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/pricing/tcgdex-client.js
+ - lib/card-import/pokemon-bulk.js
+ - test/lib/pricing/tcgdex-client.test.js
+ - test/lib/card-import/pokemon-bulk.test.js
+---
+
+# Brief 4: TCGdex Pokémon pricing ingest
+
+## Goal (1 sentence)
+
+Ingest USD TCGPlayer prices from TCGdex during Pokémon bulk sync instead of writing null prices.
+
+## Files in scope (do not edit anything else)
+- `lib/pricing/tcgdex-client.js`
+- `lib/card-import/pokemon-bulk.js`
+- `test/lib/pricing/tcgdex-client.test.js`
+- `test/lib/card-import/pokemon-bulk.test.js`
+
+## Conventions to follow
+- Base URL from `process.env.TCGDEX_BASE_URL ?? 'https://api.tcgdex.net/v2/en'`.
+- Client: `fetchSetCards(setId)` with 100ms delay between set requests; parse `pricing.tcgplayer` (USD market/low) and `pricing.cardmarket` (EUR — store in `market_price` only when USD missing, document in code comment).
+- Match TCGdex cards to DB rows by `set_code` + `card_number` (normalize leading zeros); fallback name match within set.
+- `mapPokemonBulkRow` / `buildUpsertQuery`: populate `current_price` (low or market NM), `market_price` (market), `prices_updated_at`.
+- Call `recordPriceSnapshots` with `source: 'tcgdex'`.
+- Log `unmatchedCount` in sync summary JSON.
+
+## Acceptance criteria
+- [ ] Pokémon bulk no longer writes `null` prices when TCGdex has data
+- [ ] Gentleness: 100ms sleep between set fetches
+- [ ] Unit tests with mocked fetch fixtures
+- [ ] `runBulkPokemonSync` summary includes `pricesMatched` / `unmatchedCount`
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Pokémon catalog sync currently nulls prices. TCGdex is the convoy's free USD source. Set-batched fetch keeps within polite limits.
diff --git a/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md b/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md
new file mode 100644
index 0000000..8683d61
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md
@@ -0,0 +1,43 @@
+---
+convoy: pricing-online-local
+brief_number: 5
+depends_on: [2]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/pricing/foilio-client.js
+ - lib/card-import/lorcana-bulk.js
+ - test/lib/pricing/foilio-client.test.js
+ - test/lib/card-import/lorcana-bulk.test.js
+---
+
+# Brief 5: Foilio Lorcana pricing ingest
+
+## Goal (1 sentence)
+
+Enrich Lorcana bulk sync with hourly Foilio feed prices keyed by card id and name+set.
+
+## Files in scope (do not edit anything else)
+- `lib/pricing/foilio-client.js`
+- `lib/card-import/lorcana-bulk.js`
+- `test/lib/pricing/foilio-client.test.js`
+- `test/lib/card-import/lorcana-bulk.test.js`
+
+## Conventions to follow
+- Feed URL: `process.env.FOILIO_PRICES_FEED_URL ?? 'https://foilio.net/api/prices/feed?game=lorcana'`.
+- Client fetches once per sync run; build `Map` by `card_id` and composite `normalize(name)|set`.
+- Extend `buildUpsertQuery` / `mapLorcanaCard` to include `current_price`, `market_price`, `prices_updated_at`.
+- Prefer USD fields when present; otherwise EUR with `currency` noted in snapshot.
+- `recordPriceSnapshots` with `source: 'foilio'`.
+- Preserve existing `scryfall_id = Unique_ID` keying.
+
+## Acceptance criteria
+- [ ] Lorcana cards receive prices when feed contains a match
+- [ ] Single feed fetch per `runBulkLorcanaSync` invocation
+- [ ] Summary includes `pricesMatched` / `unmatchedCount`
+- [ ] Tests pass with fixture JSON
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Lorcana bulk currently omits price columns entirely. Foilio provides a free hourly JSON feed. One fetch per sync aligns with their 1h cache.
diff --git a/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md b/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md
new file mode 100644
index 0000000..ab10777
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md
@@ -0,0 +1,40 @@
+---
+convoy: pricing-online-local
+brief_number: 6
+depends_on: [2]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - pages/api/user/stats.js
+ - pages/api/collections/[identifier].js
+ - test/api/user-stats-valuation.test.js
+---
+
+# Brief 6: Foil-aware valuation APIs
+
+## Goal (1 sentence)
+
+Fix collection total value to use nonfoil `current_price` for `user_cards.is_foil = false` rows.
+
+## Files in scope (do not edit anything else)
+- `pages/api/user/stats.js`
+- `pages/api/collections/[identifier].js`
+- `test/api/user-stats-valuation.test.js`
+
+## Conventions to follow
+- Import `effectiveUnitPriceExpr` and `listUnitPriceExpr` from `lib/pricing/collection-valuation.js`.
+- `stats.js`: replace `cards.market_price * quantity` with effective unit price expression in value, game breakdown, rarity breakdown, and recent activity.
+- `collections/[identifier].js`: replace `SUM(cards.market_price * cc.quantity)` with `listUnitPriceExpr`.
+- Auth pattern unchanged (`getUserFromRequest`).
+
+## Acceptance criteria
+- [ ] Nonfoil `user_cards` row valued at `current_price`, not foil `market_price`
+- [ ] Foil row still uses `market_price`
+- [ ] Collection list totals use `COALESCE(current_price, market_price)`
+- [ ] Regression test locks MTG foil/nonfoil scenario
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+This is the user-visible valuation bug from Phase 0. `market_price` stores Scryfall `usd_foil` for MTG. Fixing at the API layer immediately improves dashboard and profile totals.
diff --git a/.convoys/pricing-online-local/brief-7-sort-by-price.md b/.convoys/pricing-online-local/brief-7-sort-by-price.md
new file mode 100644
index 0000000..e546c66
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-7-sort-by-price.md
@@ -0,0 +1,46 @@
+---
+convoy: pricing-online-local
+brief_number: 7
+depends_on: [2, 6]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - pages/api/cards/owned.js
+ - pages/api/collections/[identifier]/cards.js
+ - lib/use-collection-view.js
+ - components/CollectionPageView.js
+ - pages/my-cards.js
+ - test/api/cards-owned-sort.test.js
+---
+
+# Brief 7: Sort by price wiring
+
+## Goal (1 sentence)
+
+Wire “Sort by Price” through owned-cards and list APIs into collection and My Collection UIs.
+
+## Files in scope (do not edit anything else)
+- `pages/api/cards/owned.js`
+- `pages/api/collections/[identifier]/cards.js`
+- `lib/use-collection-view.js`
+- `components/CollectionPageView.js`
+- `pages/my-cards.js`
+- `test/api/cards-owned-sort.test.js`
+
+## Conventions to follow
+- Query params: `sort=name|price|rarity|date`, `order=asc|desc` (default `name`/`asc`).
+- Price sort uses `listUnitPriceExpr` or `COALESCE(c.current_price, c.market_price)`; NULLs last.
+- `CollectionPageView` select options must use stable values (`name`, `price`, etc.) not display strings.
+- Server-side sort required for paginated My Collection.
+
+## Acceptance criteria
+- [ ] `/api/cards/owned?sort=price&order=desc` orders by effective price
+- [ ] Collection cards API supports same params
+- [ ] UI sort dropdown changes card order
+- [ ] API test asserts ORDER BY clause or result ordering
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Sort UI exists but is inert. Server-side sort avoids loading entire collections client-side. Depends on valuation helpers for consistent price column.
diff --git a/.convoys/pricing-online-local/brief-8-card-pricing-apis.md b/.convoys/pricing-online-local/brief-8-card-pricing-apis.md
new file mode 100644
index 0000000..952a82c
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-8-card-pricing-apis.md
@@ -0,0 +1,52 @@
+---
+convoy: pricing-online-local
+brief_number: 8
+depends_on: [2]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - lib/pricing/pricing-service.js
+ - pages/api/cards/[id]/pricing.js
+ - pages/api/cards/[id]/price-history.js
+ - pages/api/cards/[id].js
+ - test/api/cards-pricing.test.js
+ - test/api/cards-price-history.test.js
+cross_brief_commitments:
+ - brief: 9
+ description: |
+ Response shapes from `/api/cards/[id]/pricing` and
+ `/api/cards/[id]/price-history` are the contract Brief 9's UI consumes.
+ Do not rename fields without updating Brief 9.
+---
+
+# Brief 8: Card pricing + history APIs
+
+## Goal (1 sentence)
+
+Expose `GET /api/cards/[id]/pricing` and `GET /api/cards/[id]/price-history` reading DB with optional stale refresh.
+
+## Files in scope (do not edit anything else)
+- `lib/pricing/pricing-service.js`
+- `pages/api/cards/[id]/pricing.js`
+- `pages/api/cards/[id]/price-history.js`
+- `pages/api/cards/[id].js`
+- `test/api/cards-pricing.test.js`
+- `test/api/cards-price-history.test.js`
+
+## Conventions to follow
+- API route pattern from `.cursor/rules/api-routes.mdc`: method gate, try/catch, manual validation.
+- `pricing-service.js`: DB-first; live refresh only when stale >24h with Redis single-flight lock.
+- Include `outbound` links via `outbound-links.js`.
+- `price-history.js` API: query `card_price_history` last N days (default 90, max 365).
+
+## Acceptance criteria
+- [ ] `GET /api/cards/123/pricing` returns prices + `pricesUpdatedAt` + `stale` boolean
+- [ ] `GET /api/cards/123/price-history?days=30` returns `{ points: [...] }`
+- [ ] 404 when card missing; 400 on invalid id/days
+- [ ] Live refresh does not throw on upstream failure
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Phase 1 backend contract for Purchase and Price History tabs. DB-first path respects free API limits; 24h staleness matches convoy spec.
diff --git a/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md b/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md
new file mode 100644
index 0000000..004cb6a
--- /dev/null
+++ b/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md
@@ -0,0 +1,53 @@
+---
+convoy: pricing-online-local
+brief_number: 9
+depends_on: [8]
+recommended_model: composer-2.5
+model_tier: standard
+files:
+ - components/card-detail/CardDetailPurchaseTab.js
+ - components/card-detail/CardDetailPriceHistoryTab.js
+ - components/CardDetailView.js
+ - lib/use-card-detail.js
+ - test/components/CardDetailPurchaseTab.test.js
+cross_brief_commitments:
+ - brief: 8
+ description: |
+ Consumes `/api/cards/[id]/pricing` and `/api/cards/[id]/price-history`
+ response shapes defined in Brief 8.
+ - brief: 13
+ description: |
+ Brief 13 will add `CardDetailLocalStores` below the purchase panel.
+ Leave a slot in purchase tab wrapper for Brief 13 insertion.
+---
+
+# Brief 9: Card detail pricing UI
+
+## Goal (1 sentence)
+
+Replace placeholder Purchase and Price History tabs with live pricing data, staleness badge, and a simple history chart.
+
+## Files in scope (do not edit anything else)
+- `components/card-detail/CardDetailPurchaseTab.js`
+- `components/card-detail/CardDetailPriceHistoryTab.js`
+- `components/CardDetailView.js`
+- `lib/use-card-detail.js`
+- `test/components/CardDetailPurchaseTab.test.js`
+
+## Conventions to follow
+- Liquid Glass surfaces: `glass-panel`, CSS variables — no hardcoded hex.
+- Purchase tab: staleness badge, outbound links from API `outbound`.
+- Price History tab: line chart from `points`; empty state when none.
+- Reserve insertion point for Brief 13 local stores section.
+
+## Acceptance criteria
+- [ ] Purchase tab shows DB prices and staleness badge
+- [ ] Price History tab renders data when points exist; empty state when none
+- [ ] No placeholder “available soon” copy remains
+- [ ] Component test verifies staleness badge text
+- [ ] Tests pass
+- [ ] No scope expansion
+
+## Rationale (≤3 sentences)
+
+Delivers Phase 1 user-facing value on existing tab chrome. Keeps `CardDetailView` thin by extracting tab bodies. Documents insertion point for Phase 2 local stores.
diff --git a/components/CardDetailView.js b/components/CardDetailView.js
index bb5fe66..27a9ecd 100644
--- a/components/CardDetailView.js
+++ b/components/CardDetailView.js
@@ -5,6 +5,9 @@ import CardDetailDeckModal from './CardDetailDeckModal';
import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols';
import ManaSymbolSettings from './ManaSymbolSettings';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
+import CardDetailPurchaseTab from './card-detail/CardDetailPurchaseTab';
+import CardDetailPriceHistoryTab from './card-detail/CardDetailPriceHistoryTab';
+
export default function CardDetailView(props) {
const {
activeTab,
@@ -30,8 +33,16 @@ export default function CardDetailView(props) {
isFavorited,
loading,
manaSymbolSettings,
+ nearbyStores,
+ nearbyStoresError,
+ nearbyStoresLoading,
+ nearbyStoresNoHomeZip,
ownedQuantity,
particleStyles,
+ priceHistory,
+ priceHistoryLoading,
+ pricing,
+ pricingLoading,
quantity,
router,
selectedCollection,
@@ -344,85 +355,24 @@ export default function CardDetailView(props) {
)}
{activeTab === 'price-history' && (
-
-
- Price History
-
-
- {/* Price Graph Placeholder */}
-
-
- Price Trend
-
-
-
- Price history data will be available soon
-
-
-
-
- {/* Price Statistics Cards */}
-
- {/* Current Price */}
-
-
-
💰
-
- Current Price
-
-
- {formatCurrency(card.current_price || 0)}
-
-
-
-
-
+
)}
{activeTab === 'purchase' && (
-
+
)}
diff --git a/components/CollectionPageView.js b/components/CollectionPageView.js
index 1e6f49e..c433a98 100644
--- a/components/CollectionPageView.js
+++ b/components/CollectionPageView.js
@@ -63,6 +63,7 @@ export default function CollectionPageView(props) {
setShowShareModal,
setShowUploadModal,
setSortBy,
+ setSortOrder,
setViewMode,
showDeleteModal,
showEditModal,
@@ -71,6 +72,7 @@ export default function CollectionPageView(props) {
showShareModal,
showUploadModal,
sortBy,
+ sortOrder,
toggleFavorite,
togglePublic,
user,
@@ -344,11 +346,22 @@ export default function CollectionPageView(props) {
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="input-field"
+ aria-label="Sort cards"
>
-
-
-
-
+
+
+
+
+
+
+
diff --git a/components/card-detail/CardDetailLocalStores.js b/components/card-detail/CardDetailLocalStores.js
new file mode 100644
index 0000000..1a3c07a
--- /dev/null
+++ b/components/card-detail/CardDetailLocalStores.js
@@ -0,0 +1,119 @@
+import Link from 'next/link';
+
+/**
+ * Nearby game stores subsection for the Purchase tab.
+ */
+export default function CardDetailLocalStores({
+ stores = [],
+ loading = false,
+ error = null,
+ noHomeZip = false,
+}) {
+ if (loading) {
+ return (
+
+
Loading nearby stores…
+
+ );
+ }
+
+ if (noHomeZip) {
+ return (
+
+
+ Game stores near you
+
+
+ Add your home ZIP in Settings to see nearby game and hobby stores.
+
+
+ Set home ZIP in Settings
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Game stores near you
+
+
+ {error}
+
+
+ );
+ }
+
+ return (
+
+
+ Game stores near you
+
+
+ {stores.length === 0 ? (
+
+ No game or hobby stores found in your area yet. Try again later — store data comes from OpenStreetMap.
+
+ ) : (
+
+ {stores.map((store) => (
+ -
+
+
+
+ {store.name}
+
+ {store.address ? (
+
+ {store.address}
+
+ ) : null}
+
+ {store.distanceMiles} mi away
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/card-detail/CardDetailPriceHistoryTab.js b/components/card-detail/CardDetailPriceHistoryTab.js
new file mode 100644
index 0000000..368efdc
--- /dev/null
+++ b/components/card-detail/CardDetailPriceHistoryTab.js
@@ -0,0 +1,110 @@
+function buildChartGeometry(points, width, height, padding) {
+ if (!points.length) return null;
+
+ const amounts = points.map((point) => point.amount);
+ const min = Math.min(...amounts);
+ const max = Math.max(...amounts);
+ const range = max - min || 1;
+ const innerWidth = width - padding * 2;
+ const innerHeight = height - padding * 2;
+
+ const coordinates = points.map((point, index) => {
+ const x = padding + (index / Math.max(points.length - 1, 1)) * innerWidth;
+ const y = padding + innerHeight - ((point.amount - min) / range) * innerHeight;
+ return { x, y };
+ });
+
+ const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(' ');
+ const area = [
+ `${coordinates[0].x},${height - padding}`,
+ ...coordinates.map(({ x, y }) => `${x},${y}`),
+ `${coordinates[coordinates.length - 1].x},${height - padding}`,
+ ].join(' ');
+
+ return { polyline, area, min, max };
+}
+
+export default function CardDetailPriceHistoryTab({
+ points = [],
+ priceHistoryLoading,
+ formatCurrency,
+}) {
+ const chart = buildChartGeometry(points, 640, 220, 24);
+
+ return (
+
+
+ Price History
+
+
+
+
+ Price Trend
+
+
+ {priceHistoryLoading ? (
+
+
Loading price history…
+
+ ) : points.length === 0 ? (
+
+
+ No price history yet. Snapshots are recorded during catalog sync — check back after the next update.
+
+
+ ) : (
+
+
+
+ {points[0]?.date}
+
+ Low {formatCurrency(chart.min)} · High {formatCurrency(chart.max)}
+
+ {points[points.length - 1]?.date}
+
+
+ )}
+
+
+ {points.length > 0 && (
+
+
+
+
💰
+
+ Latest Price
+
+
+ {formatCurrency(points[points.length - 1].amount)}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/components/card-detail/CardDetailPurchaseTab.js b/components/card-detail/CardDetailPurchaseTab.js
new file mode 100644
index 0000000..056a972
--- /dev/null
+++ b/components/card-detail/CardDetailPurchaseTab.js
@@ -0,0 +1,132 @@
+function formatUpdatedLabel(pricesUpdatedAt) {
+ if (!pricesUpdatedAt) return 'Price date unknown';
+ const date = new Date(pricesUpdatedAt);
+ if (Number.isNaN(date.getTime())) return 'Price date unknown';
+ return `Updated ${date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}`;
+}
+
+export function stalenessBadgeText({ stale, pricesUpdatedAt }) {
+ if (stale) return 'Prices may be outdated';
+ return formatUpdatedLabel(pricesUpdatedAt);
+}
+
+import CardDetailLocalStores from './CardDetailLocalStores';
+
+export default function CardDetailPurchaseTab({
+ card,
+ pricing,
+ pricingLoading,
+ formatCurrency,
+ nearbyStores = [],
+ nearbyStoresLoading = false,
+ nearbyStoresError = null,
+ nearbyStoresNoHomeZip = false,
+}) {
+ const displayPrice = pricing?.prices?.current ?? pricing?.prices?.market ?? card?.current_price ?? null;
+ const foilPrice = pricing?.prices?.market ?? card?.market_price ?? null;
+ const badgeText = pricing ? stalenessBadgeText(pricing) : null;
+
+ return (
+
+
+
+ Where to Buy
+
+ {badgeText && (
+
+ {badgeText}
+
+ )}
+
+
+
+
+ Online Price Estimate
+
+ {pricingLoading ? (
+
Loading prices…
+ ) : (
+
+
+
NM / Non-foil
+
+ {formatCurrency(displayPrice || 0)}
+
+
+ {foilPrice != null && foilPrice !== displayPrice && (
+
+
Foil / Market
+
+ {formatCurrency(foilPrice)}
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/HOMELAB_DATABASE.md b/docs/HOMELAB_DATABASE.md
index b5813a5..bb5ec2e 100644
--- a/docs/HOMELAB_DATABASE.md
+++ b/docs/HOMELAB_DATABASE.md
@@ -116,6 +116,22 @@ Copies **data only** (schema from Phase 2 migrations). Harmless restore errors:
After Dokploy prod is healthy for 24–48 h, pause/delete the Neon project and disable the Vercel deployment.
+### Migration complete checklist (2026-09)
+
+Use this when Neon sends storage alerts but production already runs on homelab:
+
+| Step | Status (verify) | Action |
+| --- | --- | --- |
+| 1. Prod DB | Dokploy `POSTGRES_URL` → `192.168.68.102:5432/deckhearth` | CT 112 deckhearth container env |
+| 2. Data on homelab | `SELECT count(*) FROM cards` ≈ 69k | CT 102 `psql` |
+| 3. Pricing schema | `1783000000001_add-pricing-schema` in `pgmigrations` | Pricing convoy / PR #158 |
+| 4. Migrate order fix | `npm run fix-pricing-pgmigration-order` then `npm run migrate up` | Mac on LAN with homelab `.env.local` |
+| 5. Local dev | `.env.local` `POSTGRES_URL` + `POSTGRES_URL_DIRECT` → homelab (copy password from Dokploy) | Do not commit |
+| 6. Dokploy cleanup | Remove env vars whose values contain `neon.tech` (`DATABASE_URL`, `NEON_*`, legacy `PGHOST*`) | Redeploy deckhearth |
+| 7. Neon | Pause or delete project in Neon console | Frees storage; keep `NEON_DATABASE_URL` in `.env.local` until step 6 verified |
+
+Optional: Neon had **6 users** vs homelab **5** at last check — run `npm run migrate-neon-to-homelab` only if you need that missing account (uses `postgres:17` Docker if local `pg_dump` is old).
+
---
## Code changes (this convoy)
@@ -132,4 +148,6 @@ After Dokploy prod is healthy for 24–48 h, pause/delete the Neon project and d
| `connection refused` to 192.168.68.102 | Mac not on LAN / VPN; CT 102 down |
| `SSL required` against homelab | Fixed in `lib/sql.js` for private IPs |
| `type "vector" does not exist` | Run `CREATE EXTENSION vector` in `deckhearth` DB |
-| Vercel preview still on Neon | Retired — CI smoke targets homelab URL |
+| `npm run migrate up` fails "preceding already run migration" on homelab | Pricing branch backfill: `npm run fix-pricing-pgmigration-order` then `npm run migrate up`. Or one-off: `npx node-pg-migrate … --check-order false up` |
+| Neon still in Dokploy env but app uses homelab | Remove `DATABASE_URL`, `NEON_*`, `PGHOST*` pointing at `*.neon.tech`; keep only `POSTGRES_URL` + `POSTGRES_URL_DIRECT` → `192.168.68.102` |
+| Mac `.env.local` still Neon | Copy homelab `POSTGRES_URL` from Dokploy CT 112; set `POSTGRES_URL_DIRECT` to match; keep `NEON_DATABASE_URL` until Neon deleted |
diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md
index dad7a72..ab7aa8e 100644
--- a/docs/SCHEMA_MAP.md
+++ b/docs/SCHEMA_MAP.md
@@ -15,8 +15,8 @@
> historical effects into the migration history; until then this file
> remains the curated reference for the full prod shape.
>
-> **Last reviewed:** 2026-06-14 against `migrations/` HEAD (through
-> `1781442340000_cleanup-lorcana-orphans.js`).
+> **Last reviewed:** 2026-09-08 against `migrations/` HEAD (through
+> `1783000000001_add-pricing-schema.js`).
## Quick model groups
@@ -29,6 +29,7 @@
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) |
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
+| **Pricing** | `card_price_history`, `geo_zip_cache`, `geo_stores_cache` | Daily price snapshots + local store geocode caches |
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
## Tables
@@ -57,6 +58,7 @@
| `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` |
| `language` | `VARCHAR(5)` default `'en'` | |
| `is_pending` | `BOOLEAN` default `false` | Set by invitation flow before signup completes |
+| `home_zip` | `VARCHAR(10)` | US zip for nearby game store discovery (`1783000000001`) |
### cards
@@ -74,7 +76,8 @@
| `oracle_text` | `TEXT` | LARGE — never `SELECT *` |
| `power`, `toughness` | `VARCHAR(10)` | MTG creatures |
| `image_url`, `stock_image_url` | `TEXT` | |
-| `current_price`, `market_price` | `DECIMAL(10,2)` | |
+| `current_price`, `market_price` | `DECIMAL(10,2)` | MTG: `usd` / `usd_foil` from Scryfall |
+| `prices_updated_at` | `TIMESTAMPTZ` | Last price ingest timestamp (`1783000000001`) |
| `scryfall_id` | `VARCHAR(255) UNIQUE` | External dedupe key (Scryfall UUID, Pokémon TCG id, Lorcana `Unique_ID`) |
| `verified` | `BOOLEAN` default `false` | Admin-edited cards |
| `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** (`1781442330001_reconcile-cards-columns`) |
@@ -284,6 +287,49 @@ Per-scan telemetry for the identify pipeline (layer 0 = visual kNN, layer 1 = Te
Index: `idx_scan_attempts_user_created (user_id, created_at DESC)`.
+### card_price_history
+
+Daily price snapshots written during catalog sync (Scryfall, TCGdex, Foilio).
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | `SERIAL PK` | |
+| `card_id` | `INTEGER FK cards(id) ON DELETE CASCADE` | |
+| `source` | `VARCHAR(32) NOT NULL` | `'scryfall' \| 'tcgdex' \| 'foilio'` |
+| `price_type` | `VARCHAR(32)` default `'market'` | `'market' \| 'low' \| 'mid' \| 'high'` |
+| `currency` | `VARCHAR(3)` default `'USD'` | |
+| `amount` | `DECIMAL(10,2) NOT NULL` | |
+| `recorded_at` | `DATE` default today | One row per card/source/type/currency/day |
+| `created_at` | `TIMESTAMPTZ` default now | |
+
+**Unique:** `(card_id, source, price_type, currency, recorded_at)`
+**Index:** `idx_card_price_history_card_date (card_id, recorded_at DESC)`
+
+### geo_zip_cache
+
+Permanent Nominatim geocode cache (zip → coordinates).
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `zip` | `VARCHAR(10) PK` | Normalized 5-digit US zip |
+| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | |
+| `display_name` | `TEXT` | OSM display name |
+| `geocoded_at` | `TIMESTAMPTZ` default now | |
+
+### geo_stores_cache
+
+Overpass query results cache (7-day TTL enforced in application code).
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `cache_key` | `VARCHAR(64) PK` | sha256 of `{lat}:{lng}:{radiusMeters}` |
+| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | Query center |
+| `radius_meters` | `INTEGER NOT NULL` | |
+| `stores` | `JSONB NOT NULL` | Parsed store list |
+| `fetched_at` | `TIMESTAMPTZ` default now | |
+
+**Index:** `idx_geo_stores_cache_fetched (fetched_at)`
+
### user_settings (split from users.*; verify which is canonical)
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
diff --git a/lib/card-import/bulk-sync.js b/lib/card-import/bulk-sync.js
index 61f3dd8..45aac14 100644
--- a/lib/card-import/bulk-sync.js
+++ b/lib/card-import/bulk-sync.js
@@ -8,11 +8,18 @@
* Also refreshes prices, legalities, and edhrec_rank on existing cards.
*/
+import { gunzipSync } from 'node:zlib';
+
import { sql } from '../sql.js';
+import { recordPriceSnapshots } from '../pricing/price-history.js';
const BATCH_SIZE = 100;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+export function resolveBulkDownloadUri(meta) {
+ return meta?.download_uri ?? meta?.jsonl_download_uri ?? null;
+}
+
async function fetchBulkDownloadUrl() {
const response = await fetch('https://api.scryfall.com/bulk-data/oracle_cards', {
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
@@ -23,7 +30,39 @@ async function fetchBulkDownloadUrl() {
return response.json();
}
-function mapCard(card) {
+/**
+ * Download and parse Scryfall oracle bulk (JSON array or JSONL, optionally gzip).
+ */
+export async function downloadBulkOracleCards(meta) {
+ const downloadUri = resolveBulkDownloadUri(meta);
+ if (!downloadUri) {
+ throw new Error('Scryfall bulk meta missing download_uri and jsonl_download_uri');
+ }
+
+ const response = await fetch(downloadUri, {
+ headers: { 'User-Agent': 'DeckHearth/1.0' },
+ });
+ if (!response.ok) {
+ throw new Error(`Bulk download failed: ${response.status}`);
+ }
+
+ const raw = Buffer.from(await response.arrayBuffer());
+ const text = downloadUri.endsWith('.gz')
+ ? gunzipSync(raw).toString('utf8')
+ : raw.toString('utf8');
+
+ if (downloadUri.includes('.jsonl')) {
+ return text
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+ }
+
+ return JSON.parse(text);
+}
+
+export function mapCard(card) {
let currentPrice = null;
let marketPrice = null;
if (card.prices) {
@@ -65,14 +104,64 @@ function mapCard(card) {
};
}
+function hasPrice(row) {
+ return row.currentPrice != null || row.marketPrice != null;
+}
+
+async function resolveCardIds(scryfallIds) {
+ if (scryfallIds.length === 0) return new Map();
+
+ const result = await sql.query(
+ 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
+ [scryfallIds]
+ );
+
+ return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
+}
+
+async function recordBatchSnapshots(cards) {
+ const pricedCards = cards.filter(hasPrice);
+ if (pricedCards.length === 0) return 0;
+
+ const idByScryfall = await resolveCardIds(pricedCards.map((row) => row.scryfallId));
+ const snapshots = [];
+
+ for (const row of pricedCards) {
+ const cardId = idByScryfall.get(row.scryfallId);
+ if (cardId == null) continue;
+
+ if (row.currentPrice != null) {
+ snapshots.push({
+ cardId,
+ source: 'scryfall',
+ priceType: 'market',
+ currency: 'USD',
+ amount: row.currentPrice,
+ });
+ }
+
+ if (row.marketPrice != null) {
+ snapshots.push({
+ cardId,
+ source: 'scryfall',
+ priceType: 'foil',
+ currency: 'USD',
+ amount: row.marketPrice,
+ });
+ }
+ }
+
+ return recordPriceSnapshots(snapshots);
+}
+
async function upsertBatch(cards) {
- if (cards.length === 0) return 0;
+ if (cards.length === 0) return { upserted: 0, snapshotsWritten: 0 };
const columns = [
'scryfall_id', 'oracle_id', 'illustration_id', 'name', 'set_name', 'set_code',
'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors',
'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url',
- 'current_price', 'market_price', 'keywords', 'legalities', 'flavor_text', 'artist',
+ 'current_price', 'market_price', 'prices_updated_at', 'keywords', 'legalities', 'flavor_text', 'artist',
'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified',
];
@@ -81,11 +170,12 @@ async function upsertBatch(cards) {
let paramIdx = 1;
for (const row of cards) {
+ const pricesUpdatedAt = hasPrice(row) ? new Date() : null;
const rowValues = [
row.scryfallId, row.oracleId, row.illustrationId, row.name, row.setName, row.setCode,
row.cardNumber, row.rarity, 'MTG', row.manaCost, row.cmc, row.cardType, row.colors,
row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
- row.currentPrice, row.marketPrice, row.keywords, row.legalities, row.flavorText, row.artist,
+ row.currentPrice, row.marketPrice, pricesUpdatedAt, row.keywords, row.legalities, row.flavorText, row.artist,
row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true,
];
const rowP = rowValues.map(() => `$${paramIdx++}`);
@@ -105,7 +195,9 @@ async function upsertBatch(cards) {
values
);
- return cards.length;
+ const snapshotsWritten = await recordBatchSnapshots(cards);
+
+ return { upserted: cards.length, snapshotsWritten };
}
/**
@@ -113,19 +205,14 @@ async function upsertBatch(cards) {
*/
export async function runBulkMtgSync() {
const meta = await fetchBulkDownloadUrl();
- console.log(`[bulk-sync] Oracle Cards updated: ${meta.updated_at}, size: ${(meta.size / 1024 / 1024).toFixed(0)} MB`);
+ const sizeMb = ((meta.compressed_size ?? meta.size ?? 0) / 1024 / 1024).toFixed(0);
+ console.log(`[bulk-sync] Oracle Cards updated: ${meta.updated_at}, size: ~${sizeMb} MB`);
- const response = await fetch(meta.download_uri, {
- headers: { 'User-Agent': 'DeckHearth/1.0' },
- });
- if (!response.ok) {
- throw new Error(`Bulk download failed: ${response.status}`);
- }
-
- const allCards = await response.json();
+ const allCards = await downloadBulkOracleCards(meta);
const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital);
let upserted = 0;
+ let snapshotsWritten = 0;
let errors = 0;
let batch = [];
@@ -133,7 +220,9 @@ export async function runBulkMtgSync() {
batch.push(mapCard(card));
if (batch.length >= BATCH_SIZE) {
try {
- upserted += await upsertBatch(batch);
+ const result = await upsertBatch(batch);
+ upserted += result.upserted;
+ snapshotsWritten += result.snapshotsWritten;
} catch (err) {
console.error('[bulk-sync] Batch error:', err.message);
errors += batch.length;
@@ -144,7 +233,9 @@ export async function runBulkMtgSync() {
if (batch.length > 0) {
try {
- upserted += await upsertBatch(batch);
+ const result = await upsertBatch(batch);
+ upserted += result.upserted;
+ snapshotsWritten += result.snapshotsWritten;
} catch (err) {
console.error('[bulk-sync] Final batch error:', err.message);
errors += batch.length;
@@ -171,6 +262,7 @@ export async function runBulkMtgSync() {
mode: 'bulk',
totalInFile: mtgCards.length,
upserted,
+ snapshotsWritten,
errors,
backfilledOracleId: backfill.rowCount || 0,
updatedAt: meta.updated_at,
diff --git a/lib/card-import/lorcana-bulk.js b/lib/card-import/lorcana-bulk.js
index 33d3085..ed9cb8a 100644
--- a/lib/card-import/lorcana-bulk.js
+++ b/lib/card-import/lorcana-bulk.js
@@ -2,17 +2,24 @@
* Bulk catalog sync for Disney Lorcana via lorcana-api.com /bulk/cards.
*
* Single fetch returns all cards (~2.3k). Upserts keyed on scryfall_id (stores Unique_ID).
+ * Enriches prices from the Foilio hourly feed when available.
*/
import { sql as vercelSql } from '../sql.js';
+import { recordPriceSnapshots } from '../pricing/price-history.js';
+import { fetchFolioPriceIndex, matchFolioPrice } from '../pricing/foolio-client.js';
const BATCH_SIZE = 100;
const BULK_URL = 'https://api.lorcana-api.com/bulk/cards';
+function hasPrice(row) {
+ return row.currentPrice != null || row.marketPrice != null;
+}
+
/**
* Map a Lorcana bulk API card object to cards-table insert shape.
*/
-export function mapLorcanaCard(card) {
+export function mapLorcanaCard(card, pricing = null) {
const colors = card.Color
? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean)
: [];
@@ -32,7 +39,7 @@ export function mapLorcanaCard(card) {
const cost = card.Cost != null ? Number(card.Cost) : null;
- return {
+ const row = {
scryfallId: card.Unique_ID,
name: card.Name,
setName: card.Set_Name,
@@ -54,14 +61,42 @@ export function mapLorcanaCard(card) {
edhrecRank: card.Lore != null ? Number(card.Lore) : null,
keywords: JSON.stringify(keywords),
};
+
+ if (pricing) {
+ row.currentPrice = pricing.currentPrice;
+ row.marketPrice = pricing.marketPrice;
+ row.priceCurrency = pricing.currency ?? 'USD';
+ if (hasPrice(row)) {
+ row.pricesUpdatedAt = new Date();
+ }
+ }
+
+ return row;
+}
+
+export function enrichLorcanaRowWithFolio(row, folioIndex, stats) {
+ const pricing = matchFolioPrice(folioIndex, row);
+ if (!pricing || !hasPrice(pricing)) {
+ if (stats) stats.unmatchedCount += 1;
+ return row;
+ }
+
+ if (stats) stats.pricesMatched += 1;
+ return {
+ ...row,
+ currentPrice: pricing.currentPrice,
+ marketPrice: pricing.marketPrice,
+ priceCurrency: pricing.currency,
+ pricesUpdatedAt: new Date(),
+ };
}
export function buildUpsertQuery(batch) {
const columns = [
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text',
- 'power', 'toughness', 'image_url', 'stock_image_url', 'keywords', 'flavor_text',
- 'artist', 'edhrec_rank', 'verified',
+ 'power', 'toughness', 'image_url', 'stock_image_url', 'current_price', 'market_price',
+ 'prices_updated_at', 'keywords', 'flavor_text', 'artist', 'edhrec_rank', 'verified',
];
const placeholders = [];
@@ -74,6 +109,7 @@ export function buildUpsertQuery(batch) {
row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity,
'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity,
row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
+ row.currentPrice ?? null, row.marketPrice ?? null, row.pricesUpdatedAt ?? null,
row.keywords, row.flavorText, row.artist, row.edhrecRank, true,
];
@@ -101,11 +137,70 @@ export function buildUpsertQuery(batch) {
return { query, values };
}
+async function resolveCardIds(sql, scryfallIds) {
+ if (scryfallIds.length === 0) return new Map();
+
+ const result = await sql.query(
+ 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
+ [scryfallIds]
+ );
+
+ return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
+}
+
+async function recordBatchSnapshots(sql, batch) {
+ const pricedRows = batch.filter(hasPrice);
+ if (pricedRows.length === 0) return 0;
+
+ const idByScryfall = await resolveCardIds(sql, pricedRows.map((row) => row.scryfallId));
+ const snapshots = [];
+
+ for (const row of pricedRows) {
+ const cardId = idByScryfall.get(row.scryfallId);
+ if (cardId == null) continue;
+
+ const currency = row.priceCurrency ?? 'USD';
+
+ if (row.currentPrice != null) {
+ snapshots.push({
+ cardId,
+ source: 'foilio',
+ priceType: 'market',
+ currency,
+ amount: row.currentPrice,
+ });
+ }
+
+ if (row.marketPrice != null && row.marketPrice !== row.currentPrice) {
+ snapshots.push({
+ cardId,
+ source: 'foilio',
+ priceType: 'market',
+ currency,
+ amount: row.marketPrice,
+ });
+ } else if (row.marketPrice != null && row.currentPrice == null) {
+ snapshots.push({
+ cardId,
+ source: 'foilio',
+ priceType: 'market',
+ currency,
+ amount: row.marketPrice,
+ });
+ }
+ }
+
+ return recordPriceSnapshots(snapshots);
+}
+
async function upsertBatch(sql, batch) {
- if (batch.length === 0) return 0;
+ if (batch.length === 0) return { upserted: 0, snapshotsWritten: 0 };
+
const { query, values } = buildUpsertQuery(batch);
await sql.query(query, values);
- return batch.length;
+ const snapshotsWritten = await recordBatchSnapshots(sql, batch);
+
+ return { upserted: batch.length, snapshotsWritten };
}
export async function fetchLorcanaBulkCards() {
@@ -121,11 +216,21 @@ export async function fetchLorcanaBulkCards() {
/**
* Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting.
*
- * @param {{ sql?: { query: (q: string, v: unknown[]) => Promise }, dryRun?: boolean }} options
+ * @param {{ sql?: { query: (q: string, v: unknown[]) => Promise }, dryRun?: boolean, fetchFolioIndex?: () => Promise<{ byCardId: Map, byNameSet: Map }> }} options
*/
export async function runBulkLorcanaSync(options = {}) {
const dryRun = options.dryRun ?? false;
const sql = options.sql ?? vercelSql;
+ const fetchFolioIndex = options.fetchFolioIndex ?? fetchFolioPriceIndex;
+
+ let folioIndex = { byCardId: new Map(), byNameSet: new Map() };
+ if (!dryRun) {
+ try {
+ folioIndex = await fetchFolioIndex();
+ } catch (error) {
+ console.warn('[lorcana-bulk] Foilio feed fetch failed:', error.message);
+ }
+ }
const allCards = await fetchLorcanaBulkCards();
const lorcanaCards = allCards.filter(
@@ -133,21 +238,33 @@ export async function runBulkLorcanaSync(options = {}) {
);
let upserted = 0;
+ let snapshotsWritten = 0;
let errors = 0;
+ let pricesMatched = 0;
+ let unmatchedCount = 0;
let batch = [];
+ const matchStats = {
+ pricesMatched: 0,
+ unmatchedCount: 0,
+ };
+
for (const card of lorcanaCards) {
if (!card.Unique_ID) {
errors += 1;
continue;
}
- batch.push(mapLorcanaCard(card));
+ let mapped = mapLorcanaCard(card);
+ mapped = enrichLorcanaRowWithFolio(mapped, folioIndex, matchStats);
+ batch.push(mapped);
if (batch.length >= BATCH_SIZE) {
try {
if (!dryRun) {
- upserted += await upsertBatch(sql, batch);
+ const result = await upsertBatch(sql, batch);
+ upserted += result.upserted;
+ snapshotsWritten += result.snapshotsWritten;
} else {
upserted += batch.length;
}
@@ -162,7 +279,9 @@ export async function runBulkLorcanaSync(options = {}) {
if (batch.length > 0) {
try {
if (!dryRun) {
- upserted += await upsertBatch(sql, batch);
+ const result = await upsertBatch(sql, batch);
+ upserted += result.upserted;
+ snapshotsWritten += result.snapshotsWritten;
} else {
upserted += batch.length;
}
@@ -172,12 +291,18 @@ export async function runBulkLorcanaSync(options = {}) {
}
}
+ pricesMatched = matchStats.pricesMatched;
+ unmatchedCount = matchStats.unmatchedCount;
+
const summary = {
mode: 'bulk',
game: 'lorcana',
totalInFile: lorcanaCards.length,
upserted,
+ snapshotsWritten,
errors,
+ pricesMatched,
+ unmatchedCount,
};
console.log('[lorcana-bulk]', JSON.stringify(summary));
diff --git a/lib/card-import/pokemon-bulk.js b/lib/card-import/pokemon-bulk.js
index 6f2596c..0afd4ee 100644
--- a/lib/card-import/pokemon-bulk.js
+++ b/lib/card-import/pokemon-bulk.js
@@ -3,9 +3,17 @@
*
* Fetches all sets from sets/en.json, then upserts every card in each set
* (keyed on scryfall_id = external card id). Populates legalities from set metadata.
+ * Enriches prices from TCGdex when available.
*/
import { sql } from '../sql.js';
+import { recordPriceSnapshots } from '../pricing/price-history.js';
+import {
+ buildTcgdexPriceIndex,
+ fetchSetCards,
+ matchTcgdexCard,
+ parseTcgdexPricing,
+} from '../pricing/tcgdex-client.js';
import {
fetchPokemonSetCards,
@@ -25,8 +33,12 @@ function getSetLegalities(setMeta) {
return {};
}
-export function mapPokemonBulkRow(mapped, setLegalities) {
- return {
+function hasPrice(row) {
+ return row.currentPrice != null || row.marketPrice != null;
+}
+
+export function mapPokemonBulkRow(mapped, setLegalities, pricing = null) {
+ const row = {
scryfallId: mapped.externalId,
name: mapped.name,
setName: mapped.setName,
@@ -40,14 +52,29 @@ export function mapPokemonBulkRow(mapped, setLegalities) {
imageUrl: mapped.imageSmall,
stockImageUrl: mapped.imageLarge,
legalities: JSON.stringify(setLegalities),
+ currentPrice: null,
+ marketPrice: null,
+ pricesUpdatedAt: null,
+ priceCurrency: 'USD',
};
+
+ if (pricing) {
+ row.currentPrice = pricing.currentPrice;
+ row.marketPrice = pricing.marketPrice;
+ row.priceCurrency = pricing.currency ?? 'USD';
+ if (hasPrice(row)) {
+ row.pricesUpdatedAt = new Date();
+ }
+ }
+
+ return row;
}
function buildUpsertQuery(batch) {
const columns = [
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness',
- 'image_url', 'stock_image_url', 'current_price', 'market_price', 'legalities', 'verified',
+ 'image_url', 'stock_image_url', 'current_price', 'market_price', 'prices_updated_at', 'legalities', 'verified',
];
const placeholders = [];
@@ -73,8 +100,9 @@ function buildUpsertQuery(batch) {
null,
row.imageUrl,
row.stockImageUrl,
- null,
- null,
+ row.currentPrice,
+ row.marketPrice,
+ row.pricesUpdatedAt,
row.legalities,
true,
];
@@ -103,6 +131,62 @@ function buildUpsertQuery(batch) {
return { query, values };
}
+async function resolveCardIds(scryfallIds, queryFn) {
+ if (scryfallIds.length === 0) return new Map();
+
+ const result = await queryFn(
+ 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
+ [scryfallIds]
+ );
+
+ return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
+}
+
+async function recordBatchSnapshots(batch, queryFn) {
+ const pricedRows = batch.filter(hasPrice);
+ if (pricedRows.length === 0) return 0;
+
+ const idByScryfall = await resolveCardIds(pricedRows.map((row) => row.scryfallId), queryFn);
+ const snapshots = [];
+
+ for (const row of pricedRows) {
+ const cardId = idByScryfall.get(row.scryfallId);
+ if (cardId == null) continue;
+
+ const currency = row.priceCurrency ?? 'USD';
+
+ if (row.currentPrice != null) {
+ snapshots.push({
+ cardId,
+ source: 'tcgdex',
+ priceType: 'market',
+ currency,
+ amount: row.currentPrice,
+ });
+ }
+
+ if (row.marketPrice != null && row.marketPrice !== row.currentPrice) {
+ snapshots.push({
+ cardId,
+ source: 'tcgdex',
+ priceType: 'market',
+ currency,
+ amount: row.marketPrice,
+ });
+ } else if (row.marketPrice != null && row.currentPrice == null) {
+ snapshots.push({
+ cardId,
+ source: 'tcgdex',
+ priceType: 'market',
+ currency,
+ amount: row.marketPrice,
+ });
+ }
+ }
+
+ return recordPriceSnapshots(snapshots);
+}
+
async function upsertBatch(batch, queryFn, stats) {
if (batch.length === 0) return;
@@ -115,6 +199,7 @@ async function upsertBatch(batch, queryFn, stats) {
try {
await queryFn(query, values);
stats.upserted += batch.length;
+ stats.snapshotsWritten += await recordBatchSnapshots(batch, queryFn);
} catch (error) {
console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message);
for (const row of batch) {
@@ -122,6 +207,7 @@ async function upsertBatch(batch, queryFn, stats) {
const { query: singleQ, values: singleV } = buildUpsertQuery([row]);
await queryFn(singleQ, singleV);
stats.upserted += 1;
+ stats.snapshotsWritten += await recordBatchSnapshots([row], queryFn);
} catch (singleErr) {
console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message);
stats.errors += 1;
@@ -130,13 +216,49 @@ async function upsertBatch(batch, queryFn, stats) {
}
}
+function enrichRowWithTcgdex(row, tcgIndex, stats) {
+ const tcgCard = matchTcgdexCard(tcgIndex, row);
+ if (!tcgCard) {
+ stats.unmatchedCount += 1;
+ return row;
+ }
+
+ const pricing = parseTcgdexPricing(tcgCard);
+ if (!hasPrice({ currentPrice: pricing.currentPrice, marketPrice: pricing.marketPrice })) {
+ stats.unmatchedCount += 1;
+ return row;
+ }
+
+ stats.pricesMatched += 1;
+ return {
+ ...row,
+ currentPrice: pricing.currentPrice,
+ marketPrice: pricing.marketPrice,
+ priceCurrency: pricing.currency,
+ pricesUpdatedAt: new Date(),
+ };
+}
+
async function upsertSetCards(setMeta, cards, options, stats) {
const setLegalities = getSetLegalities(setMeta);
+ let tcgIndex = { byNumber: new Map(), byName: new Map() };
+
+ if (!stats.dryRun && options.fetchTcgdexSetCards) {
+ try {
+ const tcgCards = await options.fetchTcgdexSetCards(setMeta.id);
+ tcgIndex = buildTcgdexPriceIndex(tcgCards);
+ } catch (error) {
+ console.warn(`[pokemon-bulk] TCGdex fetch failed for ${setMeta.id}:`, error.message);
+ }
+ }
+
let batch = [];
for (const card of cards) {
const mapped = mapGithubCardForInsert(card, setMeta);
- batch.push(mapPokemonBulkRow(mapped, setLegalities));
+ let row = mapPokemonBulkRow(mapped, setLegalities);
+ row = enrichRowWithTcgdex(row, tcgIndex, stats);
+ batch.push(row);
if (batch.length >= options.batchSize) {
await upsertBatch(batch, options.queryFn, stats);
@@ -158,6 +280,7 @@ export async function runBulkPokemonSync(options = {}) {
const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true';
const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS;
const queryFn = options.query ?? ((query, values) => sql.query(query, values));
+ const fetchTcgdexSetCards = options.fetchTcgdexSetCards ?? fetchSetCards;
const sets = await fetchPokemonSets();
console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`);
@@ -168,6 +291,9 @@ export async function runBulkPokemonSync(options = {}) {
skipped: 0,
errors: 0,
setsProcessed: 0,
+ pricesMatched: 0,
+ unmatchedCount: 0,
+ snapshotsWritten: 0,
};
for (let index = 0; index < sets.length; index += 1) {
@@ -186,7 +312,7 @@ export async function runBulkPokemonSync(options = {}) {
if (cards.length === 0) {
console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`);
} else {
- await upsertSetCards(setMeta, cards, { batchSize, queryFn }, stats);
+ await upsertSetCards(setMeta, cards, { batchSize, queryFn, fetchTcgdexSetCards }, stats);
console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`);
}
} catch (error) {
@@ -207,6 +333,9 @@ export async function runBulkPokemonSync(options = {}) {
upserted: stats.upserted,
skipped: stats.skipped,
errors: stats.errors,
+ pricesMatched: stats.pricesMatched,
+ unmatchedCount: stats.unmatchedCount,
+ snapshotsWritten: stats.snapshotsWritten,
dryRun,
};
diff --git a/lib/pricing/card-price.js b/lib/pricing/card-price.js
new file mode 100644
index 0000000..7d9a524
--- /dev/null
+++ b/lib/pricing/card-price.js
@@ -0,0 +1,31 @@
+/** Hours after which a stored price is considered stale for live refresh. */
+export const PRICE_STALE_HOURS = 24;
+
+/**
+ * Resolve the unit price for a owned card row (foil-aware).
+ * MTG: current_price = nonfoil USD, market_price = foil USD (Scryfall mapping).
+ *
+ * @param {{ currentPrice?: number | null, marketPrice?: number | null, isFoil?: boolean }} params
+ * @returns {number | null}
+ */
+export function resolveUnitPrice({ currentPrice, marketPrice, isFoil = false }) {
+ const current = currentPrice != null ? Number(currentPrice) : null;
+ const market = marketPrice != null ? Number(marketPrice) : null;
+
+ if (isFoil) {
+ return market ?? current ?? null;
+ }
+ return current ?? market ?? null;
+}
+
+/**
+ * @param {Date | string | null | undefined} pricesUpdatedAt
+ * @returns {boolean}
+ */
+export function isPriceStale(pricesUpdatedAt) {
+ if (!pricesUpdatedAt) return true;
+ const updated = pricesUpdatedAt instanceof Date ? pricesUpdatedAt : new Date(pricesUpdatedAt);
+ if (Number.isNaN(updated.getTime())) return true;
+ const ageMs = Date.now() - updated.getTime();
+ return ageMs > PRICE_STALE_HOURS * 60 * 60 * 1000;
+}
diff --git a/lib/pricing/collection-valuation.js b/lib/pricing/collection-valuation.js
new file mode 100644
index 0000000..74084d1
--- /dev/null
+++ b/lib/pricing/collection-valuation.js
@@ -0,0 +1,19 @@
+/**
+ * SQL expression for foil-aware unit price on user-owned rows.
+ *
+ * @param {{ cardAlias?: string, userCardAlias?: string }} [options]
+ * @returns {string}
+ */
+export function effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' } = {}) {
+ return `CASE WHEN ${userCardAlias}.is_foil THEN ${cardAlias}.market_price ELSE COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price) END`;
+}
+
+/**
+ * SQL expression for list/catalog unit price (no per-row foil flag on collection_cards).
+ *
+ * @param {{ cardAlias?: string }} [options]
+ * @returns {string}
+ */
+export function listUnitPriceExpr({ cardAlias = 'c' } = {}) {
+ return `COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price)`;
+}
diff --git a/lib/pricing/foolio-client.js b/lib/pricing/foolio-client.js
new file mode 100644
index 0000000..02a2320
--- /dev/null
+++ b/lib/pricing/foolio-client.js
@@ -0,0 +1,120 @@
+const DEFAULT_FOILIO_FEED_URL = 'https://foilio.net/api/prices/feed?game=lorcana';
+
+export function getFolioFeedUrl() {
+ return process.env.FOILIO_PRICES_FEED_URL ?? DEFAULT_FOILIO_FEED_URL;
+}
+
+export function normalizeFolioName(name) {
+ return String(name ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+function parseAmount(value) {
+ if (value == null || value === '') return null;
+ const amount = Number(value);
+ return Number.isFinite(amount) ? amount : null;
+}
+
+/**
+ * Parse a Foilio feed row into current/market prices.
+ * Prefer USD fields; fall back to EUR with currency noted for snapshots.
+ */
+export function parseFolioPriceEntry(entry) {
+ const usdLow =
+ parseAmount(entry.usd_low)
+ ?? parseAmount(entry.low_usd)
+ ?? parseAmount(entry.price_usd)
+ ?? parseAmount(entry.usd);
+ const usdMarket =
+ parseAmount(entry.usd_market)
+ ?? parseAmount(entry.market_usd)
+ ?? parseAmount(entry.market)
+ ?? parseAmount(entry.price);
+
+ if (usdLow != null || usdMarket != null) {
+ return {
+ currentPrice: usdLow ?? usdMarket,
+ marketPrice: usdMarket ?? usdLow,
+ currency: 'USD',
+ };
+ }
+
+ const eurLow = parseAmount(entry.eur_low) ?? parseAmount(entry.low_eur);
+ const eurMarket = parseAmount(entry.eur_market) ?? parseAmount(entry.eur) ?? parseAmount(entry.price_eur);
+ if (eurLow != null || eurMarket != null) {
+ return {
+ currentPrice: eurLow ?? eurMarket,
+ marketPrice: eurMarket ?? eurLow,
+ currency: 'EUR',
+ };
+ }
+
+ return { currentPrice: null, marketPrice: null, currency: 'USD' };
+}
+
+function feedEntries(payload) {
+ if (Array.isArray(payload)) return payload;
+ if (Array.isArray(payload?.prices)) return payload.prices;
+ if (Array.isArray(payload?.cards)) return payload.cards;
+ if (Array.isArray(payload?.data)) return payload.data;
+ return [];
+}
+
+/**
+ * Build lookup indexes from a Foilio feed payload.
+ */
+export function buildFolioPriceIndex(payload) {
+ const byCardId = new Map();
+ const byNameSet = new Map();
+
+ for (const entry of feedEntries(payload)) {
+ const parsed = parseFolioPriceEntry(entry);
+ if (parsed.currentPrice == null && parsed.marketPrice == null) continue;
+
+ const cardId = entry.card_id ?? entry.cardId ?? entry.id ?? entry.unique_id ?? entry.Unique_ID;
+ if (cardId) {
+ byCardId.set(String(cardId), parsed);
+ }
+
+ const setCode = entry.set ?? entry.set_id ?? entry.setId ?? entry.Set_ID;
+ const name = entry.name ?? entry.Name;
+ if (setCode && name) {
+ const key = `${normalizeFolioName(name)}|${String(setCode).trim()}`;
+ if (!byNameSet.has(key)) {
+ byNameSet.set(key, parsed);
+ }
+ }
+ }
+
+ return { byCardId, byNameSet };
+}
+
+/**
+ * Match a Lorcana catalog row against the Foilio price index.
+ */
+export function matchFolioPrice(index, row) {
+ const byId = index.byCardId.get(String(row.scryfallId));
+ if (byId) return byId;
+
+ const key = `${normalizeFolioName(row.name)}|${String(row.setCode ?? '').trim()}`;
+ return index.byNameSet.get(key) ?? null;
+}
+
+/**
+ * Fetch the Foilio Lorcana price feed once per sync run.
+ */
+export async function fetchFolioPriceFeed() {
+ const response = await fetch(getFolioFeedUrl(), {
+ headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Foilio feed error: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+export async function fetchFolioPriceIndex() {
+ const payload = await fetchFolioPriceFeed();
+ return buildFolioPriceIndex(payload);
+}
diff --git a/lib/pricing/geo-cache.js b/lib/pricing/geo-cache.js
new file mode 100644
index 0000000..44b14eb
--- /dev/null
+++ b/lib/pricing/geo-cache.js
@@ -0,0 +1,110 @@
+import { createHash } from 'node:crypto';
+
+import { sql } from '../sql.js';
+import { geocodeUsZip } from './nominatim.js';
+import { fetchGameStores } from './overpass.js';
+
+export const STORES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * @param {number} lat
+ * @param {number} lng
+ * @param {number} radiusMeters
+ * @returns {string}
+ */
+export function buildStoresCacheKey(lat, lng, radiusMeters) {
+ const payload = `${lat.toFixed(5)}:${lng.toFixed(5)}:${radiusMeters}`;
+ return createHash('sha256').update(payload).digest('hex').slice(0, 64);
+}
+
+/**
+ * @param {Date | string | null | undefined} fetchedAt
+ * @param {number} [ttlMs]
+ * @returns {boolean}
+ */
+export function isStoresCacheFresh(fetchedAt, ttlMs = STORES_CACHE_TTL_MS) {
+ if (!fetchedAt) return false;
+ const ts = fetchedAt instanceof Date ? fetchedAt.getTime() : new Date(fetchedAt).getTime();
+ if (!Number.isFinite(ts)) return false;
+ return Date.now() - ts < ttlMs;
+}
+
+/**
+ * @param {string} zip
+ * @param {{ fetchImpl?: typeof fetch }} [options]
+ * @returns {Promise<{ lat: number, lng: number, displayName: string, cached: boolean } | null>}
+ */
+export async function getZipCoords(zip, options = {}) {
+ const cached = await sql`
+ SELECT lat, lng, display_name
+ FROM geo_zip_cache
+ WHERE zip = ${zip}
+ LIMIT 1
+ `;
+
+ if (cached.rows.length > 0) {
+ const row = cached.rows[0];
+ return {
+ lat: Number(row.lat),
+ lng: Number(row.lng),
+ displayName: row.display_name ?? zip,
+ cached: true,
+ };
+ }
+
+ const geocoded = await geocodeUsZip(zip, options);
+ if (!geocoded) {
+ return null;
+ }
+
+ await sql`
+ INSERT INTO geo_zip_cache (zip, lat, lng, display_name)
+ VALUES (${zip}, ${geocoded.lat}, ${geocoded.lng}, ${geocoded.displayName})
+ ON CONFLICT (zip) DO UPDATE SET
+ lat = EXCLUDED.lat,
+ lng = EXCLUDED.lng,
+ display_name = EXCLUDED.display_name,
+ geocoded_at = CURRENT_TIMESTAMP
+ `;
+
+ return { ...geocoded, cached: false };
+}
+
+/**
+ * @param {number} lat
+ * @param {number} lng
+ * @param {number} radiusMeters
+ * @param {{ fetchImpl?: typeof fetch, forceRefresh?: boolean }} [options]
+ * @returns {Promise<{ stores: unknown[], cached: boolean }>}
+ */
+export async function getStoresNear(lat, lng, radiusMeters, options = {}) {
+ const cacheKey = buildStoresCacheKey(lat, lng, radiusMeters);
+
+ if (!options.forceRefresh) {
+ const cached = await sql`
+ SELECT stores, fetched_at
+ FROM geo_stores_cache
+ WHERE cache_key = ${cacheKey}
+ LIMIT 1
+ `;
+
+ if (cached.rows.length > 0 && isStoresCacheFresh(cached.rows[0].fetched_at)) {
+ return { stores: cached.rows[0].stores, cached: true };
+ }
+ }
+
+ const stores = await fetchGameStores(lat, lng, radiusMeters, options);
+
+ await sql`
+ INSERT INTO geo_stores_cache (cache_key, lat, lng, radius_meters, stores, fetched_at)
+ VALUES (${cacheKey}, ${lat}, ${lng}, ${radiusMeters}, ${JSON.stringify(stores)}::jsonb, CURRENT_TIMESTAMP)
+ ON CONFLICT (cache_key) DO UPDATE SET
+ lat = EXCLUDED.lat,
+ lng = EXCLUDED.lng,
+ radius_meters = EXCLUDED.radius_meters,
+ stores = EXCLUDED.stores,
+ fetched_at = CURRENT_TIMESTAMP
+ `;
+
+ return { stores, cached: false };
+}
diff --git a/lib/pricing/nominatim.js b/lib/pricing/nominatim.js
new file mode 100644
index 0000000..e2913ad
--- /dev/null
+++ b/lib/pricing/nominatim.js
@@ -0,0 +1,69 @@
+const DEFAULT_NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
+const MIN_REQUEST_INTERVAL_MS = 1000;
+
+const ALLOWED_HOSTS = new Set(['nominatim.openstreetmap.org']);
+
+let lastRequestAt = 0;
+
+function assertAllowedHost(url) {
+ const host = new URL(url).hostname.toLowerCase();
+ if (!ALLOWED_HOSTS.has(host)) {
+ throw new Error(`[nominatim] Host not allowlisted: ${host}`);
+ }
+}
+
+function getUserAgent() {
+ return process.env.NOMINATIM_USER_AGENT ?? 'DeckHearth/1.0';
+}
+
+async function throttle() {
+ const now = Date.now();
+ const elapsed = now - lastRequestAt;
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
+ await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed));
+ }
+ lastRequestAt = Date.now();
+}
+
+/**
+ * @param {string} zip Five-digit US zip code.
+ * @param {{ fetchImpl?: typeof fetch }} [options]
+ * @returns {Promise<{ lat: number, lng: number, displayName: string } | null>}
+ */
+export async function geocodeUsZip(zip, { fetchImpl = fetch } = {}) {
+ const base = process.env.NOMINATIM_BASE_URL ?? DEFAULT_NOMINATIM_BASE;
+ const url = new URL('/search', base);
+ url.searchParams.set('postalcode', zip);
+ url.searchParams.set('country', 'US');
+ url.searchParams.set('format', 'json');
+ url.searchParams.set('limit', '1');
+
+ assertAllowedHost(url.toString());
+ await throttle();
+
+ const response = await fetchImpl(url.toString(), {
+ headers: { 'User-Agent': getUserAgent() },
+ });
+
+ if (!response.ok) {
+ throw new Error(`[nominatim] HTTP ${response.status}`);
+ }
+
+ const results = await response.json();
+ if (!Array.isArray(results) || results.length === 0) {
+ return null;
+ }
+
+ const hit = results[0];
+ const lat = Number(hit.lat);
+ const lng = Number(hit.lon);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+ return null;
+ }
+
+ return {
+ lat,
+ lng,
+ displayName: hit.display_name ?? `${zip}, US`,
+ };
+}
diff --git a/lib/pricing/outbound-links.js b/lib/pricing/outbound-links.js
new file mode 100644
index 0000000..4e4045f
--- /dev/null
+++ b/lib/pricing/outbound-links.js
@@ -0,0 +1,32 @@
+/**
+ * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
+ * @returns {string}
+ */
+export function buildTcgplayerUrl(card) {
+ const name = card.name || '';
+ const game = (card.game || 'mtg').toLowerCase();
+ return `https://www.tcgplayer.com/search/${game}/product?productName=${encodeURIComponent(name)}`;
+}
+
+/**
+ * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
+ * @returns {string}
+ */
+export function buildEbayUrl(card) {
+ const name = card.name || '';
+ const game = card.game || '';
+ const setName = card.set_name || card.setName || '';
+ const query = [name, setName, game].filter(Boolean).join(' ');
+ return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(query)}`;
+}
+
+/**
+ * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
+ * @returns {{ tcgplayer: string, ebay: string }}
+ */
+export function buildOutboundLinks(card) {
+ return {
+ tcgplayer: buildTcgplayerUrl(card),
+ ebay: buildEbayUrl(card),
+ };
+}
diff --git a/lib/pricing/overpass.js b/lib/pricing/overpass.js
new file mode 100644
index 0000000..3659394
--- /dev/null
+++ b/lib/pricing/overpass.js
@@ -0,0 +1,178 @@
+const DEFAULT_OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
+const METERS_PER_MILE = 1609.344;
+const MIN_RADIUS_MILES = 1;
+const MAX_RADIUS_MILES = 50;
+const DEFAULT_RADIUS_MILES = 25;
+
+const ALLOWED_HOSTS = new Set(['overpass-api.de', 'overpass.kumi.systems']);
+
+function assertAllowedHost(url) {
+ const host = new URL(url).hostname.toLowerCase();
+ if (!ALLOWED_HOSTS.has(host)) {
+ throw new Error(`[overpass] Host not allowlisted: ${host}`);
+ }
+}
+
+/**
+ * @param {number} miles
+ * @returns {number}
+ */
+export function clampRadiusMiles(miles) {
+ const value = Number(miles);
+ if (!Number.isFinite(value)) {
+ return DEFAULT_RADIUS_MILES;
+ }
+ return Math.min(MAX_RADIUS_MILES, Math.max(MIN_RADIUS_MILES, value));
+}
+
+/**
+ * @param {number} miles
+ * @returns {number}
+ */
+export function milesToMeters(miles) {
+ return Math.round(clampRadiusMiles(miles) * METERS_PER_MILE);
+}
+
+/**
+ * Build Overpass QL from validated numeric inputs only — never user strings.
+ * @param {{ lat: number, lng: number, radiusMeters: number }} params
+ * @returns {string}
+ */
+export function buildOverpassQuery({ lat, lng, radiusMeters }) {
+ const safeLat = Number(lat);
+ const safeLng = Number(lng);
+ const safeRadius = Math.round(Number(radiusMeters));
+
+ if (!Number.isFinite(safeLat) || !Number.isFinite(safeLng) || !Number.isFinite(safeRadius)) {
+ throw new Error('[overpass] Invalid numeric query parameters');
+ }
+ if (safeRadius < 1 || safeRadius > Math.round(MAX_RADIUS_MILES * METERS_PER_MILE)) {
+ throw new Error('[overpass] Radius out of bounds');
+ }
+
+ return `[out:json][timeout:25];
+(
+ node["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
+ node["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
+ way["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
+ way["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
+);
+out center tags;`;
+}
+
+function formatAddress(tags) {
+ const parts = [
+ tags['addr:housenumber'],
+ tags['addr:street'],
+ tags['addr:city'],
+ tags['addr:state'],
+ tags['addr:postcode'],
+ ].filter(Boolean);
+ if (parts.length > 0) {
+ return parts.join(', ');
+ }
+ return tags['addr:full'] ?? '';
+}
+
+function elementCoords(element) {
+ if (element.type === 'node' && element.lat != null && element.lon != null) {
+ return { lat: Number(element.lat), lng: Number(element.lon) };
+ }
+ if (element.center?.lat != null && element.center?.lon != null) {
+ return { lat: Number(element.center.lat), lng: Number(element.center.lon) };
+ }
+ return null;
+}
+
+function osmUrl(element) {
+ const type = element.type === 'node' ? 'node' : 'way';
+ return `https://www.openstreetmap.org/${type}/${element.id}`;
+}
+
+/**
+ * @param {unknown} data
+ * @param {{ originLat: number, originLng: number }} origin
+ * @returns {Array<{
+ * id: string,
+ * name: string,
+ * address: string,
+ * lat: number,
+ * lng: number,
+ * distanceMiles: number,
+ * phone: string | null,
+ * website: string | null,
+ * osmUrl: string,
+ * }>}
+ */
+export function parseOverpassResponse(data, { originLat, originLng }) {
+ const elements = data?.elements;
+ if (!Array.isArray(elements)) {
+ return [];
+ }
+
+ const stores = [];
+
+ for (const element of elements) {
+ const tags = element.tags ?? {};
+ const name = tags.name;
+ if (!name) continue;
+
+ const coords = elementCoords(element);
+ if (!coords || !Number.isFinite(coords.lat) || !Number.isFinite(coords.lng)) {
+ continue;
+ }
+
+ stores.push({
+ id: `${element.type}/${element.id}`,
+ name,
+ address: formatAddress(tags),
+ lat: coords.lat,
+ lng: coords.lng,
+ distanceMiles: haversineMiles(originLat, originLng, coords.lat, coords.lng),
+ phone: tags.phone ?? tags['contact:phone'] ?? null,
+ website: tags.website ?? tags['contact:website'] ?? null,
+ osmUrl: osmUrl(element),
+ });
+ }
+
+ stores.sort((a, b) => a.distanceMiles - b.distanceMiles);
+ return stores;
+}
+
+/**
+ * @param {number} lat
+ * @param {number} lng
+ * @param {number} radiusMeters
+ * @param {{ fetchImpl?: typeof fetch }} [options]
+ * @returns {Promise>}
+ */
+export async function fetchGameStores(lat, lng, radiusMeters, { fetchImpl = fetch } = {}) {
+ const endpoint = process.env.OVERPASS_URL ?? DEFAULT_OVERPASS_URL;
+ assertAllowedHost(endpoint);
+
+ const query = buildOverpassQuery({ lat, lng, radiusMeters });
+ const response = await fetchImpl(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: `data=${encodeURIComponent(query)}`,
+ });
+
+ if (!response.ok) {
+ throw new Error(`[overpass] HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ return parseOverpassResponse(data, { originLat: lat, originLng: lng });
+}
+
+function haversineMiles(lat1, lng1, lat2, lng2) {
+ const toRad = (deg) => (deg * Math.PI) / 180;
+ const dLat = toRad(lat2 - lat1);
+ const dLng = toRad(lng2 - lng1);
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ const earthRadiusMiles = 3958.8;
+ return Math.round(c * earthRadiusMiles * 10) / 10;
+}
diff --git a/lib/pricing/price-history.js b/lib/pricing/price-history.js
new file mode 100644
index 0000000..f9132bd
--- /dev/null
+++ b/lib/pricing/price-history.js
@@ -0,0 +1,78 @@
+import { sql } from '../sql.js';
+
+/**
+ * @typedef {Object} PriceSnapshotRow
+ * @property {number} cardId
+ * @property {string} source
+ * @property {string} [priceType]
+ * @property {string} [currency]
+ * @property {number} amount
+ * @property {string | Date} [recordedAt]
+ */
+
+/**
+ * Upsert daily price snapshots (idempotent per card/source/type/currency/day).
+ *
+ * @param {PriceSnapshotRow[]} rows
+ * @returns {Promise} rows written
+ */
+export async function recordPriceSnapshots(rows) {
+ if (!rows?.length) return 0;
+
+ let written = 0;
+ for (const row of rows) {
+ if (row.cardId == null || row.amount == null || Number.isNaN(Number(row.amount))) {
+ continue;
+ }
+
+ const priceType = row.priceType ?? 'market';
+ const currency = row.currency ?? 'USD';
+ const recordedAt =
+ row.recordedAt instanceof Date
+ ? row.recordedAt.toISOString().slice(0, 10)
+ : row.recordedAt ?? new Date().toISOString().slice(0, 10);
+
+ await sql`
+ INSERT INTO card_price_history (card_id, source, price_type, currency, amount, recorded_at)
+ VALUES (
+ ${row.cardId},
+ ${row.source},
+ ${priceType},
+ ${currency},
+ ${Number(row.amount)},
+ ${recordedAt}
+ )
+ ON CONFLICT (card_id, source, price_type, currency, recorded_at)
+ DO UPDATE SET amount = EXCLUDED.amount
+ `;
+ written += 1;
+ }
+
+ return written;
+}
+
+/**
+ * @param {number} cardId
+ * @param {{ days?: number }} [options]
+ * @returns {Promise>}
+ */
+export async function getPriceHistoryPoints(cardId, { days = 90 } = {}) {
+ const cappedDays = Math.min(Math.max(Number(days) || 90, 1), 365);
+ const result = await sql`
+ SELECT recorded_at, amount, source, price_type, currency
+ FROM card_price_history
+ WHERE card_id = ${cardId}
+ AND recorded_at >= CURRENT_DATE - ${cappedDays}::integer
+ ORDER BY recorded_at ASC
+ `;
+
+ return result.rows.map((row) => ({
+ date: row.recorded_at instanceof Date
+ ? row.recorded_at.toISOString().slice(0, 10)
+ : String(row.recorded_at).slice(0, 10),
+ amount: parseFloat(row.amount),
+ source: row.source,
+ priceType: row.price_type,
+ currency: row.currency,
+ }));
+}
diff --git a/lib/pricing/pricing-service.js b/lib/pricing/pricing-service.js
new file mode 100644
index 0000000..1f24b33
--- /dev/null
+++ b/lib/pricing/pricing-service.js
@@ -0,0 +1,256 @@
+import Redis from 'ioredis';
+import { sql } from '../sql.js';
+import { isPriceStale } from './card-price.js';
+import { buildOutboundLinks } from './outbound-links.js';
+import { recordPriceSnapshots } from './price-history.js';
+import { getTcgdexBaseUrl, parseTcgdexPricing } from './tcgdex-client.js';
+import { fetchFolioPriceIndex, matchFolioPrice } from './foolio-client.js';
+
+const REFRESH_LOCK_TTL_SEC = 60;
+const REFRESH_LOCK_PREFIX = 'deckhearth:pricing-refresh:';
+
+let redisClient = null;
+
+function getRedis() {
+ const url = process.env.REDIS_URL;
+ if (!url) return null;
+ if (!redisClient) {
+ redisClient = new Redis(url, {
+ maxRetriesPerRequest: 1,
+ enableOfflineQueue: false,
+ });
+ }
+ return redisClient;
+}
+
+/**
+ * @param {number} cardId
+ * @returns {Promise}
+ */
+async function acquireRefreshLock(cardId) {
+ const redis = getRedis();
+ if (!redis) return true;
+ try {
+ const result = await redis.set(
+ `${REFRESH_LOCK_PREFIX}${cardId}`,
+ '1',
+ 'EX',
+ REFRESH_LOCK_TTL_SEC,
+ 'NX'
+ );
+ return result === 'OK';
+ } catch (err) {
+ console.error('[pricing-service] lock acquire failed', err);
+ return false;
+ }
+}
+
+/**
+ * @param {number} cardId
+ */
+async function releaseRefreshLock(cardId) {
+ const redis = getRedis();
+ if (!redis) return;
+ try {
+ await redis.del(`${REFRESH_LOCK_PREFIX}${cardId}`);
+ } catch (err) {
+ console.error('[pricing-service] lock release failed', err);
+ }
+}
+
+/**
+ * @param {string} scryfallId
+ */
+async function fetchScryfallPrices(scryfallId) {
+ const response = await fetch(`https://api.scryfall.com/cards/${scryfallId}`, {
+ headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`Scryfall API error: ${response.status}`);
+ }
+ const card = await response.json();
+ let currentPrice = null;
+ let marketPrice = null;
+ if (card.prices) {
+ currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null;
+ marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null;
+ }
+ return { currentPrice, marketPrice, currency: 'USD' };
+}
+
+/**
+ * @param {{ set_code?: string, card_number?: string, name?: string }} card
+ */
+async function fetchPokemonPrices(card) {
+ const setCode = card.set_code;
+ if (!setCode) return null;
+
+ const base = getTcgdexBaseUrl();
+ if (!card.card_number) return null;
+
+ const paths = [
+ `/cards/${encodeURIComponent(setCode)}-${encodeURIComponent(card.card_number)}`,
+ ];
+
+ for (const path of paths) {
+ try {
+ const response = await fetch(`${base}${path}`, {
+ headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
+ });
+ if (!response.ok) continue;
+ const tcgCard = await response.json();
+ const parsed = parseTcgdexPricing(tcgCard);
+ if (parsed.currentPrice != null || parsed.marketPrice != null) {
+ return {
+ currentPrice: parsed.currentPrice,
+ marketPrice: parsed.marketPrice,
+ currency: parsed.currency ?? 'USD',
+ };
+ }
+ } catch {
+ // try next path
+ }
+ }
+
+ return null;
+}
+
+/**
+ * @param {{ name?: string, set_code?: string, scryfall_id?: string }} card
+ */
+async function fetchLorcanaPrices(card) {
+ const index = await fetchFolioPriceIndex();
+ const matched = matchFolioPrice(index, {
+ name: card.name,
+ setCode: card.set_code,
+ scryfallId: card.scryfall_id,
+ });
+ if (!matched) return null;
+ return {
+ currentPrice: matched.currentPrice,
+ marketPrice: matched.marketPrice,
+ currency: matched.currency ?? 'USD',
+ };
+}
+
+/**
+ * @param {Record} card
+ */
+async function refreshLivePrices(card) {
+ const game = String(card.game || '').toLowerCase();
+ let prices = null;
+
+ if (game === 'mtg' && card.scryfall_id) {
+ prices = await fetchScryfallPrices(card.scryfall_id);
+ } else if (game === 'pokemon') {
+ prices = await fetchPokemonPrices(card);
+ } else if (game === 'lorcana') {
+ prices = await fetchLorcanaPrices(card);
+ }
+
+ if (!prices || (prices.currentPrice == null && prices.marketPrice == null)) {
+ return null;
+ }
+
+ await sql`
+ UPDATE cards
+ SET
+ current_price = ${prices.currentPrice},
+ market_price = ${prices.marketPrice},
+ prices_updated_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ${card.id}
+ `;
+
+ const snapshots = [];
+ if (prices.currentPrice != null) {
+ snapshots.push({
+ cardId: card.id,
+ source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
+ priceType: 'market',
+ currency: prices.currency ?? 'USD',
+ amount: prices.currentPrice,
+ });
+ }
+ if (prices.marketPrice != null && prices.marketPrice !== prices.currentPrice) {
+ snapshots.push({
+ cardId: card.id,
+ source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
+ priceType: 'foil',
+ currency: prices.currency ?? 'USD',
+ amount: prices.marketPrice,
+ });
+ }
+ if (snapshots.length) {
+ await recordPriceSnapshots(snapshots);
+ }
+
+ return prices;
+}
+
+/**
+ * @param {number} cardId
+ * @returns {Promise}
+ */
+export async function getCardPricing(cardId) {
+ const result = await sql`
+ SELECT
+ id, name, game, set_name, set_code, card_number,
+ current_price, market_price, prices_updated_at, scryfall_id
+ FROM cards
+ WHERE id = ${cardId}
+ `;
+
+ if (result.rows.length === 0) {
+ return null;
+ }
+
+ let card = result.rows[0];
+ let stale = isPriceStale(card.prices_updated_at);
+
+ if (stale) {
+ const gotLock = await acquireRefreshLock(cardId);
+ if (gotLock) {
+ try {
+ await refreshLivePrices(card).catch((err) => {
+ console.error('[pricing-service] live refresh failed', err);
+ });
+
+ const refreshed = await sql`
+ SELECT
+ id, name, game, set_name, set_code, card_number,
+ current_price, market_price, prices_updated_at, scryfall_id
+ FROM cards
+ WHERE id = ${cardId}
+ `;
+ if (refreshed.rows.length) {
+ card = refreshed.rows[0];
+ stale = isPriceStale(card.prices_updated_at);
+ }
+ } finally {
+ await releaseRefreshLock(cardId);
+ }
+ }
+ }
+
+ const current = card.current_price != null ? parseFloat(card.current_price) : null;
+ const market = card.market_price != null ? parseFloat(card.market_price) : null;
+
+ return {
+ prices: {
+ current: Number.isFinite(current) ? current : null,
+ market: Number.isFinite(market) ? market : null,
+ currency: 'USD',
+ },
+ pricesUpdatedAt: card.prices_updated_at
+ ? new Date(card.prices_updated_at).toISOString()
+ : null,
+ stale,
+ outbound: buildOutboundLinks(card),
+ };
+}
diff --git a/lib/pricing/tcgdex-client.js b/lib/pricing/tcgdex-client.js
new file mode 100644
index 0000000..b212b55
--- /dev/null
+++ b/lib/pricing/tcgdex-client.js
@@ -0,0 +1,162 @@
+const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en';
+
+const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export function getTcgdexBaseUrl() {
+ return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL;
+}
+
+function tcgplayerVariant(tcgplayer) {
+ if (!tcgplayer || typeof tcgplayer !== 'object') return null;
+
+ const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition'];
+ for (const key of preferredKeys) {
+ const variant = tcgplayer[key];
+ if (variant && typeof variant === 'object') {
+ return variant;
+ }
+ }
+
+ return Object.values(tcgplayer).find(
+ (value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value)
+ ) ?? null;
+}
+
+/**
+ * Parse TCGdex pricing into Deck Hearth card price fields.
+ * USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing.
+ */
+export function parseTcgdexPricing(card) {
+ let currentPrice = null;
+ let marketPrice = null;
+ let currency = 'USD';
+
+ const variant = tcgplayerVariant(card.pricing?.tcgplayer);
+ if (variant) {
+ const low = variant.lowPrice ?? variant.directLowPrice;
+ const market = variant.marketPrice ?? variant.midPrice;
+ currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null);
+ marketPrice = market != null ? Number(market) : currentPrice;
+ }
+
+ // Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable.
+ if (currentPrice == null && marketPrice == null) {
+ const cardmarket = card.pricing?.cardmarket;
+ const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low;
+ if (eur != null) {
+ marketPrice = Number(eur);
+ currency = 'EUR';
+ }
+ }
+
+ if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null;
+ if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null;
+
+ return { currentPrice, marketPrice, currency };
+}
+
+export function normalizeTcgdexName(name) {
+ return String(name ?? '').trim().toLowerCase();
+}
+
+function cardNumberKeys(card) {
+ const keys = new Set();
+ const localId = card.localId ?? card.id?.split('-').pop();
+ if (localId != null && localId !== '') {
+ keys.add(String(localId).trim().toLowerCase());
+ const official = card.set?.cardCount?.official;
+ if (official) {
+ keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase());
+ }
+ }
+ return keys;
+}
+
+/**
+ * Build lookup indexes for matching DB rows by set_code + card_number or name.
+ */
+export function buildTcgdexPriceIndex(cards) {
+ const byNumber = new Map();
+ const byName = new Map();
+
+ for (const card of cards) {
+ for (const key of cardNumberKeys(card)) {
+ if (!byNumber.has(key)) {
+ byNumber.set(key, card);
+ }
+ }
+
+ const nameKey = normalizeTcgdexName(card.name);
+ if (!byName.has(nameKey)) {
+ byName.set(nameKey, []);
+ }
+ byName.get(nameKey).push(card);
+ }
+
+ return { byNumber, byName };
+}
+
+/**
+ * Match a catalog row to a TCGdex card within a set.
+ */
+export function matchTcgdexCard(index, row) {
+ const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase();
+ if (cardNumber) {
+ const direct = index.byNumber.get(cardNumber);
+ if (direct) return direct;
+
+ const slashIndex = cardNumber.indexOf('/');
+ if (slashIndex !== -1) {
+ const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10));
+ if (numerator !== 'NaN') {
+ const byNumerator = index.byNumber.get(numerator);
+ if (byNumerator) return byNumerator;
+ }
+ }
+ }
+
+ const nameKey = normalizeTcgdexName(row.name);
+ const candidates = index.byName.get(nameKey) ?? [];
+ return candidates.length === 1 ? candidates[0] : null;
+}
+
+async function fetchTcgdexJson(path) {
+ const response = await fetch(`${getTcgdexBaseUrl()}${path}`, {
+ headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
+ });
+
+ if (!response.ok) {
+ throw new Error(`TCGdex API error: ${response.status} for ${path}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch all cards for a Pokémon set, including pricing when available.
+ */
+export async function fetchSetCards(setId) {
+ const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`);
+ const summaries = Array.isArray(setData?.cards) ? setData.cards : [];
+
+ const cards = [];
+ for (const summary of summaries) {
+ if (!summary?.id) continue;
+
+ if (summary.pricing) {
+ cards.push(summary);
+ continue;
+ }
+
+ try {
+ const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`);
+ cards.push(fullCard);
+ } catch {
+ cards.push(summary);
+ }
+ }
+
+ return cards;
+}
+
+export { delay };
diff --git a/lib/rate-limit.js b/lib/rate-limit.js
index 7f9258b..84d4546 100644
--- a/lib/rate-limit.js
+++ b/lib/rate-limit.js
@@ -9,6 +9,8 @@ const LIMITER_CONFIG = {
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
// One camera verify may escalate L0→L2; vision path is the expensive step.
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
+ stores: { limit: 1, durationSec: 60 * 60, prefix: 'deckhearth:stores' },
+ storesIp: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:stores-ip' },
};
let cached = null;
@@ -122,3 +124,11 @@ export async function checkImportRateLimit(req, userId) {
export async function checkScanRateLimit(req, userId) {
return check('scan', extractUserIdentifier(userId));
}
+
+export async function checkStoresRateLimit(req, userId) {
+ const userResult = await check('stores', extractUserIdentifier(userId));
+ if (!userResult.allowed) {
+ return userResult;
+ }
+ return check('storesIp', extractIpIdentifier(req));
+}
diff --git a/lib/sql.js b/lib/sql.js
index 2817f96..f8a5ced 100644
--- a/lib/sql.js
+++ b/lib/sql.js
@@ -65,6 +65,23 @@ export async function sql(strings, ...values) {
};
}
+/**
+ * Parameterized raw SQL for bulk import upserts (legacy `$1` placeholders).
+ * @param {string} text
+ * @param {unknown[]} [values]
+ */
+export async function sqlQuery(text, values = []) {
+ const pg = getClient();
+ const result = await pg.unsafe(text, values);
+ const rows = Array.from(result);
+ return {
+ rows,
+ rowCount: typeof result.count === 'number' ? result.count : rows.length,
+ };
+}
+
+sql.query = sqlQuery;
+
/** Close the pooled client (tests / long-running scripts). */
export async function closeSqlPool() {
if (client) {
diff --git a/lib/use-card-detail.js b/lib/use-card-detail.js
index b500524..6b37d0c 100644
--- a/lib/use-card-detail.js
+++ b/lib/use-card-detail.js
@@ -26,6 +26,14 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
const [decks, setDecks] = useState([]);
const [cardCollections, setCardCollections] = useState([]);
const [cardDecks, setCardDecks] = useState([]);
+ const [pricing, setPricing] = useState(null);
+ const [pricingLoading, setPricingLoading] = useState(false);
+ const [priceHistory, setPriceHistory] = useState([]);
+ const [priceHistoryLoading, setPriceHistoryLoading] = useState(false);
+ const [nearbyStores, setNearbyStores] = useState([]);
+ const [nearbyStoresLoading, setNearbyStoresLoading] = useState(false);
+ const [nearbyStoresError, setNearbyStoresError] = useState(null);
+ const [nearbyStoresNoHomeZip, setNearbyStoresNoHomeZip] = useState(false);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@@ -82,6 +90,55 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
fetchCard();
}, [id]);
+
+ useEffect(() => {
+ if (!id || activeTab !== 'purchase') return;
+ let cancelled = false;
+
+ const fetchPricing = async () => {
+ setPricingLoading(true);
+ try {
+ const response = await fetch(`/api/cards/${id}/pricing`);
+ if (response.ok && !cancelled) {
+ setPricing(await response.json());
+ }
+ } catch (error) {
+ console.error('Error fetching card pricing:', error);
+ } finally {
+ if (!cancelled) setPricingLoading(false);
+ }
+ };
+
+ fetchPricing();
+ return () => {
+ cancelled = true;
+ };
+ }, [id, activeTab]);
+
+ useEffect(() => {
+ if (!id || activeTab !== 'price-history') return;
+ let cancelled = false;
+
+ const fetchHistory = async () => {
+ setPriceHistoryLoading(true);
+ try {
+ const response = await fetch(`/api/cards/${id}/price-history?days=90`);
+ if (response.ok && !cancelled) {
+ const data = await response.json();
+ setPriceHistory(data.points || []);
+ }
+ } catch (error) {
+ console.error('Error fetching price history:', error);
+ } finally {
+ if (!cancelled) setPriceHistoryLoading(false);
+ }
+ };
+
+ fetchHistory();
+ return () => {
+ cancelled = true;
+ };
+ }, [id, activeTab]);
// Fetch user's collections and decks
useEffect(() => {
@@ -129,6 +186,67 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
fetchUserData();
}
}, [card, id]);
+
+ useEffect(() => {
+ if (activeTab !== 'purchase' || !user) {
+ return;
+ }
+
+ let cancelled = false;
+
+ const fetchNearbyStores = async () => {
+ setNearbyStoresLoading(true);
+ setNearbyStoresError(null);
+ setNearbyStoresNoHomeZip(false);
+
+ try {
+ const token = localStorage.getItem('auth_token');
+ if (!token) {
+ setNearbyStores([]);
+ return;
+ }
+
+ const response = await fetch('/api/stores/nearby', {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (cancelled) return;
+
+ if (response.status === 400) {
+ const data = await response.json();
+ if (data.code === 'NO_HOME_ZIP') {
+ setNearbyStores([]);
+ setNearbyStoresNoHomeZip(true);
+ return;
+ }
+ }
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ setNearbyStores([]);
+ setNearbyStoresError(data.error || 'Could not load nearby stores');
+ return;
+ }
+
+ const data = await response.json();
+ setNearbyStores(data.stores ?? []);
+ } catch (error) {
+ if (!cancelled) {
+ console.error('Error fetching nearby stores:', error);
+ setNearbyStoresError('Could not load nearby stores');
+ }
+ } finally {
+ if (!cancelled) {
+ setNearbyStoresLoading(false);
+ }
+ }
+ };
+
+ fetchNearbyStores();
+ return () => {
+ cancelled = true;
+ };
+ }, [activeTab, user]);
const getRarityGradient = (rarity) => {
const rarityKey = rarity?.toLowerCase();
@@ -369,9 +487,17 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
isFavorited,
loading,
manaSymbolSettings,
+ nearbyStores,
+ nearbyStoresError,
+ nearbyStoresLoading,
+ nearbyStoresNoHomeZip,
ownedQuantity,
particleStyles,
getParticleCount,
+ priceHistory,
+ priceHistoryLoading,
+ pricing,
+ pricingLoading,
quantity,
router,
selectedCollection,
diff --git a/lib/use-collection-view.js b/lib/use-collection-view.js
index f645489..08bc85f 100644
--- a/lib/use-collection-view.js
+++ b/lib/use-collection-view.js
@@ -34,7 +34,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
const [selectedType, setSelectedType] = useState('All Types');
const [groupBy, setGroupBy] = useState('Group by Game');
- const [sortBy, setSortBy] = useState('Sort by Name');
+ const [sortBy, setSortBy] = useState('name');
+ const [sortOrder, setSortOrder] = useState('asc');
const [viewMode, setViewMode] = useState('grid');
const [searchCards, setSearchCards] = useState('');
const [searchResults, setSearchResults] = useState([]);
@@ -118,11 +119,14 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
});
+ const sortParams = new URLSearchParams({ sort: sortBy, order: sortOrder });
+ const cardsUrl = `/api/collections/${identifier}/cards?${sortParams}`;
+
// Fetch collection cards (try public first, then authenticated)
- let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
+ let cardsResponse = await fetch(cardsUrl);
if (!cardsResponse.ok && user) {
- cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
+ cardsResponse = await fetch(cardsUrl, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
@@ -160,8 +164,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
loadFavoritedCards();
}
}
- // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
- }, [identifier, user]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug, auth, or sort changes
+ }, [identifier, user, sortBy, sortOrder]);
const handleEditCollection = async () => {
try {
@@ -488,6 +492,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
setShowShareModal,
setShowUploadModal,
setSortBy,
+ setSortOrder,
setViewMode,
showDeleteModal,
showEditModal,
@@ -496,6 +501,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
showShareModal,
showUploadModal,
sortBy,
+ sortOrder,
toggleFavorite,
togglePublic,
viewMode
diff --git a/migrations/1783000000001_add-pricing-schema.js b/migrations/1783000000001_add-pricing-schema.js
new file mode 100644
index 0000000..271195d
--- /dev/null
+++ b/migrations/1783000000001_add-pricing-schema.js
@@ -0,0 +1,64 @@
+/**
+ * Pricing convoy schema: price timestamps, history snapshots, home zip, geo caches.
+ *
+ * @type {import('node-pg-migrate').ColumnDefinitions | undefined}
+ */
+export const shorthands = undefined;
+
+/**
+ * @param {import('node-pg-migrate').MigrationBuilder} pgm
+ */
+export const up = (pgm) => {
+ pgm.sql(`
+ ALTER TABLE cards
+ ADD COLUMN IF NOT EXISTS prices_updated_at TIMESTAMPTZ;
+
+ CREATE TABLE IF NOT EXISTS card_price_history (
+ id SERIAL PRIMARY KEY,
+ card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
+ source VARCHAR(32) NOT NULL,
+ price_type VARCHAR(32) NOT NULL DEFAULT 'market',
+ currency VARCHAR(3) NOT NULL DEFAULT 'USD',
+ amount DECIMAL(10,2) NOT NULL,
+ recorded_at DATE NOT NULL DEFAULT CURRENT_DATE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (card_id, source, price_type, currency, recorded_at)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_card_price_history_card_date
+ ON card_price_history (card_id, recorded_at DESC);
+
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS home_zip VARCHAR(10);
+
+ CREATE TABLE IF NOT EXISTS geo_zip_cache (
+ zip VARCHAR(10) PRIMARY KEY,
+ lat DOUBLE PRECISION NOT NULL,
+ lng DOUBLE PRECISION NOT NULL,
+ display_name TEXT,
+ geocoded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS geo_stores_cache (
+ cache_key VARCHAR(64) PRIMARY KEY,
+ lat DOUBLE PRECISION NOT NULL,
+ lng DOUBLE PRECISION NOT NULL,
+ radius_meters INTEGER NOT NULL,
+ stores JSONB NOT NULL,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_geo_stores_cache_fetched
+ ON geo_stores_cache (fetched_at);
+ `);
+};
+
+/**
+ * @param {import('node-pg-migrate').MigrationBuilder} pgm
+ */
+export const down = (pgm) => {
+ throw new Error(
+ '[migration:1783000000001_add-pricing-schema] Refusing to drop pricing schema. ' +
+ 'Rolling back would erase card_price_history snapshots and geo caches.'
+ );
+};
diff --git a/package.json b/package.json
index d6117fd..6ac1707 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,8 @@
"bulk-import-pokemon": "node --env-file=.env.local scripts/bulk-import-pokemon.js",
"backfill-embeddings": "node --env-file=.env.local scripts/backfill-card-embeddings.js",
"migrate-neon-to-homelab": "node --env-file=.env.local scripts/migrate-neon-to-homelab.js",
+ "fix-pricing-pgmigration-order": "node --env-file=.env.local scripts/fix-pricing-pgmigration-order.js",
+ "sync-catalog-unified": "node --env-file=.env.local scripts/run-unified-catalog-sync.js",
"import-tags": "node --env-file=.env.local scripts/import-scryfall-tags.js",
"test": "vitest",
"test:run": "vitest run",
diff --git a/pages/api/cards/[id].js b/pages/api/cards/[id].js
index ce81c7e..3f351a2 100644
--- a/pages/api/cards/[id].js
+++ b/pages/api/cards/[id].js
@@ -10,7 +10,7 @@ export default async function handler(req, res) {
id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
- current_price, market_price, scryfall_id, verified,
+ current_price, market_price, prices_updated_at, scryfall_id, verified,
quantity
FROM cards
WHERE id = ${id}
diff --git a/pages/api/cards/[id]/price-history.js b/pages/api/cards/[id]/price-history.js
new file mode 100644
index 0000000..dba3f72
--- /dev/null
+++ b/pages/api/cards/[id]/price-history.js
@@ -0,0 +1,32 @@
+import { sql } from '../../../../lib/sql.js';
+import { getPriceHistoryPoints } from '../../../../lib/pricing/price-history.js';
+
+export default async function handler(req, res) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ try {
+ const cardId = parseInt(req.query.id, 10);
+ if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) {
+ return res.status(400).json({ error: 'Valid card ID is required' });
+ }
+
+ const daysRaw = req.query.days ?? '90';
+ const days = parseInt(daysRaw, 10);
+ if (Number.isNaN(days) || days < 1 || days > 365) {
+ return res.status(400).json({ error: 'days must be between 1 and 365' });
+ }
+
+ const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`;
+ if (cardCheck.rows.length === 0) {
+ return res.status(404).json({ error: 'Card not found' });
+ }
+
+ const points = await getPriceHistoryPoints(cardId, { days });
+ return res.status(200).json({ points });
+ } catch (err) {
+ console.error('[GET /api/cards/[id]/price-history]', err);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+}
diff --git a/pages/api/cards/[id]/pricing.js b/pages/api/cards/[id]/pricing.js
new file mode 100644
index 0000000..fe00060
--- /dev/null
+++ b/pages/api/cards/[id]/pricing.js
@@ -0,0 +1,24 @@
+import { getCardPricing } from '../../../../lib/pricing/pricing-service.js';
+
+export default async function handler(req, res) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ try {
+ const cardId = parseInt(req.query.id, 10);
+ if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) {
+ return res.status(400).json({ error: 'Valid card ID is required' });
+ }
+
+ const pricing = await getCardPricing(cardId);
+ if (!pricing) {
+ return res.status(404).json({ error: 'Card not found' });
+ }
+
+ return res.status(200).json(pricing);
+ } catch (err) {
+ console.error('[GET /api/cards/[id]/pricing]', err);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+}
diff --git a/pages/api/cards/owned.js b/pages/api/cards/owned.js
index 054c422..7c43e88 100644
--- a/pages/api/cards/owned.js
+++ b/pages/api/cards/owned.js
@@ -1,5 +1,27 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
+import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
+
+const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']);
+const VALID_ORDERS = new Set(['asc', 'desc']);
+
+function buildSortClause(sort, order) {
+ const dir = order === 'desc' ? 'DESC' : 'ASC';
+ const nulls = 'NULLS LAST';
+ const priceExpr = listUnitPriceExpr({ cardAlias: 'c' });
+
+ switch (sort) {
+ case 'price':
+ return `ORDER BY ${priceExpr} ${dir} ${nulls}, c.name ASC`;
+ case 'rarity':
+ return `ORDER BY c.rarity ${dir} ${nulls}, c.name ASC`;
+ case 'date':
+ return `ORDER BY uc.created_at ${dir} ${nulls}, c.name ASC`;
+ case 'name':
+ default:
+ return `ORDER BY c.name ${dir} ${nulls}`;
+ }
+}
export default async function handler(req, res) {
if (req.method !== 'GET') {
@@ -20,9 +42,15 @@ export default async function handler(req, res) {
set = 'all',
valueRange = 'all',
page = '1',
- limit = '50'
+ limit = '50',
+ sort: sortParam = 'name',
+ order: orderParam = 'asc',
} = req.query;
+ const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name';
+ const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc';
+ const orderClause = buildSortClause(sort, order);
+
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 50;
const offset = (pageNum - 1) * limitNum;
@@ -57,6 +85,7 @@ export default async function handler(req, res) {
}
if (valueRange !== 'all') {
+ const priceExpr = listUnitPriceExpr({ cardAlias: 'c' });
// Handle value range filtering
const ranges = {
'under-1': [0, 1],
@@ -69,11 +98,11 @@ export default async function handler(req, res) {
if (ranges[valueRange]) {
const [min, max] = ranges[valueRange];
if (valueRange === 'over-100') {
- whereConditions.push(`c.market_price >= $${paramIndex}`);
+ whereConditions.push(`${priceExpr} >= $${paramIndex}`);
params.push(min);
paramIndex++;
} else {
- whereConditions.push(`c.market_price >= $${paramIndex} AND c.market_price <= $${paramIndex + 1}`);
+ whereConditions.push(`${priceExpr} >= $${paramIndex} AND ${priceExpr} <= $${paramIndex + 1}`);
params.push(min, max);
paramIndex += 2;
}
@@ -95,7 +124,7 @@ export default async function handler(req, res) {
FROM cards c
INNER JOIN user_cards uc ON c.id = uc.card_id
WHERE ${whereClause}
- ORDER BY c.name ASC
+ ${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
diff --git a/pages/api/collections/[identifier].js b/pages/api/collections/[identifier].js
index 05790d6..564bbe6 100644
--- a/pages/api/collections/[identifier].js
+++ b/pages/api/collections/[identifier].js
@@ -1,6 +1,10 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { isValidSlug } from '../../../lib/slug-utils';
+import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
+
+const LIST_UNIT_PRICE_EXPR = listUnitPriceExpr({ cardAlias: 'cards' });
+const LIST_VALUED_QUANTITY = sql([`${LIST_UNIT_PRICE_EXPR} * cc.quantity`]);
export default async function handler(req, res) {
try {
@@ -24,7 +28,7 @@ export default async function handler(req, res) {
c.*,
u.email as creator_email,
COUNT(cc.card_id) as card_count,
- COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
+ COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
${user ? sql`cp.role as user_role,
CASE
WHEN c.user_id = ${user.userId} THEN 'owner'
@@ -51,7 +55,7 @@ export default async function handler(req, res) {
c.*,
u.email as creator_email,
COUNT(cc.card_id) as card_count,
- COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
+ COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
${user ? sql`cp.role as user_role,
CASE
WHEN c.user_id = ${user.userId} THEN 'owner'
diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js
index b3a4f55..dd6394b 100644
--- a/pages/api/collections/[identifier]/cards.js
+++ b/pages/api/collections/[identifier]/cards.js
@@ -1,6 +1,28 @@
import { sql } from '../../../../lib/sql.js';
import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware';
import { isValidSlug } from '../../../../lib/slug-utils';
+import { listUnitPriceExpr } from '../../../../lib/pricing/collection-valuation.js';
+
+const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']);
+const VALID_ORDERS = new Set(['asc', 'desc']);
+
+function buildSortClause(sort, order) {
+ const dir = order === 'desc' ? 'DESC' : 'ASC';
+ const nulls = 'NULLS LAST';
+ const priceExpr = listUnitPriceExpr({ cardAlias: 'cards' });
+
+ switch (sort) {
+ case 'price':
+ return `ORDER BY ${priceExpr} ${dir} ${nulls}, cards.name ASC`;
+ case 'rarity':
+ return `ORDER BY cards.rarity ${dir} ${nulls}, cards.name ASC`;
+ case 'date':
+ return `ORDER BY cc.created_at ${dir} ${nulls}, cards.name ASC`;
+ case 'name':
+ default:
+ return `ORDER BY cards.name ${dir} ${nulls}`;
+ }
+}
export default async function handler(req, res) {
try {
@@ -50,17 +72,25 @@ export default async function handler(req, res) {
const collection = collectionResult.rows[0];
if (req.method === 'GET') {
- // Get all cards in the collection
- const cardsResult = await sql`
+ const sortParam = req.query.sort ?? 'name';
+ const orderParam = req.query.order ?? 'asc';
+ const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name';
+ const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc';
+ const orderClause = buildSortClause(sort, order);
+
+ const cardsResult = await sql.query(
+ `
SELECT
cards.*,
cc.quantity,
cc.created_at as added_at
FROM collection_cards cc
JOIN cards ON cc.card_id = cards.id
- WHERE cc.collection_id = ${collection.id}
- ORDER BY cc.created_at DESC
- `;
+ WHERE cc.collection_id = $1
+ ${orderClause}
+ `,
+ [collection.id]
+ );
const cards = cardsResult.rows.map(card => ({
id: card.id,
diff --git a/pages/api/stores/nearby.js b/pages/api/stores/nearby.js
new file mode 100644
index 0000000..c8d83e5
--- /dev/null
+++ b/pages/api/stores/nearby.js
@@ -0,0 +1,73 @@
+import { sql } from '../../../lib/sql.js';
+import { getUserFromRequest } from '../../../lib/permission-middleware.js';
+import { checkStoresRateLimit } from '../../../lib/rate-limit.js';
+import { getZipCoords, getStoresNear } from '../../../lib/pricing/geo-cache.js';
+import { clampRadiusMiles, milesToMeters } from '../../../lib/pricing/overpass.js';
+
+function isLocalPricingEnabled() {
+ const flag = process.env.PRICING_LOCAL_ENABLED;
+ return flag === undefined || flag === '' || flag === 'true';
+}
+
+export default async function handler(req, res) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ if (!isLocalPricingEnabled()) {
+ return res.status(503).json({ error: 'Local store discovery is disabled' });
+ }
+
+ try {
+ const user = await getUserFromRequest(req);
+ if (!user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ const rateLimit = await checkStoresRateLimit(req, user.userId);
+ if (!rateLimit.allowed) {
+ const retryAfterSec = Math.max(1, Math.ceil((rateLimit.reset - Date.now()) / 1000));
+ res.setHeader('Retry-After', String(retryAfterSec));
+ return res.status(429).json({ error: 'Rate limit exceeded' });
+ }
+
+ const userRow = await sql`
+ SELECT home_zip
+ FROM users
+ WHERE id = ${user.userId}
+ LIMIT 1
+ `;
+
+ if (userRow.rows.length === 0) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const homeZip = userRow.rows[0].home_zip;
+ if (!homeZip) {
+ return res.status(400).json({ error: 'Home ZIP not configured', code: 'NO_HOME_ZIP' });
+ }
+
+ const radiusMiles = clampRadiusMiles(
+ req.query.radiusMiles != null ? Number(req.query.radiusMiles) : undefined
+ );
+ const radiusMeters = milesToMeters(radiusMiles);
+
+ const coords = await getZipCoords(homeZip);
+ if (!coords) {
+ return res.status(404).json({ error: 'Could not geocode home ZIP' });
+ }
+
+ const { stores, cached } = await getStoresNear(coords.lat, coords.lng, radiusMeters);
+
+ return res.status(200).json({
+ homeZip,
+ radiusMiles,
+ stores,
+ cached,
+ coordsCached: coords.cached,
+ });
+ } catch (error) {
+ console.error('[stores/nearby]', error);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+}
diff --git a/pages/api/user/settings.js b/pages/api/user/settings.js
index df0509c..99b8386 100644
--- a/pages/api/user/settings.js
+++ b/pages/api/user/settings.js
@@ -16,7 +16,7 @@ export default async function handler(req, res) {
id, email, role, first_name, last_name, username,
collection_visibility, preferred_currency, cards_per_page,
default_view, notifications_email, notifications_marketing,
- two_factor_enabled, theme, language,
+ two_factor_enabled, theme, language, home_zip,
created_at, updated_at
FROM users
WHERE id = ${user.userId}
@@ -54,7 +54,10 @@ export default async function handler(req, res) {
// Display Settings
theme: userData.theme || 'system',
- language: userData.language || 'en'
+ language: userData.language || 'en',
+
+ // Local stores
+ home_zip: userData.home_zip ?? null,
}
};
@@ -71,7 +74,8 @@ export default async function handler(req, res) {
notifications_email,
notifications_marketing,
theme,
- language
+ language,
+ home_zip
} = req.body;
// Validate enum values
@@ -101,6 +105,9 @@ export default async function handler(req, res) {
if (language && !validLanguage.includes(language)) {
return res.status(400).json({ error: 'Invalid language' });
}
+ if (home_zip !== undefined && home_zip !== null && !/^\d{5}$/.test(String(home_zip))) {
+ return res.status(400).json({ error: 'Invalid home ZIP — use a 5-digit US zip code' });
+ }
// Build update query dynamically
const updateFields = [];
@@ -152,6 +159,11 @@ export default async function handler(req, res) {
updateValues.push(language);
paramIndex++;
}
+ if (home_zip !== undefined) {
+ updateFields.push(`home_zip = $${paramIndex}`);
+ updateValues.push(home_zip === '' ? null : home_zip);
+ paramIndex++;
+ }
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No settings to update' });
@@ -170,7 +182,7 @@ export default async function handler(req, res) {
id, email, role, first_name, last_name, username,
collection_visibility, preferred_currency, cards_per_page,
default_view, notifications_email, notifications_marketing,
- two_factor_enabled, theme, language,
+ two_factor_enabled, theme, language, home_zip,
created_at, updated_at
`;
@@ -201,7 +213,8 @@ export default async function handler(req, res) {
notifications_email: userData.notifications_email,
notifications_marketing: userData.notifications_marketing,
theme: userData.theme,
- language: userData.language
+ language: userData.language,
+ home_zip: userData.home_zip ?? null,
}
};
diff --git a/pages/api/user/stats.js b/pages/api/user/stats.js
index b388e6d..eaa07d5 100644
--- a/pages/api/user/stats.js
+++ b/pages/api/user/stats.js
@@ -1,5 +1,10 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
+import { effectiveUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
+
+const UNIT_PRICE_EXPR = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' });
+const VALUED_QUANTITY = sql([`(${UNIT_PRICE_EXPR}) * user_cards.quantity`]);
+const HAS_UNIT_PRICE = sql([`(${UNIT_PRICE_EXPR}) IS NOT NULL`]);
export default async function handler(req, res) {
if (req.method !== 'GET') {
@@ -36,11 +41,11 @@ export default async function handler(req, res) {
// Get total value of user's cards
const valueResult = await sql`
- SELECT COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
+ SELECT COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
FROM user_cards
JOIN cards ON user_cards.card_id = cards.id
WHERE user_cards.user_id = ${user.userId}
- AND cards.market_price IS NOT NULL
+ AND ${HAS_UNIT_PRICE}
`;
// Get game breakdown
@@ -49,7 +54,7 @@ export default async function handler(req, res) {
cards.game,
COUNT(DISTINCT cards.id) as unique_cards,
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
- COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
+ COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
FROM user_cards
JOIN cards ON user_cards.card_id = cards.id
WHERE user_cards.user_id = ${user.userId}
@@ -63,7 +68,7 @@ export default async function handler(req, res) {
cards.rarity,
COUNT(DISTINCT cards.id) as unique_cards,
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
- COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
+ COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
FROM user_cards
JOIN cards ON user_cards.card_id = cards.id
WHERE user_cards.user_id = ${user.userId}
@@ -78,7 +83,7 @@ export default async function handler(req, res) {
cards.game,
cards.rarity,
cards.image_url,
- cards.market_price,
+ ${sql([`${UNIT_PRICE_EXPR} as market_price`])},
user_cards.quantity,
user_cards.created_at
FROM user_cards
diff --git a/pages/my-cards.js b/pages/my-cards.js
index 07d25bb..2e55c73 100644
--- a/pages/my-cards.js
+++ b/pages/my-cards.js
@@ -23,6 +23,8 @@ export default function MyCards() {
const [selectedRarity, setSelectedRarity] = useState('all');
const [selectedSet, setSelectedSet] = useState('all');
const [selectedValueRange, setSelectedValueRange] = useState('all');
+ const [sortBy, setSortBy] = useState('name');
+ const [sortOrder, setSortOrder] = useState('asc');
const [viewMode, setViewMode] = useState('grid'); // grid or list
const [pagination, setPagination] = useState({
page: 1,
@@ -75,6 +77,8 @@ export default function MyCards() {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: pagination.limit.toString(),
+ sort: sortBy,
+ order: sortOrder,
...(searchQuery && { search: searchQuery }),
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
@@ -185,7 +189,7 @@ export default function MyCards() {
loadFavoritedCards();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
- }, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
+ }, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange, sortBy, sortOrder]);
// Infinite scroll
useEffect(() => {
@@ -382,6 +386,28 @@ export default function MyCards() {
))}
+
+
+
+
diff --git a/pages/settings.js b/pages/settings.js
index 65ee315..07ee112 100644
--- a/pages/settings.js
+++ b/pages/settings.js
@@ -23,7 +23,10 @@ export default function Settings() {
// Display Settings
theme: 'system', // light, dark, system
- language: 'en'
+ language: 'en',
+
+ // Local stores
+ home_zip: '',
});
// Password change state
@@ -531,6 +534,26 @@ export default function Settings() {
))}
+
+ {/* Home ZIP for local stores */}
+
+
+
handleSettingChange('home_zip', e.target.value.replace(/\D/g, '').slice(0, 5))}
+ placeholder="e.g. 10001"
+ className="input-field w-full"
+ />
+
+ Used to find game and hobby stores near you on card detail. Leave blank to disable.
+
+
)}
diff --git a/scripts/fix-pricing-pgmigration-order.js b/scripts/fix-pricing-pgmigration-order.js
new file mode 100644
index 0000000..7a34b41
--- /dev/null
+++ b/scripts/fix-pricing-pgmigration-order.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+/**
+ * Backfill pgmigrations.run_on for 1783000000001 so `npm run migrate up`
+ * passes node-pg-migrate order check on homelab DBs that applied pricing
+ * DDL out of timestamp sequence (pricing convoy branch merged after frame migrations).
+ *
+ * Requires POSTGRES_URL_DIRECT (homelab deckhearth URL).
+ *
+ * Usage:
+ * node --env-file=.env.local scripts/fix-pricing-pgmigration-order.js
+ */
+
+import postgres from 'postgres';
+
+function requireEnv(name) {
+ const value = process.env[name]?.trim();
+ if (!value) {
+ console.error(`❌ ${name} is required`);
+ process.exit(1);
+ }
+ return value;
+}
+
+const MIGRATION_NAME = '1783000000001_add-pricing-schema';
+/** Between 1782000000001 (embeddings) and 1786757312884 (visibility index). */
+const RUN_ON = '2026-06-15 12:00:00+00';
+
+async function main() {
+ const url = requireEnv('POSTGRES_URL_DIRECT');
+ const isPrivateLan = /(?:^|@)(?:localhost|127\.0\.0\.1|192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(url);
+ const sql = postgres(url, { ssl: isPrivateLan ? false : 'require', max: 1 });
+
+ try {
+ const existing = await sql`
+ SELECT name, run_on FROM pgmigrations WHERE name = ${MIGRATION_NAME}
+ `;
+
+ if (existing.length === 0) {
+ console.error(`❌ No pgmigrations row for ${MIGRATION_NAME}. Run pricing migration SQL first.`);
+ process.exit(1);
+ }
+
+ const before = existing[0].run_on;
+ await sql`
+ UPDATE pgmigrations
+ SET run_on = ${RUN_ON}::timestamptz
+ WHERE name = ${MIGRATION_NAME}
+ `;
+
+ console.log(`✅ Updated ${MIGRATION_NAME}`);
+ console.log(` run_on: ${before} → ${RUN_ON}`);
+ console.log('Next: npm run migrate up');
+ } finally {
+ await sql.end({ timeout: 5 });
+ }
+}
+
+main().catch((err) => {
+ console.error(err.message || err);
+ process.exit(1);
+});
diff --git a/scripts/migrate-neon-to-homelab.js b/scripts/migrate-neon-to-homelab.js
index 0130f65..72effbe 100644
--- a/scripts/migrate-neon-to-homelab.js
+++ b/scripts/migrate-neon-to-homelab.js
@@ -87,7 +87,7 @@ async function main() {
run('psql', [targetUrl, '-c', 'SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size;']);
console.log('\n✅ Data copy finished.');
- console.log('Next: set POSTGRES_URL to the homelab URL and deploy (Coolify on CT 107).');
+ console.log('Next: set POSTGRES_URL to the homelab URL and deploy (Dokploy on CT 112).');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
diff --git a/scripts/run-unified-catalog-sync.js b/scripts/run-unified-catalog-sync.js
new file mode 100644
index 0000000..f729410
--- /dev/null
+++ b/scripts/run-unified-catalog-sync.js
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/**
+ * Run MTG + Pokémon + Lorcana bulk syncs (with pricing ingest) against POSTGRES_URL.
+ *
+ * Usage:
+ * node --env-file=.env.local scripts/run-unified-catalog-sync.js
+ */
+
+import { runUnifiedCatalogSync } from '../lib/card-import/sync-catalog.js';
+import { closeSqlPool } from '../lib/sql.js';
+
+async function main() {
+ const summary = await runUnifiedCatalogSync();
+ console.log(JSON.stringify(summary, null, 2));
+
+ if (summary.errors?.length) {
+ process.exitCode = 1;
+ }
+}
+
+main()
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ try {
+ await closeSqlPool();
+ } catch {
+ // ignore
+ }
+ });
diff --git a/test/api/cards-owned-sort.test.js b/test/api/cards-owned-sort.test.js
new file mode 100644
index 0000000..d69cd0f
--- /dev/null
+++ b/test/api/cards-owned-sort.test.js
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { listUnitPriceExpr } from '../../lib/pricing/collection-valuation.js';
+
+vi.mock('../../lib/sql.js', () => ({ sql: { query: vi.fn() } }));
+vi.mock('../../lib/permission-middleware.js', () => ({
+ getUserFromRequest: vi.fn(),
+}));
+
+import { sql } from '../../lib/sql.js';
+import { getUserFromRequest } from '../../lib/permission-middleware.js';
+import handler from '../../pages/api/cards/owned.js';
+
+const UNIT_PRICE_SQL = listUnitPriceExpr({ cardAlias: 'c' });
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+describe('GET /api/cards/owned sort', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
+ sql.query.mockImplementation(async (queryText) => {
+ if (queryText.includes('COUNT(*)')) {
+ return { rows: [{ total: '0' }] };
+ }
+ if (queryText.includes('DISTINCT')) {
+ return { rows: [] };
+ }
+ return { rows: [] };
+ });
+ });
+
+ it('orders by effective price when sort=price&order=desc', async () => {
+ const req = {
+ method: 'GET',
+ query: { sort: 'price', order: 'desc' },
+ };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ const cardsQueryCall = sql.query.mock.calls.find(([queryText]) =>
+ queryText.includes('INNER JOIN user_cards')
+ );
+ expect(cardsQueryCall).toBeDefined();
+ const queryText = cardsQueryCall[0];
+ expect(queryText).toContain(UNIT_PRICE_SQL);
+ expect(queryText).toContain('ORDER BY');
+ expect(queryText).toContain('DESC');
+ expect(queryText).toContain('NULLS LAST');
+ });
+
+ it('defaults to name sort when sort param is invalid', async () => {
+ const req = {
+ method: 'GET',
+ query: { sort: 'invalid', order: 'asc' },
+ };
+ const res = createRes();
+
+ await handler(req, res);
+
+ const cardsQueryCall = sql.query.mock.calls.find(([queryText]) =>
+ queryText.includes('INNER JOIN user_cards')
+ );
+ expect(cardsQueryCall[0]).toContain('ORDER BY c.name ASC');
+ });
+});
diff --git a/test/api/cards-price-history.test.js b/test/api/cards-price-history.test.js
new file mode 100644
index 0000000..179de12
--- /dev/null
+++ b/test/api/cards-price-history.test.js
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
+vi.mock('../../lib/pricing/price-history.js', () => ({
+ getPriceHistoryPoints: vi.fn(),
+}));
+
+import { sql } from '../../lib/sql.js';
+import { getPriceHistoryPoints } from '../../lib/pricing/price-history.js';
+import handler from '../../pages/api/cards/[id]/price-history.js';
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+describe('GET /api/cards/[id]/price-history', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sql.mockResolvedValue({ rows: [{ id: 42 }] });
+ getPriceHistoryPoints.mockResolvedValue([
+ { date: '2026-01-01', amount: 1.25, source: 'scryfall', priceType: 'market', currency: 'USD' },
+ ]);
+ });
+
+ it('returns 400 for invalid card id', async () => {
+ const req = { method: 'GET', query: { id: 'nope' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(400);
+ expect(sql).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 when days is out of range', async () => {
+ const req = { method: 'GET', query: { id: '42', days: '999' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(400);
+ expect(getPriceHistoryPoints).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 when card does not exist', async () => {
+ sql.mockResolvedValue({ rows: [] });
+ const req = { method: 'GET', query: { id: '42', days: '30' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('returns points for a valid request', async () => {
+ const req = { method: 'GET', query: { id: '42', days: '30' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(getPriceHistoryPoints).toHaveBeenCalledWith(42, { days: 30 });
+ expect(res.body.points).toHaveLength(1);
+ expect(res.body.points[0].amount).toBe(1.25);
+ });
+});
diff --git a/test/api/cards-pricing.test.js b/test/api/cards-pricing.test.js
new file mode 100644
index 0000000..d0b7d72
--- /dev/null
+++ b/test/api/cards-pricing.test.js
@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/pricing/pricing-service.js', () => ({
+ getCardPricing: vi.fn(),
+}));
+
+import { getCardPricing } from '../../lib/pricing/pricing-service.js';
+import handler from '../../pages/api/cards/[id]/pricing.js';
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+describe('GET /api/cards/[id]/pricing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns 400 for invalid card id', async () => {
+ const req = { method: 'GET', query: { id: 'abc' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(400);
+ expect(getCardPricing).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 when card is missing', async () => {
+ getCardPricing.mockResolvedValue(null);
+ const req = { method: 'GET', query: { id: '99' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('returns pricing payload with staleness metadata', async () => {
+ getCardPricing.mockResolvedValue({
+ prices: { current: 1.25, market: 2.5, currency: 'USD' },
+ pricesUpdatedAt: '2026-01-01T00:00:00.000Z',
+ stale: true,
+ outbound: {
+ tcgplayer: 'https://tcgplayer.example',
+ ebay: 'https://ebay.example',
+ },
+ });
+
+ const req = { method: 'GET', query: { id: '42' } };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.stale).toBe(true);
+ expect(res.body.prices.current).toBe(1.25);
+ expect(res.body.outbound.tcgplayer).toContain('tcgplayer');
+ });
+});
diff --git a/test/api/stores-nearby.test.js b/test/api/stores-nearby.test.js
new file mode 100644
index 0000000..5fc33fc
--- /dev/null
+++ b/test/api/stores-nearby.test.js
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/sql.js', () => ({
+ sql: vi.fn(),
+}));
+vi.mock('../../lib/permission-middleware.js', () => ({
+ getUserFromRequest: vi.fn(),
+}));
+vi.mock('../../lib/rate-limit.js', () => ({
+ checkStoresRateLimit: vi.fn(),
+}));
+vi.mock('../../lib/pricing/geo-cache.js', () => ({
+ getZipCoords: vi.fn(),
+ getStoresNear: vi.fn(),
+}));
+
+import { sql } from '../../lib/sql.js';
+import { getUserFromRequest } from '../../lib/permission-middleware.js';
+import { checkStoresRateLimit } from '../../lib/rate-limit.js';
+import { getZipCoords, getStoresNear } from '../../lib/pricing/geo-cache.js';
+import handler from '../../pages/api/stores/nearby.js';
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ headers: {},
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ setHeader(name, value) {
+ res.headers[name] = value;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+describe('GET /api/stores/nearby', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.PRICING_LOCAL_ENABLED = 'true';
+ getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
+ checkStoresRateLimit.mockResolvedValue({ allowed: true, remaining: 0, reset: Date.now() + 3600000 });
+ sql.mockResolvedValue({ rows: [{ home_zip: '10001' }] });
+ getZipCoords.mockResolvedValue({ lat: 40.7, lng: -74.0, displayName: 'NYC', cached: true });
+ getStoresNear.mockResolvedValue({
+ stores: [{ name: 'Game Shop', distanceMiles: 1.2 }],
+ cached: true,
+ });
+ });
+
+ it('returns 401 without authentication', async () => {
+ getUserFromRequest.mockResolvedValue(null);
+ const res = createRes();
+ await handler({ method: 'GET', query: {} }, res);
+ expect(res.statusCode).toBe(401);
+ });
+
+ it('returns 503 when local pricing is disabled', async () => {
+ process.env.PRICING_LOCAL_ENABLED = 'false';
+ const res = createRes();
+ await handler({ method: 'GET', query: {} }, res);
+ expect(res.statusCode).toBe(503);
+ });
+
+ it('returns 429 with Retry-After when rate limited', async () => {
+ checkStoresRateLimit.mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ reset: Date.now() + 1800000,
+ });
+ const res = createRes();
+ await handler({ method: 'GET', query: {} }, res);
+ expect(res.statusCode).toBe(429);
+ expect(res.headers['Retry-After']).toBeTruthy();
+ });
+
+ it('returns stores for authenticated user with saved zip', async () => {
+ const res = createRes();
+ await handler({ method: 'GET', query: { zip: '90210' } }, res);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.homeZip).toBe('10001');
+ expect(res.body.stores).toHaveLength(1);
+ expect(getZipCoords).toHaveBeenCalledWith('10001');
+ expect(getStoresNear).toHaveBeenCalled();
+ });
+
+ it('returns 400 when home zip is not configured', async () => {
+ sql.mockResolvedValue({ rows: [{ home_zip: null }] });
+ const res = createRes();
+ await handler({ method: 'GET', query: {} }, res);
+ expect(res.statusCode).toBe(400);
+ expect(res.body.code).toBe('NO_HOME_ZIP');
+ });
+});
diff --git a/test/api/user-settings-home-zip.test.js b/test/api/user-settings-home-zip.test.js
new file mode 100644
index 0000000..65e4dff
--- /dev/null
+++ b/test/api/user-settings-home-zip.test.js
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/sql.js', () => ({
+ sql: vi.fn(),
+}));
+vi.mock('../../lib/permission-middleware.js', () => ({
+ getUserFromRequest: vi.fn(),
+}));
+
+import { sql } from '../../lib/sql.js';
+import { getUserFromRequest } from '../../lib/permission-middleware.js';
+import handler from '../../pages/api/user/settings.js';
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+const baseUserRow = {
+ id: 1,
+ email: 'a@b.c',
+ role: 'user',
+ first_name: 'A',
+ last_name: 'B',
+ username: 'ab',
+ collection_visibility: 'private',
+ preferred_currency: 'USD',
+ cards_per_page: 50,
+ default_view: 'grid',
+ notifications_email: true,
+ notifications_marketing: false,
+ two_factor_enabled: false,
+ theme: 'system',
+ language: 'en',
+ home_zip: '10001',
+ created_at: '2026-01-01',
+ updated_at: '2026-01-01',
+};
+
+describe('/api/user/settings home_zip', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
+ });
+
+ it('GET includes home_zip in settings', async () => {
+ sql.mockResolvedValue({ rows: [baseUserRow] });
+ const res = createRes();
+ await handler({ method: 'GET' }, res);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.settings.home_zip).toBe('10001');
+ });
+
+ it('PUT persists valid 5-digit zip', async () => {
+ sql.query = vi.fn().mockResolvedValue({
+ rows: [{ ...baseUserRow, home_zip: '90210' }],
+ });
+
+ const res = createRes();
+ await handler({ method: 'PUT', body: { home_zip: '90210' } }, res);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.settings.home_zip).toBe('90210');
+ expect(sql.query).toHaveBeenCalled();
+ const queryText = sql.query.mock.calls[0][0];
+ expect(queryText).toContain('home_zip');
+ });
+
+ it('PUT rejects invalid zip', async () => {
+ const res = createRes();
+ await handler({ method: 'PUT', body: { home_zip: '1234' } }, res);
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toMatch(/zip/i);
+ });
+
+ it('PUT allows null to clear zip', async () => {
+ sql.query = vi.fn().mockResolvedValue({
+ rows: [{ ...baseUserRow, home_zip: null }],
+ });
+
+ const res = createRes();
+ await handler({ method: 'PUT', body: { home_zip: null } }, res);
+ expect(res.statusCode).toBe(200);
+ expect(res.body.settings.home_zip).toBeNull();
+ });
+});
diff --git a/test/api/user-stats-valuation.test.js b/test/api/user-stats-valuation.test.js
new file mode 100644
index 0000000..16fa00a
--- /dev/null
+++ b/test/api/user-stats-valuation.test.js
@@ -0,0 +1,152 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../lib/sql.js', () => ({
+ sql: vi.fn((strings, ...values) => {
+ if (values.length === 0) {
+ return { __sqlFragment: strings.join('') };
+ }
+ return Promise.resolve({ rows: [] });
+ }),
+}));
+vi.mock('../../lib/permission-middleware.js', () => ({
+ getUserFromRequest: vi.fn(),
+}));
+
+import { sql } from '../../lib/sql.js';
+import { getUserFromRequest } from '../../lib/permission-middleware.js';
+import { effectiveUnitPriceExpr } from '../../lib/pricing/collection-valuation.js';
+import handler from '../../pages/api/user/stats.js';
+
+const UNIT_PRICE_SQL = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' });
+
+function createRes() {
+ const res = {
+ statusCode: 200,
+ body: null,
+ status(code) {
+ res.statusCode = code;
+ return res;
+ },
+ json(data) {
+ res.body = data;
+ return res;
+ },
+ };
+ return res;
+}
+
+function queryText(call) {
+ const [strings, ...values] = call;
+ const fragments = values.map((value) =>
+ value && typeof value === 'object' && value.__sqlFragment ? value.__sqlFragment : '?'
+ );
+ return strings.reduce((text, part, index) => text + part + (fragments[index] ?? ''), '');
+}
+
+describe('GET /api/user/stats foil-aware valuation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
+
+ sql.mockImplementation((strings, ...values) => {
+ if (values.length === 0) {
+ return { __sqlFragment: strings.join('') };
+ }
+ return Promise.resolve({ rows: [] });
+ });
+ });
+
+ it('uses effective unit price SQL for totals and breakdowns', async () => {
+ sql.mockImplementation((strings, ...values) => {
+ if (values.length === 0) {
+ return { __sqlFragment: strings.join('') };
+ }
+
+ const text = queryText([strings, ...values]);
+
+ if (text.includes('SUM') && text.includes('total_value') && !text.includes('GROUP BY')) {
+ return Promise.resolve({ rows: [{ total_value: '6.50' }] });
+ }
+ if (text.includes('GROUP BY cards.game')) {
+ return Promise.resolve({
+ rows: [{ game: 'MTG', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
+ });
+ }
+ if (text.includes('GROUP BY cards.rarity')) {
+ return Promise.resolve({
+ rows: [{ rarity: 'rare', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
+ });
+ }
+ if (text.includes('ORDER BY user_cards.created_at DESC')) {
+ return Promise.resolve({
+ rows: [
+ {
+ name: 'Lightning Bolt',
+ game: 'MTG',
+ rarity: 'common',
+ image_url: null,
+ market_price: '1.00',
+ quantity: 2,
+ created_at: '2026-01-01T00:00:00.000Z',
+ },
+ {
+ name: 'Lightning Bolt',
+ game: 'MTG',
+ rarity: 'common',
+ image_url: null,
+ market_price: '4.50',
+ quantity: 1,
+ created_at: '2026-01-02T00:00:00.000Z',
+ },
+ ],
+ });
+ }
+ if (text.includes('total_cards')) {
+ return Promise.resolve({ rows: [{ total_cards: '3' }] });
+ }
+ if (text.includes('total_collections')) {
+ return Promise.resolve({ rows: [{ total_collections: '1' }] });
+ }
+ if (text.includes('total_decks')) {
+ return Promise.resolve({ rows: [{ total_decks: '0' }] });
+ }
+
+ return Promise.resolve({ rows: [] });
+ });
+
+ const req = { method: 'GET' };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.totalValue).toBe(6.5);
+ expect(res.body.recentActivity[0].marketPrice).toBe(1);
+ expect(res.body.recentActivity[1].marketPrice).toBe(4.5);
+
+ const valuationCalls = sql.mock.calls.filter((call) => queryText(call).includes('total_value'));
+
+ for (const call of valuationCalls) {
+ const text = queryText(call);
+ expect(text).toContain(UNIT_PRICE_SQL);
+ expect(text).not.toContain('cards.market_price * user_cards.quantity');
+ }
+
+ const recentActivityCall = sql.mock.calls.find((call) =>
+ queryText(call).includes('ORDER BY user_cards.created_at DESC')
+ );
+ expect(queryText(recentActivityCall)).toContain(`${UNIT_PRICE_SQL} as market_price`);
+ });
+
+ it('returns 401 when unauthenticated', async () => {
+ getUserFromRequest.mockResolvedValue(null);
+
+ const req = { method: 'GET' };
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(401);
+ expect(sql).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/components/CardDetailLocalStores.test.js b/test/components/CardDetailLocalStores.test.js
new file mode 100644
index 0000000..cc58e72
--- /dev/null
+++ b/test/components/CardDetailLocalStores.test.js
@@ -0,0 +1,42 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render, screen } from '@testing-library/react';
+
+import CardDetailLocalStores from '../../components/card-detail/CardDetailLocalStores.js';
+
+describe('CardDetailLocalStores', () => {
+ afterEach(() => cleanup());
+
+ it('prompts to set zip when missing', () => {
+ render();
+ expect(screen.getByText(/game stores near you/i)).toBeTruthy();
+ expect(screen.getByRole('link', { name: /set home zip/i }).getAttribute('href')).toBe('/settings');
+ });
+
+ it('renders populated store list', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Game Haven')).toBeTruthy();
+ expect(screen.getByText(/2\.1 mi away/i)).toBeTruthy();
+ expect(screen.getByRole('link', { name: /visit website/i }).getAttribute('href')).toBe('https://example.com');
+ });
+
+ it('shows gentle empty copy when no stores found', () => {
+ render();
+ expect(screen.getByText(/no game or hobby stores found/i)).toBeTruthy();
+ });
+});
diff --git a/test/components/CardDetailPurchaseTab.test.js b/test/components/CardDetailPurchaseTab.test.js
new file mode 100644
index 0000000..103fc34
--- /dev/null
+++ b/test/components/CardDetailPurchaseTab.test.js
@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, test } from 'vitest';
+import { cleanup, render, screen } from '@testing-library/react';
+import CardDetailPurchaseTab, { stalenessBadgeText } from '../../components/card-detail/CardDetailPurchaseTab';
+
+afterEach(() => cleanup());
+
+const formatCurrency = (amount) => `$${Number(amount).toFixed(2)}`;
+
+describe('stalenessBadgeText', () => {
+ test('shows outdated copy when stale', () => {
+ expect(stalenessBadgeText({ stale: true, pricesUpdatedAt: '2026-01-01T00:00:00.000Z' }))
+ .toBe('Prices may be outdated');
+ });
+
+ test('shows updated date when fresh', () => {
+ const text = stalenessBadgeText({ stale: false, pricesUpdatedAt: '2026-01-15T00:00:00.000Z' });
+ expect(text).toContain('Updated');
+ expect(text).toContain('2026');
+ });
+});
+
+describe('', () => {
+ test('renders staleness badge text from pricing payload', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('pricing-staleness-badge').textContent)
+ .toBe('Prices may be outdated');
+ expect(screen.getByText('View on TCGPlayer')).toBeDefined();
+ expect(screen.getByTestId('card-detail-local-stores-slot')).toBeDefined();
+ });
+});
diff --git a/test/lib/card-import/bulk-sync.test.js b/test/lib/card-import/bulk-sync.test.js
new file mode 100644
index 0000000..7e891ba
--- /dev/null
+++ b/test/lib/card-import/bulk-sync.test.js
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+
+import { mapCard, resolveBulkDownloadUri } from '../../../lib/card-import/bulk-sync.js';
+
+describe('resolveBulkDownloadUri', () => {
+ it('prefers download_uri then jsonl_download_uri', () => {
+ expect(resolveBulkDownloadUri({ download_uri: 'https://a.test/json' })).toBe('https://a.test/json');
+ expect(resolveBulkDownloadUri({ jsonl_download_uri: 'https://a.test/file.jsonl.gz' }))
+ .toBe('https://a.test/file.jsonl.gz');
+ });
+});
+
+describe('mapCard', () => {
+ it('maps Scryfall usd to current_price and usd_foil to market_price', () => {
+ const mapped = mapCard({
+ id: 'abc-123',
+ oracle_id: 'oracle-1',
+ name: 'Lightning Bolt',
+ set_name: 'Alpha',
+ set: 'lea',
+ collector_number: '161',
+ rarity: 'common',
+ prices: {
+ usd: '1.50',
+ usd_foil: '12.00',
+ },
+ });
+
+ expect(mapped.currentPrice).toBe(1.5);
+ expect(mapped.marketPrice).toBe(12);
+ expect(mapped.scryfallId).toBe('abc-123');
+ });
+
+ it('leaves prices null when Scryfall omits price fields', () => {
+ const mapped = mapCard({
+ id: 'no-price',
+ name: 'Unknown Card',
+ set_name: 'Test',
+ set: 'tst',
+ collector_number: '1',
+ rarity: 'common',
+ prices: {},
+ });
+
+ expect(mapped.currentPrice).toBeNull();
+ expect(mapped.marketPrice).toBeNull();
+ });
+
+ it('parses only nonfoil price when foil is absent', () => {
+ const mapped = mapCard({
+ id: 'nonfoil-only',
+ name: 'Island',
+ set_name: 'Test',
+ set: 'tst',
+ collector_number: '2',
+ rarity: 'common',
+ prices: { usd: '0.25' },
+ });
+
+ expect(mapped.currentPrice).toBe(0.25);
+ expect(mapped.marketPrice).toBeNull();
+ });
+});
diff --git a/test/lib/card-import/lorcana-bulk.test.js b/test/lib/card-import/lorcana-bulk.test.js
new file mode 100644
index 0000000..0a90e22
--- /dev/null
+++ b/test/lib/card-import/lorcana-bulk.test.js
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../lib/pricing/price-history.js', () => ({
+ recordPriceSnapshots: vi.fn(async (rows) => rows.length),
+}));
+
+import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
+import {
+ enrichLorcanaRowWithFolio,
+ mapLorcanaCard,
+ runBulkLorcanaSync,
+} from '../../../lib/card-import/lorcana-bulk.js';
+import { buildFolioPriceIndex } from '../../../lib/pricing/foolio-client.js';
+
+describe('mapLorcanaCard pricing', () => {
+ it('includes price fields when Foilio pricing is provided', () => {
+ const mapped = mapLorcanaCard(
+ {
+ Unique_ID: 'TFC-001',
+ Name: 'Ariel - On Human Legs',
+ Set_Name: 'The First Chapter',
+ Set_ID: 'TFC',
+ Card_Num: 1,
+ },
+ { currentPrice: 1.25, marketPrice: 1.5, currency: 'USD' }
+ );
+
+ expect(mapped.currentPrice).toBe(1.25);
+ expect(mapped.marketPrice).toBe(1.5);
+ expect(mapped.pricesUpdatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe('enrichLorcanaRowWithFolio', () => {
+ it('matches by card id and tracks matched/unmatched counts', () => {
+ const index = buildFolioPriceIndex([
+ { card_id: 'TFC-001', usd_low: 1, usd_market: 2 },
+ ]);
+ const stats = { pricesMatched: 0, unmatchedCount: 0 };
+
+ const enriched = enrichLorcanaRowWithFolio(
+ mapLorcanaCard({
+ Unique_ID: 'TFC-001',
+ Name: 'Ariel - On Human Legs',
+ Set_ID: 'TFC',
+ }),
+ index,
+ stats
+ );
+
+ expect(enriched.currentPrice).toBe(1);
+ expect(enriched.marketPrice).toBe(2);
+ expect(stats.pricesMatched).toBe(1);
+ expect(stats.unmatchedCount).toBe(0);
+ });
+
+ it('increments unmatchedCount when no feed row matches', () => {
+ const stats = { pricesMatched: 0, unmatchedCount: 0 };
+ const enriched = enrichLorcanaRowWithFolio(
+ mapLorcanaCard({
+ Unique_ID: 'TFC-999',
+ Name: 'Unknown',
+ Set_ID: 'TFC',
+ }),
+ buildFolioPriceIndex([]),
+ stats
+ );
+
+ expect(enriched.currentPrice ?? null).toBeNull();
+ expect(stats.unmatchedCount).toBe(1);
+ });
+});
+
+describe('runBulkLorcanaSync', () => {
+ beforeEach(() => {
+ vi.mocked(recordPriceSnapshots).mockClear();
+ vi.restoreAllMocks();
+ });
+
+ it('fetches Foilio feed once and reports price match summary', async () => {
+ const fetchFolioIndex = vi.fn(async () => buildFolioPriceIndex([
+ { card_id: 'TFC-001', usd_low: 0.5, usd_market: 0.75 },
+ ]));
+
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ json: async () => ([
+ {
+ Unique_ID: 'TFC-001',
+ Name: 'Ariel - On Human Legs',
+ Set_Name: 'The First Chapter',
+ Set_ID: 'TFC',
+ Card_Num: 1,
+ Gamemode: 'Lorcana',
+ },
+ ]),
+ })));
+
+ const query = vi.fn(async (text, values) => {
+ if (String(text).includes('SELECT id, scryfall_id')) {
+ return { rows: [{ id: 1, scryfall_id: 'TFC-001' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const summary = await runBulkLorcanaSync({
+ sql: { query },
+ fetchFolioIndex,
+ });
+
+ expect(fetchFolioIndex).toHaveBeenCalledTimes(1);
+ expect(summary.pricesMatched).toBe(1);
+ expect(summary.unmatchedCount).toBe(0);
+ expect(summary.upserted).toBe(1);
+ expect(recordPriceSnapshots).toHaveBeenCalled();
+ });
+});
diff --git a/test/lib/card-import/pokemon-bulk.test.js b/test/lib/card-import/pokemon-bulk.test.js
new file mode 100644
index 0000000..4ea4620
--- /dev/null
+++ b/test/lib/card-import/pokemon-bulk.test.js
@@ -0,0 +1,115 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../lib/sql.js', () => ({
+ sql: Object.assign(vi.fn(async () => ({ rows: [], rowCount: 0 })), {
+ query: vi.fn(async (text, values) => {
+ if (String(text).includes('SELECT id, scryfall_id')) {
+ const ids = values?.[0] ?? [];
+ return {
+ rows: ids.map((scryfallId, index) => ({ id: index + 1, scryfall_id: scryfallId })),
+ rowCount: ids.length,
+ };
+ }
+ return { rows: [], rowCount: 0 };
+ }),
+ }),
+}));
+
+vi.mock('../../../lib/pricing/price-history.js', () => ({
+ recordPriceSnapshots: vi.fn(async (rows) => rows.length),
+}));
+
+vi.mock('../../../lib/card-import/pokemon-github.js', () => ({
+ fetchPokemonSets: vi.fn(async () => [{ id: 'me3', name: 'Perfect Order', printedTotal: 88 }]),
+ fetchPokemonSetCards: vi.fn(async () => [
+ {
+ id: 'me3-18',
+ name: 'Seel',
+ supertype: 'Pokémon',
+ number: '18',
+ rarity: 'Common',
+ types: ['Water'],
+ },
+ ]),
+ mapGithubCardForInsert: vi.fn((card, setMeta) => ({
+ externalId: card.id,
+ name: card.name,
+ setName: setMeta.name,
+ setCode: setMeta.id,
+ cardNumber: `${card.number}/${setMeta.printedTotal}`,
+ rarity: card.rarity,
+ cardType: card.supertype,
+ types: card.types,
+ flavorText: null,
+ hp: null,
+ imageSmall: null,
+ imageLarge: null,
+ })),
+}));
+
+import { sql } from '../../../lib/sql.js';
+import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
+import {
+ mapPokemonBulkRow,
+ runBulkPokemonSync,
+} from '../../../lib/card-import/pokemon-bulk.js';
+
+describe('mapPokemonBulkRow', () => {
+ it('includes TCGdex prices and prices_updated_at when pricing is provided', () => {
+ const row = mapPokemonBulkRow(
+ {
+ externalId: 'me3-18',
+ name: 'Seel',
+ setName: 'Perfect Order',
+ setCode: 'me3',
+ cardNumber: '18/88',
+ rarity: 'Common',
+ cardType: 'Pokémon',
+ types: ['Water'],
+ flavorText: null,
+ hp: null,
+ imageSmall: null,
+ imageLarge: null,
+ },
+ { unlimited: 'Legal' },
+ { currentPrice: 0.1, marketPrice: 0.2, currency: 'USD' }
+ );
+
+ expect(row.currentPrice).toBe(0.1);
+ expect(row.marketPrice).toBe(0.2);
+ expect(row.pricesUpdatedAt).toBeInstanceOf(Date);
+ });
+});
+
+describe('runBulkPokemonSync', () => {
+ beforeEach(() => {
+ vi.mocked(sql.query).mockClear();
+ vi.mocked(recordPriceSnapshots).mockClear();
+ });
+
+ it('enriches rows from TCGdex and reports match counts', async () => {
+ const fetchTcgdexSetCards = vi.fn(async () => [
+ {
+ id: 'me3-18',
+ localId: '18',
+ name: 'Seel',
+ set: { cardCount: { official: 88 } },
+ pricing: {
+ tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } },
+ },
+ },
+ ]);
+
+ const summary = await runBulkPokemonSync({
+ fetchTcgdexSetCards,
+ query: sql.query,
+ setDelayMs: 0,
+ });
+
+ expect(summary.pricesMatched).toBe(1);
+ expect(summary.unmatchedCount).toBe(0);
+ expect(summary.snapshotsWritten).toBeGreaterThan(0);
+ expect(recordPriceSnapshots).toHaveBeenCalled();
+ expect(sql.query).toHaveBeenCalled();
+ });
+});
diff --git a/test/lib/pricing/card-price.test.js b/test/lib/pricing/card-price.test.js
new file mode 100644
index 0000000..6af7062
--- /dev/null
+++ b/test/lib/pricing/card-price.test.js
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { isPriceStale, resolveUnitPrice, PRICE_STALE_HOURS } from '../../../lib/pricing/card-price.js';
+
+describe('card-price', () => {
+ it('uses current_price for nonfoil MTG rows', () => {
+ expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: false })).toBe(1.5);
+ });
+
+ it('uses market_price for foil MTG rows', () => {
+ expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: true })).toBe(10);
+ });
+
+ it('falls back when primary column is null', () => {
+ expect(resolveUnitPrice({ currentPrice: null, marketPrice: 3, isFoil: false })).toBe(3);
+ expect(resolveUnitPrice({ currentPrice: 2, marketPrice: null, isFoil: true })).toBe(2);
+ });
+
+ it('detects stale prices', () => {
+ const fresh = new Date(Date.now() - 60 * 60 * 1000);
+ const stale = new Date(Date.now() - (PRICE_STALE_HOURS + 1) * 60 * 60 * 1000);
+ expect(isPriceStale(fresh)).toBe(false);
+ expect(isPriceStale(stale)).toBe(true);
+ expect(isPriceStale(null)).toBe(true);
+ });
+});
diff --git a/test/lib/pricing/collection-valuation.test.js b/test/lib/pricing/collection-valuation.test.js
new file mode 100644
index 0000000..0c170c8
--- /dev/null
+++ b/test/lib/pricing/collection-valuation.test.js
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { effectiveUnitPriceExpr, listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
+
+describe('collection-valuation', () => {
+ it('builds foil-aware unit price SQL', () => {
+ expect(effectiveUnitPriceExpr()).toContain('uc.is_foil');
+ expect(effectiveUnitPriceExpr()).toContain('c.current_price');
+ expect(effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' })).toContain(
+ 'user_cards.is_foil'
+ );
+ });
+
+ it('builds list unit price SQL', () => {
+ expect(listUnitPriceExpr()).toBe('COALESCE(c.current_price, c.market_price)');
+ });
+});
diff --git a/test/lib/pricing/foolio-client.test.js b/test/lib/pricing/foolio-client.test.js
new file mode 100644
index 0000000..4dbcefc
--- /dev/null
+++ b/test/lib/pricing/foolio-client.test.js
@@ -0,0 +1,70 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ buildFolioPriceIndex,
+ getFolioFeedUrl,
+ matchFolioPrice,
+ parseFolioPriceEntry,
+} from '../../../lib/pricing/foolio-client.js';
+
+describe('foolio-client', () => {
+ beforeEach(() => {
+ delete process.env.FOILIO_PRICES_FEED_URL;
+ vi.restoreAllMocks();
+ });
+
+ it('uses default Foilio feed URL', () => {
+ expect(getFolioFeedUrl()).toBe('https://foilio.net/api/prices/feed?game=lorcana');
+ });
+
+ it('prefers USD fields when parsing feed rows', () => {
+ expect(parseFolioPriceEntry({ usd_low: 1, usd_market: 2 })).toEqual({
+ currentPrice: 1,
+ marketPrice: 2,
+ currency: 'USD',
+ });
+ });
+
+ it('falls back to EUR with currency noted when USD is absent', () => {
+ expect(parseFolioPriceEntry({ eur_low: 0.5, eur_market: 0.8 })).toEqual({
+ currentPrice: 0.5,
+ marketPrice: 0.8,
+ currency: 'EUR',
+ });
+ });
+
+ it('indexes feed rows by card id and normalized name|set', () => {
+ const index = buildFolioPriceIndex([
+ {
+ card_id: 'TFC-001',
+ name: 'Ariel - On Human Legs',
+ set: 'TFC',
+ usd_low: 1,
+ usd_market: 1.5,
+ },
+ ]);
+
+ expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(1.5);
+ expect(
+ matchFolioPrice(index, {
+ scryfallId: 'OTHER',
+ name: 'Ariel - On Human Legs',
+ setCode: 'TFC',
+ })?.currentPrice
+ ).toBe(1);
+ });
+
+ it('fetchFolioPriceFeed loads and indexes the feed once', async () => {
+ const payload = [{ card_id: 'TFC-001', usd_market: 3 }];
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ json: async () => payload,
+ })));
+
+ const { fetchFolioPriceIndex } = await import('../../../lib/pricing/foolio-client.js');
+ const index = await fetchFolioPriceIndex();
+
+ expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(3);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/lib/pricing/geo-cache.test.js b/test/lib/pricing/geo-cache.test.js
new file mode 100644
index 0000000..f37d581
--- /dev/null
+++ b/test/lib/pricing/geo-cache.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ STORES_CACHE_TTL_MS,
+ buildStoresCacheKey,
+ isStoresCacheFresh,
+} from '../../../lib/pricing/geo-cache.js';
+
+describe('geo-cache', () => {
+ it('builds stable cache keys for the same coordinates', () => {
+ const a = buildStoresCacheKey(40.12345, -74.98765, 16093);
+ const b = buildStoresCacheKey(40.12345, -74.98765, 16093);
+ expect(a).toBe(b);
+ expect(a.length).toBeLessThanOrEqual(64);
+ });
+
+ it('treats fresh cache entries as valid within TTL', () => {
+ const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS + 1000);
+ expect(isStoresCacheFresh(fetchedAt)).toBe(true);
+ });
+
+ it('expires cache entries after TTL', () => {
+ const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS - 1);
+ expect(isStoresCacheFresh(fetchedAt)).toBe(false);
+ });
+
+ it('rejects invalid fetched_at values', () => {
+ expect(isStoresCacheFresh(null)).toBe(false);
+ expect(isStoresCacheFresh('not-a-date')).toBe(false);
+ });
+});
diff --git a/test/lib/pricing/nominatim.test.js b/test/lib/pricing/nominatim.test.js
new file mode 100644
index 0000000..b228325
--- /dev/null
+++ b/test/lib/pricing/nominatim.test.js
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { geocodeUsZip } from '../../../lib/pricing/nominatim.js';
+
+describe('nominatim', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('geocodes a US zip via allowlisted host', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ lat: '40.7128', lon: '-74.0060', display_name: 'New York, NY' }],
+ });
+
+ const result = await geocodeUsZip('10001', { fetchImpl });
+ expect(result).toEqual({
+ lat: 40.7128,
+ lng: -74.006,
+ displayName: 'New York, NY',
+ });
+
+ const calledUrl = fetchImpl.mock.calls[0][0];
+ expect(calledUrl).toContain('nominatim.openstreetmap.org');
+ expect(calledUrl).toContain('postalcode=10001');
+ });
+
+ it('returns null when no results', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [],
+ });
+
+ const result = await geocodeUsZip('00000', { fetchImpl });
+ expect(result).toBeNull();
+ });
+
+ it('enforces minimum 1s between requests', async () => {
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
+ vi.resetModules();
+ const { geocodeUsZip: geocodeFresh } = await import('../../../lib/pricing/nominatim.js');
+
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ lat: '40.0', lon: '-74.0', display_name: 'Test' }],
+ });
+
+ await geocodeFresh('10001', { fetchImpl });
+ const secondPromise = geocodeFresh('10002', { fetchImpl });
+
+ await vi.advanceTimersByTimeAsync(500);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(500);
+ await secondPromise;
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/test/lib/pricing/outbound-links.test.js b/test/lib/pricing/outbound-links.test.js
new file mode 100644
index 0000000..877522c
--- /dev/null
+++ b/test/lib/pricing/outbound-links.test.js
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildEbayUrl, buildOutboundLinks, buildTcgplayerUrl } from '../../../lib/pricing/outbound-links.js';
+
+describe('outbound-links', () => {
+ const card = { name: 'Lightning Bolt', game: 'MTG', set_name: 'Alpha' };
+
+ it('builds tcgplayer search url', () => {
+ expect(buildTcgplayerUrl(card)).toContain('tcgplayer.com');
+ expect(buildTcgplayerUrl(card)).toContain(encodeURIComponent('Lightning Bolt'));
+ });
+
+ it('builds ebay search url', () => {
+ expect(buildEbayUrl(card)).toContain('ebay.com');
+ expect(buildEbayUrl(card)).toContain(encodeURIComponent('Lightning Bolt Alpha MTG'));
+ });
+
+ it('returns both outbound links', () => {
+ const links = buildOutboundLinks(card);
+ expect(links.tcgplayer).toBeTruthy();
+ expect(links.ebay).toBeTruthy();
+ });
+});
diff --git a/test/lib/pricing/overpass.test.js b/test/lib/pricing/overpass.test.js
new file mode 100644
index 0000000..43e122d
--- /dev/null
+++ b/test/lib/pricing/overpass.test.js
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildOverpassQuery,
+ clampRadiusMiles,
+ milesToMeters,
+ parseOverpassResponse,
+} from '../../../lib/pricing/overpass.js';
+
+describe('overpass', () => {
+ it('clamps radius miles between 1 and 50', () => {
+ expect(clampRadiusMiles(0)).toBe(1);
+ expect(clampRadiusMiles(100)).toBe(50);
+ expect(clampRadiusMiles(25)).toBe(25);
+ });
+
+ it('builds fixed-template query with numeric injection only', () => {
+ const query = buildOverpassQuery({ lat: 40.1, lng: -74.2, radiusMeters: 16093 });
+ expect(query).toContain('around:16093,40.1,-74.2');
+ expect(query).toContain('shop"="games"');
+ expect(query).toContain('shop"="hobby"');
+ expect(query).not.toContain('; DROP');
+ });
+
+ it('parses elements with distance-ready coordinates', () => {
+ const stores = parseOverpassResponse(
+ {
+ elements: [
+ {
+ type: 'node',
+ id: 42,
+ lat: 40.11,
+ lon: -74.21,
+ tags: {
+ name: 'Game Haven',
+ 'addr:street': 'Main St',
+ 'addr:city': 'Town',
+ phone: '555-0100',
+ website: 'https://example.com',
+ },
+ },
+ ],
+ },
+ { originLat: 40.1, originLng: -74.2 }
+ );
+
+ expect(stores).toHaveLength(1);
+ expect(stores[0]).toMatchObject({
+ name: 'Game Haven',
+ lat: 40.11,
+ lng: -74.21,
+ phone: '555-0100',
+ website: 'https://example.com',
+ osmUrl: 'https://www.openstreetmap.org/node/42',
+ });
+ expect(stores[0].distanceMiles).toBeGreaterThan(0);
+ expect(stores[0].address).toContain('Main St');
+ });
+
+ it('converts miles to meters with clamping', () => {
+ expect(milesToMeters(25)).toBe(Math.round(25 * 1609.344));
+ expect(milesToMeters(0)).toBe(Math.round(1 * 1609.344));
+ });
+});
diff --git a/test/lib/pricing/price-history.test.js b/test/lib/pricing/price-history.test.js
new file mode 100644
index 0000000..8e3803d
--- /dev/null
+++ b/test/lib/pricing/price-history.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../lib/sql.js', () => ({
+ sql: vi.fn(async () => ({ rows: [], rowCount: 0 })),
+}));
+
+import { sql } from '../../../lib/sql.js';
+import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
+
+describe('price-history', () => {
+ beforeEach(() => {
+ vi.mocked(sql).mockClear();
+ });
+
+ it('skips invalid rows', async () => {
+ const written = await recordPriceSnapshots([
+ { cardId: 1, source: 'scryfall', amount: null },
+ { cardId: null, source: 'scryfall', amount: 1 },
+ ]);
+ expect(written).toBe(0);
+ expect(sql).not.toHaveBeenCalled();
+ });
+
+ it('upserts valid snapshot rows', async () => {
+ const written = await recordPriceSnapshots([
+ { cardId: 42, source: 'scryfall', amount: 1.25, priceType: 'market', currency: 'USD' },
+ ]);
+ expect(written).toBe(1);
+ expect(sql).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/lib/pricing/tcgdex-client.test.js b/test/lib/pricing/tcgdex-client.test.js
new file mode 100644
index 0000000..d21d88e
--- /dev/null
+++ b/test/lib/pricing/tcgdex-client.test.js
@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ buildTcgdexPriceIndex,
+ getTcgdexBaseUrl,
+ matchTcgdexCard,
+ parseTcgdexPricing,
+} from '../../../lib/pricing/tcgdex-client.js';
+
+describe('tcgdex-client', () => {
+ beforeEach(() => {
+ delete process.env.TCGDEX_BASE_URL;
+ vi.restoreAllMocks();
+ });
+
+ it('uses default TCGdex base URL', () => {
+ expect(getTcgdexBaseUrl()).toBe('https://api.tcgdex.net/v2/en');
+ });
+
+ it('parses TCGPlayer USD pricing into current and market prices', () => {
+ const parsed = parseTcgdexPricing({
+ pricing: {
+ tcgplayer: {
+ normal: {
+ lowPrice: 0.5,
+ marketPrice: 1.25,
+ },
+ },
+ },
+ });
+
+ expect(parsed).toEqual({
+ currentPrice: 0.5,
+ marketPrice: 1.25,
+ currency: 'USD',
+ });
+ });
+
+ it('falls back to Cardmarket EUR for market_price when USD is missing', () => {
+ const parsed = parseTcgdexPricing({
+ pricing: {
+ cardmarket: {
+ trend: 2.5,
+ unit: 'EUR',
+ },
+ },
+ });
+
+ expect(parsed.currentPrice).toBeNull();
+ expect(parsed.marketPrice).toBe(2.5);
+ expect(parsed.currency).toBe('EUR');
+ });
+
+ it('matches cards by set card number with leading zero normalization', () => {
+ const index = buildTcgdexPriceIndex([
+ {
+ id: 'me3-18',
+ localId: '18',
+ name: 'Seel',
+ set: { cardCount: { official: 88 } },
+ },
+ ]);
+
+ const match = matchTcgdexCard(index, {
+ name: 'Seel',
+ cardNumber: '018/88',
+ });
+
+ expect(match?.id).toBe('me3-18');
+ });
+
+ it('falls back to unique name match within a set index', () => {
+ const index = buildTcgdexPriceIndex([
+ { id: 'sv1-001', localId: '1', name: 'Sprigatito' },
+ ]);
+
+ const match = matchTcgdexCard(index, {
+ name: 'Sprigatito',
+ cardNumber: null,
+ });
+
+ expect(match?.id).toBe('sv1-001');
+ });
+
+ it('fetchSetCards loads set summaries and full card details', async () => {
+ const fetchMock = vi.fn(async (url) => {
+ if (url.endsWith('/sets/me3')) {
+ return {
+ ok: true,
+ json: async () => ({
+ cards: [{ id: 'me3-18', localId: '18', name: 'Seel' }],
+ }),
+ };
+ }
+ if (url.endsWith('/cards/me3-18')) {
+ return {
+ ok: true,
+ json: async () => ({
+ id: 'me3-18',
+ localId: '18',
+ name: 'Seel',
+ pricing: {
+ tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } },
+ },
+ }),
+ };
+ }
+ throw new Error(`unexpected url ${url}`);
+ });
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { fetchSetCards } = await import('../../../lib/pricing/tcgdex-client.js');
+ const cards = await fetchSetCards('me3');
+
+ expect(cards).toHaveLength(1);
+ expect(cards[0].pricing.tcgplayer.normal.marketPrice).toBe(0.2);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+});