Compare changes

Choose any two refs to compare.

Changed files
+25206 -1285
.claude
lexicons
com
deckbelcher
collection
deck
richtext
public
fonts
keyrune
scripts
src
components
lib
routes
workers
typelex
+201
.claude/ATPROTO.md
··· 1 + # ATProto Integration 2 + 3 + DeckBelcher uses AT Protocol for decentralized data storage. Decks are stored in users' Personal Data Servers (PDS), enabling data portability. 4 + 5 + ## Architecture Overview 6 + 7 + ``` 8 + ┌──────────────┐ reads ┌─────────────────┐ 9 + │ Browser │ ────────────── │ Slingshot │ (cached public reads) 10 + │ │ │ (microcosm.blue)│ 11 + │ │ writes └─────────────────┘ 12 + │ │ ────────────── ┌─────────────────┐ 13 + │ │ │ User's PDS │ (authenticated writes) 14 + └──────────────┘ └─────────────────┘ 15 + ``` 16 + 17 + ## Key Files 18 + 19 + | File | Purpose | 20 + |------|---------| 21 + | `src/lib/atproto-client.ts` | CRUD operations for deck records | 22 + | `src/lib/identity.ts` | Handle ↔ DID resolution | 23 + | `src/lib/useAuth.tsx` | OAuth context and session management | 24 + | `src/lib/lexicons/` | Generated TypeScript types from lexicons | 25 + | `typelex/*.tsp` | TypeSpec lexicon definitions | 26 + 27 + ## Branded Types 28 + 29 + Type safety for AT Protocol identifiers: 30 + 31 + ```typescript 32 + // Prevents mixing up PDS URLs with regular strings 33 + declare const PdsUrlBrand: unique symbol; 34 + export type PdsUrl = string & { readonly [PdsUrlBrand]: typeof PdsUrlBrand }; 35 + 36 + // Prevents mixing up rkeys with other IDs 37 + declare const RkeyBrand: unique symbol; 38 + export type Rkey = string & { readonly [RkeyBrand]: typeof RkeyBrand }; 39 + 40 + // Usage 41 + const pds = asPdsUrl("https://bsky.social"); 42 + const rkey = asRkey("3jxyz..."); 43 + ``` 44 + 45 + ## Result Pattern 46 + 47 + All ATProto operations use `Result<T, E>` instead of throwing: 48 + 49 + ```typescript 50 + type Result<T, E = Error> = 51 + | { success: true; data: T } 52 + | { success: false; error: E }; 53 + 54 + // Usage 55 + const result = await getDeckRecord(did, rkey); 56 + if (result.success) { 57 + console.log(result.data.value.name); 58 + } else { 59 + console.error(result.error.message); 60 + } 61 + ``` 62 + 63 + ## CRUD Operations 64 + 65 + ### Reading (via Slingshot) 66 + 67 + Slingshot is a caching xRPC proxy. Public reads go through it for performance. 68 + 69 + ```typescript 70 + import { getDeckRecord } from "@/lib/atproto-client"; 71 + 72 + const result = await getDeckRecord(did, rkey); 73 + // Fetches: https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord 74 + ``` 75 + 76 + ### Writing (via PDS) 77 + 78 + Writes require authentication and go directly to user's PDS. 79 + 80 + ```typescript 81 + import { createDeckRecord, updateDeckRecord, deleteDeckRecord } from "@/lib/atproto-client"; 82 + 83 + // Create 84 + const result = await createDeckRecord(agent, { 85 + name: "My Deck", 86 + format: "commander", 87 + cards: [...], 88 + createdAt: new Date().toISOString(), 89 + }); 90 + 91 + // Update 92 + await updateDeckRecord(agent, rkey, updatedRecord); 93 + 94 + // Delete 95 + await deleteDeckRecord(agent, rkey); 96 + ``` 97 + 98 + ### Listing User's Decks 99 + 100 + ```typescript 101 + import { listUserDecks } from "@/lib/atproto-client"; 102 + 103 + const result = await listUserDecks(pdsUrl, did); 104 + // Returns: { records: DeckRecordResponse[], cursor?: string } 105 + ``` 106 + 107 + ## Identity Resolution 108 + 109 + Located in `src/lib/identity.ts`. 110 + 111 + ```typescript 112 + import { resolveHandle, resolveDid, getPdsUrl } from "@/lib/identity"; 113 + 114 + // Handle → DID 115 + const did = await resolveHandle("alice.bsky.social"); 116 + // did:plc:abc123... 117 + 118 + // DID → Handle (from DID document) 119 + const handle = await resolveHandleFromDid(did); 120 + 121 + // DID → PDS URL 122 + const pds = await getPdsUrl(did); 123 + // https://bsky.social 124 + ``` 125 + 126 + ## OAuth Flow 127 + 128 + Uses `@atcute/oauth-browser-client` for browser-based OAuth. 129 + 130 + **Setup:** OAuth metadata is configured in `public/client-metadata.json` and injected via Vite plugin. 131 + 132 + **Flow:** 133 + 1. User enters handle 134 + 2. Redirect to PDS authorization endpoint 135 + 3. Callback to `/oauth/callback` 136 + 4. Store session in `useAuth` context 137 + 138 + **Environment variables:** 139 + - `VITE_OAUTH_CLIENT_ID` - OAuth client ID (injected by vite plugin) 140 + - `VITE_OAUTH_REDIRECT_URI` - Callback URL 141 + - `VITE_OAUTH_SCOPE` - Requested scopes 142 + 143 + ## Lexicons 144 + 145 + Lexicons define the schema for deck records. See `.claude/TYPELEX.md` for TypeSpec syntax. 146 + 147 + **Current lexicons:** 148 + - `com.deckbelcher.actor.profile` - User profile 149 + - `com.deckbelcher.deck.list` - Deck/decklist record 150 + - `com.deckbelcher.social.like` - Likes on decks 151 + - `com.deckbelcher.richtext.facet` - Rich text facets (card mentions) 152 + 153 + **Generating types:** 154 + ```bash 155 + npm run build:typelex # TypeSpec → JSON lexicons 156 + # Types are auto-generated to src/lib/lexicons/ 157 + ``` 158 + 159 + ## Query Integration 160 + 161 + TanStack Query hooks for deck operations: 162 + 163 + ```typescript 164 + // src/lib/deck-queries.ts 165 + 166 + // Fetching 167 + const { data: deck } = useQuery(getDeckQueryOptions(did, rkey)); 168 + 169 + // Creating 170 + const createMutation = useCreateDeckMutation(); 171 + await createMutation.mutateAsync({ name, format, cards }); 172 + 173 + // Updating (optimistic) 174 + const updateMutation = useUpdateDeckMutation(); 175 + await updateMutation.mutateAsync({ rkey, record }); 176 + ``` 177 + 178 + **Debouncing:** Mutations should be debounced at call site to batch rapid changes. 179 + 180 + ## Error Handling Patterns 181 + 182 + ```typescript 183 + // Prefer Result over try/catch 184 + const result = await createDeckRecord(agent, record); 185 + if (!result.success) { 186 + toast.error(result.error.message); 187 + return; 188 + } 189 + 190 + // Use useMutationWithToast for automatic error toasts 191 + const mutation = useMutationWithToast({ 192 + mutationFn: (data) => updateDeckRecord(agent, rkey, data), 193 + errorMessage: "Failed to save deck", 194 + }); 195 + ``` 196 + 197 + ## Development Notes 198 + 199 + - **Slingshot URL:** `https://slingshot.microcosm.blue` (hardcoded in atproto-client.ts) 200 + - **Collection:** `com.deckbelcher.deck.list` (the NSID for deck records) 201 + - **Record format:** See `typelex/deck.tsp` for canonical schema
+160
.claude/CARD_DATA.md
··· 1 + # Card Data Architecture 2 + 3 + The app loads the full Scryfall card database for instant client-side search. This document explains the data pipeline and provider architecture. 4 + 5 + ## Overview 6 + 7 + ``` 8 + Scryfall Bulk Data (~300MB compressed) 9 + 10 + download-scryfall.ts (build time) 11 + 12 + ┌─────────────────────────────────────────┐ 13 + │ public/data/ │ 14 + │ ├── cards/ │ 15 + │ │ └── cards-NNN-HASH.json (~5MB ea) │ (4096 cards per chunk) 16 + │ ├── cards-byteindex.bin │ (byte-range index for SSR) 17 + │ ├── migrations.json │ (oracle ID mappings) 18 + │ └── version.json │ (data version) 19 + └─────────────────────────────────────────┘ 20 + 21 + ┌─────────────────────────────────────────┐ 22 + │ CardDataProvider (isomorphic) │ 23 + │ ├── ClientCardProvider (Web Worker) │ 24 + │ └── ServerCardProvider (byte-range) │ 25 + └─────────────────────────────────────────┘ 26 + ``` 27 + 28 + ## Data Pipeline (`scripts/download-scryfall.ts`) 29 + 30 + Run with `npm run download:scryfall` (not part of normal build). 31 + 32 + **What it does:** 33 + 1. Downloads Scryfall bulk data (`default_cards.json`) 34 + 2. Filters to kept fields (see `src/lib/scryfall-types.ts`) 35 + 3. Sorts by release date (oldest first) so new cards append to later chunks 36 + 4. Chunks cards into files of 4096 cards each (~5MB per chunk) 37 + 5. Generates byte-range index (`cards-byteindex.bin`) for SSR lookups 38 + 6. Creates oracle ID migration mappings 39 + 7. Downloads mana symbol SVGs to `public/symbols/` 40 + 41 + **Chunk naming:** `cards-NNN-HASH.json` where NNN is chunk index and HASH is content hash for cache busting. 42 + 43 + **Offline mode:** `--offline` flag reprocesses cached data without re-downloading. 44 + 45 + ## Provider Architecture 46 + 47 + ### `CardDataProvider` Interface 48 + 49 + Located in `src/lib/card-data-provider.ts`. Unified interface for both environments. 50 + 51 + ```typescript 52 + interface CardDataProvider { 53 + getCardById(id: ScryfallId): Promise<Card | undefined>; 54 + getPrintingsByOracleId(oracleId: OracleId): Promise<ScryfallId[]>; 55 + getCanonicalPrinting(oracleId: OracleId): Promise<ScryfallId | undefined>; 56 + getMetadata(): Promise<{ version: string; cardCount: number }>; 57 + getVolatileData(id: ScryfallId): Promise<VolatileData | null>; 58 + 59 + // Optional - client-only 60 + searchCards?(query, restrictions?, maxResults?): Promise<Card[]>; 61 + syntaxSearch?(query, maxResults?): Promise<Result<Card[]>>; 62 + unifiedSearch?(query, restrictions?, maxResults?): Promise<UnifiedSearchResult>; 63 + } 64 + ``` 65 + 66 + ### `getCardDataProvider()` 67 + 68 + Isomorphic function (TanStack Start `createIsomorphicFn`): 69 + - **Client:** Returns `ClientCardProvider` 70 + - **Server:** Returns `ServerCardProvider` 71 + 72 + Provider is singleton—safe to call multiple times. 73 + 74 + ### ClientCardProvider (`src/lib/cards-client-provider.ts`) 75 + 76 + Uses Web Worker to load and search full card dataset off main thread. 77 + 78 + **Worker selection:** 79 + - Desktop: SharedWorker (shared across tabs, single memory footprint) 80 + - Mobile/unsupported: Regular Worker (per-tab, falls back gracefully) 81 + - Dev mode (`import.meta.env.DEV`): Regular Worker only (SharedWorker HMR issues) 82 + 83 + **Initialization:** 84 + 1. Worker loads all card chunks in parallel 85 + 2. Builds in-memory indexes (by ID, by oracle ID) 86 + 3. Volatile data loaded lazily on first access 87 + 88 + **Communication:** Uses Comlink for RPC-style calls to worker. 89 + 90 + ### ServerCardProvider (`src/lib/cards-server-provider.ts`) 91 + 92 + Optimized for SSR—doesn't load full dataset into memory. 93 + 94 + **Byte-range index (`cards-byteindex.bin`):** 95 + - 25 bytes per record: 16 (UUID) + 1 (chunk index) + 4 (offset) + 4 (length) 96 + - Sorted by card ID for binary search 97 + - Slice only needed bytes from chunk file 98 + 99 + **Cloudflare Workers:** Uses `env.ASSETS` for file access when deployed. 100 + 101 + ## Worker Architecture (`src/workers/cards.worker.ts`) 102 + 103 + The worker handles: 104 + - Loading chunked card data 105 + - Building search indexes 106 + - Fuzzy search (fuse.js) 107 + - Syntax search (parser + matcher) 108 + - Volatile data loading 109 + 110 + **Key exports (via Comlink):** 111 + ```typescript 112 + { 113 + getCardById(id): Card | undefined; 114 + searchCards(query, restrictions?, maxResults?): Card[]; 115 + syntaxSearch(query, maxResults?): Result<Card[]>; 116 + unifiedSearch(query, restrictions?, maxResults?): UnifiedSearchResult; 117 + // ... etc 118 + } 119 + ``` 120 + 121 + ## Multi-Face Cards 122 + 123 + Located in `src/lib/card-faces.ts`. Handles transform, MDFC, split, flip, adventure, meld cards. 124 + 125 + **Layout categories:** 126 + - `MODAL_LAYOUTS` - Both faces castable (MDFC, split, adventure) 127 + - `TRANSFORM_IN_PLAY_LAYOUTS` - Only front castable (transform, flip, meld) 128 + - `HAS_BACK_IMAGE_LAYOUTS` - Back face has distinct image 129 + 130 + **Key functions:** 131 + - `getCastableFaces(card)` - Returns faces that can be cast from hand 132 + - `getManaCostForFace(face)` - Parses mana cost to value 133 + - `parseManaValue(cost)` - Handles X, hybrid, phyrexian symbols 134 + 135 + ## Data Types 136 + 137 + See `src/lib/scryfall-types.ts` for full type definitions. 138 + 139 + **Branded types for safety:** 140 + ```typescript 141 + type ScryfallId = string & { readonly __brand: "ScryfallId" }; 142 + type OracleId = string & { readonly __brand: "OracleId" }; 143 + ``` 144 + 145 + **Card type:** Filtered subset of Scryfall fields. Image URIs reconstructed from ID + set to save space. 146 + 147 + ## Performance Considerations 148 + 149 + - **Chunked loading:** Parallel chunk fetching, cards sorted oldest-first 150 + - **SharedWorker:** Desktop tabs share memory for card data (~500MB) 151 + - **Content-hash filenames:** Stable cache boundaries, only changed chunks invalidate 152 + - **Byte-range SSR:** Server never loads full dataset 153 + - **Debounced search:** Input debounced in UI, not provider 154 + 155 + ## Updating Card Data 156 + 157 + 1. Run `npm run download:scryfall` 158 + 2. Generates new files in `public/data/` 159 + 3. Updates `src/lib/card-manifest.ts` with chunk list 160 + 4. Commit the generated files (or gitignore and deploy separately)
+1 -2
.claude/DECK_EDITOR.md
··· 65 65 name: string; 66 66 format?: string; 67 67 cards: DeckCard[]; 68 - primer?: string; 69 - primerFacets?: RichtextFacet[]; 68 + primer?: RichText; // { text?: string, facets?: RichtextFacet[] } 70 69 createdAt: string; 71 70 updatedAt?: string; 72 71 }
+155
.claude/HOOKS.md
··· 1 + # Custom React Hooks 2 + 3 + This project includes several custom hooks with non-trivial behavior. Understanding these is critical for maintaining and extending the codebase. 4 + 5 + ## usePersistedState 6 + 7 + **Location**: `src/lib/usePersistedState.ts` 8 + 9 + SSR-safe localStorage hook with cross-tab synchronization. 10 + 11 + **Key behaviors:** 12 + - Server render: Always uses `defaultValue` (no localStorage access) 13 + - Client hydration: Initially uses `defaultValue` to match server HTML 14 + - After mount: Reads from localStorage and updates if different 15 + - Cross-tab sync via `storage` event listener 16 + 17 + **Constraints:** 18 + - Key must be prefixed with `deckbelcher:` (enforced by type) 19 + - Value cannot be `null` (used internally to detect missing keys) 20 + 21 + **Custom serialization:** 22 + ```typescript 23 + const [tags, setTags] = usePersistedState( 24 + "deckbelcher:tags", 25 + new Map<string, number>(), 26 + { 27 + serialize: (map) => JSON.stringify(Array.from(map.entries())), 28 + deserialize: (str) => new Map(JSON.parse(str)), 29 + } 30 + ); 31 + ``` 32 + 33 + ## useMutationWithToast 34 + 35 + **Location**: `src/lib/useMutationWithToast.ts` 36 + 37 + Wrapper around TanStack Query's `useMutation` that automatically shows error toasts via Sonner. 38 + 39 + **Use this for all mutations** to ensure consistent error handling across the app. 40 + 41 + **Options:** 42 + - `errorMessage`: String or function `(error) => string` for custom error messages 43 + - All other `useMutation` options pass through 44 + 45 + ```typescript 46 + const mutation = useMutationWithToast({ 47 + mutationFn: updateDeck, 48 + errorMessage: (err) => `Failed to save: ${err.message}`, 49 + onSuccess: () => navigate("/decks"), 50 + }); 51 + ``` 52 + 53 + ## useSeededRandom 54 + 55 + **Location**: `src/lib/useSeededRandom.tsx` 56 + 57 + SSR-safe seeded PRNG that maintains consistent randomization across server render and hydration. 58 + 59 + **How it works:** 60 + 1. On SSR: Generates random seed, embeds it in a hidden `<span data-seed="...">` 61 + 2. On hydration: Reads seed from DOM element 62 + 3. On client-only: Generates fresh seed 63 + 64 + **Usage:** 65 + ```tsx 66 + function ShuffledCards({ cards }) { 67 + const { rng, SeedEmbed } = useSeededRandom(); 68 + const shuffled = seededShuffle(cards, rng); 69 + 70 + return ( 71 + <> 72 + <SeedEmbed /> {/* Must render this! */} 73 + {shuffled.map(card => <Card key={card.id} {...card} />)} 74 + </> 75 + ); 76 + } 77 + ``` 78 + 79 + **Exports:** 80 + - `useSeededRandom()` - Hook returning `{ seed, rng, SeedEmbed }` 81 + - `createSeededRng(stateRef)` - Create RNG from mutable ref (mulberry32) 82 + - `seededShuffle(array, rng)` - Fisher-Yates shuffle with provided RNG 83 + 84 + ## useWorkerStatus 85 + 86 + **Location**: `src/lib/useWorkerStatus.ts` 87 + 88 + Tracks Web Worker initialization for visual indication only. 89 + 90 + **Important:** DO NOT use this as a gate for queries. Always check `query.isLoading` and `query.data` in components that depend on card data. 91 + 92 + ```typescript 93 + const { isLoaded } = useWorkerStatus(); 94 + // Use for showing loading spinner in header, NOT for gating data access 95 + ``` 96 + 97 + The underlying `initializeWorker()` is idempotent—safe to call multiple times. 98 + 99 + ## useDebounce 100 + 101 + **Location**: `src/lib/useDebounce.ts` 102 + 103 + Debounce hook with flush capability. Value updates are delayed by specified milliseconds. 104 + 105 + ```typescript 106 + const [search, setSearch] = useState(""); 107 + const { value: debouncedSearch, flush, isPending } = useDebounce(search, 300); 108 + 109 + // flush() immediately updates to current value (useful for beforeunload) 110 + // isPending is true when there's a pending debounced update 111 + ``` 112 + 113 + ## useCommonTags 114 + 115 + **Location**: `src/lib/useCommonTags.ts` 116 + 117 + Extracts the N most common tags from a deck's cards with stable memoization. 118 + 119 + ```typescript 120 + const commonTags = useCommonTags(deck.cards, 10); 121 + // Returns: ["ramp", "removal", "draw", ...] 122 + ``` 123 + 124 + ## useAuth 125 + 126 + **Location**: `src/lib/useAuth.tsx` 127 + 128 + OAuth context hook for accessing session state and ATProto agent. 129 + 130 + ```typescript 131 + const { session, agent, isLoading } = useAuth(); 132 + if (session) { 133 + // User is authenticated, agent is available 134 + } 135 + ``` 136 + 137 + ## useDeckStats 138 + 139 + **Location**: `src/lib/useDeckStats.ts` 140 + 141 + Aggregates deck statistics (mana curve, type distribution, land speeds) using TanStack Query. 142 + 143 + Returns memoized stats that update when deck cards change. 144 + 145 + ## useTheme 146 + 147 + **Location**: `src/lib/useTheme.tsx` 148 + 149 + Theme context hook. Uses `usePersistedState` under the hood. 150 + 151 + ```typescript 152 + const { theme, setTheme, resolvedTheme } = useTheme(); 153 + // theme: "light" | "dark" | "system" 154 + // resolvedTheme: "light" | "dark" (actual applied theme) 155 + ```
+258
.claude/SEARCH.md
··· 1 + # Search Syntax Parser 2 + 3 + Full implementation of Scryfall-like search syntax. Located in `src/lib/search/`. 4 + 5 + ## Architecture 6 + 7 + ``` 8 + Query String 9 + 10 + ┌─────────┐ 11 + │ Lexer │ → Token[] 12 + └─────────┘ 13 + 14 + ┌─────────┐ 15 + │ Parser │ → SearchNode (AST) 16 + └─────────┘ 17 + 18 + ┌─────────┐ 19 + │ Matcher │ → (Card) => boolean 20 + └─────────┘ 21 + ``` 22 + 23 + ## File Overview 24 + 25 + | File | Purpose | 26 + |------|---------| 27 + | `lexer.ts` | Tokenization (handles quoted strings, regexes, exact names) | 28 + | `parser.ts` | Recursive descent parser producing AST | 29 + | `matcher.ts` | Compiles AST to predicate function | 30 + | `types.ts` | AST node types, Result<T,E>, field definitions | 31 + | `fields.ts` | Field compilation (type, color, rarity, stats, etc.) | 32 + | `operators.ts` | Operator definitions, query complexity detection | 33 + | `colors.ts` | Color parsing and set comparison utilities | 34 + | `describe.ts` | Human-readable query descriptions for UI | 35 + | `index.ts` | Public API exports | 36 + 37 + ## Grammar 38 + 39 + ``` 40 + query = or_expr 41 + or_expr = and_expr ("OR" and_expr)* 42 + and_expr = unary_expr+ 43 + unary_expr = "-" unary_expr | primary 44 + primary = "(" or_expr ")" | field_expr | name_expr 45 + field_expr = WORD operator value 46 + name_expr = EXACT_NAME | WORD | QUOTED | REGEX 47 + ``` 48 + 49 + ## Supported Operators 50 + 51 + | Operator | Meaning | 52 + |----------|---------| 53 + | `:` | Contains/includes (default) | 54 + | `=` | Exact match | 55 + | `!=` | Not equal | 56 + | `<` | Less than | 57 + | `>` | Greater than | 58 + | `<=` | Less than or equal | 59 + | `>=` | Greater than or equal | 60 + 61 + ## Field Reference 62 + 63 + ### Card Properties 64 + - `name`, `n` - Card name 65 + - `type`, `t` - Type line (creature, instant, etc.) 66 + - `oracle`, `o` - Oracle text 67 + - `manavalue`, `mv`, `cmc` - Mana value (prefer `mv` - see CLAUDE.md terminology note) 68 + - `color`, `c` - Card colors 69 + - `identity`, `ci`, `id` - Color identity 70 + 71 + ### Stats 72 + - `power`, `pow` - Power (creatures) 73 + - `toughness`, `tou` - Toughness (creatures) 74 + - `loyalty`, `loy` - Loyalty (planeswalkers) 75 + - `defense`, `def` - Defense (battles) 76 + 77 + ### Metadata 78 + - `rarity`, `r` - Card rarity (common, uncommon, rare, mythic) 79 + - `set`, `s`, `e` - Set code 80 + - `format`, `f` - Format legality 81 + - `year` - Release year 82 + - `date` - Release date 83 + - `layout` - Card layout (normal, split, flip, transform, etc.) 84 + - `frame` - Frame edition (1993, 1997, 2003, 2015, future) 85 + - `border` - Border color (black, white, borderless, silver, gold) 86 + - `game` - Game availability (paper, mtgo, arena) 87 + - `in` - Unified filter for game, set type, set code, or language (see below) 88 + 89 + ### Boolean Filters (is:) 90 + 91 + **Card types:** 92 + - `is:creature`, `is:instant`, `is:sorcery`, `is:artifact`, `is:enchantment`, `is:land`, `is:planeswalker` 93 + - `is:permanent`, `is:spell` - Broader categories 94 + - `is:legendary`, `is:snow`, `is:historic` 95 + 96 + **Layouts:** 97 + - `is:split`, `is:flip`, `is:transform`, `is:mdfc`, `is:dfc`, `is:meld` 98 + - `is:saga`, `is:adventure`, `is:battle`, `is:prototype`, `is:leveler` 99 + - `is:token`, `is:art_series` 100 + 101 + **Printing characteristics:** 102 + - `is:reprint`, `is:promo`, `is:digital`, `is:reserved` 103 + - `is:full`, `is:fullart`, `is:hires` 104 + - `is:foil`, `is:nonfoil`, `is:etched` 105 + 106 + **Frame effects:** 107 + - `is:showcase`, `is:extendedart`, `is:borderless`, `is:inverted`, `is:colorshifted` 108 + - `is:retro`, `is:old` (1993/1997 frames), `is:modern` (2003/2015), `is:new` (2015), `is:future` 109 + - `is:boosterfun` 110 + 111 + **Promo types:** 112 + - `is:buyabox`, `is:prerelease`, `is:fnm`, `is:gameday`, `is:release`, `is:datestamped`, `is:promopacks` 113 + 114 + **Land types:** (derived from oracle text patterns) 115 + - `is:fetchland` - Fetch lands (search + pay life) 116 + - `is:shockland` - Shock lands (pay 2 life) 117 + - `is:dual` - Original dual lands (two basic types, no text) 118 + - `is:triome` - Triomes (three basic land types) 119 + - `is:checkland` - Check lands (enters tapped unless you control...) 120 + - `is:fastland` - Fast lands (enters tapped unless two or fewer lands) 121 + - `is:slowland` - Slow lands (enters tapped unless two or more lands) 122 + - `is:painland` - Pain lands (tap + damage for colored) 123 + - `is:filterland` - Filter lands (pay life to filter mana) 124 + - `is:bounceland` - Bounce lands (return a land) 125 + - `is:tangoland`, `is:battleland` - Battle/tango lands 126 + - `is:scryland` - Scry lands (enters tapped, scry 1) 127 + - `is:gainland` - Gain lands (enters tapped, gain 1 life) 128 + - `is:manland`, `is:creatureland` - Creature lands (becomes a creature) 129 + - `is:canopyland` - Canopy lands (sacrifice to draw) 130 + 131 + **Card archetypes:** 132 + - `is:vanilla` - Creatures with no text 133 + - `is:frenchvanilla` - Creatures with only keywords 134 + - `is:bear` - 2/2 for 2 creatures 135 + - `is:modal`, `is:spree` - Modal spells (choose one/two/etc) 136 + - `is:party` - Party creatures (cleric, rogue, warrior, wizard) 137 + - `is:outlaw` - Outlaw creatures (assassin, mercenary, pirate, rogue, warlock) 138 + - `is:commander` - Can be your commander 139 + 140 + **Note:** Land type predicates use oracle text pattern matching and may need refinement. See `fields.ts` IS_PREDICATES for implementation details. 141 + 142 + ## Color Syntax 143 + 144 + Colors can be specified as: 145 + - Single letters: `c:wubrgc` (W=white, U=blue, B=black, R=red, G=green, C=colorless) 146 + - Combined codes: `c:uw`, `c:bg`, `c:wubrg` 147 + - Full names: `c:white`, `c:blue`, `c:colorless` 148 + 149 + **Not yet supported** (see `colors.ts:137`): 150 + - Guild names (azorius, dimir, etc.) 151 + - Shard names (bant, esper, etc.) 152 + - Wedge names (abzan, jeskai, etc.) 153 + 154 + ### Color Operators 155 + 156 + Color comparisons use set theory: 157 + - `:` or `>=` - Card has at least these colors (superset) 158 + - `=` - Card has exactly these colors 159 + - `<=` - Card has at most these colors (subset) - useful for commander 160 + - `<` - Strict subset 161 + - `>` - Strict superset 162 + - `!=` - Not exactly these colors 163 + 164 + ### Identity Count Queries 165 + 166 + The `identity` field also supports numeric comparisons on the *number* of colors: 167 + - `id>1` - Cards with more than 1 identity color (multicolor+) 168 + - `id=2` - Cards with exactly 2 identity colors 169 + - `id<=1` - Mono-color or colorless cards 170 + - `id=0` - Colorless cards only 171 + 172 + This is useful for finding mono-color commanders, multicolor cards, etc. 173 + 174 + ### The `in:` Field 175 + 176 + The `in:` field is a unified filter that matches multiple contexts: 177 + 178 + | Value Type | Examples | What it matches | 179 + |------------|----------|-----------------| 180 + | Game | `in:paper`, `in:mtgo`, `in:arena` | `card.games` array | 181 + | Set type | `in:core`, `in:expansion`, `in:commander` | `card.set_type` | 182 + | Set code | `in:lea`, `in:m21` | `card.set` | 183 + | Language | `in:ja`, `in:ru` | `card.lang` | 184 + 185 + Note: For set codes and languages that could overlap, both are checked. 186 + 187 + ## Examples 188 + 189 + ``` 190 + # Creatures with CMC 3 or less 191 + t:creature cmc<=3 192 + 193 + # Blue instants or sorceries 194 + c:u (t:instant OR t:sorcery) 195 + 196 + # Cards legal in Pauper that draw cards 197 + f:pauper o:"draw a card" 198 + 199 + # Mythic rares from recent sets 200 + r:mythic year>=2023 201 + 202 + # NOT red creatures 203 + -c:r t:creature 204 + 205 + # Exact name match 206 + !"Lightning Bolt" 207 + ``` 208 + 209 + ## Query Description 210 + 211 + The `describe.ts` module generates human-readable descriptions of queries for UI feedback: 212 + 213 + ```typescript 214 + import { describeQuery } from "@/lib/search"; 215 + 216 + describeQuery("t:creature cmc<=3"); 217 + // → "creatures with mana value 3 or less" 218 + 219 + describeQuery("c:uw f:modern"); 220 + // → "white and blue cards legal in Modern" 221 + ``` 222 + 223 + ## Integration 224 + 225 + The search system integrates with `CardDataProvider`: 226 + 227 + ```typescript 228 + // Unified search (auto-routes to fuzzy or syntax) 229 + const result = await provider.unifiedSearch("t:creature cmc<=3"); 230 + // result.mode: "fuzzy" | "syntax" 231 + // result.cards: Card[] 232 + // result.description: "creatures with mana value 3 or less" 233 + // result.error: null | { message, start, end } 234 + ``` 235 + 236 + ## Result Type Pattern 237 + 238 + The parser uses a functional `Result<T, E>` pattern instead of exceptions: 239 + 240 + ```typescript 241 + type Result<T, E = Error> = 242 + | { ok: true; value: T } 243 + | { ok: false; error: E }; 244 + 245 + const result = parse("t:creature"); 246 + if (result.ok) { 247 + // result.value is SearchNode 248 + } else { 249 + // result.error is ParseError 250 + } 251 + ``` 252 + 253 + ## Adding New Fields 254 + 255 + 1. Add field name and aliases to `FIELD_ALIASES` in `types.ts` 256 + 2. Add case to `compileField()` in `fields.ts` 257 + 3. Add description logic to `describe.ts` 258 + 4. Add tests
+25
.claude/hooks/stop-test.sh
··· 1 + #!/usr/bin/env bash 2 + set -e 3 + 4 + input=$(cat) 5 + if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then 6 + exit 0 7 + fi 8 + 9 + output=$(npm run test -- --changed 2>&1) && exit 0 10 + 11 + # Get the failure summary (last 30 lines usually has the good stuff) 12 + errors=$(echo "$output" | tail -30) 13 + 14 + # Escape for JSON 15 + escaped=$(echo "$errors" | jq -Rs .) 16 + # Remove surrounding quotes that jq adds 17 + escaped=${escaped:1:-1} 18 + 19 + cat <<EOF 20 + { 21 + "decision": "block", 22 + "reason": "${escaped}\n\n[Stop Hook] Tests failed. Assess whether these failures are related to changes you made this session - if not, mention them to the user and stop." 23 + } 24 + EOF 25 + exit 2
+25
.claude/hooks/stop-typecheck.sh
··· 1 + #!/usr/bin/env bash 2 + set -e 3 + 4 + input=$(cat) 5 + if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then 6 + exit 0 7 + fi 8 + 9 + output=$(npm run typecheck:faster 2>&1) && exit 0 10 + 11 + # Extract just the error lines (skip npm boilerplate) 12 + errors=$(echo "$output" | grep -E "error TS[0-9]+:" | head -15) 13 + 14 + # Escape for JSON 15 + escaped=$(echo "$errors" | jq -Rs .) 16 + # Remove surrounding quotes that jq adds 17 + escaped=${escaped:1:-1} 18 + 19 + cat <<EOF 20 + { 21 + "decision": "block", 22 + "reason": "${escaped}\n\n[Stop Hook] Typecheck failed. Assess whether these errors are related to changes you made this session - if not, mention them to the user and stop." 23 + } 24 + EOF 25 + exit 2
+29
.claude/settings.json
··· 1 + { 2 + "hooks": { 3 + "PostToolUse": [ 4 + { 5 + "matcher": "Write|Edit", 6 + "hooks": [ 7 + { 8 + "type": "command", 9 + "command": "npm run check -- --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 1>&2 || exit 2" 10 + } 11 + ] 12 + } 13 + ], 14 + "Stop": [ 15 + { 16 + "hooks": [ 17 + { 18 + "type": "command", 19 + "command": ".claude/hooks/stop-typecheck.sh" 20 + }, 21 + { 22 + "type": "command", 23 + "command": ".claude/hooks/stop-test.sh" 24 + } 25 + ] 26 + } 27 + ] 28 + } 29 + }
+27 -1
.claude/settings.local.json
··· 36 36 "Bash(npx vitest run:*)", 37 37 "WebFetch(domain:slingshot.microcosm.blue)", 38 38 "Bash(git stash show:*)", 39 - "Bash(npm audit)" 39 + "Bash(npm audit)", 40 + "WebFetch(domain:developers.cloudflare.com)", 41 + "Bash(for card in )", 42 + "Bash(\"Tropical Island\" )", 43 + "Bash(\"Misty Rainforest\" )", 44 + "Bash(\"Hinterland Harbor\" )", 45 + "Bash(\"Botanical Sanctum\" )", 46 + "Bash(\"Yavimaya Coast\" )", 47 + "Bash(\"Flooded Grove\" )", 48 + "Bash(\"Ketria Triome\" )", 49 + "Bash(\"Canopy Vista\" )", 50 + "Bash(\"Barkchannel Pathway\" )", 51 + "Bash(\"Undercity Sewers\" )", 52 + "Bash(\"Exotic Orchard\" )", 53 + "Bash(\"Mana Confluence\" )", 54 + "Bash(\"City of Brass\" )", 55 + "Bash(\"Ancient Tomb\" )", 56 + "Bash(\"Gaea''s Cradle\" )", 57 + "Bash(\"Dryad Arbor\" )", 58 + "Bash(\"Azorius Chancery\")", 59 + "Bash(do)", 60 + "Bash(./src/lib/__tests__/add-test-card.sh:*)", 61 + "Bash(done)", 62 + "Bash(wc:*)", 63 + "Bash(ls:*)", 64 + "Bash(npm run screenshot:wireframe:*)", 65 + "Bash(curl:*)" 40 66 ], 41 67 "deny": [], 42 68 "ask": []
+4
.gitignore
··· 14 14 # Scryfall data (downloaded during build) 15 15 public/data/ 16 16 public/symbols/ 17 + src/lib/card-manifest.ts 18 + src/lib/set-symbols.ts 17 19 .cache/ 20 + 21 + tsconfig.tsbuildinfo
+53 -2
CLAUDE.md
··· 22 22 npm run build 23 23 24 24 # Preview production build 25 - npm run serve 25 + npm run preview 26 26 27 27 # Testing 28 28 npm run test # Run all tests ··· 45 45 Routes live in `src/routes/` and are managed by TanStack Router: 46 46 - `__root.tsx` - Root layout with header, devtools, and HTML shell 47 47 - `index.tsx` - Homepage route 48 - - `demo/*` - Demo routes showcasing various TanStack Start features 48 + - `card/`, `cards/`, `deck/`, `profile/`, `u/` - Feature routes 49 + - `oauth/`, `signin.tsx` - Authentication routes 49 50 50 51 Route files are auto-generated into `src/routeTree.gen.ts` (excluded from linting). 51 52 ··· 95 96 96 97 Additional reference docs are in `.claude/` - **read and update these when working on relevant topics**: 97 98 99 + ### Core Architecture 98 100 - **PROJECT.md** - DeckBelcher project overview, lexicon structure, and product decisions 101 + - **ATPROTO.md** - ATProto integration (Slingshot, PDS, branded types, Result pattern) 102 + - **CARD_DATA.md** - Card data pipeline and provider architecture (workers, chunks, SSR) 103 + - **SEARCH.md** - Search syntax parser (lexer, parser, matcher, operators) 104 + 105 + ### Reference 99 106 - **SCRYFALL.md** - Scryfall card API reference (IDs, fields, image handling) 100 107 - **TYPELEX.md** - Typelex syntax guide (decorators, external refs, patterns) 108 + - **DECK_EDITOR.md** - Deck editor UI patterns and data model 109 + - **HOOKS.md** - Custom React hooks (usePersistedState, useSeededRandom, etc.) 101 110 102 111 These contain important context about project decisions, API details, and tooling. Keep them updated as the project evolves. 103 112 104 113 **When to create new reference docs:** If you're doing significant research, explaining complex topics repeatedly, or the user is spending time teaching you something important—create a new markdown file in `.claude/` to preserve that knowledge for future sessions. 105 114 115 + ## Build Configuration 116 + 117 + ### OAuth Plugin (vite.config.ts) 118 + The Vite config includes a custom plugin that reads `public/client-metadata.json` and injects OAuth environment variables: 119 + - `VITE_OAUTH_CLIENT_ID` - Client identifier 120 + - `VITE_OAUTH_REDIRECT_URI` - Callback URL (differs between dev and build) 121 + - `VITE_OAUTH_SCOPE` - Requested ATProto scopes 122 + 123 + In dev mode, uses `http://localhost` redirect trick for local OAuth flow. 124 + 125 + ### Cloudflare Workers 126 + Deployed via `@cloudflare/vite-plugin`. Server-side code runs in Workers environment: 127 + - `env.ASSETS` for static file access (card data chunks) 128 + - `worker-configuration.d.ts` for type definitions 129 + 130 + ### Data File Exclusions 131 + Vite is configured to ignore large generated files in watch mode: 132 + - `/public/data/**` - Card data chunks (~500MB) 133 + - `/public/symbols/**` - Mana symbol SVGs 134 + 135 + ## Environment Variables 136 + 137 + | Variable | Description | 138 + |----------|-------------| 139 + | `VITE_OAUTH_CLIENT_ID` | OAuth client ID (injected by plugin) | 140 + | `VITE_OAUTH_REDIRECT_URI` | OAuth callback URL (injected by plugin) | 141 + | `VITE_OAUTH_SCOPE` | ATProto scopes (injected by plugin) | 142 + | `VITE_CLIENT_URI` | Public client URL | 143 + | `VITE_DEV_SERVER_PORT` | Dev server port (default 3000) | 144 + | `import.meta.env.SSR` | True during SSR (TanStack Start) | 145 + | `import.meta.env.DEV` | True in development mode | 146 + 106 147 ## Important Notes 107 148 149 + ### Documentation Hygiene 150 + - **Update docs when you change things** - If you modify behavior documented in `.claude/*.md`, update the docs in the same PR. Stale docs are worse than no docs. 151 + - **Add to todos.md when you notice issues** - Found a bug? Spotted code that needs refactoring? Add it to `todos.md` so it doesn't get lost. 152 + - **Create new `.claude/` docs for complex features** - If you're implementing something that took significant research or has non-obvious behavior, document it. 153 + 154 + ### MTG Terminology 155 + - **Use "mana value" (mv) not "CMC"** - Scryfall still uses `cmc` in their API, but the official MTG terminology changed in 2021. Use `mv` or `manavalue` in code, comments, and UI. The search parser accepts both but prefer `mv`. 156 + 157 + ### Code Standards 158 + - **Stay focused on your current task** - If a global check (typecheck, lint) reports errors in files you haven't modified in this session, don't automatically fix them—mention them and let the user decide. Errors in files you did modify are your responsibility to fix. When in doubt, ask 108 159 - **This is a TypeScript project** - ALL code (including scripts) must use TypeScript with proper types 109 160 - **Use `nix-shell -p <package>` for missing commands** - If a command isn't in PATH, use nix-shell to get it temporarily 110 161 - **Prefer functional style over exceptions** - Avoid throwing errors for control flow. Use type predicates, Option/Result patterns, and early returns instead. Throwing is like GOTO—it breaks local reasoning and makes code harder to follow
+1 -1
README.md
··· 6 6 7 7 ```bash 8 8 npm install 9 - npm run start 9 + npm run dev 10 10 ``` 11 11 12 12 # Building For Production
+2 -1
biome.json
··· 9 9 "ignoreUnknown": false, 10 10 "includes": [ 11 11 "**/src/**/*", 12 + "**/scripts/**/*", 12 13 "**/.vscode/**/*", 13 14 "**/index.html", 14 - "**/vite.config.js", 15 + "**/vite.config.ts", 15 16 "!**/src/routeTree.gen.ts", 16 17 "!**/src/styles.css" 17 18 ]
+6
flake.nix
··· 28 28 # language servers 29 29 typescript-language-server 30 30 typespec 31 + # playwright browser for screenshot scripts 32 + playwright-driver.browsers 31 33 ]; 34 + shellHook = '' 35 + export PLAYWRIGHT_BROWSERS_PATH=${playwright-driver.browsers} 36 + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true 37 + ''; 32 38 }; 33 39 } 34 40 );
+92
lexicons/com/deckbelcher/collection/list.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.deckbelcher.collection.list", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "name": { 12 + "type": "string", 13 + "maxLength": 1280, 14 + "maxGraphemes": 128, 15 + "description": "Name of the list." 16 + }, 17 + "description": { 18 + "type": "ref", 19 + "ref": "com.deckbelcher.richtext", 20 + "description": "Description of the list." 21 + }, 22 + "items": { 23 + "type": "array", 24 + "items": { 25 + "type": "union", 26 + "refs": [ 27 + "#cardItem", 28 + "#deckItem" 29 + ] 30 + }, 31 + "description": "Items in the list." 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "Timestamp when the list was created." 37 + }, 38 + "updatedAt": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "Timestamp when the list was last updated." 42 + } 43 + }, 44 + "required": [ 45 + "name", 46 + "items", 47 + "createdAt" 48 + ] 49 + }, 50 + "description": "A curated list of cards and/or decks." 51 + }, 52 + "cardItem": { 53 + "type": "object", 54 + "properties": { 55 + "scryfallId": { 56 + "type": "string", 57 + "description": "Scryfall UUID for the card." 58 + }, 59 + "addedAt": { 60 + "type": "string", 61 + "format": "datetime", 62 + "description": "Timestamp when this item was added to the list." 63 + } 64 + }, 65 + "description": "A card saved to the list.", 66 + "required": [ 67 + "scryfallId", 68 + "addedAt" 69 + ] 70 + }, 71 + "deckItem": { 72 + "type": "object", 73 + "properties": { 74 + "deckUri": { 75 + "type": "string", 76 + "format": "at-uri", 77 + "description": "AT-URI of the deck record." 78 + }, 79 + "addedAt": { 80 + "type": "string", 81 + "format": "datetime", 82 + "description": "Timestamp when this item was added to the list." 83 + } 84 + }, 85 + "description": "A deck saved to the list.", 86 + "required": [ 87 + "deckUri", 88 + "addedAt" 89 + ] 90 + } 91 + } 92 + }
+2 -11
lexicons/com/deckbelcher/deck/list.json
··· 29 29 "description": "Array of cards in the decklist." 30 30 }, 31 31 "primer": { 32 - "type": "string", 33 - "maxLength": 100000, 34 - "maxGraphemes": 10000, 32 + "type": "ref", 33 + "ref": "com.deckbelcher.richtext", 35 34 "description": "Deck primer with strategy, combos, and card choices." 36 - }, 37 - "primerFacets": { 38 - "type": "array", 39 - "items": { 40 - "type": "ref", 41 - "ref": "com.deckbelcher.richtext.facet" 42 - }, 43 - "description": "Annotations of text in the primer (mentions, URLs, hashtags, card references, etc)." 44 35 }, 45 36 "createdAt": { 46 37 "type": "string",
+25 -1
lexicons/com/deckbelcher/richtext/facet.json
··· 16 16 "refs": [ 17 17 "#mention", 18 18 "#link", 19 - "#tag" 19 + "#tag", 20 + "#bold", 21 + "#italic", 22 + "#code", 23 + "#codeBlock" 20 24 ] 21 25 } 22 26 } ··· 84 88 "required": [ 85 89 "tag" 86 90 ] 91 + }, 92 + "bold": { 93 + "type": "object", 94 + "properties": {}, 95 + "description": "Facet feature for bold text formatting.\nTypically rendered as `<strong>` in HTML." 96 + }, 97 + "italic": { 98 + "type": "object", 99 + "properties": {}, 100 + "description": "Facet feature for italic text formatting.\nTypically rendered as `<em>` in HTML." 101 + }, 102 + "code": { 103 + "type": "object", 104 + "properties": {}, 105 + "description": "Facet feature for inline code.\nTypically rendered as `<code>` in HTML." 106 + }, 107 + "codeBlock": { 108 + "type": "object", 109 + "properties": {}, 110 + "description": "Facet feature for code blocks.\nTypically rendered as `<pre><code>` in HTML." 87 111 } 88 112 } 89 113 }
+26
lexicons/com/deckbelcher/richtext.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.deckbelcher.richtext", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "text": { 9 + "type": "string", 10 + "maxLength": 500000, 11 + "maxGraphemes": 50000, 12 + "description": "The text content." 13 + }, 14 + "facets": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "com.deckbelcher.richtext.facet" 19 + }, 20 + "description": "Annotations of text (mentions, URLs, hashtags, card references, etc)." 21 + } 22 + }, 23 + "description": "Rich text content with optional facet annotations.\nUsed for primers, descriptions, and other formatted text." 24 + } 25 + } 26 + }
+418 -185
package-lock.json
··· 5 5 "packages": { 6 6 "": { 7 7 "name": "deckbelcher", 8 + "hasInstallScript": true, 8 9 "dependencies": { 9 10 "@atcute/bluesky": "^3.2.8", 10 11 "@atcute/client": "^4.0.5", ··· 13 14 "@cloudflare/vite-plugin": "^1.13.19", 14 15 "@dnd-kit/core": "^6.3.1", 15 16 "@dnd-kit/utilities": "^3.2.2", 16 - "@headlessui/react": "^2.2.9", 17 17 "@tailwindcss/vite": "^4.0.6", 18 18 "@tanstack/react-devtools": "^0.7.0", 19 19 "@tanstack/react-query": "^5.66.5", ··· 22 22 "@tanstack/react-router-devtools": "^1.132.0", 23 23 "@tanstack/react-router-ssr-query": "^1.131.7", 24 24 "@tanstack/react-start": "^1.132.0", 25 + "@tanstack/react-virtual": "^3.13.16", 25 26 "@tanstack/router-plugin": "^1.132.0", 26 27 "comlink": "^4.4.2", 27 28 "lucide-react": "^0.544.0", 28 29 "minisearch": "^7.2.0", 29 30 "react": "^19.2.0", 30 31 "react-dom": "^19.2.0", 32 + "recharts": "^3.6.0", 31 33 "sonner": "^2.0.7", 32 34 "tailwindcss": "^4.0.6", 33 35 "vite-tsconfig-paths": "^5.1.4" ··· 46 48 "@vitest/web-worker": "^3.2.4", 47 49 "fast-check": "^4.4.0", 48 50 "jsdom": "^27.0.0", 51 + "playwright": "^1.54.1", 49 52 "typescript": "^5.7.2", 50 53 "vite": "^7.1.7", 51 54 "vitest": "^3.0.5", ··· 2123 2126 "node": ">=18" 2124 2127 } 2125 2128 }, 2126 - "node_modules/@floating-ui/core": { 2127 - "version": "1.7.3", 2128 - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", 2129 - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", 2130 - "license": "MIT", 2131 - "dependencies": { 2132 - "@floating-ui/utils": "^0.2.10" 2133 - } 2134 - }, 2135 - "node_modules/@floating-ui/dom": { 2136 - "version": "1.7.4", 2137 - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", 2138 - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", 2139 - "license": "MIT", 2140 - "dependencies": { 2141 - "@floating-ui/core": "^1.7.3", 2142 - "@floating-ui/utils": "^0.2.10" 2143 - } 2144 - }, 2145 - "node_modules/@floating-ui/react": { 2146 - "version": "0.26.28", 2147 - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", 2148 - "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", 2149 - "license": "MIT", 2150 - "dependencies": { 2151 - "@floating-ui/react-dom": "^2.1.2", 2152 - "@floating-ui/utils": "^0.2.8", 2153 - "tabbable": "^6.0.0" 2154 - }, 2155 - "peerDependencies": { 2156 - "react": ">=16.8.0", 2157 - "react-dom": ">=16.8.0" 2158 - } 2159 - }, 2160 - "node_modules/@floating-ui/react-dom": { 2161 - "version": "2.1.6", 2162 - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", 2163 - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", 2164 - "license": "MIT", 2165 - "dependencies": { 2166 - "@floating-ui/dom": "^1.7.4" 2167 - }, 2168 - "peerDependencies": { 2169 - "react": ">=16.8.0", 2170 - "react-dom": ">=16.8.0" 2171 - } 2172 - }, 2173 - "node_modules/@floating-ui/utils": { 2174 - "version": "0.2.10", 2175 - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", 2176 - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", 2177 - "license": "MIT" 2178 - }, 2179 - "node_modules/@headlessui/react": { 2180 - "version": "2.2.9", 2181 - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", 2182 - "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", 2183 - "license": "MIT", 2184 - "dependencies": { 2185 - "@floating-ui/react": "^0.26.16", 2186 - "@react-aria/focus": "^3.20.2", 2187 - "@react-aria/interactions": "^3.25.0", 2188 - "@tanstack/react-virtual": "^3.13.9", 2189 - "use-sync-external-store": "^1.5.0" 2190 - }, 2191 - "engines": { 2192 - "node": ">=10" 2193 - }, 2194 - "peerDependencies": { 2195 - "react": "^18 || ^19 || ^19.0.0-rc", 2196 - "react-dom": "^18 || ^19 || ^19.0.0-rc" 2197 - } 2198 - }, 2199 2129 "node_modules/@img/sharp-darwin-arm64": { 2200 2130 "version": "0.33.5", 2201 2131 "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", ··· 3127 3057 "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", 3128 3058 "license": "MIT" 3129 3059 }, 3130 - "node_modules/@react-aria/focus": { 3131 - "version": "3.21.2", 3132 - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", 3133 - "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", 3134 - "license": "Apache-2.0", 3060 + "node_modules/@reduxjs/toolkit": { 3061 + "version": "2.11.2", 3062 + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", 3063 + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", 3064 + "license": "MIT", 3135 3065 "dependencies": { 3136 - "@react-aria/interactions": "^3.25.6", 3137 - "@react-aria/utils": "^3.31.0", 3138 - "@react-types/shared": "^3.32.1", 3139 - "@swc/helpers": "^0.5.0", 3140 - "clsx": "^2.0.0" 3066 + "@standard-schema/spec": "^1.0.0", 3067 + "@standard-schema/utils": "^0.3.0", 3068 + "immer": "^11.0.0", 3069 + "redux": "^5.0.1", 3070 + "redux-thunk": "^3.1.0", 3071 + "reselect": "^5.1.0" 3141 3072 }, 3142 3073 "peerDependencies": { 3143 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 3144 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3145 - } 3146 - }, 3147 - "node_modules/@react-aria/interactions": { 3148 - "version": "3.25.6", 3149 - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", 3150 - "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", 3151 - "license": "Apache-2.0", 3152 - "dependencies": { 3153 - "@react-aria/ssr": "^3.9.10", 3154 - "@react-aria/utils": "^3.31.0", 3155 - "@react-stately/flags": "^3.1.2", 3156 - "@react-types/shared": "^3.32.1", 3157 - "@swc/helpers": "^0.5.0" 3158 - }, 3159 - "peerDependencies": { 3160 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 3161 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3162 - } 3163 - }, 3164 - "node_modules/@react-aria/ssr": { 3165 - "version": "3.9.10", 3166 - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", 3167 - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", 3168 - "license": "Apache-2.0", 3169 - "dependencies": { 3170 - "@swc/helpers": "^0.5.0" 3171 - }, 3172 - "engines": { 3173 - "node": ">= 12" 3174 - }, 3175 - "peerDependencies": { 3176 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3177 - } 3178 - }, 3179 - "node_modules/@react-aria/utils": { 3180 - "version": "3.31.0", 3181 - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", 3182 - "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", 3183 - "license": "Apache-2.0", 3184 - "dependencies": { 3185 - "@react-aria/ssr": "^3.9.10", 3186 - "@react-stately/flags": "^3.1.2", 3187 - "@react-stately/utils": "^3.10.8", 3188 - "@react-types/shared": "^3.32.1", 3189 - "@swc/helpers": "^0.5.0", 3190 - "clsx": "^2.0.0" 3191 - }, 3192 - "peerDependencies": { 3193 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 3194 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3195 - } 3196 - }, 3197 - "node_modules/@react-stately/flags": { 3198 - "version": "3.1.2", 3199 - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", 3200 - "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", 3201 - "license": "Apache-2.0", 3202 - "dependencies": { 3203 - "@swc/helpers": "^0.5.0" 3204 - } 3205 - }, 3206 - "node_modules/@react-stately/utils": { 3207 - "version": "3.10.8", 3208 - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", 3209 - "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", 3210 - "license": "Apache-2.0", 3211 - "dependencies": { 3212 - "@swc/helpers": "^0.5.0" 3074 + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", 3075 + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" 3213 3076 }, 3214 - "peerDependencies": { 3215 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3077 + "peerDependenciesMeta": { 3078 + "react": { 3079 + "optional": true 3080 + }, 3081 + "react-redux": { 3082 + "optional": true 3083 + } 3216 3084 } 3217 3085 }, 3218 - "node_modules/@react-types/shared": { 3219 - "version": "3.32.1", 3220 - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", 3221 - "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", 3222 - "license": "Apache-2.0", 3223 - "peerDependencies": { 3224 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 3086 + "node_modules/@reduxjs/toolkit/node_modules/immer": { 3087 + "version": "11.1.3", 3088 + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", 3089 + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", 3090 + "license": "MIT", 3091 + "funding": { 3092 + "type": "opencollective", 3093 + "url": "https://opencollective.com/immer" 3225 3094 } 3226 3095 }, 3227 3096 "node_modules/@remix-run/node-fetch-server": { ··· 3633 3502 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", 3634 3503 "license": "MIT" 3635 3504 }, 3636 - "node_modules/@swc/helpers": { 3637 - "version": "0.5.17", 3638 - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", 3639 - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", 3640 - "license": "Apache-2.0", 3641 - "dependencies": { 3642 - "tslib": "^2.8.0" 3643 - } 3505 + "node_modules/@standard-schema/utils": { 3506 + "version": "0.3.0", 3507 + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", 3508 + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", 3509 + "license": "MIT" 3644 3510 }, 3645 3511 "node_modules/@tailwindcss/node": { 3646 3512 "version": "4.1.16", ··· 4271 4137 } 4272 4138 }, 4273 4139 "node_modules/@tanstack/react-virtual": { 4274 - "version": "3.13.12", 4275 - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", 4276 - "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", 4140 + "version": "3.13.16", 4141 + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.16.tgz", 4142 + "integrity": "sha512-y4xLKvLu6UZWiGdNcgk3yYlzCznYIV0m8dSyUzr3eAC0dHLos5V74qhUHxutYddFGgGU8sWLkp6H5c2RCrsrXw==", 4277 4143 "license": "MIT", 4278 4144 "dependencies": { 4279 - "@tanstack/virtual-core": "3.13.12" 4145 + "@tanstack/virtual-core": "3.13.16" 4280 4146 }, 4281 4147 "funding": { 4282 4148 "type": "github", ··· 4601 4467 } 4602 4468 }, 4603 4469 "node_modules/@tanstack/virtual-core": { 4604 - "version": "3.13.12", 4605 - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", 4606 - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", 4470 + "version": "3.13.16", 4471 + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.16.tgz", 4472 + "integrity": "sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==", 4607 4473 "license": "MIT", 4608 4474 "funding": { 4609 4475 "type": "github", ··· 4766 4632 "assertion-error": "^2.0.1" 4767 4633 } 4768 4634 }, 4635 + "node_modules/@types/d3-array": { 4636 + "version": "3.2.2", 4637 + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", 4638 + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", 4639 + "license": "MIT" 4640 + }, 4641 + "node_modules/@types/d3-color": { 4642 + "version": "3.1.3", 4643 + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", 4644 + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", 4645 + "license": "MIT" 4646 + }, 4647 + "node_modules/@types/d3-ease": { 4648 + "version": "3.0.2", 4649 + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", 4650 + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", 4651 + "license": "MIT" 4652 + }, 4653 + "node_modules/@types/d3-interpolate": { 4654 + "version": "3.0.4", 4655 + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", 4656 + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", 4657 + "license": "MIT", 4658 + "dependencies": { 4659 + "@types/d3-color": "*" 4660 + } 4661 + }, 4662 + "node_modules/@types/d3-path": { 4663 + "version": "3.1.1", 4664 + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", 4665 + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", 4666 + "license": "MIT" 4667 + }, 4668 + "node_modules/@types/d3-scale": { 4669 + "version": "4.0.9", 4670 + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", 4671 + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", 4672 + "license": "MIT", 4673 + "dependencies": { 4674 + "@types/d3-time": "*" 4675 + } 4676 + }, 4677 + "node_modules/@types/d3-shape": { 4678 + "version": "3.1.7", 4679 + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", 4680 + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", 4681 + "license": "MIT", 4682 + "dependencies": { 4683 + "@types/d3-path": "*" 4684 + } 4685 + }, 4686 + "node_modules/@types/d3-time": { 4687 + "version": "3.0.4", 4688 + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", 4689 + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", 4690 + "license": "MIT" 4691 + }, 4692 + "node_modules/@types/d3-timer": { 4693 + "version": "3.0.2", 4694 + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", 4695 + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", 4696 + "license": "MIT" 4697 + }, 4769 4698 "node_modules/@types/deep-eql": { 4770 4699 "version": "4.0.2", 4771 4700 "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", ··· 4806 4735 "peerDependencies": { 4807 4736 "@types/react": "^19.2.0" 4808 4737 } 4738 + }, 4739 + "node_modules/@types/use-sync-external-store": { 4740 + "version": "0.0.6", 4741 + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", 4742 + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", 4743 + "license": "MIT" 4809 4744 }, 4810 4745 "node_modules/@typespec/compiler": { 4811 4746 "version": "1.5.0", ··· 5643 5578 "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 5644 5579 "license": "MIT" 5645 5580 }, 5581 + "node_modules/d3-array": { 5582 + "version": "3.2.4", 5583 + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", 5584 + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", 5585 + "license": "ISC", 5586 + "dependencies": { 5587 + "internmap": "1 - 2" 5588 + }, 5589 + "engines": { 5590 + "node": ">=12" 5591 + } 5592 + }, 5593 + "node_modules/d3-color": { 5594 + "version": "3.1.0", 5595 + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", 5596 + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", 5597 + "license": "ISC", 5598 + "engines": { 5599 + "node": ">=12" 5600 + } 5601 + }, 5602 + "node_modules/d3-ease": { 5603 + "version": "3.0.1", 5604 + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", 5605 + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", 5606 + "license": "BSD-3-Clause", 5607 + "engines": { 5608 + "node": ">=12" 5609 + } 5610 + }, 5611 + "node_modules/d3-format": { 5612 + "version": "3.1.0", 5613 + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", 5614 + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", 5615 + "license": "ISC", 5616 + "engines": { 5617 + "node": ">=12" 5618 + } 5619 + }, 5620 + "node_modules/d3-interpolate": { 5621 + "version": "3.0.1", 5622 + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", 5623 + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", 5624 + "license": "ISC", 5625 + "dependencies": { 5626 + "d3-color": "1 - 3" 5627 + }, 5628 + "engines": { 5629 + "node": ">=12" 5630 + } 5631 + }, 5632 + "node_modules/d3-path": { 5633 + "version": "3.1.0", 5634 + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", 5635 + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", 5636 + "license": "ISC", 5637 + "engines": { 5638 + "node": ">=12" 5639 + } 5640 + }, 5641 + "node_modules/d3-scale": { 5642 + "version": "4.0.2", 5643 + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", 5644 + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", 5645 + "license": "ISC", 5646 + "dependencies": { 5647 + "d3-array": "2.10.0 - 3", 5648 + "d3-format": "1 - 3", 5649 + "d3-interpolate": "1.2.0 - 3", 5650 + "d3-time": "2.1.1 - 3", 5651 + "d3-time-format": "2 - 4" 5652 + }, 5653 + "engines": { 5654 + "node": ">=12" 5655 + } 5656 + }, 5657 + "node_modules/d3-shape": { 5658 + "version": "3.2.0", 5659 + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", 5660 + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", 5661 + "license": "ISC", 5662 + "dependencies": { 5663 + "d3-path": "^3.1.0" 5664 + }, 5665 + "engines": { 5666 + "node": ">=12" 5667 + } 5668 + }, 5669 + "node_modules/d3-time": { 5670 + "version": "3.1.0", 5671 + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", 5672 + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", 5673 + "license": "ISC", 5674 + "dependencies": { 5675 + "d3-array": "2 - 3" 5676 + }, 5677 + "engines": { 5678 + "node": ">=12" 5679 + } 5680 + }, 5681 + "node_modules/d3-time-format": { 5682 + "version": "4.1.0", 5683 + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", 5684 + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", 5685 + "license": "ISC", 5686 + "dependencies": { 5687 + "d3-time": "1 - 3" 5688 + }, 5689 + "engines": { 5690 + "node": ">=12" 5691 + } 5692 + }, 5693 + "node_modules/d3-timer": { 5694 + "version": "3.0.1", 5695 + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", 5696 + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", 5697 + "license": "ISC", 5698 + "engines": { 5699 + "node": ">=12" 5700 + } 5701 + }, 5646 5702 "node_modules/data-urls": { 5647 5703 "version": "6.0.0", 5648 5704 "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", ··· 5681 5737 "dev": true, 5682 5738 "license": "MIT" 5683 5739 }, 5740 + "node_modules/decimal.js-light": { 5741 + "version": "2.5.1", 5742 + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", 5743 + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", 5744 + "license": "MIT" 5745 + }, 5684 5746 "node_modules/deep-eql": { 5685 5747 "version": "5.0.2", 5686 5748 "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", ··· 5861 5923 "dev": true, 5862 5924 "license": "MIT" 5863 5925 }, 5926 + "node_modules/es-toolkit": { 5927 + "version": "1.43.0", 5928 + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", 5929 + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", 5930 + "license": "MIT", 5931 + "workspaces": [ 5932 + "docs", 5933 + "benchmarks" 5934 + ] 5935 + }, 5864 5936 "node_modules/esbuild": { 5865 5937 "version": "0.25.11", 5866 5938 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", ··· 5939 6011 "dependencies": { 5940 6012 "@types/estree": "^1.0.0" 5941 6013 } 6014 + }, 6015 + "node_modules/eventemitter3": { 6016 + "version": "5.0.1", 6017 + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", 6018 + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", 6019 + "license": "MIT" 5942 6020 }, 5943 6021 "node_modules/exit-hook": { 5944 6022 "version": "2.2.1", ··· 6306 6384 "license": "MIT", 6307 6385 "engines": { 6308 6386 "node": ">= 4" 6387 + } 6388 + }, 6389 + "node_modules/immer": { 6390 + "version": "10.2.0", 6391 + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", 6392 + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", 6393 + "license": "MIT", 6394 + "funding": { 6395 + "type": "opencollective", 6396 + "url": "https://opencollective.com/immer" 6397 + } 6398 + }, 6399 + "node_modules/internmap": { 6400 + "version": "2.0.3", 6401 + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", 6402 + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", 6403 + "license": "ISC", 6404 + "engines": { 6405 + "node": ">=12" 6309 6406 } 6310 6407 }, 6311 6408 "node_modules/is-arrayish": { ··· 7144 7241 "url": "https://github.com/sponsors/jonschlinkert" 7145 7242 } 7146 7243 }, 7244 + "node_modules/playwright": { 7245 + "version": "1.54.1", 7246 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", 7247 + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", 7248 + "dev": true, 7249 + "license": "Apache-2.0", 7250 + "dependencies": { 7251 + "playwright-core": "1.54.1" 7252 + }, 7253 + "bin": { 7254 + "playwright": "cli.js" 7255 + }, 7256 + "engines": { 7257 + "node": ">=18" 7258 + }, 7259 + "optionalDependencies": { 7260 + "fsevents": "2.3.2" 7261 + } 7262 + }, 7263 + "node_modules/playwright-core": { 7264 + "version": "1.54.1", 7265 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", 7266 + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", 7267 + "dev": true, 7268 + "license": "Apache-2.0", 7269 + "bin": { 7270 + "playwright-core": "cli.js" 7271 + }, 7272 + "engines": { 7273 + "node": ">=18" 7274 + } 7275 + }, 7276 + "node_modules/playwright/node_modules/fsevents": { 7277 + "version": "2.3.2", 7278 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 7279 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 7280 + "dev": true, 7281 + "hasInstallScript": true, 7282 + "license": "MIT", 7283 + "optional": true, 7284 + "os": [ 7285 + "darwin" 7286 + ], 7287 + "engines": { 7288 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 7289 + } 7290 + }, 7147 7291 "node_modules/postcss": { 7148 7292 "version": "8.5.6", 7149 7293 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", ··· 7275 7419 "version": "17.0.2", 7276 7420 "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", 7277 7421 "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", 7278 - "dev": true, 7279 7422 "license": "MIT" 7423 + }, 7424 + "node_modules/react-redux": { 7425 + "version": "9.2.0", 7426 + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", 7427 + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", 7428 + "license": "MIT", 7429 + "dependencies": { 7430 + "@types/use-sync-external-store": "^0.0.6", 7431 + "use-sync-external-store": "^1.4.0" 7432 + }, 7433 + "peerDependencies": { 7434 + "@types/react": "^18.2.25 || ^19", 7435 + "react": "^18.0 || ^19", 7436 + "redux": "^5.0.0" 7437 + }, 7438 + "peerDependenciesMeta": { 7439 + "@types/react": { 7440 + "optional": true 7441 + }, 7442 + "redux": { 7443 + "optional": true 7444 + } 7445 + } 7280 7446 }, 7281 7447 "node_modules/react-refresh": { 7282 7448 "version": "0.18.0", ··· 7325 7491 "node": ">=0.10.0" 7326 7492 } 7327 7493 }, 7494 + "node_modules/recharts": { 7495 + "version": "3.6.0", 7496 + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", 7497 + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", 7498 + "license": "MIT", 7499 + "workspaces": [ 7500 + "www" 7501 + ], 7502 + "dependencies": { 7503 + "@reduxjs/toolkit": "1.x.x || 2.x.x", 7504 + "clsx": "^2.1.1", 7505 + "decimal.js-light": "^2.5.1", 7506 + "es-toolkit": "^1.39.3", 7507 + "eventemitter3": "^5.0.1", 7508 + "immer": "^10.1.1", 7509 + "react-redux": "8.x.x || 9.x.x", 7510 + "reselect": "5.1.1", 7511 + "tiny-invariant": "^1.3.3", 7512 + "use-sync-external-store": "^1.2.2", 7513 + "victory-vendor": "^37.0.2" 7514 + }, 7515 + "engines": { 7516 + "node": ">=18" 7517 + }, 7518 + "peerDependencies": { 7519 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 7520 + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 7521 + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 7522 + } 7523 + }, 7524 + "node_modules/redux": { 7525 + "version": "5.0.1", 7526 + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", 7527 + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", 7528 + "license": "MIT" 7529 + }, 7530 + "node_modules/redux-thunk": { 7531 + "version": "3.1.0", 7532 + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", 7533 + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", 7534 + "license": "MIT", 7535 + "peerDependencies": { 7536 + "redux": "^5.0.0" 7537 + } 7538 + }, 7328 7539 "node_modules/require-from-string": { 7329 7540 "version": "2.0.2", 7330 7541 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", ··· 7334 7545 "engines": { 7335 7546 "node": ">=0.10.0" 7336 7547 } 7548 + }, 7549 + "node_modules/reselect": { 7550 + "version": "5.1.1", 7551 + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", 7552 + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", 7553 + "license": "MIT" 7337 7554 }, 7338 7555 "node_modules/resolve-pkg-maps": { 7339 7556 "version": "1.0.0", ··· 7729 7946 "dev": true, 7730 7947 "license": "MIT" 7731 7948 }, 7732 - "node_modules/tabbable": { 7733 - "version": "6.3.0", 7734 - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", 7735 - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", 7736 - "license": "MIT" 7737 - }, 7738 7949 "node_modules/tailwindcss": { 7739 7950 "version": "4.1.16", 7740 7951 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", ··· 8124 8335 "license": "MIT", 8125 8336 "peerDependencies": { 8126 8337 "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 8338 + } 8339 + }, 8340 + "node_modules/victory-vendor": { 8341 + "version": "37.3.6", 8342 + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", 8343 + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", 8344 + "license": "MIT AND ISC", 8345 + "dependencies": { 8346 + "@types/d3-array": "^3.0.3", 8347 + "@types/d3-ease": "^3.0.0", 8348 + "@types/d3-interpolate": "^3.0.1", 8349 + "@types/d3-scale": "^4.0.2", 8350 + "@types/d3-shape": "^3.1.0", 8351 + "@types/d3-time": "^3.0.0", 8352 + "@types/d3-timer": "^3.0.0", 8353 + "d3-array": "^3.1.6", 8354 + "d3-ease": "^3.0.1", 8355 + "d3-interpolate": "^3.0.1", 8356 + "d3-scale": "^4.0.2", 8357 + "d3-shape": "^3.1.0", 8358 + "d3-time": "^3.0.0", 8359 + "d3-timer": "^3.0.1" 8127 8360 } 8128 8361 }, 8129 8362 "node_modules/vite": {
+6 -4
package.json
··· 3 3 "private": true, 4 4 "type": "module", 5 5 "scripts": { 6 + "postinstall": "npm run download:scryfall", 6 7 "dev": "SSL_CERT_FILE=\"$NIX_SSL_CERT_FILE\" vite dev --port 3000", 7 8 "build": "vite build", 8 9 "prebuild": "npm run download:scryfall", ··· 14 15 "lint": "biome lint", 15 16 "check": "biome check", 16 17 "typecheck": "tsc --noEmit", 18 + "typecheck:faster": "npm run typecheck -- --skipLibCheck --incremental", 17 19 "build:typelex": "typelex compile com.deckbelcher.*", 18 20 "build:lexicons": "lex-cli generate -c ./lex.config.js", 19 - "download:scryfall": "node --experimental-strip-types scripts/download-scryfall.ts", 20 - "load:d1:local": "bash scripts/load-d1-local.sh", 21 - "load:d1:remote": "bash scripts/load-d1-remote.sh" 21 + "download:scryfall": "node --experimental-strip-types scripts/download-scryfall.ts" 22 22 }, 23 23 "dependencies": { 24 24 "@atcute/bluesky": "^3.2.8", ··· 28 28 "@cloudflare/vite-plugin": "^1.13.19", 29 29 "@dnd-kit/core": "^6.3.1", 30 30 "@dnd-kit/utilities": "^3.2.2", 31 - "@headlessui/react": "^2.2.9", 32 31 "@tailwindcss/vite": "^4.0.6", 33 32 "@tanstack/react-devtools": "^0.7.0", 34 33 "@tanstack/react-query": "^5.66.5", ··· 37 36 "@tanstack/react-router-devtools": "^1.132.0", 38 37 "@tanstack/react-router-ssr-query": "^1.131.7", 39 38 "@tanstack/react-start": "^1.132.0", 39 + "@tanstack/react-virtual": "^3.13.16", 40 40 "@tanstack/router-plugin": "^1.132.0", 41 41 "comlink": "^4.4.2", 42 42 "lucide-react": "^0.544.0", 43 43 "minisearch": "^7.2.0", 44 44 "react": "^19.2.0", 45 45 "react-dom": "^19.2.0", 46 + "recharts": "^3.6.0", 46 47 "sonner": "^2.0.7", 47 48 "tailwindcss": "^4.0.6", 48 49 "vite-tsconfig-paths": "^5.1.4" ··· 61 62 "@vitest/web-worker": "^3.2.4", 62 63 "fast-check": "^4.4.0", 63 64 "jsdom": "^27.0.0", 65 + "playwright": "^1.54.1", 64 66 "typescript": "^5.7.2", 65 67 "vite": "^7.1.7", 66 68 "vitest": "^3.0.5",
+18
public/_headers
··· 1 + # Immutable caching for content-hashed card chunks (1 year) 2 + # Filename pattern: cards-000-a1b2c3d4e5f6g7h8.json 3 + /data/cards/* 4 + Cache-Control: public, max-age=31536000, immutable 5 + 6 + # Symbols rarely change (1 week) 7 + /symbols/* 8 + Cache-Control: public, max-age=604800 9 + 10 + # Volatile data - changes with each data update 11 + /data/cards-byteindex.bin 12 + Cache-Control: public, max-age=3600 13 + 14 + /data/migrations.json 15 + Cache-Control: public, max-age=86400 16 + 17 + /data/version.json 18 + Cache-Control: public, max-age=3600
public/fonts/keyrune/keyrune.woff2

This is a binary file and will not be displayed.

+94 -37
scripts/download-scryfall.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 - import { isDefaultPrinting, compareCards } from "./download-scryfall.ts"; 1 + import { describe, expect, it } from "vitest"; 3 2 import type { Card } from "../src/lib/scryfall-types.ts"; 4 - import { asScryfallId, asOracleId } from "../src/lib/scryfall-types.ts"; 3 + import { asOracleId, asScryfallId } from "../src/lib/scryfall-types.ts"; 4 + import { compareCards, isDefaultPrinting } from "./download-scryfall.ts"; 5 + 6 + type TestCard = Card & { security_stamp?: string; [key: string]: unknown }; 5 7 6 - function createCard(overrides: Partial<Card>): Card { 8 + function createCard(overrides: Partial<TestCard>): TestCard { 7 9 return { 8 10 id: asScryfallId("00000000-0000-0000-0000-000000000000"), 9 11 oracle_id: asOracleId("00000000-0000-0000-0000-000000000000"), ··· 46 48 expect( 47 49 isDefaultPrinting(createCard({ frame_effects: ["extendedart"] })), 48 50 ).toBe(false); 49 - expect( 50 - isDefaultPrinting(createCard({ frame_effects: ["showcase"] })), 51 - ).toBe(false); 52 - expect( 53 - isDefaultPrinting(createCard({ frame_effects: ["inverted"] })), 54 - ).toBe(false); 51 + expect(isDefaultPrinting(createCard({ frame_effects: ["showcase"] }))).toBe( 52 + false, 53 + ); 54 + expect(isDefaultPrinting(createCard({ frame_effects: ["inverted"] }))).toBe( 55 + false, 56 + ); 55 57 }); 56 58 57 59 it("rejects full art", () => { ··· 71 73 }); 72 74 73 75 it("rejects special promo types (serialized, doublerainbow, etc)", () => { 74 - expect( 75 - isDefaultPrinting(createCard({ promo_types: ["serialized"] })), 76 - ).toBe(false); 76 + expect(isDefaultPrinting(createCard({ promo_types: ["serialized"] }))).toBe( 77 + false, 78 + ); 77 79 expect( 78 80 isDefaultPrinting( 79 81 createCard({ promo_types: ["serialized", "doublerainbow"] }), ··· 85 87 expect( 86 88 isDefaultPrinting(createCard({ promo_types: ["confettifoil"] })), 87 89 ).toBe(false); 88 - expect( 89 - isDefaultPrinting(createCard({ promo_types: ["galaxyfoil"] })), 90 - ).toBe(false); 90 + expect(isDefaultPrinting(createCard({ promo_types: ["galaxyfoil"] }))).toBe( 91 + false, 92 + ); 91 93 expect(isDefaultPrinting(createCard({ promo_types: ["textured"] }))).toBe( 92 94 false, 93 95 ); ··· 164 166 expect(isDefaultPrinting(ubDefault)).toBe(true); 165 167 }); 166 168 167 - it("uses UB status as tiebreaker within same default status and date", () => { 169 + it("prefers non-UB over UB (by promo_types)", () => { 168 170 const nonUB = createCard({ 169 171 released_at: "2024-01-01", 172 + games: ["paper"], 170 173 }); 171 174 const ub = createCard({ 172 175 released_at: "2024-01-01", 176 + games: ["paper"], 173 177 promo_types: ["universesbeyond"], 174 178 }); 175 179 expect(compareCards(nonUB, ub)).toBe(-1); 176 180 expect(compareCards(ub, nonUB)).toBe(1); 177 181 }); 178 182 179 - it("prefers paper over digital-only (even if digital is newer)", () => { 180 - const olderPaper = createCard({ 183 + it("prefers non-UB over UB (by security_stamp triangle)", () => { 184 + const nonUB = createCard({ 185 + released_at: "2024-01-01", 186 + games: ["paper"], 187 + }); 188 + const ub = createCard({ 189 + released_at: "2024-01-01", 190 + games: ["paper"], 191 + security_stamp: "triangle", 192 + }); 193 + expect(compareCards(nonUB, ub)).toBe(-1); 194 + expect(compareCards(ub, nonUB)).toBe(1); 195 + }); 196 + 197 + it("deprioritizes The List (plst) printings", () => { 198 + const normal = createCard({ 199 + set: "ddj", 200 + released_at: "2012-01-01", 201 + games: ["paper"], 202 + }); 203 + const theList = createCard({ 204 + set: "plst", 205 + released_at: "2024-01-01", 206 + games: ["paper"], 207 + }); 208 + expect(compareCards(normal, theList)).toBe(-1); 209 + expect(compareCards(theList, normal)).toBe(1); 210 + }); 211 + 212 + it("deprioritizes Secret Lair (sld) printings", () => { 213 + const normal = createCard({ 214 + set: "m21", 181 215 released_at: "2020-01-01", 182 - games: ["paper", "mtgo"], 216 + games: ["paper"], 183 217 }); 184 - const newerDigital = createCard({ 218 + const secretLair = createCard({ 219 + set: "sld", 185 220 released_at: "2024-01-01", 221 + games: ["paper"], 222 + }); 223 + expect(compareCards(normal, secretLair)).toBe(-1); 224 + expect(compareCards(secretLair, normal)).toBe(1); 225 + }); 226 + 227 + it("deprioritizes arena-only (paper and mtgo are fine)", () => { 228 + const paper = createCard({ 229 + released_at: "2020-01-01", 230 + games: ["paper"], 231 + }); 232 + const mtgo = createCard({ 233 + released_at: "2020-01-01", 186 234 games: ["mtgo"], 187 235 }); 188 - expect(compareCards(olderPaper, newerDigital)).toBe(-1); 189 - expect(compareCards(newerDigital, olderPaper)).toBe(1); 236 + const arenaOnly = createCard({ 237 + released_at: "2024-01-01", 238 + games: ["arena"], 239 + }); 240 + // Both paper and MTGO beat arena-only 241 + expect(compareCards(paper, arenaOnly)).toBe(-1); 242 + expect(compareCards(mtgo, arenaOnly)).toBe(-1); 243 + expect(compareCards(arenaOnly, paper)).toBe(1); 244 + // Paper wins over MTGO as final tiebreaker 245 + expect(compareCards(paper, mtgo)).toBe(-1); 246 + expect(compareCards(mtgo, paper)).toBe(1); 190 247 }); 191 248 192 249 it("prefers highres images over newer date", () => { ··· 226 283 expect(compareCards(normal, variant)).toBe(-1); 227 284 }); 228 285 229 - it("prefers older paper default over newer digital-only default (regression test)", () => { 230 - // Real-world bug: Hobgoblin Bandit Lord was choosing mtgo promo over paper version 231 - const newerDigital = createCard({ 286 + it("prefers paper over arena-only even if arena is newer (regression test)", () => { 287 + // Arena-only printings should lose to paper/mtgo 288 + const newerArena = createCard({ 232 289 id: asScryfallId("5e3f2736-9d13-44e3-a4bf-4f64314e5848"), 233 - name: "Hobgoblin Bandit Lord", 234 - set: "prm", 235 - released_at: "2021-10-28", 236 - games: ["mtgo"], 290 + name: "Test Card", 291 + set: "aa3", 292 + released_at: "2025-01-01", 293 + games: ["arena"], 237 294 frame: "2015", 238 295 border_color: "black", 239 296 }); 240 297 const olderPaper = createCard({ 241 298 id: asScryfallId("7da8e543-c9ef-4f2d-99e4-ef6ba496ae75"), 242 - name: "Hobgoblin Bandit Lord", 299 + name: "Test Card", 243 300 set: "afr", 244 301 released_at: "2021-07-23", 245 302 games: ["arena", "paper", "mtgo"], ··· 247 304 border_color: "black", 248 305 }); 249 306 250 - expect(compareCards(olderPaper, newerDigital)).toBe(-1); 307 + expect(compareCards(olderPaper, newerArena)).toBe(-1); 251 308 }); 252 309 253 310 it("prefers normal card over serialized promo (regression test)", () => { ··· 283 340 expect(compareCards(normal, serialized)).toBe(-1); 284 341 }); 285 342 286 - it("correctly prioritizes: english > is:default > paper > highres > newer", () => { 343 + it("correctly prioritizes: english > is:default > paper > highres > non-UB > frame > newer", () => { 287 344 const cards = [ 288 345 createCard({ 289 346 id: asScryfallId("00000000-0000-0000-0000-000000000001"), ··· 333 390 ]; 334 391 335 392 const sorted = [...cards].sort(compareCards); 336 - // Priority: english > is:default > paper > highres > newer 393 + // Priority: english > is:default > paper > highres > non-UB > black border > modern frame > newer 337 394 expect(sorted[0].id).toBe(cards[2].id); // en, default, paper, highres, 2020 (best) 338 - expect(sorted[1].id).toBe(cards[0].id); // en, default, paper, lowres, 2024 339 - expect(sorted[2].id).toBe(cards[1].id); // en, default, digital, highres, 2025 (is:default beats paper) 340 - expect(sorted[3].id).toBe(cards[3].id); // en, non-default, paper, highres, 2025 395 + expect(sorted[1].id).toBe(cards[0].id); // en, default, paper, lowres, 2024 (paper > digital even with lowres) 396 + expect(sorted[2].id).toBe(cards[1].id); // en, default, digital, highres, 2025 397 + expect(sorted[3].id).toBe(cards[3].id); // en, non-default (borderless), paper, highres, 2025 341 398 expect(sorted[4].id).toBe(cards[4].id); // ja, default, paper, highres, 2025 342 399 }); 343 400 });
+632 -224
scripts/download-scryfall.ts
··· 3 3 /** 4 4 * Downloads Scryfall bulk data and processes it for client use. 5 5 * 6 + * Usage: 7 + * npm run download:scryfall # Normal mode: fetch + process 8 + * npm run download:scryfall --offline # Offline mode: reprocess cached data only 9 + * 6 10 * Fetches: 7 11 * - default_cards bulk data (all English cards) 8 12 * - migrations data (UUID changes) ··· 15 19 * - public/data/metadata.json - version and count info 16 20 * - public/data/migrations.json - ID migration mappings 17 21 * - public/symbols/*.svg - mana symbol images 18 - * - src/lib/card-chunks.ts - TypeScript chunk manifest 22 + * - src/lib/card-manifest.ts - TypeScript manifest of all data files 19 23 */ 20 24 21 - import { writeFile, mkdir, readFile } from "node:fs/promises"; 22 - import { join, dirname } from "node:path"; 25 + import { createHash } from "node:crypto"; 26 + import { mkdir, readFile, writeFile } from "node:fs/promises"; 27 + import { dirname, join } from "node:path"; 23 28 import { fileURLToPath } from "node:url"; 24 29 import type { Card, CardDataOutput } from "../src/lib/scryfall-types.ts"; 25 - import { asScryfallId, asOracleId } from "../src/lib/scryfall-types.ts"; 30 + import { asOracleId, asScryfallId } from "../src/lib/scryfall-types.ts"; 26 31 27 32 const __filename = fileURLToPath(import.meta.url); 28 33 const __dirname = dirname(__filename); 29 34 const OUTPUT_DIR = join(__dirname, "../public/data"); 35 + const CARDS_DIR = join(OUTPUT_DIR, "cards"); 30 36 const SYMBOLS_DIR = join(__dirname, "../public/symbols"); 37 + const KEYRUNE_DIR = join(__dirname, "../public/fonts/keyrune"); 31 38 const TEMP_DIR = join(__dirname, "../.cache"); 32 39 33 40 // Fields to keep from Scryfall data ··· 47 54 "toughness", 48 55 "loyalty", 49 56 "defense", 57 + "produced_mana", 50 58 51 59 // Legalities & formats 52 60 "legalities", ··· 55 63 56 64 // Search & filtering 57 65 "set", 66 + "set_type", 58 67 "set_name", 59 68 "collector_number", 60 69 "rarity", 61 70 "released_at", 62 - "prices", 63 71 "artist", 64 72 65 73 // Printing selection (image_uris omitted - can reconstruct from ID) ··· 77 85 "layout", 78 86 79 87 // Nice-to-have (flavor_text omitted - visible on card image) 80 - "edhrec_rank", 88 + // edhrec_rank omitted - goes in volatile.bin 81 89 "reprint", 82 90 "variation", 83 91 "lang", ··· 88 96 id: string; 89 97 oracle_id: string; 90 98 name: string; 99 + // Fields used for canonical printing comparison (before filtering) 100 + lang?: string; 101 + set?: string; 102 + frame?: string; 103 + border_color?: string; 104 + frame_effects?: string[]; 105 + full_art?: boolean; 106 + promo_types?: string[]; 107 + finishes?: string[]; 108 + games?: string[]; 109 + security_stamp?: string; 110 + highres_image?: boolean; 111 + released_at?: string; 112 + variation?: boolean; 91 113 [key: string]: unknown; 92 114 } 93 - 94 115 95 116 interface BulkDataItem { 96 117 type: string; ··· 184 205 * Determines if a card printing matches Scryfall's is:default criteria. 185 206 * Default = traditional frames (1993/1997/2003/2015), standard borders, no special treatments 186 207 */ 187 - export function isDefaultPrinting(card: Card): boolean { 208 + export function isDefaultPrinting(card: ScryfallCard): boolean { 188 209 // Frame must be traditional (1993, 1997, 2003, or 2015) 189 210 const validFrames = ["1993", "1997", "2003", "2015"]; 190 211 if (!card.frame || !validFrames.includes(card.frame)) { ··· 226 247 // Note: finishes is optional, so we're lenient here 227 248 if (card.finishes) { 228 249 const validFinishes = ["nonfoil", "foil"]; 229 - const hasValidFinish = card.finishes.some((f) => 230 - validFinishes.includes(f), 231 - ); 250 + const hasValidFinish = card.finishes.some((f) => validFinishes.includes(f)); 232 251 if (!hasValidFinish) { 233 252 return false; 234 253 } ··· 239 258 240 259 /** 241 260 * Comparator for selecting canonical card printings. 242 - * Priority: english > is:default > paper > highres > newer > non-variant > non-UB 261 + * Priority: english > is:default > non-memorabilia > non-promo > non-arena-without-paper > highres > non-UB > black border > modern frame > non-plst/sld > newer > non-variant > paper 262 + * 263 + * Accepts ScryfallCard (raw) to access fields like security_stamp before filtering. 243 264 */ 244 - export function compareCards(a: Card, b: Card): number { 265 + export function compareCards(a: ScryfallCard, b: ScryfallCard): number { 245 266 // English first (essential for our use case) 246 267 if (a.lang === "en" && b.lang !== "en") return -1; 247 268 if (a.lang !== "en" && b.lang === "en") return 1; ··· 252 273 if (aDefault && !bDefault) return -1; 253 274 if (!aDefault && bDefault) return 1; 254 275 255 - // Paper over digital-only (paper cards are more canonical) 256 - const aPaper = a.games?.includes("paper"); 257 - const bPaper = b.games?.includes("paper"); 258 - if (aPaper && !bPaper) return -1; 259 - if (!aPaper && bPaper) return 1; 276 + // Deprioritize memorabilia (gold border, world champs, placeholder cards) 277 + const aMemo = a.set_type === "memorabilia"; 278 + const bMemo = b.set_type === "memorabilia"; 279 + if (!aMemo && bMemo) return -1; 280 + if (aMemo && !bMemo) return 1; 281 + 282 + // Deprioritize promo sets (prerelease, buy-a-box, FNM, etc) 283 + const aPromo = a.set_type === "promo"; 284 + const bPromo = b.set_type === "promo"; 285 + if (!aPromo && bPromo) return -1; 286 + if (aPromo && !bPromo) return 1; 287 + 288 + // Deprioritize arena printings without paper release 289 + // (arena-style images are condensed/smoothed; MTGO-only is fine) 290 + const aArenaWithoutPaper = 291 + a.games?.includes("arena") && !a.games?.includes("paper"); 292 + const bArenaWithoutPaper = 293 + b.games?.includes("arena") && !b.games?.includes("paper"); 294 + if (!aArenaWithoutPaper && bArenaWithoutPaper) return -1; 295 + if (aArenaWithoutPaper && !bArenaWithoutPaper) return 1; 260 296 261 - // Highres (image quality matters more than recency) 297 + // Highres (image quality - digital-only may have better scans) 262 298 if (a.highres_image && !b.highres_image) return -1; 263 299 if (!a.highres_image && b.highres_image) return 1; 264 300 301 + // Non-Universes Beyond / non-planeswalker stamp (triangle = UB/crossover products) 302 + const aUB = 303 + a.promo_types?.includes("universesbeyond") || 304 + a.security_stamp === "triangle"; 305 + const bUB = 306 + b.promo_types?.includes("universesbeyond") || 307 + b.security_stamp === "triangle"; 308 + if (!aUB && bUB) return -1; 309 + if (aUB && !bUB) return 1; 310 + 311 + // Prefer black border over white/silver (aesthetic preference) 312 + const aBlack = a.border_color === "black"; 313 + const bBlack = b.border_color === "black"; 314 + if (aBlack && !bBlack) return -1; 315 + if (!aBlack && bBlack) return 1; 316 + 317 + // Prefer modern frames (2015 > 2003 > 1997 > 1993, "future" deprioritized) 318 + const getFrameRank = (frame: string | undefined): number => { 319 + if (!frame) return 100; 320 + if (frame === "future") return 99; // quirky futuresight aesthetic 321 + const year = parseInt(frame, 10); 322 + if (!Number.isNaN(year)) return -year; // newer years = lower rank = preferred 323 + return -10000; // unknown non-numeric frame, assume it's new and prefer it 324 + }; 325 + const aFrameRank = getFrameRank(a.frame); 326 + const bFrameRank = getFrameRank(b.frame); 327 + if (aFrameRank !== bFrameRank) return aFrameRank - bFrameRank; 328 + 329 + // Deprioritize specialty products: The List, Secret Lair 330 + const deprioritizedSets = ["plst", "sld"]; 331 + const aDeprio = deprioritizedSets.includes(a.set ?? ""); 332 + const bDeprio = deprioritizedSets.includes(b.set ?? ""); 333 + if (!aDeprio && bDeprio) return -1; 334 + if (aDeprio && !bDeprio) return 1; 335 + 265 336 // Newer (tiebreaker among similar quality printings) 266 337 if (a.released_at && b.released_at && a.released_at !== b.released_at) { 267 338 return b.released_at.localeCompare(a.released_at); ··· 271 342 if (!a.variation && b.variation) return -1; 272 343 if (a.variation && !b.variation) return 1; 273 344 274 - // Non-Universes Beyond (subjective UX preference) 275 - const aUB = a.promo_types?.includes("universesbeyond"); 276 - const bUB = b.promo_types?.includes("universesbeyond"); 277 - if (!aUB && bUB) return -1; 278 - if (aUB && !bUB) return 1; 345 + // Paper over digital (final tiebreaker) 346 + const aPaper = a.games?.includes("paper"); 347 + const bPaper = b.games?.includes("paper"); 348 + if (aPaper && !bPaper) return -1; 349 + if (!aPaper && bPaper) return 1; 279 350 280 351 return 0; 281 352 } 282 353 283 - async function processBulkData(): Promise<CardDataOutput> { 284 - // Get bulk data list 285 - const bulkData = await fetchJSON<BulkDataResponse>( 286 - "https://api.scryfall.com/bulk-data", 287 - ); 288 - const defaultCards = bulkData.data.find((d) => d.type === "default_cards"); 354 + interface ProcessedCards { 355 + data: CardDataOutput; 356 + indexesFilename: string; 357 + volatileFilename: string; 358 + chunkFilenames: string[]; 359 + } 289 360 290 - if (!defaultCards) { 291 - throw new Error("Could not find default_cards bulk data"); 361 + async function writeManifest( 362 + indexesFilename: string, 363 + volatileFilename: string, 364 + chunkFilenames: string[], 365 + symbolNames: string[], 366 + ): Promise<void> { 367 + const tsContent = `/** 368 + * Auto-generated by scripts/download-scryfall.ts 369 + * Content-hashed filenames enable immutable caching. 370 + */ 371 + 372 + export const CARD_INDEXES = "${indexesFilename}"; 373 + 374 + export const CARD_VOLATILE = "${volatileFilename}"; 375 + 376 + export const CARD_CHUNKS = [\n${chunkFilenames.map((n) => `\t"${n}",\n`).join("")}] as const; 377 + 378 + export const VALID_SYMBOLS: ReadonlySet<string> = new Set([\n${symbolNames.map((n) => `\t"${n}",\n`).join("")}]); 379 + `; 380 + 381 + const tsPath = join(__dirname, "../src/lib/card-manifest.ts"); 382 + await writeFile(tsPath, tsContent); 383 + console.log(`Wrote manifest: ${tsPath}`); 384 + } 385 + 386 + async function getCachedSymbolNames(): Promise<string[]> { 387 + const symbolsCachePath = join(TEMP_DIR, "symbols-cache.json"); 388 + try { 389 + const cached = JSON.parse( 390 + await readFile(symbolsCachePath, "utf-8"), 391 + ) as string[]; 392 + // Cache stores full symbols like "{W}", extract names 393 + return cached.map((s) => s.replace(/[{}]/g, "")).sort(); 394 + } catch { 395 + console.warn( 396 + "Warning: No cached symbols found, VALID_SYMBOLS will be empty", 397 + ); 398 + return []; 292 399 } 400 + } 293 401 294 - console.log(`Remote version: ${defaultCards.updated_at}`); 295 - console.log( 296 - `Download size: ${(defaultCards.size / 1024 / 1024).toFixed(2)} MB`, 297 - ); 298 - 299 - // Check if we already have this version 402 + async function processBulkData(offline: boolean): Promise<ProcessedCards> { 300 403 await mkdir(TEMP_DIR, { recursive: true }); 301 404 const tempFile = join(TEMP_DIR, "cards-bulk.json"); 302 - const indexesPath = join(OUTPUT_DIR, "cards-indexes.json"); 405 + const versionPath = join(OUTPUT_DIR, "version.json"); 406 + 407 + let version: string; 303 408 304 - try { 305 - const existingIndexes = JSON.parse( 306 - await readFile(indexesPath, "utf-8"), 307 - ) as { version: string; cardCount: number }; 409 + if (offline) { 410 + console.log("Offline mode: using cached data"); 411 + try { 412 + const existingVersion = JSON.parse( 413 + await readFile(versionPath, "utf-8"), 414 + ) as { version: string; cardCount: number }; 415 + version = existingVersion.version; 416 + } catch { 417 + // No version file, use placeholder 418 + version = "offline"; 419 + } 420 + } else { 421 + // Get bulk data list 422 + const bulkData = await fetchJSON<BulkDataResponse>( 423 + "https://api.scryfall.com/bulk-data", 424 + ); 425 + const defaultCards = bulkData.data.find((d) => d.type === "default_cards"); 308 426 309 - if (existingIndexes.version === defaultCards.updated_at) { 310 - console.log( 311 - `✓ Already have latest version (${defaultCards.updated_at}), skipping Scryfall download`, 312 - ); 313 - console.log("Using cached data for local processing..."); 314 - } else { 315 - console.log(`Local version: ${existingIndexes.version}`); 316 - console.log("Version changed, downloading update..."); 427 + if (!defaultCards) { 428 + throw new Error("Could not find default_cards bulk data"); 429 + } 430 + 431 + console.log(`Remote version: ${defaultCards.updated_at}`); 432 + console.log( 433 + `Download size: ${(defaultCards.size / 1024 / 1024).toFixed(2)} MB`, 434 + ); 435 + 436 + version = defaultCards.updated_at; 437 + 438 + try { 439 + const existingVersion = JSON.parse( 440 + await readFile(versionPath, "utf-8"), 441 + ) as { version: string; cardCount: number }; 442 + 443 + if (existingVersion.version === defaultCards.updated_at) { 444 + console.log( 445 + `✓ Already have latest version (${defaultCards.updated_at}), skipping Scryfall download`, 446 + ); 447 + console.log("Using cached data for local processing..."); 448 + } else { 449 + console.log(`Local version: ${existingVersion.version}`); 450 + console.log("Version changed, downloading update..."); 451 + await downloadFile(defaultCards.download_uri, tempFile); 452 + } 453 + } catch { 454 + console.log("No local version found, downloading..."); 317 455 await downloadFile(defaultCards.download_uri, tempFile); 318 456 } 319 - } catch { 320 - console.log("No local version found, downloading..."); 321 - await downloadFile(defaultCards.download_uri, tempFile); 322 457 } 323 458 324 459 // Parse and filter 325 460 console.log("Processing cards..."); 326 461 const rawData: ScryfallCard[] = JSON.parse(await readFile(tempFile, "utf-8")); 462 + 463 + // Build raw card map for sorting (before filtering strips fields like security_stamp) 464 + const rawCardById = Object.fromEntries( 465 + rawData.map((card) => [card.id, card]), 466 + ); 327 467 328 468 const cards = rawData.map(filterCard); 329 469 console.log(`Filtered ${cards.length} cards`); ··· 332 472 console.log("Building indexes..."); 333 473 const cardById = Object.fromEntries(cards.map((card) => [card.id, card])); 334 474 335 - const oracleIdToPrintings = cards.reduce<CardDataOutput['oracleIdToPrintings']>( 336 - (acc, card) => { 337 - if (!acc[card.oracle_id]) { 338 - acc[card.oracle_id] = []; 339 - } 340 - acc[card.oracle_id].push(card.id); 341 - return acc; 342 - }, 343 - {}, 344 - ); 475 + const oracleIdToPrintings = cards.reduce< 476 + CardDataOutput["oracleIdToPrintings"] 477 + >((acc, card) => { 478 + if (!acc[card.oracle_id]) { 479 + acc[card.oracle_id] = []; 480 + } 481 + acc[card.oracle_id].push(card.id); 482 + return acc; 483 + }, {}); 345 484 346 - // Sort printings by release date (newest first), then collector number (lowest first) 347 - console.log("Sorting printings by release date..."); 485 + // Sort printings by canonical order (most canonical first) 486 + // Uses raw cards for comparison (has fields like security_stamp that get stripped) 487 + // First element of each array is the canonical printing for that oracle ID 488 + // UI layers that need release date order (e.g., card detail page) can re-sort before rendering 489 + console.log("Sorting printings by canonical order..."); 348 490 for (const printingIds of Object.values(oracleIdToPrintings)) { 349 - printingIds.sort((aId, bId) => { 350 - const a = cardById[aId]; 351 - const b = cardById[bId]; 352 - 353 - // Primary: release date (newest first) 354 - const dateA = a.released_at ?? ""; 355 - const dateB = b.released_at ?? ""; 356 - const dateCompare = dateB.localeCompare(dateA); 357 - if (dateCompare !== 0) return dateCompare; 358 - 359 - // Tiebreaker: collector number (numeric part first, then full string) 360 - const extractNumber = (cn: string | undefined): number => { 361 - if (!cn) return Number.MAX_SAFE_INTEGER; 362 - const match = cn.match(/\d+/); 363 - return match ? Number.parseInt(match[0], 10) : Number.MAX_SAFE_INTEGER; 364 - }; 365 - 366 - const numA = extractNumber(a.collector_number); 367 - const numB = extractNumber(b.collector_number); 368 - if (numA !== numB) return numA - numB; 369 - 370 - // Sub-tiebreaker: full collector number string (handles "141a" vs "141b") 371 - return (a.collector_number ?? "").localeCompare(b.collector_number ?? ""); 372 - }); 491 + printingIds.sort((aId, bId) => 492 + compareCards(rawCardById[aId], rawCardById[bId]), 493 + ); 373 494 } 374 495 375 - // Calculate canonical printing for each oracle ID 376 - // Follows Scryfall's is:default logic: prefer most recent "default" printing 377 - // Priority: english > is:default > paper > highres > newer > non-variant > non-UB 378 - console.log("Calculating canonical printings..."); 379 - 380 - const canonicalPrintingByOracleId = Object.fromEntries( 381 - Object.entries(oracleIdToPrintings).map(([oracleId, printingIds]) => { 382 - const sortedIds = [...printingIds].sort((aId, bId) => { 383 - return compareCards(cardById[aId], cardById[bId]); 384 - }); 385 - return [oracleId, sortedIds[0]]; 386 - }), 387 - ); 388 - 389 496 const output: CardDataOutput = { 390 - version: defaultCards.updated_at, 497 + version, 391 498 cardCount: cards.length, 392 499 cards: cardById, 393 500 oracleIdToPrintings, 394 - canonicalPrintingByOracleId, 395 501 }; 396 502 397 503 await mkdir(OUTPUT_DIR, { recursive: true }); 504 + await mkdir(CARDS_DIR, { recursive: true }); 398 505 399 - // Chunk cards.json for Cloudflare Workers (25MB upload limit) 400 - console.log("Chunking cards for Workers deployment..."); 401 - const chunkFilenames = await chunkCardsForWorkers(output); 506 + // Generate volatile data from raw data (before filtering strips prices/rank) 507 + const volatileFilename = await generateVolatileData(rawData); 508 + 509 + // Chunk cards for deployment 510 + console.log("Chunking cards for deployment..."); 511 + const { chunkFilenames, indexesFilename } = 512 + await chunkCardsForWorkers(output); 402 513 403 514 // Generate binary byte-range index for SSR 404 515 console.log("Generating binary byte-range index for SSR..."); 405 516 await generateByteRangeIndex(chunkFilenames); 406 517 407 - return output; 518 + return { data: output, indexesFilename, volatileFilename, chunkFilenames }; 408 519 } 409 520 410 521 /** 411 - * Chunk cards.json into sub-25MB files for Cloudflare Workers deployment 412 - * Returns list of chunk filenames 522 + * Chunk cards into fixed-count chunks for stable cache boundaries 523 + * 524 + * All files are written to public/data/cards/ with content hashes in filenames 525 + * for immutable caching. This subfolder is configured for aggressive edge caching 526 + * via Cloudflare rules (TanStack Start doesn't support custom cache headers). 527 + * 528 + * Uses fixed card count per chunk (not byte size) so that card content changes 529 + * only affect the single chunk containing that card, not subsequent chunks. 530 + * 531 + * Cards are sorted by release date (oldest first) so new cards append to later chunks. 532 + * 533 + * Returns list of chunk filenames (without path prefix) 413 534 */ 414 - async function chunkCardsForWorkers(data: CardDataOutput): Promise<string[]> { 415 - const CHUNK_SIZE_TARGET = 20 * 1024 * 1024; // 20MB target (leaving headroom under 25MB limit) 535 + async function chunkCardsForWorkers( 536 + data: CardDataOutput, 537 + ): Promise<{ chunkFilenames: string[]; indexesFilename: string }> { 538 + const CARDS_PER_CHUNK = 4096; // Fixed count for stable chunk boundaries 539 + const MAX_CHUNKS = 256; // Chunk index stored as u8 in byteindex 416 540 417 - const cardEntries = Object.entries(data.cards); 418 - const chunkFilenames: string[] = []; 541 + // Sort cards by release date (oldest first) so new cards go to later chunks 542 + // Cards without dates go to the beginning (ancient weirdness, not future prereleases) 543 + const cardEntries = Object.entries(data.cards).sort(([idA, a], [idB, b]) => { 544 + const dateA = a.released_at ?? "0000-00-00"; 545 + const dateB = b.released_at ?? "0000-00-00"; 546 + const dateCompare = dateA.localeCompare(dateB); 547 + if (dateCompare !== 0) return dateCompare; 548 + // Stable tiebreaker: card ID (UUID) ensures consistent ordering 549 + return idA.localeCompare(idB); 550 + }); 419 551 420 - let currentChunk: [string, Card][] = []; 421 - let currentSize = 0; 422 - let chunkIndex = 0; 552 + const chunkCount = Math.ceil(cardEntries.length / CARDS_PER_CHUNK); 553 + if (chunkCount > MAX_CHUNKS) { 554 + throw new Error( 555 + `Too many chunks: ${chunkCount} exceeds max ${MAX_CHUNKS} (chunk index stored as u8)`, 556 + ); 557 + } 423 558 424 - // Helper to estimate JSON size 425 - const estimateSize = (obj: unknown): number => { 426 - return JSON.stringify(obj).length; 427 - }; 428 - 429 - // Initial chunk wrapper overhead 430 - const chunkOverhead = estimateSize({ cards: {} }); 559 + const chunkFilenames = await Promise.all( 560 + Array.from({ length: chunkCount }, async (_, chunkIndex) => { 561 + const start = chunkIndex * CARDS_PER_CHUNK; 562 + const end = Math.min(start + CARDS_PER_CHUNK, cardEntries.length); 563 + const chunkEntries = cardEntries.slice(start, end); 431 564 432 - for (const [cardId, card] of cardEntries) { 433 - const entrySize = estimateSize({ [cardId]: card }); 434 - 435 - // Check if adding this card would exceed the target 436 - if ( 437 - currentSize + entrySize + chunkOverhead > CHUNK_SIZE_TARGET && 438 - currentChunk.length > 0 439 - ) { 440 - // Write current chunk 441 - const chunkFilename = `cards-${String(chunkIndex).padStart(3, "0")}.json`; 442 - const chunkPath = join(OUTPUT_DIR, chunkFilename); 443 - const chunkData = { 444 - cards: Object.fromEntries(currentChunk), 445 - }; 565 + const chunkData = { cards: Object.fromEntries(chunkEntries) }; 446 566 const chunkContent = JSON.stringify(chunkData); 567 + const contentHash = createHash("sha256") 568 + .update(chunkContent) 569 + .digest("hex") 570 + .slice(0, 16); 571 + const chunkFilename = `cards-${String(chunkIndex).padStart(3, "0")}-${contentHash}.json`; 447 572 448 - await writeFile(chunkPath, chunkContent); 449 - chunkFilenames.push(chunkFilename); 450 - 573 + await writeFile(join(CARDS_DIR, chunkFilename), chunkContent); 451 574 console.log( 452 - `Wrote ${chunkFilename}: ${currentChunk.length} cards, ${(chunkContent.length / 1024 / 1024).toFixed(2)}MB`, 575 + `Wrote ${chunkFilename}: ${chunkEntries.length} cards, ${(chunkContent.length / 1024 / 1024).toFixed(2)}MB`, 453 576 ); 454 577 455 - // Reset for next chunk 456 - currentChunk = []; 457 - currentSize = 0; 458 - chunkIndex++; 459 - } 460 - 461 - currentChunk.push([cardId, card]); 462 - currentSize += entrySize; 463 - } 464 - 465 - // Write final chunk if there's anything left 466 - if (currentChunk.length > 0) { 467 - const chunkFilename = `cards-${String(chunkIndex).padStart(3, "0")}.json`; 468 - const chunkPath = join(OUTPUT_DIR, chunkFilename); 469 - const chunkData = { 470 - cards: Object.fromEntries(currentChunk), 471 - }; 472 - const chunkContent = JSON.stringify(chunkData); 473 - 474 - await writeFile(chunkPath, chunkContent); 475 - chunkFilenames.push(chunkFilename); 476 - 477 - console.log( 478 - `Wrote ${chunkFilename}: ${currentChunk.length} cards, ${(chunkContent.length / 1024 / 1024).toFixed(2)}MB`, 479 - ); 480 - } 578 + return chunkFilename; 579 + }), 580 + ); 481 581 482 - // Write indexes file (oracle mappings for client) 582 + // Write indexes file with content hash (oracle mappings for client) 583 + // oracleIdToPrintings is sorted by canonical order - first element is the canonical printing 483 584 const indexesData = { 484 585 version: data.version, 485 586 cardCount: data.cardCount, 486 587 oracleIdToPrintings: data.oracleIdToPrintings, 487 - canonicalPrintingByOracleId: data.canonicalPrintingByOracleId, 488 588 }; 489 - 490 - const indexesPath = join(OUTPUT_DIR, "cards-indexes.json"); 491 - await writeFile(indexesPath, JSON.stringify(indexesData)); 492 - console.log(`Wrote indexes: ${indexesPath}`); 493 - 494 - // Write TS file with chunk list 495 - const tsContent = `/** 496 - * Auto-generated by scripts/download-scryfall.ts 497 - * Contains card data chunk filenames for client loading 498 - */ 499 - 500 - export const CARD_CHUNKS = [\n${chunkFilenames.map(n => `\t"${n}",\n`).join('')}] as const; 501 - `; 589 + const indexesContent = JSON.stringify(indexesData); 590 + const indexesHash = createHash("sha256") 591 + .update(indexesContent) 592 + .digest("hex") 593 + .slice(0, 16); 594 + const indexesFilename = `indexes-${indexesHash}.json`; 595 + const indexesPath = join(CARDS_DIR, indexesFilename); 596 + await writeFile(indexesPath, indexesContent); 597 + console.log(`Wrote indexes: ${indexesFilename}`); 502 598 503 - const tsPath = join(__dirname, "../src/lib/card-chunks.ts"); 504 - await writeFile(tsPath, tsContent); 599 + // Write version.json at top level for quick version checks 600 + const versionData = { version: data.version, cardCount: data.cardCount }; 601 + await writeFile( 602 + join(OUTPUT_DIR, "version.json"), 603 + JSON.stringify(versionData), 604 + ); 605 + console.log(`Wrote version.json`); 505 606 506 - console.log(`\nWrote TS chunk list: ${tsPath}`); 507 - console.log(`Total chunks: ${chunkFilenames.length}`); 607 + console.log(`\nTotal chunks: ${chunkFilenames.length}`); 508 608 509 - return chunkFilenames; 609 + return { chunkFilenames, indexesFilename }; 510 610 } 511 611 512 612 /** ··· 519 619 } 520 620 521 621 /** 622 + * Create a packer for writing sequential values to a buffer 623 + */ 624 + function createBufferPacker(buffer: Buffer) { 625 + let offset = 0; 626 + return { 627 + writeUUID(uuid: string) { 628 + uuidToBytes(uuid).copy(buffer, offset); 629 + offset += 16; 630 + }, 631 + writeUint32(value: number) { 632 + buffer.writeUInt32LE(value, offset); 633 + offset += 4; 634 + }, 635 + writeUint8(value: number) { 636 + buffer.writeUInt8(value, offset); 637 + offset += 1; 638 + }, 639 + }; 640 + } 641 + 642 + /** 522 643 * Generate binary byte-range index for SSR card lookups 523 644 * Format: Fixed-size binary records sorted by UUID for O(log n) binary search 524 645 * ··· 542 663 543 664 for (let chunkIndex = 0; chunkIndex < chunkFilenames.length; chunkIndex++) { 544 665 const chunkFilename = chunkFilenames[chunkIndex]; 545 - const chunkPath = join(OUTPUT_DIR, chunkFilename); 666 + const chunkPath = join(CARDS_DIR, chunkFilename); 546 667 const chunkContent = await readFile(chunkPath, "utf-8"); 547 668 548 669 // Single-pass parse: find all "cardId":{...} patterns ··· 645 766 // Write binary format 646 767 const RECORD_SIZE = 25; // 16 (UUID) + 1 (chunk) + 4 (offset) + 4 (length) 647 768 const buffer = Buffer.alloc(RECORD_SIZE * indexes.length); 648 - let bufferOffset = 0; 769 + const packer = createBufferPacker(buffer); 649 770 650 771 for (const { cardId, chunkIndex, offset, length } of indexes) { 651 - // Write UUID (16 bytes) 652 - const uuidBytes = uuidToBytes(cardId); 653 - uuidBytes.copy(buffer, bufferOffset); 654 - bufferOffset += 16; 655 - 656 - // Write chunk index (1 byte) 657 - buffer.writeUInt8(chunkIndex, bufferOffset); 658 - bufferOffset += 1; 659 - 660 - // Write offset (4 bytes, little-endian) 661 - buffer.writeUInt32LE(offset, bufferOffset); 662 - bufferOffset += 4; 663 - 664 - // Write length (4 bytes, little-endian) 665 - buffer.writeUInt32LE(length, bufferOffset); 666 - bufferOffset += 4; 772 + packer.writeUUID(cardId); 773 + packer.writeUint8(chunkIndex); 774 + packer.writeUint32(offset); 775 + packer.writeUint32(length); 667 776 } 668 777 669 778 const binPath = join(OUTPUT_DIR, "cards-byteindex.bin"); ··· 681 790 ); 682 791 } 683 792 793 + /** 794 + * Generate volatile.bin containing frequently-changing card data 795 + * 796 + * This data (prices, EDHREC rank) changes often and would cause cache busting 797 + * if included in the main card chunks. Stored separately so card data stays stable. 798 + * 799 + * Format: Fixed-size binary records sorted by UUID for O(log n) binary search 800 + * Uses same UUID byte comparison as generateByteRangeIndex for consistency. 801 + * 802 + * Record format (44 bytes per record): 803 + * - UUID: 16 bytes (binary) 804 + * - edhrec_rank: 4 bytes (uint32 LE, 0xFFFFFFFF = null) 805 + * - usd: 4 bytes (cents as uint32 LE, 0xFFFFFFFF = null) 806 + * - usd_foil: 4 bytes 807 + * - usd_etched: 4 bytes 808 + * - eur: 4 bytes 809 + * - eur_foil: 4 bytes 810 + * - tix: 4 bytes (hundredths, e.g. 0.02 -> 2) 811 + */ 812 + async function generateVolatileData(rawCards: ScryfallCard[]): Promise<string> { 813 + console.log("Generating volatile data..."); 814 + 815 + const RECORD_SIZE = 44; // 16 (UUID) + 4 (rank) + 6*4 (prices) 816 + const NULL_VALUE = 0xffffffff; 817 + 818 + // Parse price string to cents (hundredths), returns NULL_VALUE if null/invalid 819 + const parsePriceToCents = (price: string | null | undefined): number => { 820 + if (price == null) return NULL_VALUE; 821 + const parsed = parseFloat(price); 822 + if (Number.isNaN(parsed)) return NULL_VALUE; 823 + return Math.round(parsed * 100); 824 + }; 825 + 826 + // Extract volatile data from each card 827 + interface VolatileRecord { 828 + id: string; 829 + edhrec_rank: number; 830 + usd: number; 831 + usd_foil: number; 832 + usd_etched: number; 833 + eur: number; 834 + eur_foil: number; 835 + tix: number; 836 + } 837 + 838 + const records: VolatileRecord[] = rawCards.map((card) => { 839 + const prices = (card.prices ?? {}) as Record<string, string | null>; 840 + return { 841 + id: card.id, 842 + edhrec_rank: 843 + typeof card.edhrec_rank === "number" ? card.edhrec_rank : NULL_VALUE, 844 + usd: parsePriceToCents(prices.usd), 845 + usd_foil: parsePriceToCents(prices.usd_foil), 846 + usd_etched: parsePriceToCents(prices.usd_etched), 847 + eur: parsePriceToCents(prices.eur), 848 + eur_foil: parsePriceToCents(prices.eur_foil), 849 + tix: parsePriceToCents(prices.tix), 850 + }; 851 + }); 852 + 853 + // Sort by UUID bytes (not string!) for binary search - same as generateByteRangeIndex 854 + records.sort((a, b) => { 855 + const aBytes = uuidToBytes(a.id); 856 + const bBytes = uuidToBytes(b.id); 857 + for (let i = 0; i < 16; i++) { 858 + if (aBytes[i] !== bBytes[i]) { 859 + return aBytes[i] - bBytes[i]; 860 + } 861 + } 862 + return 0; 863 + }); 864 + 865 + // Write binary format 866 + const buffer = Buffer.alloc(RECORD_SIZE * records.length); 867 + const packer = createBufferPacker(buffer); 868 + 869 + for (const record of records) { 870 + packer.writeUUID(record.id); 871 + packer.writeUint32(record.edhrec_rank); 872 + packer.writeUint32(record.usd); 873 + packer.writeUint32(record.usd_foil); 874 + packer.writeUint32(record.usd_etched); 875 + packer.writeUint32(record.eur); 876 + packer.writeUint32(record.eur_foil); 877 + packer.writeUint32(record.tix); 878 + } 879 + 880 + // Hash the buffer for immutable filename 881 + const contentHash = createHash("sha256") 882 + .update(buffer) 883 + .digest("hex") 884 + .slice(0, 16); 885 + const volatileFilename = `volatile-${contentHash}.bin`; 886 + const binPath = join(CARDS_DIR, volatileFilename); 887 + await writeFile(binPath, buffer); 888 + 889 + console.log( 890 + `✓ Wrote ${volatileFilename}: ${records.length} cards, ${(buffer.length / 1024 / 1024).toFixed(2)}MB`, 891 + ); 892 + console.log( 893 + ` Binary format: 44 bytes/record (16 UUID + 4 rank + 24 prices)`, 894 + ); 895 + 896 + return volatileFilename; 897 + } 898 + 684 899 async function processMigrations(): Promise<MigrationMap> { 685 900 console.log("Fetching migrations..."); 686 901 const migrations = await fetchJSON<MigrationsResponse>( ··· 702 917 return migrationMap; 703 918 } 704 919 705 - async function downloadSymbols(): Promise<number> { 920 + interface SymbolsResult { 921 + count: number; 922 + names: string[]; 923 + } 924 + 925 + async function downloadSymbols(): Promise<SymbolsResult> { 706 926 console.log("Fetching symbology..."); 707 927 const symbology = await fetchJSON<SymbologyResponse>( 708 928 "https://api.scryfall.com/symbology", ··· 710 930 711 931 console.log(`Found ${symbology.data.length} symbols`); 712 932 933 + // Extract symbol names (without braces) for the manifest whitelist 934 + // e.g., "{W}" -> "W", "{10}" -> "10", "{W/U}" -> "W/U" 935 + const symbolNames = symbology.data 936 + .map((s) => s.symbol.replace(/[{}]/g, "")) 937 + .sort(); 938 + 713 939 // Check if we already have these symbols 714 940 const symbolsCachePath = join(TEMP_DIR, "symbols-cache.json"); 715 941 const currentSymbols = symbology.data.map((s) => s.symbol).sort(); ··· 726 952 console.log( 727 953 `✓ Already have latest symbols (${currentSymbols.length}), skipping download`, 728 954 ); 729 - return currentSymbols.length; 955 + return { count: currentSymbols.length, names: symbolNames }; 730 956 } 731 957 732 958 console.log("Symbol list changed, downloading update..."); ··· 738 964 739 965 await Promise.all( 740 966 symbology.data.map((symbol) => { 741 - const filename = symbol.symbol.replace(/[{}\/]/g, "").toLowerCase(); 967 + const filename = symbol.symbol.replace(/[{}/]/g, "").toLowerCase(); 742 968 const outputPath = join(SYMBOLS_DIR, `${filename}.svg`); 743 969 return downloadFile(symbol.svg_uri, outputPath); 744 970 }), ··· 747 973 // Cache the symbol list 748 974 await writeFile(symbolsCachePath, JSON.stringify(currentSymbols)); 749 975 750 - console.log(`Downloaded ${symbology.data.length} symbol SVGs to: ${SYMBOLS_DIR}`); 751 - return symbology.data.length; 976 + console.log( 977 + `Downloaded ${symbology.data.length} symbol SVGs to: ${SYMBOLS_DIR}`, 978 + ); 979 + return { count: symbology.data.length, names: symbolNames }; 980 + } 981 + 982 + interface KeyruneResult { 983 + version: string; 984 + setCount: number; 985 + } 986 + 987 + /** 988 + * Downloads Keyrune set symbol font from GitHub release 989 + * https://github.com/andrewgioia/keyrune 990 + * 991 + * Font licensed under SIL OFL 1.1, CSS under MIT 992 + */ 993 + async function downloadKeyrune(offline: boolean): Promise<KeyruneResult> { 994 + const cssCachePath = join(TEMP_DIR, "keyrune.css"); 995 + const versionCachePath = join(TEMP_DIR, "keyrune-version.json"); 996 + 997 + let version = "unknown"; 998 + 999 + if (!offline) { 1000 + console.log("Checking Keyrune version..."); 1001 + 1002 + // Get latest release tag 1003 + const releaseInfo = await fetchJSON<{ tag_name: string }>( 1004 + "https://api.github.com/repos/andrewgioia/keyrune/releases/latest", 1005 + ); 1006 + version = releaseInfo.tag_name; 1007 + const keyruneBase = `https://raw.githubusercontent.com/andrewgioia/keyrune/${version}`; 1008 + 1009 + console.log(`Latest Keyrune version: ${version}`); 1010 + 1011 + // Check if we already have this version 1012 + let needsDownload = true; 1013 + try { 1014 + const cached = JSON.parse(await readFile(versionCachePath, "utf-8")) as { 1015 + version: string; 1016 + }; 1017 + 1018 + if (cached.version === version) { 1019 + console.log(`✓ Already have Keyrune ${version}, skipping download`); 1020 + needsDownload = false; 1021 + } else { 1022 + console.log(`Local version: ${cached.version}, updating...`); 1023 + } 1024 + } catch { 1025 + console.log("No cached Keyrune found, downloading..."); 1026 + } 1027 + 1028 + if (needsDownload) { 1029 + await mkdir(KEYRUNE_DIR, { recursive: true }); 1030 + await mkdir(TEMP_DIR, { recursive: true }); 1031 + 1032 + // Download woff2 font to public 1033 + await downloadFile( 1034 + `${keyruneBase}/fonts/keyrune.woff2`, 1035 + join(KEYRUNE_DIR, "keyrune.woff2"), 1036 + ); 1037 + 1038 + // Download CSS to cache for processing 1039 + console.log(`Fetching: ${keyruneBase}/css/keyrune.css`); 1040 + const cssResponse = await fetch(`${keyruneBase}/css/keyrune.css`); 1041 + if (!cssResponse.ok) { 1042 + throw new Error( 1043 + `HTTP ${cssResponse.status}: ${cssResponse.statusText}`, 1044 + ); 1045 + } 1046 + const css = await cssResponse.text(); 1047 + await writeFile(cssCachePath, css); 1048 + 1049 + // Cache version 1050 + await writeFile(versionCachePath, JSON.stringify({ version })); 1051 + } 1052 + } else { 1053 + // Offline mode: read cached version 1054 + try { 1055 + const cached = JSON.parse(await readFile(versionCachePath, "utf-8")) as { 1056 + version: string; 1057 + }; 1058 + version = cached.version; 1059 + console.log(`Using cached Keyrune ${version}`); 1060 + } catch { 1061 + console.log("Using cached Keyrune (unknown version)"); 1062 + } 1063 + } 1064 + 1065 + // Process: extract mappings from cached CSS 1066 + // CSS has comma-separated selectors like: 1067 + // .ss-grn:before, 1068 + // .ss-gk1:before { 1069 + // content: "\e94b"; 1070 + // } 1071 + const css = await readFile(cssCachePath, "utf-8"); 1072 + 1073 + const mappings: Record<string, number> = {}; 1074 + 1075 + // Match rule blocks: selectors { content: "\xxxx"; } 1076 + const blockRegex = 1077 + /((?:\.ss-[a-z0-9]+:before[,\s]*)+)\s*\{\s*content:\s*"\\([0-9a-f]+)"/gi; 1078 + const selectorRegex = /\.ss-([a-z0-9]+):before/g; 1079 + 1080 + for (const match of css.matchAll(blockRegex)) { 1081 + const [, selectors, codepoint] = match; 1082 + const cp = parseInt(codepoint, 16); 1083 + 1084 + // Extract all set codes from the selector list 1085 + for (const selectorMatch of selectors.matchAll(selectorRegex)) { 1086 + mappings[selectorMatch[1]] = cp; 1087 + } 1088 + } 1089 + 1090 + const setCount = Object.keys(mappings).length; 1091 + console.log(`Extracted ${setCount} set symbol mappings`); 1092 + 1093 + // Generate TypeScript file 1094 + const tsContent = `/** 1095 + * Keyrune set symbol unicode mappings 1096 + * Auto-generated by scripts/download-scryfall.ts from Keyrune ${version} 1097 + * https://github.com/andrewgioia/keyrune 1098 + * 1099 + * Font licensed under SIL OFL 1.1 1100 + */ 1101 + 1102 + export const SET_SYMBOLS: Record<string, number> = { 1103 + ${Object.entries(mappings) 1104 + .sort(([a], [b]) => a.localeCompare(b)) 1105 + .map(([code, cp]) => `\t${JSON.stringify(code)}: 0x${cp.toString(16)},`) 1106 + .join("\n")} 1107 + }; 1108 + 1109 + /** 1110 + * Get unicode character for a set symbol 1111 + * Returns empty string if set not found 1112 + */ 1113 + export function getSetSymbol(setCode: string): string { 1114 + const codepoint = SET_SYMBOLS[setCode.toLowerCase()]; 1115 + return codepoint ? String.fromCodePoint(codepoint) : ""; 1116 + } 1117 + `; 1118 + 1119 + const tsPath = join(__dirname, "../src/lib/set-symbols.ts"); 1120 + await writeFile(tsPath, tsContent); 1121 + console.log(`Wrote set symbols: ${tsPath}`); 1122 + 1123 + console.log(`✓ Keyrune ${version}: ${setCount} set symbols`); 1124 + return { version, setCount }; 752 1125 } 753 1126 754 1127 async function main(): Promise<void> { 755 1128 try { 1129 + const offline = process.argv.includes("--offline"); 1130 + 756 1131 console.log("=== Scryfall Data Download ===\n"); 757 1132 758 - const [cardsData, migrations, symbolCount] = await Promise.all([ 759 - processBulkData(), 760 - processMigrations(), 761 - downloadSymbols(), 762 - ]); 1133 + if (offline) { 1134 + console.log("Running in offline mode (reprocessing only)\n"); 1135 + const [cards, cachedSymbols, keyrune] = await Promise.all([ 1136 + processBulkData(true), 1137 + getCachedSymbolNames(), 1138 + downloadKeyrune(true), 1139 + ]); 1140 + 1141 + await writeManifest( 1142 + cards.indexesFilename, 1143 + cards.volatileFilename, 1144 + cards.chunkFilenames, 1145 + cachedSymbols, 1146 + ); 1147 + 1148 + console.log("\n=== Summary ==="); 1149 + console.log(`Cards: ${cards.data.cardCount.toLocaleString()}`); 1150 + console.log(`Set symbols: ${keyrune.setCount.toLocaleString()}`); 1151 + console.log(`Version: ${cards.data.version}`); 1152 + console.log("\n✓ Done!"); 1153 + } else { 1154 + const [cards, migrations, symbols, keyrune] = await Promise.all([ 1155 + processBulkData(false), 1156 + processMigrations(), 1157 + downloadSymbols(), 1158 + downloadKeyrune(false), 1159 + ]); 1160 + 1161 + await writeManifest( 1162 + cards.indexesFilename, 1163 + cards.volatileFilename, 1164 + cards.chunkFilenames, 1165 + symbols.names, 1166 + ); 763 1167 764 - console.log("\n=== Summary ==="); 765 - console.log(`Cards: ${cardsData.cardCount.toLocaleString()}`); 766 - console.log(`Migrations: ${Object.keys(migrations).length.toLocaleString()}`); 767 - console.log(`Symbols: ${symbolCount.toLocaleString()}`); 768 - console.log(`Version: ${cardsData.version}`); 769 - console.log("\n✓ Done!"); 1168 + console.log("\n=== Summary ==="); 1169 + console.log(`Cards: ${cards.data.cardCount.toLocaleString()}`); 1170 + console.log( 1171 + `Migrations: ${Object.keys(migrations).length.toLocaleString()}`, 1172 + ); 1173 + console.log(`Mana symbols: ${symbols.count.toLocaleString()}`); 1174 + console.log(`Set symbols: ${keyrune.setCount.toLocaleString()}`); 1175 + console.log(`Version: ${cards.data.version}`); 1176 + console.log("\n✓ Done!"); 1177 + } 770 1178 } catch (error) { 771 1179 console.error( 772 1180 "\n✗ Error:",
+90
scripts/scryfall.sh
··· 1 + #!/usr/bin/env bash 2 + # Query Scryfall API for card data 3 + # 4 + # Usage: 5 + # ./scripts/scryfall.sh "ancestral recall" 6 + # ./scripts/scryfall.sh -f set_type "sol ring" 7 + # ./scripts/scryfall.sh -q "layout:token treasure" 8 + 9 + set -euo pipefail 10 + 11 + FIELD="" 12 + RAW_QUERY=false 13 + 14 + while [[ $# -gt 0 ]]; do 15 + case $1 in 16 + -f|--field) 17 + FIELD="$2" 18 + shift 2 19 + ;; 20 + -q|--query) 21 + RAW_QUERY=true 22 + shift 23 + ;; 24 + -h|--help) 25 + echo "Usage: $0 [options] <query>" 26 + echo "" 27 + echo "Options:" 28 + echo " -f, --field <name> Show specific field (e.g., set_type, layout, legalities)" 29 + echo " -q, --query Pass query directly to Scryfall (don't wrap in name search)" 30 + echo " -h, --help Show this help" 31 + echo "" 32 + echo "Examples:" 33 + echo " $0 \"ancestral recall\" # Search by name" 34 + echo " $0 -f set_type \"sol ring\" # Show set_type for each printing" 35 + echo " $0 -q \"layout:token treasure\" # Raw Scryfall query" 36 + exit 0 37 + ;; 38 + *) 39 + QUERY="$1" 40 + shift 41 + ;; 42 + esac 43 + done 44 + 45 + if [[ -z "${QUERY:-}" ]]; then 46 + echo "Error: No query provided" >&2 47 + exit 1 48 + fi 49 + 50 + # Build the search query 51 + if [[ "$RAW_QUERY" == true ]]; then 52 + SEARCH_QUERY="$QUERY" 53 + else 54 + SEARCH_QUERY="!\"$QUERY\"" 55 + fi 56 + 57 + # URL encode the query 58 + ENCODED=$(printf '%s' "$SEARCH_QUERY" | jq -sRr @uri) 59 + 60 + # Fetch from Scryfall 61 + URL="https://api.scryfall.com/cards/search?q=${ENCODED}&unique=prints&order=released" 62 + 63 + echo "Query: $SEARCH_QUERY" >&2 64 + echo "URL: $URL" >&2 65 + echo "" >&2 66 + 67 + RESPONSE=$(curl -s "$URL") 68 + 69 + # Check for errors 70 + if echo "$RESPONSE" | jq -e '.object == "error"' > /dev/null 2>&1; then 71 + echo "$RESPONSE" | jq -r '.details' 72 + exit 1 73 + fi 74 + 75 + # Extract and display results 76 + if [[ -n "$FIELD" ]]; then 77 + echo "$RESPONSE" | jq -r ".data[] | [.set, .name, .$FIELD] | @tsv" | while IFS=$'\t' read -r set name val; do 78 + printf "%-6s %-30s → %s: %s\n" "$set" "$name" "$FIELD" "$val" 79 + done 80 + else 81 + echo "$RESPONSE" | jq -r '.data[] | [.set, .set_type, .layout, .name] | @tsv' | while IFS=$'\t' read -r set set_type layout name; do 82 + printf "%-6s set_type: %-12s layout: %-15s %s\n" "$set" "$set_type" "$layout" "$name" 83 + done 84 + fi 85 + 86 + # Show count 87 + COUNT=$(echo "$RESPONSE" | jq '.data | length') 88 + TOTAL=$(echo "$RESPONSE" | jq '.total_cards') 89 + echo "" >&2 90 + echo "Showing $COUNT of $TOTAL results" >&2
+98
src/components/ActivityFeed.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { DeckPreview } from "@/components/DeckPreview"; 3 + import { ListPreview } from "@/components/ListPreview"; 4 + import type { CollectionList } from "@/lib/collection-list-types"; 5 + import { 6 + isDeckRecord, 7 + isListRecord, 8 + recentActivityQueryOptions, 9 + } from "@/lib/ufos-queries"; 10 + 11 + interface ActivityFeedProps { 12 + limit?: number; 13 + } 14 + 15 + export function ActivityFeed({ limit = 6 }: ActivityFeedProps) { 16 + const { 17 + data: records, 18 + isLoading, 19 + error, 20 + } = useQuery(recentActivityQueryOptions(limit)); 21 + 22 + if (isLoading) { 23 + return <ActivityFeedSkeleton count={limit} />; 24 + } 25 + 26 + if (error || !records) { 27 + return ( 28 + <div className="text-center py-8 text-gray-500 dark:text-gray-400"> 29 + Unable to load recent activity 30 + </div> 31 + ); 32 + } 33 + 34 + if (records.length === 0) { 35 + return ( 36 + <div className="text-center py-8 text-gray-500 dark:text-gray-400"> 37 + No recent activity yet 38 + </div> 39 + ); 40 + } 41 + 42 + return ( 43 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 44 + {records.map((record) => { 45 + if (isDeckRecord(record)) { 46 + return ( 47 + <DeckPreview 48 + key={`${record.did}/${record.rkey}`} 49 + did={record.did} 50 + rkey={record.rkey} 51 + deck={record.record} 52 + showHandle 53 + showCounts={false} 54 + /> 55 + ); 56 + } 57 + if (isListRecord(record)) { 58 + return ( 59 + <ListPreview 60 + key={`${record.did}/${record.rkey}`} 61 + did={record.did} 62 + rkey={record.rkey} 63 + list={record.record as CollectionList} 64 + showHandle 65 + /> 66 + ); 67 + } 68 + return null; 69 + })} 70 + </div> 71 + ); 72 + } 73 + 74 + function ActivityFeedSkeleton({ count }: { count: number }) { 75 + return ( 76 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 77 + {Array.from({ length: count }).map((_, i) => ( 78 + <div 79 + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton 80 + key={i} 81 + className="flex gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg animate-pulse" 82 + > 83 + <div className="flex-shrink-0 w-16 h-[90px] bg-gray-200 dark:bg-slate-700 rounded" /> 84 + <div className="flex-1 min-w-0"> 85 + {/* Handle */} 86 + <div className="h-5 w-24 bg-gray-200 dark:bg-slate-700 rounded mb-1" /> 87 + {/* Name */} 88 + <div className="h-7 w-40 bg-gray-200 dark:bg-slate-700 rounded" /> 89 + {/* Format */} 90 + <div className="h-5 w-20 bg-gray-200 dark:bg-slate-700 rounded mt-1" /> 91 + {/* Date */} 92 + <div className="h-5 w-28 bg-gray-200 dark:bg-slate-700 rounded mt-2" /> 93 + </div> 94 + </div> 95 + ))} 96 + </div> 97 + ); 98 + }
+148 -10
src/components/CardImage.tsx
··· 3 3 */ 4 4 5 5 import { Link } from "@tanstack/react-router"; 6 - import type { Card, ImageSize, ScryfallId } from "../lib/scryfall-types"; 7 - import { getImageUri } from "../lib/scryfall-utils"; 6 + import { RotateCcw } from "lucide-react"; 7 + import { useState } from "react"; 8 + import { canFlip, getFlipBehavior, hasBackImage } from "../lib/card-faces"; 9 + import type { 10 + Card, 11 + ImageSize, 12 + Layout, 13 + ScryfallId, 14 + } from "../lib/scryfall-types"; 15 + import { type CardFaceType, getImageUri } from "../lib/scryfall-utils"; 16 + 17 + export const PLACEHOLDER_STRIPES = `repeating-linear-gradient( 18 + -45deg, 19 + transparent, 20 + transparent 8px, 21 + rgba(0,0,0,0.05) 8px, 22 + rgba(0,0,0,0.05) 16px 23 + )`; 8 24 9 25 interface CardImageProps { 10 - card: Pick<Card, "name" | "id">; 26 + card: Pick<Card, "name" | "id"> & { layout?: Layout }; 11 27 size?: ImageSize; 28 + face?: CardFaceType; 12 29 className?: string; 30 + isFlipped?: boolean; 31 + onFlip?: (flipped: boolean) => void; 13 32 } 14 33 15 34 /** 16 - * Basic card image - use specific components (CardThumbnail, CardDetail) when possible 35 + * Card image with optional flip support for multi-faced cards. 36 + * 37 + * Flip behavior is auto-detected from card.layout: 38 + * - transform/modal_dfc/meld: 3D flip to back face image 39 + * - split: 90° rotation (scaled to fit) 40 + * - flip (Kamigawa): 180° rotation 41 + * 42 + * Uncontrolled by default (manages own flip state). 43 + * Pass isFlipped + onFlip for controlled mode. 17 44 */ 18 45 export function CardImage({ 19 46 card, 20 47 size = "normal", 48 + face = "front", 21 49 className, 50 + isFlipped: controlledFlipped, 51 + onFlip, 22 52 }: CardImageProps) { 53 + const [internalFlipped, setInternalFlipped] = useState(false); 54 + const isControlled = controlledFlipped !== undefined; 55 + const isFlipped = isControlled ? controlledFlipped : internalFlipped; 56 + 57 + const flippable = canFlip({ layout: card.layout } as Card); 58 + const flipBehavior = getFlipBehavior(card.layout); 59 + const hasBack = hasBackImage(card.layout); 60 + 61 + // Button position varies by card type to sit nicely over art 62 + const buttonPosition = 63 + flipBehavior === "rotate90" 64 + ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" // split: center 65 + : flipBehavior === "rotate180" 66 + ? "top-[15%] right-[15%]" // flip (Kamigawa): more inset 67 + : "top-[15%] right-[8%]"; // transform/MDFC: top-right of art 68 + 69 + const handleFlip = (e: React.MouseEvent) => { 70 + e.preventDefault(); 71 + e.stopPropagation(); 72 + const newValue = !isFlipped; 73 + if (onFlip) { 74 + onFlip(newValue); 75 + } 76 + if (!isControlled) { 77 + setInternalFlipped(newValue); 78 + } 79 + }; 80 + 81 + const baseClassName = `${className ?? ""} rounded-[4.75%/3.5%]`; 82 + 83 + if (!flippable) { 84 + return ( 85 + <img 86 + src={getImageUri(card.id, size, face)} 87 + alt={card.name} 88 + className={`${baseClassName} bg-gray-200 dark:bg-slate-700`} 89 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 90 + loading="lazy" 91 + /> 92 + ); 93 + } 94 + 95 + // Scale factor for 90° rotation to keep card in bounds (card is 5:7 ratio) 96 + const rotateScale = 5 / 7; 97 + 23 98 return ( 24 - <img 25 - src={getImageUri(card.id, size)} 26 - alt={card.name} 27 - className={`${className ?? ""} rounded-[4.75%/3.5%]`} 28 - loading="lazy" 99 + <div className="relative group"> 100 + {flipBehavior === "transform" && hasBack ? ( 101 + <div 102 + className="w-full motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-in-out" 103 + style={{ 104 + transformStyle: "preserve-3d", 105 + transform: isFlipped ? "rotateY(180deg)" : "rotateY(0deg)", 106 + }} 107 + > 108 + <img 109 + src={getImageUri(card.id, size, "front")} 110 + alt={card.name} 111 + className={`${baseClassName} bg-gray-200 dark:bg-slate-700`} 112 + loading="lazy" 113 + style={{ 114 + backfaceVisibility: "hidden", 115 + backgroundImage: PLACEHOLDER_STRIPES, 116 + }} 117 + /> 118 + <img 119 + src={getImageUri(card.id, size, "back")} 120 + alt={`${card.name} (back)`} 121 + className={`${baseClassName} bg-gray-200 dark:bg-slate-700 absolute inset-0`} 122 + loading="lazy" 123 + style={{ 124 + backfaceVisibility: "hidden", 125 + transform: "rotateY(180deg)", 126 + backgroundImage: PLACEHOLDER_STRIPES, 127 + }} 128 + /> 129 + </div> 130 + ) : ( 131 + <img 132 + src={getImageUri(card.id, size, face)} 133 + alt={card.name} 134 + className={`${baseClassName} bg-gray-200 dark:bg-slate-700 motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-in-out`} 135 + loading="lazy" 136 + style={{ 137 + backgroundImage: PLACEHOLDER_STRIPES, 138 + transformOrigin: "center center", 139 + transform: isFlipped 140 + ? flipBehavior === "rotate90" 141 + ? `rotate(90deg) scale(${rotateScale})` 142 + : "rotate(180deg)" 143 + : "rotate(0deg)", 144 + }} 145 + /> 146 + )} 147 + <button 148 + type="button" 149 + onClick={handleFlip} 150 + className={`absolute ${buttonPosition} p-3 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10`} 151 + aria-label="Flip card" 152 + > 153 + <RotateCcw className="w-6 h-6" /> 154 + </button> 155 + </div> 156 + ); 157 + } 158 + 159 + /** 160 + * Loading placeholder for card thumbnails 161 + */ 162 + export function CardSkeleton() { 163 + return ( 164 + <div 165 + className="aspect-[5/7] rounded-[4.75%/3.5%] bg-gray-200 dark:bg-slate-700 animate-pulse" 166 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 29 167 /> 30 168 ); 31 169 } ··· 67 205 68 206 if (href) { 69 207 return ( 70 - <Link to={href} className={className}> 208 + <Link to={href} className={className} onClick={onClick}> 71 209 {content} 72 210 </Link> 73 211 );
+2 -1
src/components/CardSymbol.tsx
··· 6 6 7 7 interface CardSymbolProps { 8 8 symbol: string; 9 - size?: "small" | "medium" | "large"; 9 + size?: "small" | "medium" | "large" | "text"; 10 10 className?: string; 11 11 } 12 12 ··· 14 14 small: "w-4 h-4", 15 15 medium: "w-5 h-5", 16 16 large: "w-6 h-6", 17 + text: "h-[1.1em] w-[1.1em]", 17 18 }; 18 19 19 20 export function CardSymbol({
+27
src/components/ClientDate.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + 3 + interface ClientDateProps { 4 + dateString: string; 5 + className?: string; 6 + } 7 + 8 + export function ClientDate({ dateString, className }: ClientDateProps) { 9 + const [formatted, setFormatted] = useState<string | null>(null); 10 + 11 + useEffect(() => { 12 + setFormatted(new Date(dateString).toLocaleDateString()); 13 + }, [dateString]); 14 + 15 + if (formatted === null) { 16 + return ( 17 + <span className={className}> 18 + <span 19 + className="inline-block align-middle bg-gray-200 dark:bg-slate-700 rounded animate-pulse" 20 + style={{ width: "5.5em", height: "0.75em" }} 21 + /> 22 + </span> 23 + ); 24 + } 25 + 26 + return <span className={className}>{formatted}</span>; 27 + }
+136
src/components/DeckPreview.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Link } from "@tanstack/react-router"; 4 + import { CardImage } from "@/components/CardImage"; 5 + import { ClientDate } from "@/components/ClientDate"; 6 + import { asRkey, type Rkey } from "@/lib/atproto-client"; 7 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 8 + import { formatDisplayName } from "@/lib/format-utils"; 9 + import type { ScryfallId } from "@/lib/scryfall-types"; 10 + 11 + export interface DeckData { 12 + name: string; 13 + format?: string; 14 + cards: Array<{ scryfallId: string; quantity: number; section: string }>; 15 + createdAt: string; 16 + updatedAt?: string; 17 + } 18 + 19 + export interface DeckPreviewProps { 20 + did: Did; 21 + rkey: Rkey | string; 22 + deck: DeckData; 23 + /** Whether to show handle row (fetches DID document, with skeleton while loading) */ 24 + showHandle?: boolean; 25 + /** Whether to show section counts like "100 main · 15 side" (default: true) */ 26 + showCounts?: boolean; 27 + } 28 + 29 + function getSectionCounts(cards: { quantity: number; section: string }[]) { 30 + const counts: Record<string, number> = {}; 31 + for (const card of cards) { 32 + counts[card.section] = (counts[card.section] ?? 0) + card.quantity; 33 + } 34 + return counts; 35 + } 36 + 37 + function formatSectionCounts(counts: Record<string, number>): string { 38 + const parts: string[] = []; 39 + 40 + if (counts.commander) { 41 + parts.push(`${counts.commander} cmdr`); 42 + } 43 + if (counts.mainboard) { 44 + parts.push(`${counts.mainboard} main`); 45 + } 46 + if (counts.sideboard) { 47 + parts.push(`${counts.sideboard} side`); 48 + } 49 + if (counts.maybeboard) { 50 + parts.push(`${counts.maybeboard} maybe`); 51 + } 52 + 53 + for (const [section, count] of Object.entries(counts)) { 54 + if ( 55 + !["commander", "mainboard", "sideboard", "maybeboard"].includes(section) 56 + ) { 57 + parts.push(`${count} ${section}`); 58 + } 59 + } 60 + 61 + return parts.join(" · "); 62 + } 63 + 64 + function getThumbnailId( 65 + cards: { scryfallId: string; section: string }[], 66 + ): ScryfallId | null { 67 + const commander = cards.find((c) => c.section === "commander"); 68 + if (commander) return commander.scryfallId as ScryfallId; 69 + 70 + const mainboard = cards.find((c) => c.section === "mainboard"); 71 + if (mainboard) return mainboard.scryfallId as ScryfallId; 72 + 73 + return cards[0]?.scryfallId as ScryfallId | null; 74 + } 75 + 76 + export function DeckPreview({ 77 + did, 78 + rkey, 79 + deck, 80 + showHandle = false, 81 + showCounts = true, 82 + }: DeckPreviewProps) { 83 + const { data: didDocument } = useQuery({ 84 + ...didDocumentQueryOptions(did), 85 + enabled: showHandle, 86 + }); 87 + const handle = showHandle ? extractHandle(didDocument ?? null) : null; 88 + 89 + const sectionString = showCounts 90 + ? formatSectionCounts(getSectionCounts(deck.cards)) 91 + : ""; 92 + const dateString = deck.updatedAt ?? deck.createdAt; 93 + const thumbnailId = getThumbnailId(deck.cards); 94 + 95 + return ( 96 + <Link 97 + to="/profile/$did/deck/$rkey" 98 + params={{ did, rkey: asRkey(rkey) }} 99 + className="grid grid-cols-[auto_1fr] grid-rows-[auto_auto_auto_auto_auto] gap-x-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-cyan-500 dark:hover:border-cyan-500 transition-colors" 100 + > 101 + {thumbnailId && ( 102 + <CardImage 103 + card={{ id: thumbnailId, name: deck.name }} 104 + size="small" 105 + className="row-span-5 h-0 min-h-full aspect-[5/7]" 106 + /> 107 + )} 108 + 109 + {showHandle && 110 + (handle ? ( 111 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 112 + @{handle} 113 + </p> 114 + ) : ( 115 + <div className="h-5 w-24 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 116 + ))} 117 + 118 + <h2 className="text-lg font-bold text-gray-900 dark:text-white truncate"> 119 + {deck.name} 120 + </h2> 121 + {deck.format && ( 122 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 123 + {formatDisplayName(deck.format)} 124 + </p> 125 + )} 126 + {sectionString && ( 127 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 128 + {sectionString} 129 + </p> 130 + )} 131 + <p className="text-sm text-gray-500 dark:text-gray-500"> 132 + Updated <ClientDate dateString={dateString} /> 133 + </p> 134 + </Link> 135 + ); 136 + }
+50 -17
src/components/Header.tsx
··· 1 - import { Link } from "@tanstack/react-router"; 2 - import { Home, Library, LogIn, Menu, Moon, Sun, X } from "lucide-react"; 1 + import { Link, useLocation } from "@tanstack/react-router"; 2 + import { Home, Library, LogIn, Menu, Moon, Search, Sun, X } from "lucide-react"; 3 3 import { useState } from "react"; 4 - import { useAuth } from "@/lib/useAuth"; 4 + import { RETURN_TO_KEY, useAuth } from "@/lib/useAuth"; 5 5 import { useTheme } from "@/lib/useTheme"; 6 6 import UserMenu from "./UserMenu"; 7 7 ··· 9 9 const [isOpen, setIsOpen] = useState(false); 10 10 const { theme, toggleTheme } = useTheme(); 11 11 const { session, isLoading } = useAuth(); 12 + const location = useLocation(); 13 + 14 + const handleSignInClick = () => { 15 + if (location.pathname !== "/signin") { 16 + sessionStorage.setItem(RETURN_TO_KEY, location.href); 17 + } 18 + }; 12 19 13 20 return ( 14 21 <> 15 - <header className="p-4 flex items-center justify-between bg-gray-800 dark:bg-gray-900 text-white shadow-lg"> 22 + <header className="p-4 flex items-center bg-gray-800 dark:bg-gray-900 text-white shadow-lg"> 16 23 <div className="flex items-center"> 17 24 <button 18 25 type="button" ··· 26 33 <Link to="/">DeckBelcher</Link> 27 34 </h1> 28 35 </div> 36 + 37 + <div className="flex-1 flex justify-center px-4"> 38 + <Link 39 + to="/cards" 40 + search={{ q: "", sort: undefined }} 41 + className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg transition-colors text-white text-sm font-medium" 42 + > 43 + <Search size={18} /> 44 + <span className="hidden sm:inline">Search Cards</span> 45 + </Link> 46 + </div> 47 + 29 48 <div className="flex items-center gap-2"> 30 - {!isLoading && 31 - (session ? ( 32 - <UserMenu /> 33 - ) : ( 34 - <Link 35 - to="/signin" 36 - className="flex items-center gap-2 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg transition-colors" 37 - > 38 - <LogIn size={16} /> 39 - <span className="text-sm font-medium">Sign In</span> 40 - </Link> 41 - ))} 49 + {isLoading ? ( 50 + <div className="flex items-center gap-2 px-3 py-2 bg-gray-700 dark:bg-gray-800 rounded-lg animate-pulse"> 51 + <LogIn size={16} className="invisible" /> 52 + <span className="text-sm font-medium invisible">Sign In</span> 53 + </div> 54 + ) : session ? ( 55 + <UserMenu /> 56 + ) : ( 57 + <Link 58 + to="/signin" 59 + onClick={handleSignInClick} 60 + className="flex items-center gap-2 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg transition-colors" 61 + > 62 + <LogIn size={16} /> 63 + <span className="text-sm font-medium">Sign In</span> 64 + </Link> 65 + )} 42 66 <button 43 67 type="button" 44 68 onClick={toggleTheme} ··· 55 79 </button> 56 80 </div> 57 81 </header> 82 + 83 + {isOpen && ( 84 + <div 85 + className="fixed inset-0 bg-black/50 z-40" 86 + onClick={() => setIsOpen(false)} 87 + onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)} 88 + aria-hidden="true" 89 + /> 90 + )} 58 91 59 92 <aside 60 93 className={`fixed top-0 left-0 h-full w-80 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${ ··· 89 122 90 123 <Link 91 124 to="/cards" 92 - search={{ q: "" }} 125 + search={{ q: "", sort: undefined }} 93 126 onClick={() => setIsOpen(false)} 94 127 className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors mb-2" 95 128 activeProps={{
+132
src/components/ListPreview.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Link } from "@tanstack/react-router"; 4 + import { Bookmark } from "lucide-react"; 5 + import { CardImage } from "@/components/CardImage"; 6 + import { ClientDate } from "@/components/ClientDate"; 7 + import { asRkey, type Rkey } from "@/lib/atproto-client"; 8 + import { 9 + type CollectionList, 10 + isCardItem, 11 + isDeckItem, 12 + } from "@/lib/collection-list-types"; 13 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 14 + import type { ScryfallId } from "@/lib/scryfall-types"; 15 + 16 + export interface ListPreviewProps { 17 + did: Did; 18 + rkey: Rkey | string; 19 + list: CollectionList; 20 + showHandle?: boolean; 21 + } 22 + 23 + function getItemSummary(list: CollectionList): string { 24 + const cardCount = list.items.filter(isCardItem).length; 25 + const deckCount = list.items.filter(isDeckItem).length; 26 + 27 + const parts: string[] = []; 28 + if (cardCount > 0) { 29 + parts.push(`${cardCount} ${cardCount === 1 ? "card" : "cards"}`); 30 + } 31 + if (deckCount > 0) { 32 + parts.push(`${deckCount} ${deckCount === 1 ? "deck" : "decks"}`); 33 + } 34 + 35 + return parts.length > 0 ? parts.join(" · ") : "Empty"; 36 + } 37 + 38 + function getCardIds(list: CollectionList): string[] { 39 + return list.items.filter(isCardItem).map((item) => item.scryfallId); 40 + } 41 + 42 + function CardSpread({ cardIds }: { cardIds: string[] }) { 43 + const cards = cardIds.slice(-3); 44 + 45 + if (cards.length === 0) { 46 + return ( 47 + <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0"> 48 + <Bookmark className="w-5 h-5 text-blue-600 dark:text-blue-400" /> 49 + </div> 50 + ); 51 + } 52 + 53 + const layouts: Record<number, { rotations: number[]; xPercents: number[] }> = 54 + { 55 + 1: { rotations: [0], xPercents: [20] }, 56 + 2: { rotations: [-8, 8], xPercents: [12, 28] }, 57 + 3: { rotations: [-12, 0, 12], xPercents: [10, 22, 34] }, 58 + }; 59 + 60 + const layout = layouts[cards.length] ?? layouts[3]; 61 + 62 + return ( 63 + <div className="relative shrink-0 w-24 h-[90px]"> 64 + {cards.map((id, i) => ( 65 + <div 66 + key={id} 67 + className="absolute w-3/5 shadow-md origin-bottom" 68 + style={{ 69 + left: `${layout.xPercents[i]}%`, 70 + bottom: "5%", 71 + transform: `rotate(${layout.rotations[i]}deg)`, 72 + zIndex: i, 73 + }} 74 + > 75 + <CardImage 76 + card={{ id: id as ScryfallId, name: "" }} 77 + size="small" 78 + className="rounded" 79 + /> 80 + </div> 81 + ))} 82 + </div> 83 + ); 84 + } 85 + 86 + export function ListPreview({ 87 + did, 88 + rkey, 89 + list, 90 + showHandle = false, 91 + }: ListPreviewProps) { 92 + const { data: didDocument } = useQuery({ 93 + ...didDocumentQueryOptions(did), 94 + enabled: showHandle, 95 + }); 96 + const handle = showHandle ? extractHandle(didDocument ?? null) : null; 97 + 98 + const dateString = list.updatedAt ?? list.createdAt; 99 + const itemSummary = getItemSummary(list); 100 + const cardIds = getCardIds(list); 101 + 102 + return ( 103 + <Link 104 + to="/profile/$did/list/$rkey" 105 + params={{ did, rkey: asRkey(rkey) }} 106 + className="flex items-start gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-cyan-500 dark:hover:border-cyan-500 transition-colors" 107 + > 108 + <CardSpread cardIds={cardIds} /> 109 + 110 + <div className="flex-1 min-w-0"> 111 + {showHandle && 112 + (handle ? ( 113 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 114 + @{handle} 115 + </p> 116 + ) : ( 117 + <div className="h-5 w-24 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 118 + ))} 119 + 120 + <h2 className="text-lg font-bold text-gray-900 dark:text-white truncate"> 121 + {list.name} 122 + </h2> 123 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 124 + {itemSummary} 125 + </p> 126 + <p className="text-sm text-gray-500 dark:text-gray-500"> 127 + Updated <ClientDate dateString={dateString} /> 128 + </p> 129 + </div> 130 + </Link> 131 + ); 132 + }
+3 -1
src/components/ManaCost.tsx
··· 9 9 interface ManaCostProps { 10 10 cost: string; 11 11 size?: "small" | "medium" | "large"; 12 + className?: string; 12 13 } 13 14 14 - export function ManaCost({ cost, size = "medium" }: ManaCostProps) { 15 + export function ManaCost({ cost, size = "medium", className }: ManaCostProps) { 15 16 // Parse mana cost string like "{2}{U}{B}" into symbols 16 17 const symbols = Array.from( 17 18 cost.matchAll(/\{([^}]+)\}/g), ··· 30 31 key={i} 31 32 symbol={symbol} 32 33 size={size} 34 + className={className} 33 35 /> 34 36 ))} 35 37 </div>
+24 -11
src/components/OracleText.tsx
··· 6 6 */ 7 7 8 8 import type React from "react"; 9 + import { VALID_SYMBOLS } from "@/lib/card-manifest"; 9 10 import { CardSymbol } from "./CardSymbol"; 10 11 11 12 interface OracleTextProps { 12 13 text: string; 13 14 className?: string; 15 + symbolSize?: "small" | "medium" | "large" | "text"; 14 16 } 15 17 16 18 type ParsedPart = ··· 27 29 if (line[i] === "{") { 28 30 const closeIdx = line.indexOf("}", i); 29 31 if (closeIdx !== -1) { 30 - parts.push({ 31 - type: "symbol", 32 - content: line.slice(i + 1, closeIdx), 33 - }); 34 - i = closeIdx + 1; 35 - continue; 32 + const symbolContent = line.slice(i + 1, closeIdx).toUpperCase(); 33 + // Only treat as symbol if it's a valid MTG symbol 34 + if (VALID_SYMBOLS.has(symbolContent)) { 35 + parts.push({ 36 + type: "symbol", 37 + content: symbolContent, 38 + }); 39 + i = closeIdx + 1; 40 + continue; 41 + } 36 42 } 37 43 } 38 44 ··· 93 99 return -1; 94 100 } 95 101 96 - function renderParts(parts: ParsedPart[]): React.ReactNode[] { 102 + function renderParts( 103 + parts: ParsedPart[], 104 + symbolSize: "small" | "medium" | "large" | "text", 105 + ): React.ReactNode[] { 97 106 return parts.map((part, i) => { 98 107 if (part.type === "symbol") { 99 108 return ( ··· 101 110 // biome-ignore lint/suspicious/noArrayIndexKey: symbols in oracle text are stable ordered list 102 111 key={i} 103 112 symbol={part.content} 104 - size="small" 113 + size={symbolSize} 105 114 className="inline align-middle mx-0.5" 106 115 /> 107 116 ); ··· 110 119 return ( 111 120 // biome-ignore lint/suspicious/noArrayIndexKey: text fragments in oracle text are stable ordered list 112 121 <span key={i} className="italic"> 113 - ({renderParts(part.parts)}) 122 + ({renderParts(part.parts, symbolSize)}) 114 123 </span> 115 124 ); 116 125 } ··· 121 130 }); 122 131 } 123 132 124 - export function OracleText({ text, className }: OracleTextProps) { 133 + export function OracleText({ 134 + text, 135 + className, 136 + symbolSize = "small", 137 + }: OracleTextProps) { 125 138 const lines = text.split("\n"); 126 139 127 140 return ( ··· 131 144 return ( 132 145 // biome-ignore lint/suspicious/noArrayIndexKey: oracle text lines are stable ordered list 133 146 <span key={lineIndex}> 134 - {renderParts(parts)} 147 + {renderParts(parts, symbolSize)} 135 148 {lineIndex < lines.length - 1 && <br />} 136 149 </span> 137 150 );
+52
src/components/SetSymbol.tsx
··· 1 + /** 2 + * Renders an MTG set symbol using the Keyrune font 3 + * 4 + * https://github.com/andrewgioia/keyrune 5 + */ 6 + 7 + import { getSetSymbol } from "@/lib/set-symbols"; 8 + 9 + interface SetSymbolProps { 10 + setCode: string; 11 + rarity?: "common" | "uncommon" | "rare" | "mythic" | "timeshifted"; 12 + size?: "small" | "medium" | "large"; 13 + className?: string; 14 + } 15 + 16 + const SIZE_CLASSES = { 17 + small: "text-base", 18 + medium: "text-xl", 19 + large: "text-2xl", 20 + }; 21 + 22 + const RARITY_CLASSES = { 23 + common: "text-rarity-common dark:text-gray-400", 24 + uncommon: "text-rarity-uncommon", 25 + rare: "text-rarity-rare", 26 + mythic: "text-rarity-mythic", 27 + timeshifted: "text-rarity-timeshifted", 28 + }; 29 + 30 + export function SetSymbol({ 31 + setCode, 32 + rarity = "common", 33 + size = "medium", 34 + className, 35 + }: SetSymbolProps) { 36 + const symbol = getSetSymbol(setCode); 37 + 38 + if (!symbol) { 39 + return null; 40 + } 41 + 42 + return ( 43 + <span 44 + role="img" 45 + className={`font-['Keyrune'] ${SIZE_CLASSES[size]} ${RARITY_CLASSES[rarity]} ${className ?? ""}`} 46 + title={`${setCode.toUpperCase()} (${rarity})`} 47 + aria-label={`Set: ${setCode.toUpperCase()}, Rarity: ${rarity}`} 48 + > 49 + {symbol} 50 + </span> 51 + ); 52 + }
+16 -4
src/components/UserMenu.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 + import { Link } from "@tanstack/react-router"; 2 3 import { ChevronDown, LogOut, User } from "lucide-react"; 3 4 import { useEffect, useRef, useState } from "react"; 4 5 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; ··· 9 10 const menuRef = useRef<HTMLDivElement>(null); 10 11 const { session, signOut } = useAuth(); 11 12 12 - const { data: handle } = useQuery({ 13 + const { data: handle, isLoading: isHandleLoading } = useQuery({ 13 14 ...didDocumentQueryOptions(session?.info.sub), 14 15 enabled: !!session, 15 16 select: extractHandle, ··· 41 42 aria-expanded={isOpen} 42 43 > 43 44 <User size={16} /> 44 - <span className="text-sm"> 45 - {handle ? `@${handle}` : session.info.sub} 46 - </span> 45 + {!isHandleLoading && ( 46 + <span className="text-sm"> 47 + {handle ? `@${handle}` : session.info.sub} 48 + </span> 49 + )} 47 50 <ChevronDown 48 51 size={16} 49 52 className={`transition-transform ${isOpen ? "rotate-180" : ""}`} ··· 52 55 53 56 {isOpen && ( 54 57 <div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50"> 58 + <Link 59 + to="/profile/$did" 60 + params={{ did: session.info.sub }} 61 + onClick={() => setIsOpen(false)} 62 + className="w-full flex items-center gap-2 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 63 + > 64 + <User size={16} /> 65 + <span>View Profile</span> 66 + </Link> 55 67 <button 56 68 type="button" 57 69 onClick={() => {
+73 -45
src/components/deck/CardModal.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { Minus, Plus, Trash2, X } from "lucide-react"; 3 - import { useEffect, useState } from "react"; 3 + import { useEffect, useId, useState } from "react"; 4 + import { CardImage } from "@/components/CardImage"; 5 + import { TagAutocomplete } from "@/components/deck/TagAutocomplete"; 4 6 import { ManaCost } from "@/components/ManaCost"; 7 + import { getPrimaryFace } from "@/lib/card-faces"; 5 8 import type { DeckCard, Section } from "@/lib/deck-types"; 6 9 import { getCardByIdQueryOptions } from "@/lib/queries"; 7 10 ··· 14 17 onMoveToSection: (section: Section) => void; 15 18 onDelete: () => void; 16 19 readOnly?: boolean; 20 + allTags?: string[]; 17 21 } 18 22 19 23 export function CardModal({ ··· 25 29 onMoveToSection, 26 30 onDelete, 27 31 readOnly = false, 32 + allTags = [], 28 33 }: CardModalProps) { 29 34 const [quantity, setQuantity] = useState(card.quantity); 30 35 const [tags, setTags] = useState<string[]>(card.tags ?? []); 31 - const [newTag, setNewTag] = useState(""); 36 + 37 + const titleId = useId(); 32 38 33 39 const { data: cardData } = useQuery(getCardByIdQueryOptions(card.scryfallId)); 40 + const primaryFace = cardData ? getPrimaryFace(cardData) : null; 41 + 42 + // Suggestions: all tags except ones already on this card 43 + const tagSuggestions = allTags.filter((t) => !tags.includes(t)); 34 44 35 45 useEffect(() => { 36 46 setQuantity(card.quantity); 37 47 setTags(card.tags ?? []); 38 48 }, [card]); 39 49 40 - // Close on escape 50 + // Keyboard shortcuts: Escape to close, 1-9 to set quantity 41 51 useEffect(() => { 42 - const handleEscape = (e: KeyboardEvent) => { 52 + const handleKeyDown = (e: KeyboardEvent) => { 43 53 if (e.key === "Escape") { 44 54 onClose(); 55 + return; 56 + } 57 + 58 + // Number keys 1-9 set quantity (only when not typing in an input) 59 + const isTyping = 60 + document.activeElement instanceof HTMLInputElement || 61 + document.activeElement instanceof HTMLTextAreaElement; 62 + if (!readOnly && !isTyping) { 63 + const num = Number.parseInt(e.key, 10); 64 + if (num >= 1 && num <= 9) { 65 + setQuantity(num); 66 + onUpdateQuantity(num); 67 + } 45 68 } 46 69 }; 47 70 48 71 if (isOpen) { 49 - document.addEventListener("keydown", handleEscape); 50 - return () => document.removeEventListener("keydown", handleEscape); 72 + document.addEventListener("keydown", handleKeyDown); 73 + return () => document.removeEventListener("keydown", handleKeyDown); 51 74 } 52 - }, [isOpen, onClose]); 75 + }, [isOpen, onClose, onUpdateQuantity, readOnly]); 53 76 54 77 if (!isOpen) return null; 55 78 ··· 60 83 } 61 84 }; 62 85 63 - const handleAddTag = () => { 64 - const trimmed = newTag.trim(); 65 - if (trimmed && !tags.includes(trimmed)) { 66 - const newTags = [...tags, trimmed]; 86 + const handleAddTag = (tag: string) => { 87 + if (!tags.includes(tag)) { 88 + const newTags = [...tags, tag]; 67 89 setTags(newTags); 68 90 onUpdateTags(newTags); 69 - setNewTag(""); 70 91 } 71 92 }; 72 93 ··· 84 105 return ( 85 106 <> 86 107 {/* Backdrop */} 87 - <button 88 - type="button" 108 + <div 89 109 className="fixed inset-0 bg-black/50 z-40" 90 110 onClick={onClose} 91 - aria-label="Close modal" 111 + aria-hidden="true" 92 112 /> 93 113 94 114 {/* Modal */} 95 115 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"> 96 - <div className="bg-white dark:bg-slate-900 rounded-lg shadow-2xl max-w-md w-full pointer-events-auto border border-gray-300 dark:border-slate-700"> 116 + <div 117 + role="dialog" 118 + aria-modal="true" 119 + aria-labelledby={titleId} 120 + className="bg-white dark:bg-slate-900 rounded-lg shadow-2xl max-w-md w-full pointer-events-auto border border-gray-300 dark:border-slate-700" 121 + > 97 122 {/* Header */} 98 123 <div className="flex items-start justify-between p-6 border-b border-gray-200 dark:border-slate-800"> 99 124 <div className="flex-1 min-w-0"> 100 - {cardData ? ( 125 + {primaryFace ? ( 101 126 <> 102 127 <div className="flex items-baseline gap-2 mb-2 flex-wrap"> 103 - <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 104 - {cardData.name} 128 + <h2 129 + id={titleId} 130 + className="text-2xl font-bold text-gray-900 dark:text-white" 131 + > 132 + {primaryFace.name} 105 133 </h2> 106 - {cardData.mana_cost && ( 134 + {primaryFace.mana_cost && ( 107 135 <div className="flex-shrink-0"> 108 - <ManaCost cost={cardData.mana_cost} size="small" /> 136 + <ManaCost cost={primaryFace.mana_cost} size="small" /> 109 137 </div> 110 138 )} 111 139 </div> 112 - {cardData.type_line && ( 140 + {primaryFace.type_line && ( 113 141 <div className="text-sm text-gray-600 dark:text-gray-400"> 114 - {cardData.type_line} 142 + {primaryFace.type_line} 115 143 </div> 116 144 )} 117 145 </> 118 146 ) : ( 119 - <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 147 + <h2 148 + id={titleId} 149 + className="text-2xl font-bold text-gray-900 dark:text-white" 150 + > 120 151 Loading... 121 152 </h2> 122 153 )} ··· 124 155 <button 125 156 type="button" 126 157 onClick={onClose} 158 + aria-label="Close" 127 159 className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors ml-4" 128 160 > 129 161 <X className="w-6 h-6" /> ··· 132 164 133 165 {/* Body */} 134 166 <div className="p-6 space-y-6"> 167 + {/* Card image - only shown on mobile where sidebar preview is hidden */} 168 + {cardData && ( 169 + <div className="md:hidden flex justify-center"> 170 + <div className="w-48"> 171 + <CardImage 172 + card={cardData} 173 + size="normal" 174 + className="w-full h-auto shadow-lg rounded-[4.75%/3.5%]" 175 + /> 176 + </div> 177 + </div> 178 + )} 179 + 135 180 {/* Quantity */} 136 181 <div> 137 182 <div className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> ··· 192 237 <div className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 193 238 Tags 194 239 </div> 195 - <div className="flex gap-2 mb-2"> 196 - <input 197 - type="text" 198 - value={newTag} 199 - onChange={(e) => setNewTag(e.target.value)} 200 - onKeyDown={(e) => { 201 - if (e.key === "Enter") { 202 - e.preventDefault(); 203 - handleAddTag(); 204 - } 205 - }} 206 - placeholder="Add tag..." 240 + <div className="mb-2"> 241 + <TagAutocomplete 242 + suggestions={tagSuggestions} 243 + onAdd={handleAddTag} 207 244 disabled={readOnly} 208 - className="flex-1 px-3 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed" 209 245 /> 210 - <button 211 - type="button" 212 - onClick={handleAddTag} 213 - disabled={readOnly} 214 - className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors" 215 - > 216 - Add 217 - </button> 218 246 </div> 219 247 {tags.length > 0 && ( 220 248 <div className="flex flex-wrap gap-2">
+3 -3
src/components/deck/CardPreviewPane.tsx
··· 11 11 const { data } = useQuery(getCardByIdQueryOptions(cardId)); 12 12 13 13 return ( 14 - <div className="sticky top-20 flex items-center justify-center"> 15 - {cardId && ( 14 + <div className="flex items-center justify-center"> 15 + {cardId && data && ( 16 16 <CardImage 17 - card={{ id: cardId, name: data?.name ?? "" }} 17 + card={data} 18 18 size="large" 19 19 className="shadow-[0_0.5rem_1.875rem_rgba(0,0,0,0.4)] dark:shadow-[0_0.5rem_1.875rem_rgba(0,0,0,0.8)] w-full h-auto object-contain rounded-[4.75%/3.5%]" 20 20 />
+38 -34
src/components/deck/CardSearchAutocomplete.tsx
··· 1 1 import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 2 import { useEffect, useId, useMemo, useRef, useState } from "react"; 3 3 import { toast } from "sonner"; 4 + import { getPrimaryFace } from "@/lib/card-faces"; 4 5 import { type Deck, getCommanderColorIdentity } from "@/lib/deck-types"; 5 6 import { 6 7 getCardByIdQueryOptions, ··· 42 43 const resultRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); 43 44 44 45 const queryClient = useQueryClient(); 45 - const debouncedSearch = useDebounce(inputValue, 300); 46 + const { value: debouncedSearch } = useDebounce(inputValue, 300); 46 47 const toggleId = useId(); 47 48 48 49 // Calculate search restrictions ··· 267 268 > 268 269 {hasResults ? ( 269 270 <div className="py-1"> 270 - {displayCards.map((card, index) => ( 271 - <button 272 - type="button" 273 - key={card.id} 274 - ref={(el) => { 275 - if (el) { 276 - resultRefs.current.set(index, el); 277 - } else { 278 - resultRefs.current.delete(index); 279 - } 280 - }} 281 - onMouseEnter={() => { 282 - handleMouseEnterCard(card); 283 - setSelectedIndex(index); 284 - }} 285 - onClick={() => handleCardSelect(card)} 286 - className={`w-full px-3 py-1.5 text-left cursor-pointer transition-colors ${ 287 - index === selectedIndex 288 - ? "bg-blue-100 dark:bg-blue-900/30" 289 - : "hover:bg-gray-100 dark:hover:bg-slate-800" 290 - }`} 291 - > 292 - <div className="flex items-center justify-between gap-2"> 293 - <div className="font-medium text-sm text-gray-900 dark:text-white truncate"> 294 - {card.name} 271 + {displayCards.map((card, index) => { 272 + const face = getPrimaryFace(card); 273 + return ( 274 + <button 275 + type="button" 276 + key={card.id} 277 + ref={(el) => { 278 + if (el) { 279 + resultRefs.current.set(index, el); 280 + } else { 281 + resultRefs.current.delete(index); 282 + } 283 + }} 284 + onMouseEnter={() => { 285 + handleMouseEnterCard(card); 286 + setSelectedIndex(index); 287 + }} 288 + onClick={() => handleCardSelect(card)} 289 + className={`w-full px-3 py-1.5 text-left cursor-pointer transition-colors ${ 290 + index === selectedIndex 291 + ? "bg-blue-100 dark:bg-blue-900/30" 292 + : "hover:bg-gray-100 dark:hover:bg-slate-800" 293 + }`} 294 + > 295 + <div className="flex items-center justify-between gap-2"> 296 + <div className="font-medium text-sm text-gray-900 dark:text-white truncate"> 297 + {face.name} 298 + </div> 299 + {face.mana_cost && ( 300 + <div className="flex-shrink-0"> 301 + <ManaCost cost={face.mana_cost} size="small" /> 302 + </div> 303 + )} 295 304 </div> 296 - {card.mana_cost && ( 297 - <div className="flex-shrink-0"> 298 - <ManaCost cost={card.mana_cost} size="small" /> 299 - </div> 300 - )} 301 - </div> 302 - </button> 303 - ))} 305 + </button> 306 + ); 307 + })} 304 308 </div> 305 309 ) : ( 306 310 <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
+168
src/components/deck/DeckActionsMenu.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { MoreVertical, Play, Trash2 } from "lucide-react"; 4 + import { useEffect, useRef, useState } from "react"; 5 + import { toast } from "sonner"; 6 + import { DeleteDeckDialog } from "@/components/deck/DeleteDeckDialog"; 7 + import type { Rkey } from "@/lib/atproto-client"; 8 + import { getCardDataProvider } from "@/lib/card-data-provider"; 9 + import { prefetchCards } from "@/lib/card-prefetch"; 10 + import { useDeleteDeckMutation } from "@/lib/deck-queries"; 11 + import type { Deck } from "@/lib/deck-types"; 12 + import { 13 + findAllCanonicalPrintings, 14 + findAllCheapestPrintings, 15 + updateDeckPrintings, 16 + } from "@/lib/printing-selection"; 17 + import type { ScryfallId } from "@/lib/scryfall-types"; 18 + 19 + interface DeckActionsMenuProps { 20 + deck: Deck; 21 + did: string; 22 + rkey: Rkey; 23 + onUpdateDeck?: (updater: (prev: Deck) => Deck) => Promise<void>; 24 + onCardsChanged?: (changedIds: Set<ScryfallId>) => void; 25 + readOnly?: boolean; 26 + } 27 + 28 + export function DeckActionsMenu({ 29 + deck, 30 + did, 31 + rkey, 32 + onUpdateDeck, 33 + onCardsChanged, 34 + readOnly = false, 35 + }: DeckActionsMenuProps) { 36 + const queryClient = useQueryClient(); 37 + const [isOpen, setIsOpen] = useState(false); 38 + const [showDeleteDialog, setShowDeleteDialog] = useState(false); 39 + const menuRef = useRef<HTMLDivElement>(null); 40 + const deleteMutation = useDeleteDeckMutation(rkey); 41 + 42 + useEffect(() => { 43 + const handleClickOutside = (event: MouseEvent) => { 44 + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 45 + setIsOpen(false); 46 + } 47 + }; 48 + 49 + if (isOpen) { 50 + document.addEventListener("mousedown", handleClickOutside); 51 + return () => 52 + document.removeEventListener("mousedown", handleClickOutside); 53 + } 54 + }, [isOpen]); 55 + 56 + const handleSetAllToCheapest = async () => { 57 + if (!onUpdateDeck) return; 58 + setIsOpen(false); 59 + const toastId = toast.loading("Finding cheapest printings..."); 60 + 61 + try { 62 + const provider = await getCardDataProvider(); 63 + const updates = await findAllCheapestPrintings(deck, provider); 64 + 65 + if (updates.size > 0) { 66 + const newIds = [...new Set(updates.values())]; 67 + await prefetchCards(queryClient, newIds); 68 + await onUpdateDeck((prev) => updateDeckPrintings(prev, updates)); 69 + onCardsChanged?.(new Set(newIds)); 70 + toast.success(`Updated ${updates.size} printing(s)`, { id: toastId }); 71 + } else { 72 + toast.success("All cards already at cheapest", { id: toastId }); 73 + } 74 + } catch { 75 + toast.error("Failed to update printings", { id: toastId }); 76 + } 77 + }; 78 + 79 + const handleSetAllToBest = async () => { 80 + if (!onUpdateDeck) return; 81 + setIsOpen(false); 82 + const toastId = toast.loading("Finding best printings..."); 83 + 84 + try { 85 + const provider = await getCardDataProvider(); 86 + const updates = await findAllCanonicalPrintings(deck, provider); 87 + 88 + if (updates.size > 0) { 89 + const newIds = [...new Set(updates.values())]; 90 + await prefetchCards(queryClient, newIds); 91 + await onUpdateDeck((prev) => updateDeckPrintings(prev, updates)); 92 + onCardsChanged?.(new Set(newIds)); 93 + toast.success(`Updated ${updates.size} printing(s)`, { id: toastId }); 94 + } else { 95 + toast.success("All cards already at best", { id: toastId }); 96 + } 97 + } catch { 98 + toast.error("Failed to update printings", { id: toastId }); 99 + } 100 + }; 101 + 102 + return ( 103 + <div className="relative" ref={menuRef}> 104 + <button 105 + type="button" 106 + onClick={() => setIsOpen(!isOpen)} 107 + className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" 108 + aria-label="Deck actions" 109 + aria-expanded={isOpen} 110 + > 111 + <MoreVertical size={16} /> 112 + </button> 113 + 114 + {isOpen && ( 115 + <div className="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50"> 116 + <Link 117 + to="/profile/$did/deck/$rkey/play" 118 + params={{ did, rkey }} 119 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm flex items-center gap-2" 120 + onClick={() => setIsOpen(false)} 121 + > 122 + <Play size={14} /> 123 + Playtest 124 + </Link> 125 + {!readOnly && ( 126 + <> 127 + <div className="border-t border-gray-200 dark:border-gray-700" /> 128 + <button 129 + type="button" 130 + onClick={handleSetAllToCheapest} 131 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 132 + > 133 + Set all to cheapest 134 + </button> 135 + <button 136 + type="button" 137 + onClick={handleSetAllToBest} 138 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 139 + > 140 + Set all to best 141 + </button> 142 + <div className="border-t border-gray-200 dark:border-gray-700" /> 143 + <button 144 + type="button" 145 + onClick={() => { 146 + setIsOpen(false); 147 + setShowDeleteDialog(true); 148 + }} 149 + className="w-full text-left px-4 py-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400 text-sm flex items-center gap-2" 150 + > 151 + <Trash2 size={14} /> 152 + Delete deck 153 + </button> 154 + </> 155 + )} 156 + </div> 157 + )} 158 + 159 + <DeleteDeckDialog 160 + deckName={deck.name} 161 + isOpen={showDeleteDialog} 162 + onClose={() => setShowDeleteDialog(false)} 163 + onConfirm={() => deleteMutation.mutate()} 164 + isDeleting={deleteMutation.isPending} 165 + /> 166 + </div> 167 + ); 168 + }
+11 -9
src/components/deck/DeckHeader.tsx
··· 1 1 import { useState } from "react"; 2 + import { FORMAT_GROUPS } from "@/lib/format-utils"; 2 3 3 4 interface DeckHeaderProps { 4 5 name: string; ··· 40 41 41 42 return ( 42 43 <div className="mb-6 space-y-4"> 43 - <div className="flex items-center gap-4"> 44 + <div className="flex flex-wrap items-center gap-4"> 44 45 {isEditingName ? ( 45 46 <input 46 47 type="text" ··· 71 72 className="px-4 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 72 73 > 73 74 <option value="">No Format</option> 74 - <option value="commander">Commander</option> 75 - <option value="cube">Cube</option> 76 - <option value="pauper">Pauper</option> 77 - <option value="paupercommander">Pauper Commander (PDH)</option> 78 - <option value="standard">Standard</option> 79 - <option value="modern">Modern</option> 80 - <option value="legacy">Legacy</option> 81 - <option value="vintage">Vintage</option> 75 + {FORMAT_GROUPS.map((group) => ( 76 + <optgroup key={group.label} label={group.label}> 77 + {group.formats.map((fmt) => ( 78 + <option key={fmt.value} value={fmt.value}> 79 + {fmt.label} 80 + </option> 81 + ))} 82 + </optgroup> 83 + ))} 82 84 </select> 83 85 </div> 84 86 </div>
+7 -16
src/components/deck/DeckSection.tsx
··· 2 2 import { useMemo } from "react"; 3 3 import { groupCards, sortCards, sortGroupNames } from "@/lib/deck-grouping"; 4 4 import type { DeckCard, GroupBy, Section, SortBy } from "@/lib/deck-types"; 5 - import { getCardByIdQueryOptions } from "@/lib/queries"; 6 - import type { Card, ScryfallId } from "@/lib/scryfall-types"; 5 + import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; 6 + import type { ScryfallId } from "@/lib/scryfall-types"; 7 7 import { DraggableCard } from "./DraggableCard"; 8 8 import { DroppableSection } from "./DroppableSection"; 9 9 import { DroppableSectionHeader } from "./DroppableSectionHeader"; 10 10 import { DroppableTagGroup } from "./DroppableTagGroup"; 11 11 12 - // Combine function for useQueries - converts query results into a Map 13 - function combineCardQueries( 14 - results: Array<{ data?: Card | undefined }>, 15 - ): Map<ScryfallId, Card> | undefined { 16 - const map = new Map<ScryfallId, Card>(); 17 - for (const result of results) { 18 - if (result.data) { 19 - map.set(result.data.id, result.data); 20 - } 21 - } 22 - // Only return the map if all cards are loaded 23 - return results.every((r) => r.data) ? map : undefined; 24 - } 25 - 26 12 interface DeckSectionProps { 27 13 section: Section; 28 14 cards: DeckCard[]; ··· 32 18 onCardClick?: (card: DeckCard) => void; 33 19 isDragging: boolean; 34 20 readOnly?: boolean; 21 + highlightedCards?: Set<ScryfallId>; 35 22 } 36 23 37 24 export function DeckSection({ ··· 43 30 onCardClick, 44 31 isDragging, 45 32 readOnly = false, 33 + highlightedCards, 46 34 }: DeckSectionProps) { 47 35 const sectionNames: Record<Section, string> = { 48 36 commander: "Commander", ··· 130 118 onCardClick={onCardClick} 131 119 disabled={readOnly} 132 120 isDraggingGlobal={isDragging} 121 + isHighlighted={highlightedCards?.has(card.scryfallId)} 133 122 /> 134 123 </div> 135 124 ); ··· 145 134 onCardClick={onCardClick} 146 135 disabled={readOnly} 147 136 isDraggingGlobal={isDragging} 137 + isHighlighted={highlightedCards?.has(card.scryfallId)} 148 138 /> 149 139 </div> 150 140 ); ··· 188 178 onCardClick={onCardClick} 189 179 disabled={readOnly} 190 180 isDraggingGlobal={isDragging} 181 + isHighlighted={highlightedCards?.has(card.scryfallId)} 191 182 /> 192 183 ); 193 184 })}
+86
src/components/deck/DeckStats.tsx
··· 1 + import type { StatsSelection } from "@/lib/stats-selection"; 2 + import type { DeckStatsData } from "@/lib/useDeckStats"; 3 + import { ManaBreakdown } from "./stats/ManaBreakdown"; 4 + import { ManaCurveChart } from "./stats/ManaCurveChart"; 5 + import { SpeedPieChart } from "./stats/SpeedPieChart"; 6 + import { SubtypesPieChart } from "./stats/SubtypesPieChart"; 7 + import { TypesPieChart } from "./stats/TypesPieChart"; 8 + 9 + interface DeckStatsProps { 10 + stats: DeckStatsData; 11 + selection: StatsSelection; 12 + onSelect: (selection: StatsSelection) => void; 13 + } 14 + 15 + export function DeckStats({ stats, selection, onSelect }: DeckStatsProps) { 16 + const { 17 + manaCurve, 18 + typeDistribution, 19 + subtypeDistribution, 20 + speedDistribution, 21 + manaBreakdown, 22 + isLoading, 23 + } = stats; 24 + 25 + if (isLoading) { 26 + return ( 27 + <div className="mt-8 pt-8 border-t border-gray-200 dark:border-slate-700"> 28 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> 29 + Statistics 30 + </h2> 31 + <div className="flex items-center justify-center h-48 bg-gray-100 dark:bg-slate-800 rounded-lg"> 32 + <div className="text-gray-500 dark:text-gray-400"> 33 + Loading statistics... 34 + </div> 35 + </div> 36 + </div> 37 + ); 38 + } 39 + 40 + const hasData = 41 + manaCurve.length > 0 || 42 + typeDistribution.length > 0 || 43 + subtypeDistribution.length > 0 || 44 + speedDistribution.length > 0 || 45 + manaBreakdown.length > 0; 46 + 47 + if (!hasData) { 48 + return null; 49 + } 50 + 51 + return ( 52 + <div className="mt-8 pt-8 border-t border-gray-200 dark:border-slate-700"> 53 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> 54 + Statistics 55 + </h2> 56 + 57 + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> 58 + <ManaCurveChart 59 + data={manaCurve} 60 + selection={selection} 61 + onSelect={onSelect} 62 + /> 63 + <TypesPieChart 64 + data={typeDistribution} 65 + selection={selection} 66 + onSelect={onSelect} 67 + /> 68 + <SpeedPieChart 69 + data={speedDistribution} 70 + selection={selection} 71 + onSelect={onSelect} 72 + /> 73 + <SubtypesPieChart 74 + data={subtypeDistribution} 75 + selection={selection} 76 + onSelect={onSelect} 77 + /> 78 + <ManaBreakdown 79 + data={manaBreakdown} 80 + selection={selection} 81 + onSelect={onSelect} 82 + /> 83 + </div> 84 + </div> 85 + ); 86 + }
+136
src/components/deck/DeleteDeckDialog.tsx
··· 1 + import { AlertTriangle } from "lucide-react"; 2 + import { useEffect, useId, useState } from "react"; 3 + 4 + interface DeleteDeckDialogProps { 5 + deckName: string; 6 + isOpen: boolean; 7 + onClose: () => void; 8 + onConfirm: () => void; 9 + isDeleting?: boolean; 10 + } 11 + 12 + export function DeleteDeckDialog({ 13 + deckName, 14 + isOpen, 15 + onClose, 16 + onConfirm, 17 + isDeleting = false, 18 + }: DeleteDeckDialogProps) { 19 + const [confirmText, setConfirmText] = useState(""); 20 + const titleId = useId(); 21 + const inputId = useId(); 22 + 23 + const isMatch = confirmText === deckName; 24 + 25 + useEffect(() => { 26 + if (!isOpen) { 27 + setConfirmText(""); 28 + } 29 + }, [isOpen]); 30 + 31 + useEffect(() => { 32 + const handleKeyDown = (e: KeyboardEvent) => { 33 + if (e.key === "Escape" && !isDeleting) { 34 + onClose(); 35 + } 36 + }; 37 + 38 + if (isOpen) { 39 + document.addEventListener("keydown", handleKeyDown); 40 + return () => document.removeEventListener("keydown", handleKeyDown); 41 + } 42 + }, [isOpen, isDeleting, onClose]); 43 + 44 + if (!isOpen) return null; 45 + 46 + const handleSubmit = (e: React.FormEvent) => { 47 + e.preventDefault(); 48 + if (isMatch && !isDeleting) { 49 + onConfirm(); 50 + } 51 + }; 52 + 53 + return ( 54 + <> 55 + {/* Backdrop */} 56 + <div 57 + className="fixed inset-0 bg-black/50 z-40" 58 + onClick={isDeleting ? undefined : onClose} 59 + aria-hidden="true" 60 + /> 61 + 62 + {/* Dialog */} 63 + <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"> 64 + <div 65 + role="alertdialog" 66 + aria-modal="true" 67 + aria-labelledby={titleId} 68 + className="bg-white dark:bg-slate-900 rounded-lg shadow-2xl max-w-md w-full pointer-events-auto border border-gray-300 dark:border-slate-700" 69 + > 70 + {/* Header */} 71 + <div className="flex items-center gap-3 p-6 border-b border-gray-200 dark:border-slate-800"> 72 + <div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full"> 73 + <AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" /> 74 + </div> 75 + <h2 76 + id={titleId} 77 + className="text-xl font-bold text-gray-900 dark:text-white" 78 + > 79 + Delete deck 80 + </h2> 81 + </div> 82 + 83 + {/* Body */} 84 + <form onSubmit={handleSubmit} className="p-6 space-y-4"> 85 + <p className="text-gray-600 dark:text-gray-400"> 86 + This action <strong>cannot</strong> be undone. This will 87 + permanently delete the deck and all its cards. 88 + </p> 89 + 90 + <div> 91 + <label 92 + htmlFor={inputId} 93 + className="block text-sm text-gray-700 dark:text-gray-300 mb-2" 94 + > 95 + Please type{" "} 96 + <span className="font-mono font-semibold text-gray-900 dark:text-white bg-gray-100 dark:bg-slate-800 px-1.5 py-0.5 rounded"> 97 + {deckName} 98 + </span>{" "} 99 + to confirm. 100 + </label> 101 + <input 102 + id={inputId} 103 + type="text" 104 + value={confirmText} 105 + onChange={(e) => setConfirmText(e.target.value)} 106 + disabled={isDeleting} 107 + autoComplete="off" 108 + className="w-full px-4 py-2 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" 109 + placeholder="Deck name" 110 + /> 111 + </div> 112 + 113 + {/* Footer */} 114 + <div className="flex items-center justify-end gap-3 pt-2"> 115 + <button 116 + type="button" 117 + onClick={onClose} 118 + disabled={isDeleting} 119 + className="px-4 py-2 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 120 + > 121 + Cancel 122 + </button> 123 + <button 124 + type="submit" 125 + disabled={!isMatch || isDeleting} 126 + className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-600/50 disabled:cursor-not-allowed text-white rounded-lg transition-colors" 127 + > 128 + {isDeleting ? "Deleting..." : "Delete this deck"} 129 + </button> 130 + </div> 131 + </form> 132 + </div> 133 + </div> 134 + </> 135 + ); 136 + }
+31 -7
src/components/deck/DragDropProvider.tsx
··· 1 1 import { 2 2 DndContext, 3 + type DragCancelEvent, 3 4 type DragEndEvent, 4 5 KeyboardSensor, 5 6 PointerSensor, ··· 7 8 useSensor, 8 9 useSensors, 9 10 } from "@dnd-kit/core"; 10 - import type { ReactNode } from "react"; 11 + import { type ReactNode, useId, useState } from "react"; 11 12 12 13 interface DragDropProviderProps { 13 14 children: ReactNode; 14 15 onDragEnd: (event: DragEndEvent) => void; 16 + onDragCancel?: (event: DragCancelEvent) => void; 15 17 } 16 18 17 19 export function DragDropProvider({ 18 20 children, 19 21 onDragEnd, 22 + onDragCancel, 20 23 }: DragDropProviderProps) { 21 - // Configure sensors for different input methods 24 + const dndContextId = useId(); 25 + 26 + // WARN: Screen size is checked once on mount and never updated. 27 + // dnd-kit's useSensors doesn't support dynamic sensor changes. 28 + // This will break on foldable phones that change size mid-session. 29 + // Fix requires either dnd-kit fix or remounting DndContext on resize. 30 + const [isLargeScreen] = useState(() => { 31 + if (typeof window === "undefined") return true; 32 + return window.matchMedia("(min-width: 768px)").matches; 33 + }); 34 + 22 35 const pointerSensor = useSensor(PointerSensor, { 23 36 activationConstraint: { 24 - distance: 8, // Require 8px movement to start drag (prevents accidental drags) 37 + distance: 8, 25 38 }, 26 39 }); 27 40 28 41 const touchSensor = useSensor(TouchSensor, { 29 42 activationConstraint: { 30 - delay: 250, // 250ms delay before drag starts on touch 31 - tolerance: 5, // 5px tolerance for distinguishing scroll vs drag 43 + delay: 250, 44 + tolerance: 5, 32 45 }, 33 46 }); 34 47 35 48 const keyboardSensor = useSensor(KeyboardSensor); 36 49 37 - const sensors = useSensors(pointerSensor, touchSensor, keyboardSensor); 50 + // Only include touch sensor on larger screens (tablets, laptops) 51 + // On small screens (<768px), touch scrolls instead of dragging 52 + const sensors = useSensors( 53 + pointerSensor, 54 + ...(isLargeScreen ? [touchSensor] : []), 55 + keyboardSensor, 56 + ); 38 57 39 58 return ( 40 - <DndContext sensors={sensors} onDragEnd={onDragEnd}> 59 + <DndContext 60 + id={dndContextId} 61 + sensors={sensors} 62 + onDragEnd={onDragEnd} 63 + onDragCancel={onDragCancel} 64 + > 41 65 {children} 42 66 </DndContext> 43 67 );
+25 -4
src/components/deck/DraggableCard.tsx
··· 1 1 import { useDraggable } from "@dnd-kit/core"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 + import { useEffect, useRef } from "react"; 3 4 import { ManaCost } from "@/components/ManaCost"; 5 + import { getPrimaryFace } from "@/lib/card-faces"; 4 6 import type { DeckCard } from "@/lib/deck-types"; 5 7 import { getCardByIdQueryOptions } from "@/lib/queries"; 6 8 import type { ScryfallId } from "@/lib/scryfall-types"; ··· 12 14 onCardClick?: (card: DeckCard) => void; 13 15 disabled?: boolean; 14 16 isDraggingGlobal?: boolean; 17 + isHighlighted?: boolean; 15 18 } 16 19 17 20 export interface DragData { ··· 27 30 onCardClick, 28 31 disabled = false, 29 32 isDraggingGlobal = false, 33 + isHighlighted = false, 30 34 }: DraggableCardProps) { 31 35 const { data: cardData, isLoading } = useQuery( 32 36 getCardByIdQueryOptions(card.scryfallId), ··· 44 48 disabled, 45 49 }); 46 50 51 + const highlightRef = useRef<HTMLDivElement>(null); 52 + 53 + useEffect(() => { 54 + if (isHighlighted && highlightRef.current) { 55 + highlightRef.current.animate([{ opacity: 1 }, { opacity: 0 }], { 56 + duration: 2000, 57 + easing: "ease-out", 58 + }); 59 + } 60 + }, [isHighlighted]); 61 + 62 + const primaryFace = cardData ? getPrimaryFace(cardData) : null; 63 + 47 64 return ( 48 65 <button 49 66 ref={setNodeRef} 50 67 {...attributes} 51 68 {...(disabled ? {} : listeners)} 52 69 type="button" 53 - className="bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 rounded px-2 py-1 transition-colors w-full text-left touch-none" 70 + className="relative rounded px-2 py-1 w-full text-left md:touch-none bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700" 54 71 style={{ 55 72 opacity: isDragging ? 0.5 : 1, 56 73 cursor: disabled ? "pointer" : isDragging ? "grabbing" : "grab", ··· 71 88 } 72 89 }} 73 90 > 91 + <div 92 + ref={highlightRef} 93 + className="absolute inset-0 rounded bg-amber-100 dark:bg-slate-700 opacity-0 pointer-events-none" 94 + /> 74 95 <div className="flex items-center gap-2"> 75 96 <span className="text-gray-600 dark:text-gray-400 font-mono text-xs w-4 text-right flex-shrink-0"> 76 97 {card.quantity} 77 98 </span> 78 99 <span className="text-gray-900 dark:text-white text-sm truncate flex-1 min-w-0"> 79 - {cardData ? cardData.name : isLoading ? "" : "Unknown Card"} 100 + {primaryFace ? primaryFace.name : isLoading ? "" : "Unknown Card"} 80 101 </span> 81 102 <div className="flex-shrink-0 flex items-center ml-auto"> 82 - {cardData?.mana_cost ? ( 83 - <ManaCost cost={cardData.mana_cost} size="small" /> 103 + {primaryFace?.mana_cost ? ( 104 + <ManaCost cost={primaryFace.mana_cost} size="small" /> 84 105 ) : isLoading ? ( 85 106 <div className="h-5 w-12 bg-gray-300 dark:bg-slate-700 rounded animate-pulse" /> 86 107 ) : null}
+140
src/components/deck/GoldfishDragDropProvider.tsx
··· 1 + import { 2 + DndContext, 3 + type DragEndEvent, 4 + type DragMoveEvent, 5 + type DragOverEvent, 6 + DragOverlay, 7 + type DragStartEvent, 8 + KeyboardSensor, 9 + PointerSensor, 10 + TouchSensor, 11 + useSensor, 12 + useSensors, 13 + } from "@dnd-kit/core"; 14 + import { type ReactNode, useId, useRef, useState } from "react"; 15 + import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; 16 + import type { CardInstance } from "@/lib/goldfish/types"; 17 + import { getImageUri } from "@/lib/scryfall-utils"; 18 + 19 + export interface DragPosition { 20 + translated: { left: number; top: number } | null; 21 + } 22 + 23 + interface GoldfishDragDropProviderProps { 24 + children: ReactNode; 25 + onDragStart?: (event: DragStartEvent) => void; 26 + onDragOver?: (event: DragOverEvent) => void; 27 + onDragEnd: (event: DragEndEvent, lastPosition: DragPosition | null) => void; 28 + } 29 + 30 + export function GoldfishDragDropProvider({ 31 + children, 32 + onDragStart, 33 + onDragOver, 34 + onDragEnd, 35 + }: GoldfishDragDropProviderProps) { 36 + const dndContextId = useId(); 37 + const [activeCard, setActiveCard] = useState<CardInstance | null>(null); 38 + // Track position during drag since rect.current.translated is null in onDragEnd 39 + // See: https://github.com/clauderic/dnd-kit/discussions/236 40 + const lastPositionRef = useRef<DragPosition | null>(null); 41 + 42 + const pointerSensor = useSensor(PointerSensor, { 43 + activationConstraint: { 44 + distance: 8, 45 + }, 46 + }); 47 + 48 + const touchSensor = useSensor(TouchSensor, { 49 + activationConstraint: { 50 + delay: 200, 51 + tolerance: 5, 52 + }, 53 + }); 54 + 55 + const keyboardSensor = useSensor(KeyboardSensor); 56 + 57 + const sensors = useSensors(pointerSensor, touchSensor, keyboardSensor); 58 + 59 + const handleDragStart = (event: DragStartEvent) => { 60 + const data = event.active.data.current as 61 + | { instance: CardInstance } 62 + | undefined; 63 + if (data?.instance) { 64 + setActiveCard(data.instance); 65 + } 66 + lastPositionRef.current = null; 67 + onDragStart?.(event); 68 + }; 69 + 70 + const handleDragMove = (event: DragMoveEvent) => { 71 + const rect = event.active.rect.current.translated; 72 + if (rect) { 73 + lastPositionRef.current = { 74 + translated: { left: rect.left, top: rect.top }, 75 + }; 76 + } 77 + }; 78 + 79 + const handleDragEnd = (event: DragEndEvent) => { 80 + const lastPosition = lastPositionRef.current; 81 + setActiveCard(null); 82 + lastPositionRef.current = null; 83 + onDragEnd(event, lastPosition); 84 + }; 85 + 86 + return ( 87 + <DndContext 88 + id={dndContextId} 89 + sensors={sensors} 90 + onDragStart={handleDragStart} 91 + onDragMove={handleDragMove} 92 + onDragOver={onDragOver} 93 + onDragEnd={handleDragEnd} 94 + > 95 + {children} 96 + <DragOverlay dropAnimation={null}> 97 + {activeCard && <DragPreview instance={activeCard} />} 98 + </DragOverlay> 99 + </DndContext> 100 + ); 101 + } 102 + 103 + function DragPreview({ instance }: { instance: CardInstance }) { 104 + const isFlipped = instance.faceIndex > 0; 105 + const imageSrc = instance.isFaceDown 106 + ? null 107 + : getImageUri(instance.cardId, "normal", isFlipped ? "back" : "front"); 108 + 109 + const counterEntries = Object.entries(instance.counters); 110 + 111 + return ( 112 + <div 113 + className={`relative pointer-events-none ${instance.isTapped ? "rotate-90" : ""}`} 114 + > 115 + {imageSrc ? ( 116 + <img 117 + src={imageSrc} 118 + alt="Dragging card" 119 + className="h-40 aspect-[5/7] rounded-[4.75%/3.5%] bg-gray-200 dark:bg-slate-700 shadow-2xl" 120 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 121 + draggable={false} 122 + /> 123 + ) : ( 124 + <div className="h-40 aspect-[5/7] rounded-[4.75%/3.5%] bg-amber-700 shadow-2xl" /> 125 + )} 126 + {counterEntries.length > 0 && ( 127 + <div className="absolute bottom-1 left-1 flex flex-wrap gap-1 max-w-full"> 128 + {counterEntries.map(([type, count]) => ( 129 + <span 130 + key={type} 131 + className="px-1.5 py-0.5 text-xs font-bold rounded bg-black/70 text-white" 132 + > 133 + {type === "+1/+1" ? `+${count}/+${count}` : `${count}`} 134 + </span> 135 + ))} 136 + </div> 137 + )} 138 + </div> 139 + ); 140 + }
+133
src/components/deck/GoldfishView.tsx
··· 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import type { DeckCard } from "@/lib/deck-types"; 3 + import type { ScryfallId } from "@/lib/scryfall-types"; 4 + import { seededShuffle, useSeededRandom } from "@/lib/useSeededRandom"; 5 + import { CardImage } from "../CardImage"; 6 + 7 + interface GoldfishViewProps { 8 + cards: DeckCard[]; 9 + onCardHover?: (cardId: ScryfallId | null) => void; 10 + } 11 + 12 + interface CardInstance { 13 + cardId: ScryfallId; 14 + instanceId: number; 15 + } 16 + 17 + interface DeckState { 18 + hand: CardInstance[]; 19 + library: CardInstance[]; 20 + } 21 + 22 + function dealHand( 23 + deck: CardInstance[], 24 + rng: () => number, 25 + handSize = 7, 26 + ): DeckState { 27 + const shuffled = seededShuffle(deck, rng); 28 + return { 29 + hand: shuffled.slice(0, handSize), 30 + library: shuffled.slice(handSize), 31 + }; 32 + } 33 + 34 + export function GoldfishView({ cards, onCardHover }: GoldfishViewProps) { 35 + const containerRef = useRef<HTMLDivElement>(null); 36 + const { rng, SeedEmbed } = useSeededRandom(); 37 + 38 + const fullDeck = useMemo(() => { 39 + const deck: CardInstance[] = []; 40 + let instanceId = 0; 41 + for (const card of cards) { 42 + for (let i = 0; i < card.quantity; i++) { 43 + deck.push({ cardId: card.scryfallId, instanceId: instanceId++ }); 44 + } 45 + } 46 + return deck; 47 + }, [cards]); 48 + 49 + const [state, setState] = useState(() => dealHand(fullDeck, rng)); 50 + 51 + const newHand = useCallback(() => { 52 + setState(dealHand(fullDeck, rng)); 53 + }, [fullDeck, rng]); 54 + 55 + const shouldScrollRef = useRef(false); 56 + 57 + const draw = useCallback(() => { 58 + setState((prev) => { 59 + if (prev.library.length === 0) return prev; 60 + shouldScrollRef.current = true; 61 + return { 62 + hand: [...prev.hand, prev.library[0]], 63 + library: prev.library.slice(1), 64 + }; 65 + }); 66 + }, []); 67 + 68 + // Scroll last card into view after draw renders 69 + const handLength = state.hand.length; 70 + useEffect(() => { 71 + if (handLength > 0 && shouldScrollRef.current && containerRef.current) { 72 + shouldScrollRef.current = false; 73 + onCardHover?.(state.hand[handLength - 1].cardId); 74 + // Wait for layout to settle before scrolling 75 + requestAnimationFrame(() => { 76 + const lastCard = containerRef.current?.lastElementChild; 77 + lastCard?.scrollIntoView({ 78 + behavior: "smooth", 79 + block: "nearest", 80 + inline: "nearest", 81 + }); 82 + }); 83 + } 84 + }, [handLength, onCardHover, state.hand]); 85 + 86 + if (cards.length === 0) return null; 87 + 88 + return ( 89 + <div className="mt-8 pt-8 border-t border-gray-200 dark:border-slate-700"> 90 + <SeedEmbed /> 91 + <div className="flex items-center justify-between mb-4"> 92 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white"> 93 + Goldfish 94 + </h2> 95 + <div className="flex gap-2"> 96 + <button 97 + type="button" 98 + onClick={newHand} 99 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-700" 100 + > 101 + New Hand 102 + </button> 103 + <button 104 + type="button" 105 + onClick={draw} 106 + disabled={state.library.length === 0} 107 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" 108 + > 109 + Draw ({state.library.length}) 110 + </button> 111 + </div> 112 + </div> 113 + 114 + <div ref={containerRef} className="flex gap-2 overflow-x-auto pb-2"> 115 + {state.hand.map((card) => ( 116 + <div 117 + key={card.instanceId} 118 + role="img" 119 + className="flex-shrink-0" 120 + onMouseEnter={() => onCardHover?.(card.cardId)} 121 + onMouseLeave={() => onCardHover?.(null)} 122 + > 123 + <CardImage 124 + card={{ id: card.cardId, name: "" }} 125 + size="normal" 126 + className="h-52 aspect-[5/7] rounded-lg" 127 + /> 128 + </div> 129 + ))} 130 + </div> 131 + </div> 132 + ); 133 + }
+135
src/components/deck/PrimerSection.tsx
··· 1 + import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; 2 + import type { RefObject } from "react"; 3 + import { useState } from "react"; 4 + import { RichTextEditor } from "@/components/richtext/RichTextEditor"; 5 + import { type ParseResult, RichText } from "@/lib/richtext"; 6 + 7 + interface PrimerSectionProps { 8 + inputRef: RefObject<HTMLTextAreaElement | null>; 9 + onInput: () => void; 10 + defaultValue: string; 11 + parsed: ParseResult; 12 + isDirty?: boolean; 13 + isSaving?: boolean; 14 + readOnly?: boolean; 15 + } 16 + 17 + const COLLAPSED_LINES = 8; 18 + const LINE_HEIGHT = 1.5; 19 + 20 + export function PrimerSection({ 21 + inputRef, 22 + onInput, 23 + defaultValue, 24 + parsed, 25 + isDirty, 26 + isSaving, 27 + readOnly = false, 28 + }: PrimerSectionProps) { 29 + const [isEditing, setIsEditing] = useState(false); 30 + const [isExpanded, setIsExpanded] = useState(false); 31 + 32 + const hasContent = parsed.text.trim().length > 0; 33 + const lineCount = parsed.text.split("\n").length; 34 + const needsTruncation = lineCount > COLLAPSED_LINES; 35 + 36 + if (isEditing && !readOnly) { 37 + return ( 38 + <div className="space-y-3"> 39 + <RichTextEditor 40 + inputRef={inputRef} 41 + onInput={onInput} 42 + defaultValue={defaultValue} 43 + parsed={parsed} 44 + isDirty={isDirty} 45 + isSaving={isSaving} 46 + placeholder="Write about your deck's strategy, key combos, card choices..." 47 + /> 48 + <div className="flex justify-end"> 49 + <button 50 + type="button" 51 + onClick={() => setIsEditing(false)} 52 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 53 + > 54 + Done 55 + </button> 56 + </div> 57 + </div> 58 + ); 59 + } 60 + 61 + if (!hasContent && readOnly) { 62 + return null; 63 + } 64 + 65 + if (!hasContent) { 66 + return ( 67 + <button 68 + type="button" 69 + onClick={() => setIsEditing(true)} 70 + className="text-sm text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 italic" 71 + > 72 + Add a description... 73 + </button> 74 + ); 75 + } 76 + 77 + return ( 78 + <div> 79 + <div className="relative"> 80 + <div 81 + className={ 82 + !isExpanded && needsTruncation ? "overflow-hidden" : undefined 83 + } 84 + style={ 85 + !isExpanded && needsTruncation 86 + ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 87 + : undefined 88 + } 89 + > 90 + <RichText 91 + text={parsed.text} 92 + facets={parsed.facets} 93 + className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" 94 + /> 95 + </div> 96 + 97 + {needsTruncation && !isExpanded && ( 98 + <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white dark:from-slate-900 to-transparent pointer-events-none" /> 99 + )} 100 + </div> 101 + 102 + <div className="flex items-center gap-2 mt-2"> 103 + {needsTruncation && ( 104 + <button 105 + type="button" 106 + onClick={() => setIsExpanded(!isExpanded)} 107 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 108 + > 109 + {isExpanded ? ( 110 + <> 111 + <ChevronUp className="w-4 h-4" /> 112 + Show less 113 + </> 114 + ) : ( 115 + <> 116 + <ChevronDown className="w-4 h-4" /> 117 + Show more 118 + </> 119 + )} 120 + </button> 121 + )} 122 + {!readOnly && ( 123 + <button 124 + type="button" 125 + onClick={() => setIsEditing(true)} 126 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 127 + > 128 + <Pencil className="w-4 h-4" /> 129 + Edit 130 + </button> 131 + )} 132 + </div> 133 + </div> 134 + ); 135 + }
+142
src/components/deck/TagAutocomplete.tsx
··· 1 + import { useId, useRef, useState } from "react"; 2 + 3 + interface TagAutocompleteProps { 4 + suggestions: string[]; 5 + onAdd: (tag: string) => void; 6 + disabled?: boolean; 7 + placeholder?: string; 8 + } 9 + 10 + export function TagAutocomplete({ 11 + suggestions, 12 + onAdd, 13 + disabled = false, 14 + placeholder = "Add tag...", 15 + }: TagAutocompleteProps) { 16 + const [value, setValue] = useState(""); 17 + const [selectedIndex, setSelectedIndex] = useState(0); 18 + const [isOpen, setIsOpen] = useState(false); 19 + const containerRef = useRef<HTMLDivElement>(null); 20 + const listboxId = useId(); 21 + 22 + const filtered = value.trim() 23 + ? suggestions.filter((s) => s.toLowerCase().includes(value.toLowerCase())) 24 + : []; 25 + 26 + const showDropdown = isOpen && filtered.length > 0; 27 + 28 + const handleInputChange = (newValue: string) => { 29 + setValue(newValue); 30 + setSelectedIndex(0); 31 + setIsOpen(true); 32 + }; 33 + 34 + const handleAdd = (tag: string) => { 35 + const trimmed = tag.trim(); 36 + if (trimmed) { 37 + onAdd(trimmed); 38 + setValue(""); 39 + setIsOpen(false); 40 + } 41 + }; 42 + 43 + const handleBlur = (e: React.FocusEvent) => { 44 + // Only close if focus leaves the entire component 45 + if (!containerRef.current?.contains(e.relatedTarget)) { 46 + setIsOpen(false); 47 + } 48 + }; 49 + 50 + const handleKeyDown = (e: React.KeyboardEvent) => { 51 + if (!showDropdown) { 52 + if (e.key === "Enter") { 53 + e.preventDefault(); 54 + handleAdd(value); 55 + } 56 + return; 57 + } 58 + 59 + switch (e.key) { 60 + case "ArrowDown": 61 + e.preventDefault(); 62 + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); 63 + break; 64 + case "ArrowUp": 65 + e.preventDefault(); 66 + setSelectedIndex((i) => Math.max(i - 1, 0)); 67 + break; 68 + case "Enter": 69 + e.preventDefault(); 70 + handleAdd(filtered[selectedIndex]); 71 + break; 72 + case "Escape": 73 + e.preventDefault(); 74 + setIsOpen(false); 75 + break; 76 + } 77 + }; 78 + 79 + const activeDescendant = showDropdown 80 + ? `${listboxId}-option-${selectedIndex}` 81 + : undefined; 82 + 83 + return ( 84 + <div ref={containerRef} className="relative flex-1"> 85 + <div className="flex gap-2"> 86 + <input 87 + type="text" 88 + role="combobox" 89 + aria-expanded={showDropdown} 90 + aria-controls={listboxId} 91 + aria-activedescendant={activeDescendant} 92 + aria-autocomplete="list" 93 + value={value} 94 + onChange={(e) => handleInputChange(e.target.value)} 95 + onKeyDown={handleKeyDown} 96 + onFocus={() => setIsOpen(true)} 97 + onBlur={handleBlur} 98 + placeholder={placeholder} 99 + disabled={disabled} 100 + className="flex-1 px-3 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed" 101 + /> 102 + <button 103 + type="button" 104 + onClick={() => handleAdd(value)} 105 + disabled={disabled} 106 + className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors" 107 + > 108 + Add 109 + </button> 110 + </div> 111 + 112 + {showDropdown && ( 113 + <div 114 + id={listboxId} 115 + role="listbox" 116 + className="absolute z-10 mt-1 w-full bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg shadow-lg max-h-48 overflow-y-auto" 117 + > 118 + {filtered.map((tag, i) => ( 119 + <div 120 + key={tag} 121 + id={`${listboxId}-option-${i}`} 122 + role="option" 123 + aria-selected={i === selectedIndex} 124 + tabIndex={-1} 125 + onMouseDown={(e) => { 126 + e.preventDefault(); 127 + handleAdd(tag); 128 + }} 129 + className={`px-3 py-2 text-sm cursor-pointer ${ 130 + i === selectedIndex 131 + ? "bg-cyan-100 dark:bg-cyan-900 text-cyan-900 dark:text-cyan-100" 132 + : "text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-slate-700" 133 + }`} 134 + > 135 + {tag} 136 + </div> 137 + ))} 138 + </div> 139 + )} 140 + </div> 141 + ); 142 + }
+44 -126
src/components/deck/ViewControls.tsx
··· 1 - import { 2 - Label, 3 - Listbox, 4 - ListboxButton, 5 - ListboxOption, 6 - ListboxOptions, 7 - } from "@headlessui/react"; 1 + import { useId } from "react"; 8 2 import type { GroupBy, SortBy } from "@/lib/deck-types"; 9 3 10 4 interface ViewControlsProps { ··· 35 29 onGroupByChange, 36 30 onSortByChange, 37 31 }: ViewControlsProps) { 38 - const groupByLabel = 39 - GROUP_BY_OPTIONS.find((opt) => opt.value === groupBy)?.label ?? "Group By"; 40 - const sortByLabel = 41 - SORT_BY_OPTIONS.find((opt) => opt.value === sortBy)?.label ?? "Sort By"; 32 + const groupById = useId(); 33 + const sortById = useId(); 42 34 43 35 return ( 44 - <div className="flex gap-2 items-center mb-4"> 45 - <Listbox value={groupBy} onChange={onGroupByChange}> 46 - <div className="relative flex items-center gap-1.5"> 47 - <Label className="text-xs font-medium text-gray-600 dark:text-gray-400"> 48 - Group: 49 - </Label> 50 - <ListboxButton className="relative min-w-32 cursor-pointer rounded bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 py-1 pl-2 pr-6 text-left text-sm text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-colors"> 51 - <span className="block truncate">{groupByLabel}</span> 52 - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-1.5"> 53 - <svg 54 - className="h-3.5 w-3.5 text-gray-500 dark:text-gray-400" 55 - viewBox="0 0 20 20" 56 - fill="currentColor" 57 - aria-hidden="true" 58 - > 59 - <path 60 - fillRule="evenodd" 61 - d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" 62 - clipRule="evenodd" 63 - /> 64 - </svg> 65 - </span> 66 - </ListboxButton> 67 - <ListboxOptions className="absolute top-full left-0 z-10 mt-1 min-w-40 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none"> 68 - {GROUP_BY_OPTIONS.map((option) => ( 69 - <ListboxOption 70 - key={option.value} 71 - value={option.value} 72 - className="relative cursor-pointer select-none py-1.5 pl-8 pr-3 text-gray-900 dark:text-gray-200 data-[focus]:bg-cyan-600 data-[focus]:text-white" 73 - > 74 - {({ selected }) => ( 75 - <> 76 - <span className={selected ? "font-medium" : "font-normal"}> 77 - {option.label} 78 - </span> 79 - {selected && ( 80 - <span className="absolute inset-y-0 left-0 flex items-center pl-2 text-cyan-600 data-[focus]:text-white"> 81 - <svg 82 - className="h-3.5 w-3.5" 83 - viewBox="0 0 20 20" 84 - fill="currentColor" 85 - aria-hidden="true" 86 - > 87 - <path 88 - fillRule="evenodd" 89 - d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" 90 - clipRule="evenodd" 91 - /> 92 - </svg> 93 - </span> 94 - )} 95 - </> 96 - )} 97 - </ListboxOption> 98 - ))} 99 - </ListboxOptions> 100 - </div> 101 - </Listbox> 36 + <div className="flex gap-4 items-center mb-4 flex-wrap"> 37 + <div className="flex items-center gap-1.5"> 38 + <label 39 + htmlFor={groupById} 40 + className="text-xs font-medium text-gray-600 dark:text-gray-400" 41 + > 42 + Group: 43 + </label> 44 + <select 45 + id={groupById} 46 + value={groupBy} 47 + onChange={(e) => onGroupByChange(e.target.value as GroupBy)} 48 + className="min-w-32 cursor-pointer rounded bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 py-1 px-2 text-sm text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-colors" 49 + > 50 + {GROUP_BY_OPTIONS.map((option) => ( 51 + <option key={option.value} value={option.value}> 52 + {option.label} 53 + </option> 54 + ))} 55 + </select> 56 + </div> 102 57 103 - <Listbox value={sortBy} onChange={onSortByChange}> 104 - <div className="relative flex items-center gap-1.5"> 105 - <Label className="text-xs font-medium text-gray-600 dark:text-gray-400"> 106 - Sort: 107 - </Label> 108 - <ListboxButton className="relative min-w-28 cursor-pointer rounded bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 py-1 pl-2 pr-6 text-left text-sm text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-colors"> 109 - <span className="block truncate">{sortByLabel}</span> 110 - <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-1.5"> 111 - <svg 112 - className="h-3.5 w-3.5 text-gray-500 dark:text-gray-400" 113 - viewBox="0 0 20 20" 114 - fill="currentColor" 115 - aria-hidden="true" 116 - > 117 - <path 118 - fillRule="evenodd" 119 - d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" 120 - clipRule="evenodd" 121 - /> 122 - </svg> 123 - </span> 124 - </ListboxButton> 125 - <ListboxOptions className="absolute top-full left-0 z-10 mt-1 min-w-32 overflow-auto rounded-md bg-white dark:bg-slate-800 py-1 text-sm shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none"> 126 - {SORT_BY_OPTIONS.map((option) => ( 127 - <ListboxOption 128 - key={option.value} 129 - value={option.value} 130 - className="relative cursor-pointer select-none py-1.5 pl-8 pr-3 text-gray-900 dark:text-gray-200 data-[focus]:bg-cyan-600 data-[focus]:text-white" 131 - > 132 - {({ selected }) => ( 133 - <> 134 - <span className={selected ? "font-medium" : "font-normal"}> 135 - {option.label} 136 - </span> 137 - {selected && ( 138 - <span className="absolute inset-y-0 left-0 flex items-center pl-2 text-cyan-600 data-[focus]:text-white"> 139 - <svg 140 - className="h-3.5 w-3.5" 141 - viewBox="0 0 20 20" 142 - fill="currentColor" 143 - aria-hidden="true" 144 - > 145 - <path 146 - fillRule="evenodd" 147 - d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" 148 - clipRule="evenodd" 149 - /> 150 - </svg> 151 - </span> 152 - )} 153 - </> 154 - )} 155 - </ListboxOption> 156 - ))} 157 - </ListboxOptions> 158 - </div> 159 - </Listbox> 58 + <div className="flex items-center gap-1.5"> 59 + <label 60 + htmlFor={sortById} 61 + className="text-xs font-medium text-gray-600 dark:text-gray-400" 62 + > 63 + Sort: 64 + </label> 65 + <select 66 + id={sortById} 67 + value={sortBy} 68 + onChange={(e) => onSortByChange(e.target.value as SortBy)} 69 + className="min-w-28 cursor-pointer rounded bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 py-1 px-2 text-sm text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-colors" 70 + > 71 + {SORT_BY_OPTIONS.map((option) => ( 72 + <option key={option.value} value={option.value}> 73 + {option.label} 74 + </option> 75 + ))} 76 + </select> 77 + </div> 160 78 </div> 161 79 ); 162 80 }
+56
src/components/deck/goldfish/GoldfishBattlefield.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import { forwardRef } from "react"; 3 + import type { CardInstance } from "@/lib/goldfish/types"; 4 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 5 + import { GoldfishCard } from "./GoldfishCard"; 6 + 7 + interface GoldfishBattlefieldProps { 8 + cards: CardInstance[]; 9 + cardLookup?: (id: ScryfallId) => Card | undefined; 10 + onHover?: (instanceId: number | null) => void; 11 + onClick?: (instanceId: number) => void; 12 + } 13 + 14 + export const GoldfishBattlefield = forwardRef< 15 + HTMLDivElement, 16 + GoldfishBattlefieldProps 17 + >(function GoldfishBattlefield({ cards, cardLookup, onHover, onClick }, ref) { 18 + const { setNodeRef, isOver } = useDroppable({ 19 + id: "zone-battlefield", 20 + data: { zone: "battlefield" }, 21 + }); 22 + 23 + return ( 24 + <div ref={ref} className="flex-1 min-h-[300px]"> 25 + <div 26 + ref={setNodeRef} 27 + className={`isolate relative w-full h-full rounded-lg border-2 border-dashed transition-colors ${ 28 + isOver 29 + ? "border-green-500 bg-green-500/10" 30 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 31 + }`} 32 + > 33 + {cards.length === 0 && ( 34 + <div className="absolute inset-0 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm pointer-events-none"> 35 + Battlefield 36 + </div> 37 + )} 38 + {cards.map((instance) => ( 39 + <GoldfishCard 40 + key={instance.instanceId} 41 + instance={instance} 42 + card={cardLookup?.(instance.cardId)} 43 + onHover={onHover} 44 + onClick={onClick} 45 + positioning="absolute" 46 + style={{ 47 + left: instance.position?.x ?? 100, 48 + top: instance.position?.y ?? 100, 49 + zIndex: instance.zIndex, 50 + }} 51 + /> 52 + ))} 53 + </div> 54 + </div> 55 + ); 56 + });
+171
src/components/deck/goldfish/GoldfishBoard.tsx
··· 1 + import type { DragEndEvent } from "@dnd-kit/core"; 2 + import { useCallback, useRef } from "react"; 3 + import { CardImage } from "@/components/CardImage"; 4 + import type { DeckCard } from "@/lib/deck-types"; 5 + import { useGoldfishGame } from "@/lib/goldfish"; 6 + import type { CardInstance, Zone } from "@/lib/goldfish/types"; 7 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 8 + import { 9 + type DragPosition, 10 + GoldfishDragDropProvider, 11 + } from "../GoldfishDragDropProvider"; 12 + import { GoldfishBattlefield } from "./GoldfishBattlefield"; 13 + import { GoldfishHand } from "./GoldfishHand"; 14 + import { GoldfishSidebar } from "./GoldfishSidebar"; 15 + 16 + interface GoldfishBoardProps { 17 + deck: DeckCard[]; 18 + cardLookup: (id: ScryfallId) => Card | undefined; 19 + startingLife?: number; 20 + } 21 + 22 + export function GoldfishBoard({ 23 + deck, 24 + cardLookup, 25 + startingLife = 20, 26 + }: GoldfishBoardProps) { 27 + const { state, actions, SeedEmbed } = useGoldfishGame(deck, { 28 + startingLife, 29 + cardLookup, 30 + }); 31 + 32 + const battlefieldRef = useRef<HTMLDivElement>(null); 33 + 34 + const handleDragEnd = useCallback( 35 + (event: DragEndEvent, lastPosition: DragPosition | null) => { 36 + const { active, over, delta } = event; 37 + 38 + if (!over) return; 39 + 40 + const cardData = active.data.current as 41 + | { instance: CardInstance; fromLibrary?: boolean } 42 + | undefined; 43 + if (!cardData?.instance) return; 44 + 45 + const instance = cardData.instance; 46 + const instanceId = instance.instanceId; 47 + const fromLibrary = cardData.fromLibrary ?? false; 48 + const targetZone = (over.data.current as { zone: Zone } | undefined) 49 + ?.zone; 50 + 51 + if (!targetZone) return; 52 + 53 + if (targetZone === "battlefield") { 54 + const battlefieldRect = battlefieldRef.current?.getBoundingClientRect(); 55 + 56 + if (battlefieldRect) { 57 + let x: number; 58 + let y: number; 59 + 60 + if (instance.position) { 61 + // Card is already on battlefield - just add delta 62 + x = instance.position.x + delta.x; 63 + y = instance.position.y + delta.y; 64 + } else if (lastPosition?.translated) { 65 + // Card coming from another zone - use tracked translated position 66 + // (rect.current.translated is null in onDragEnd, so we track it in onDragMove) 67 + x = lastPosition.translated.left - battlefieldRect.left; 68 + y = lastPosition.translated.top - battlefieldRect.top; 69 + } else { 70 + // Fallback: center of battlefield 71 + x = battlefieldRect.width / 2; 72 + y = battlefieldRect.height / 2; 73 + } 74 + 75 + // Only force face-down if from library AND not already revealed 76 + const faceDown = 77 + fromLibrary && instance.isFaceDown ? true : undefined; 78 + actions.moveCard(instanceId, targetZone, { 79 + position: { x, y }, 80 + faceDown, 81 + }); 82 + } else { 83 + const faceDown = 84 + fromLibrary && instance.isFaceDown ? true : undefined; 85 + actions.moveCard(instanceId, targetZone, { faceDown }); 86 + } 87 + } else { 88 + // From library: hand = always reveal, other = preserve state 89 + const faceDown = 90 + targetZone === "hand" 91 + ? false 92 + : fromLibrary && instance.isFaceDown 93 + ? true 94 + : undefined; 95 + actions.moveCard(instanceId, targetZone, { faceDown }); 96 + } 97 + }, 98 + [actions], 99 + ); 100 + 101 + const hoveredCard = state.hoveredId 102 + ? [ 103 + ...state.hand, 104 + ...state.battlefield, 105 + ...state.graveyard, 106 + ...state.exile, 107 + ...state.library.slice(0, 1), 108 + ].find((c) => c.instanceId === state.hoveredId) 109 + : null; 110 + 111 + const hoveredCardData = hoveredCard ? cardLookup(hoveredCard.cardId) : null; 112 + 113 + return ( 114 + <GoldfishDragDropProvider onDragEnd={handleDragEnd}> 115 + <SeedEmbed /> 116 + <div className="flex h-full gap-4 p-4 bg-white dark:bg-slate-950"> 117 + {/* Left: Card Preview */} 118 + <div className="w-64 flex-shrink-0"> 119 + {hoveredCardData && !hoveredCard?.isFaceDown ? ( 120 + <CardImage 121 + card={hoveredCardData} 122 + size="large" 123 + className="w-full aspect-[5/7] rounded-lg shadow-lg" 124 + isFlipped={ 125 + hoveredCard?.faceIndex ? hoveredCard.faceIndex > 0 : false 126 + } 127 + /> 128 + ) : ( 129 + <div className="w-full aspect-[5/7] rounded-lg bg-gray-100 dark:bg-slate-800 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm"> 130 + {hoveredCard?.isFaceDown ? "Face down" : "Hover a card"} 131 + </div> 132 + )} 133 + </div> 134 + 135 + {/* Center: Battlefield + Hand */} 136 + <div className="flex-1 flex flex-col gap-4 min-w-0"> 137 + <GoldfishBattlefield 138 + ref={battlefieldRef} 139 + cards={state.battlefield} 140 + cardLookup={cardLookup} 141 + onHover={actions.setHoveredCard} 142 + onClick={actions.toggleTap} 143 + /> 144 + <GoldfishHand 145 + cards={state.hand} 146 + cardLookup={cardLookup} 147 + onHover={actions.setHoveredCard} 148 + onClick={(id) => actions.moveCard(id, "battlefield")} 149 + /> 150 + </div> 151 + 152 + {/* Right: Sidebar */} 153 + <GoldfishSidebar 154 + library={state.library} 155 + graveyard={state.graveyard} 156 + exile={state.exile} 157 + player={state.player} 158 + cardLookup={cardLookup} 159 + onHover={actions.setHoveredCard} 160 + onClick={actions.toggleTap} 161 + onDraw={actions.draw} 162 + onUntapAll={actions.untapAll} 163 + onMulligan={actions.mulligan} 164 + onReset={actions.reset} 165 + onAdjustLife={actions.adjustLife} 166 + onAdjustPoison={actions.adjustPoison} 167 + /> 168 + </div> 169 + </GoldfishDragDropProvider> 170 + ); 171 + }
+88
src/components/deck/goldfish/GoldfishCard.tsx
··· 1 + import { useDraggable } from "@dnd-kit/core"; 2 + import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; 3 + import type { CardInstance } from "@/lib/goldfish/types"; 4 + import type { Card } from "@/lib/scryfall-types"; 5 + import { getImageUri } from "@/lib/scryfall-utils"; 6 + 7 + interface GoldfishCardProps { 8 + instance: CardInstance; 9 + card?: Card; 10 + onHover?: (instanceId: number | null) => void; 11 + onClick?: (instanceId: number) => void; 12 + size?: "tiny" | "small" | "normal"; 13 + positioning?: "relative" | "absolute"; 14 + className?: string; 15 + style?: React.CSSProperties; 16 + fromLibrary?: boolean; 17 + } 18 + 19 + export function GoldfishCard({ 20 + instance, 21 + card, 22 + onHover, 23 + onClick, 24 + size = "normal", 25 + positioning = "relative", 26 + className = "", 27 + style, 28 + fromLibrary = false, 29 + }: GoldfishCardProps) { 30 + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ 31 + id: fromLibrary 32 + ? `library-top-${instance.instanceId}` 33 + : `card-${instance.instanceId}`, 34 + data: { instance, fromLibrary }, 35 + }); 36 + 37 + const isFlipped = instance.faceIndex > 0; 38 + const imageSize = size === "normal" ? "normal" : "small"; 39 + const imageSrc = instance.isFaceDown 40 + ? null 41 + : getImageUri(instance.cardId, imageSize, isFlipped ? "back" : "front"); 42 + 43 + const counterEntries = Object.entries(instance.counters); 44 + const sizeClass = 45 + size === "tiny" ? "h-14" : size === "small" ? "h-24" : "h-40"; 46 + 47 + return ( 48 + <button 49 + type="button" 50 + ref={setNodeRef} 51 + className={`${positioning} select-none w-fit ${className} ${isDragging ? "opacity-0" : ""} ${instance.isTapped ? "rotate-90" : ""}`} 52 + style={style} 53 + onMouseEnter={() => onHover?.(instance.instanceId)} 54 + onMouseLeave={() => onHover?.(null)} 55 + onClick={() => onClick?.(instance.instanceId)} 56 + {...listeners} 57 + {...attributes} 58 + > 59 + {imageSrc ? ( 60 + <img 61 + src={imageSrc} 62 + alt={card?.name ?? "Card"} 63 + className={`rounded-[4.75%/3.5%] bg-gray-200 dark:bg-slate-700 ${sizeClass} aspect-[5/7]`} 64 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 65 + draggable={false} 66 + loading="lazy" 67 + /> 68 + ) : ( 69 + <div 70 + className={`rounded-[4.75%/3.5%] bg-amber-700 ${sizeClass} aspect-[5/7]`} 71 + /> 72 + )} 73 + {counterEntries.length > 0 && ( 74 + <div className="absolute bottom-1 left-1 flex flex-wrap gap-1 max-w-full"> 75 + {counterEntries.map(([type, count]) => ( 76 + <span 77 + key={type} 78 + className="px-1.5 py-0.5 text-xs font-bold rounded bg-black/70 text-white" 79 + title={type} 80 + > 81 + {type === "+1/+1" ? `+${count}/+${count}` : `${count}`} 82 + </span> 83 + ))} 84 + </div> 85 + )} 86 + </button> 87 + ); 88 + }
+51
src/components/deck/goldfish/GoldfishHand.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import type { CardInstance } from "@/lib/goldfish/types"; 3 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 4 + import { GoldfishCard } from "./GoldfishCard"; 5 + 6 + interface GoldfishHandProps { 7 + cards: CardInstance[]; 8 + cardLookup?: (id: ScryfallId) => Card | undefined; 9 + onHover?: (instanceId: number | null) => void; 10 + onClick?: (instanceId: number) => void; 11 + } 12 + 13 + export function GoldfishHand({ 14 + cards, 15 + cardLookup, 16 + onHover, 17 + onClick, 18 + }: GoldfishHandProps) { 19 + const { setNodeRef, isOver } = useDroppable({ 20 + id: "zone-hand", 21 + data: { zone: "hand" }, 22 + }); 23 + 24 + return ( 25 + <div 26 + ref={setNodeRef} 27 + className={`flex gap-2 overflow-x-auto p-2 min-h-[11rem] rounded-lg border-2 border-dashed transition-colors ${ 28 + isOver 29 + ? "border-blue-500 bg-blue-500/10" 30 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 31 + }`} 32 + > 33 + {cards.length === 0 ? ( 34 + <div className="flex items-center justify-center w-full text-gray-400 dark:text-gray-500 text-sm"> 35 + Hand is empty 36 + </div> 37 + ) : ( 38 + cards.map((instance) => ( 39 + <GoldfishCard 40 + key={instance.instanceId} 41 + instance={instance} 42 + card={cardLookup?.(instance.cardId)} 43 + onHover={onHover} 44 + onClick={onClick} 45 + className="flex-shrink-0" 46 + /> 47 + )) 48 + )} 49 + </div> 50 + ); 51 + }
+93
src/components/deck/goldfish/GoldfishPile.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import { ChevronDown, ChevronUp } from "lucide-react"; 3 + import { useState } from "react"; 4 + import type { CardInstance, Zone } from "@/lib/goldfish/types"; 5 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 6 + import { GoldfishCard } from "./GoldfishCard"; 7 + 8 + interface GoldfishPileProps { 9 + zone: Zone; 10 + label: string; 11 + cards: CardInstance[]; 12 + cardLookup?: (id: ScryfallId) => Card | undefined; 13 + onHover?: (instanceId: number | null) => void; 14 + onClick?: (instanceId: number) => void; 15 + } 16 + 17 + export function GoldfishPile({ 18 + zone, 19 + label, 20 + cards, 21 + cardLookup, 22 + onHover, 23 + onClick, 24 + }: GoldfishPileProps) { 25 + const [expanded, setExpanded] = useState(false); 26 + 27 + const { setNodeRef, isOver } = useDroppable({ 28 + id: `zone-${zone}`, 29 + data: { zone }, 30 + }); 31 + 32 + return ( 33 + <div 34 + ref={setNodeRef} 35 + className={`rounded-lg border-2 border-dashed transition-colors ${ 36 + isOver 37 + ? "border-purple-500 bg-purple-500/10" 38 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 39 + }`} 40 + > 41 + <button 42 + type="button" 43 + onClick={() => setExpanded(!expanded)} 44 + className="w-full flex items-center justify-between p-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-t-lg" 45 + > 46 + <span> 47 + {label} ({cards.length}) 48 + </span> 49 + {expanded ? ( 50 + <ChevronUp className="w-4 h-4" /> 51 + ) : ( 52 + <ChevronDown className="w-4 h-4" /> 53 + )} 54 + </button> 55 + {expanded && cards.length > 0 && ( 56 + <div className="p-2 pt-0 grid grid-cols-3 gap-1 max-h-36 overflow-y-auto"> 57 + {cards.map((instance) => ( 58 + <GoldfishCard 59 + key={instance.instanceId} 60 + instance={instance} 61 + card={cardLookup?.(instance.cardId)} 62 + onHover={onHover} 63 + onClick={onClick} 64 + size="tiny" 65 + /> 66 + ))} 67 + </div> 68 + )} 69 + {!expanded && cards.length > 0 && ( 70 + <div className="p-2 pt-0"> 71 + <div className="relative h-14 w-10"> 72 + {cards.slice(-3).map((instance, i) => ( 73 + <GoldfishCard 74 + key={instance.instanceId} 75 + instance={instance} 76 + card={cardLookup?.(instance.cardId)} 77 + onHover={onHover} 78 + onClick={onClick} 79 + size="tiny" 80 + positioning="absolute" 81 + style={{ 82 + top: i * 2, 83 + left: i * 2, 84 + zIndex: i, 85 + }} 86 + /> 87 + ))} 88 + </div> 89 + </div> 90 + )} 91 + </div> 92 + ); 93 + }
+181
src/components/deck/goldfish/GoldfishSidebar.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import { Droplet, Minus, Plus, RefreshCw, RotateCcw } from "lucide-react"; 3 + import type { CardInstance, PlayerState } from "@/lib/goldfish/types"; 4 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 5 + import { GoldfishCard } from "./GoldfishCard"; 6 + import { GoldfishPile } from "./GoldfishPile"; 7 + 8 + interface GoldfishSidebarProps { 9 + library: CardInstance[]; 10 + graveyard: CardInstance[]; 11 + exile: CardInstance[]; 12 + player: PlayerState; 13 + cardLookup?: (id: ScryfallId) => Card | undefined; 14 + onHover?: (instanceId: number | null) => void; 15 + onClick?: (instanceId: number) => void; 16 + onDraw: () => void; 17 + onUntapAll: () => void; 18 + onMulligan: () => void; 19 + onReset: () => void; 20 + onAdjustLife: (amount: number) => void; 21 + onAdjustPoison: (amount: number) => void; 22 + } 23 + 24 + export function GoldfishSidebar({ 25 + library, 26 + graveyard, 27 + exile, 28 + player, 29 + cardLookup, 30 + onHover, 31 + onClick, 32 + onDraw, 33 + onUntapAll, 34 + onMulligan, 35 + onReset, 36 + onAdjustLife, 37 + onAdjustPoison, 38 + }: GoldfishSidebarProps) { 39 + const { setNodeRef: setLibraryRef, isOver: isOverLibrary } = useDroppable({ 40 + id: "zone-library", 41 + data: { zone: "library" }, 42 + }); 43 + 44 + return ( 45 + <div className="w-48 flex flex-col gap-3 p-2 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-y-auto overflow-x-hidden"> 46 + {/* Library */} 47 + <div 48 + ref={setLibraryRef} 49 + className={`rounded-lg border-2 border-dashed p-2 transition-colors ${ 50 + isOverLibrary 51 + ? "border-green-500 bg-green-500/10" 52 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 53 + }`} 54 + > 55 + <div className="flex items-center justify-between mb-2"> 56 + <span className="text-sm font-medium text-gray-700 dark:text-gray-300"> 57 + Library ({library.length}) 58 + </span> 59 + <span className="text-xs text-gray-500 dark:text-gray-400"> 60 + D to draw 61 + </span> 62 + </div> 63 + {library.length > 0 ? ( 64 + <GoldfishCard 65 + instance={library[0]} 66 + card={cardLookup?.(library[0].cardId)} 67 + onHover={onHover} 68 + onClick={onDraw} 69 + fromLibrary 70 + /> 71 + ) : ( 72 + <div className="h-40 aspect-[5/7] rounded-[4.75%/3.5%] border-2 border-dashed border-gray-300 dark:border-slate-600" /> 73 + )} 74 + </div> 75 + 76 + {/* Graveyard */} 77 + <GoldfishPile 78 + zone="graveyard" 79 + label="Graveyard" 80 + cards={graveyard} 81 + cardLookup={cardLookup} 82 + onHover={onHover} 83 + onClick={onClick} 84 + /> 85 + 86 + {/* Exile */} 87 + <GoldfishPile 88 + zone="exile" 89 + label="Exile" 90 + cards={exile} 91 + cardLookup={cardLookup} 92 + onHover={onHover} 93 + onClick={onClick} 94 + /> 95 + 96 + {/* Player Stats */} 97 + <div className="space-y-2"> 98 + {/* Life */} 99 + <div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-800"> 100 + <button 101 + type="button" 102 + onClick={() => onAdjustLife(-1)} 103 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 104 + aria-label="Decrease life" 105 + > 106 + <Minus className="w-4 h-4" /> 107 + </button> 108 + <span className="text-lg font-bold text-gray-700 dark:text-gray-200"> 109 + {player.life} 110 + </span> 111 + <button 112 + type="button" 113 + onClick={() => onAdjustLife(1)} 114 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 115 + aria-label="Increase life" 116 + > 117 + <Plus className="w-4 h-4" /> 118 + </button> 119 + </div> 120 + 121 + {/* Poison */} 122 + <div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-800"> 123 + <button 124 + type="button" 125 + onClick={() => onAdjustPoison(-1)} 126 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 127 + aria-label="Decrease poison" 128 + > 129 + <Minus className="w-4 h-4" /> 130 + </button> 131 + <span className="flex items-center gap-1 text-lg font-bold text-green-600 dark:text-green-400"> 132 + <Droplet className="w-4 h-4" /> 133 + {player.poison} 134 + </span> 135 + <button 136 + type="button" 137 + onClick={() => onAdjustPoison(1)} 138 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 139 + aria-label="Increase poison" 140 + > 141 + <Plus className="w-4 h-4" /> 142 + </button> 143 + </div> 144 + </div> 145 + 146 + {/* Actions */} 147 + <div className="space-y-2"> 148 + <button 149 + type="button" 150 + onClick={onUntapAll} 151 + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded bg-blue-600 text-white hover:bg-blue-700" 152 + > 153 + <RotateCcw className="w-4 h-4" /> 154 + Untap All (U) 155 + </button> 156 + <button 157 + type="button" 158 + onClick={onMulligan} 159 + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-slate-600" 160 + > 161 + <RefreshCw className="w-4 h-4" /> 162 + Mulligan 163 + </button> 164 + <button 165 + type="button" 166 + onClick={onReset} 167 + className="w-full px-3 py-2 text-sm font-medium rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50" 168 + > 169 + Reset Game 170 + </button> 171 + </div> 172 + 173 + {/* Keyboard hints */} 174 + <div className="text-xs text-gray-400 dark:text-gray-500 space-y-1"> 175 + <p>T/Space: tap • F: flip</p> 176 + <p>G: graveyard • E: exile</p> 177 + <p>H: hand • B: play</p> 178 + </div> 179 + </div> 180 + ); 181 + }
+6
src/components/deck/goldfish/index.ts
··· 1 + export { GoldfishBattlefield } from "./GoldfishBattlefield"; 2 + export { GoldfishBoard } from "./GoldfishBoard"; 3 + export { GoldfishCard } from "./GoldfishCard"; 4 + export { GoldfishHand } from "./GoldfishHand"; 5 + export { GoldfishPile } from "./GoldfishPile"; 6 + export { GoldfishSidebar } from "./GoldfishSidebar";
+444
src/components/deck/stats/ManaBreakdown.tsx
··· 1 + import { CardSymbol } from "@/components/CardSymbol"; 2 + import type { ManaSymbolsData } from "@/lib/deck-stats"; 3 + import type { ManaColorWithColorless } from "@/lib/scryfall-types"; 4 + import type { ManaSelectionType, StatsSelection } from "@/lib/stats-selection"; 5 + 6 + interface ManaBreakdownProps { 7 + data: ManaSymbolsData[]; 8 + selection: StatsSelection; 9 + onSelect: (selection: StatsSelection) => void; 10 + } 11 + 12 + const COLOR_ORDER: ManaColorWithColorless[] = ["W", "U", "B", "R", "G", "C"]; 13 + 14 + const COLOR_NAMES: Record<ManaColorWithColorless, string> = { 15 + W: "White", 16 + U: "Blue", 17 + B: "Black", 18 + R: "Red", 19 + G: "Green", 20 + C: "Colorless", 21 + }; 22 + 23 + export function ManaBreakdown({ 24 + data, 25 + selection, 26 + onSelect, 27 + }: ManaBreakdownProps) { 28 + const byColor = new Map(data.map((d) => [d.color, d])); 29 + 30 + const activeColors = COLOR_ORDER.filter((c) => { 31 + const d = byColor.get(c); 32 + return d && (d.symbolCount > 0 || d.sourceCount > 0); 33 + }); 34 + 35 + if (activeColors.length === 0) { 36 + return null; 37 + } 38 + 39 + const totalImmediate = data.reduce((s, d) => s + d.immediateSourceCount, 0); 40 + const totalSources = data.reduce((s, d) => s + d.sourceCount, 0); 41 + const totalSymbols = data.reduce((s, d) => s + d.symbolCount, 0); 42 + const immediatePercent = 43 + totalSources > 0 ? Math.round((totalImmediate / totalSources) * 100) : 0; 44 + 45 + // Use totalLandCount from any color entry (they all have the same value) 46 + const totalLands = data[0]?.totalLandCount ?? 0; 47 + 48 + const isSelected = (color: ManaColorWithColorless, type: ManaSelectionType) => 49 + selection?.chart === "mana" && 50 + selection.color === color && 51 + selection.type === type; 52 + 53 + return ( 54 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700 col-span-full xl:col-span-2"> 55 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 56 + Mana Breakdown 57 + </h3> 58 + 59 + {/* Symbols, pips, sparklines, tempo */} 60 + <div 61 + className="grid gap-4" 62 + style={{ 63 + gridTemplateColumns: `repeat(${activeColors.length}, minmax(0, 1fr))`, 64 + }} 65 + > 66 + {activeColors.map((color) => { 67 + const d = byColor.get(color); 68 + if (!d) return null; 69 + return ( 70 + <ManaColumn 71 + key={color} 72 + data={d} 73 + colorName={COLOR_NAMES[color]} 74 + totalSymbols={totalSymbols} 75 + totalSources={totalSources} 76 + isSelected={(type) => isSelected(color, type)} 77 + onSelect={(type) => onSelect({ chart: "mana", color, type })} 78 + /> 79 + ); 80 + })} 81 + </div> 82 + 83 + {/* Land Production section */} 84 + {totalLands > 0 && ( 85 + <div className="mt-4 pt-3 border-t border-gray-200 dark:border-slate-700"> 86 + <div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2"> 87 + Land Production ({totalLands} lands) 88 + </div> 89 + <div 90 + className="grid gap-4" 91 + style={{ 92 + gridTemplateColumns: `repeat(${activeColors.length}, minmax(0, 1fr))`, 93 + }} 94 + > 95 + {activeColors.map((color) => { 96 + const d = byColor.get(color); 97 + if (!d) return null; 98 + return ( 99 + <LandBar 100 + key={color} 101 + data={d} 102 + colorName={COLOR_NAMES[color]} 103 + totalLands={totalLands} 104 + isSelected={isSelected(color, "land")} 105 + onSelect={() => 106 + onSelect({ chart: "mana", color, type: "land" }) 107 + } 108 + /> 109 + ); 110 + })} 111 + </div> 112 + </div> 113 + )} 114 + 115 + {/* Legend */} 116 + <div className="mt-4 pt-3 border-t border-gray-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-2"> 117 + <div className="flex gap-3 text-xs"> 118 + <span 119 + className="flex items-center gap-1 cursor-help" 120 + title="Untapped lands, fast mana rocks, shocklands (pay life = your choice)" 121 + > 122 + <span className="w-2 h-2 rounded-full bg-emerald-500" /> 123 + <span className="text-gray-600 dark:text-gray-400">Immediate</span> 124 + </span> 125 + <span 126 + className="flex items-center gap-1 cursor-help" 127 + title="Check lands, fast lands, battle lands - may enter tapped based on game state" 128 + > 129 + <span className="w-2 h-2 rounded-full bg-sky-500" /> 130 + <span className="text-gray-600 dark:text-gray-400"> 131 + Conditional 132 + </span> 133 + </span> 134 + <span 135 + className="flex items-center gap-1 cursor-help" 136 + title="Tap lands, mana dorks (summoning sickness)" 137 + > 138 + <span className="w-2 h-2 rounded-full bg-rose-500" /> 139 + <span className="text-gray-600 dark:text-gray-400">Delayed</span> 140 + </span> 141 + <span 142 + className="flex items-center gap-1 cursor-help" 143 + title="Bouncelands - enter tapped and return a land" 144 + > 145 + <span className="w-2 h-2 rounded-full bg-violet-500" /> 146 + <span className="text-gray-600 dark:text-gray-400">Bounce</span> 147 + </span> 148 + </div> 149 + {totalSources > 0 && ( 150 + <div 151 + className="text-xs text-gray-600 dark:text-gray-400 cursor-help" 152 + title={`${totalImmediate} of ${totalSources} sources can produce mana the turn they enter`} 153 + > 154 + {immediatePercent}% of sources produce mana immediately 155 + </div> 156 + )} 157 + </div> 158 + </div> 159 + ); 160 + } 161 + 162 + interface ManaColumnProps { 163 + data: ManaSymbolsData; 164 + colorName: string; 165 + totalSymbols: number; 166 + totalSources: number; 167 + isSelected: (type: ManaSelectionType) => boolean; 168 + onSelect: (type: ManaSelectionType) => void; 169 + } 170 + 171 + function ManaColumn({ 172 + data, 173 + colorName, 174 + totalSymbols, 175 + totalSources, 176 + isSelected, 177 + onSelect, 178 + }: ManaColumnProps) { 179 + const sourceCount = data.sourceCount; 180 + const immediatePercent = 181 + sourceCount > 0 ? (data.immediateSourceCount / sourceCount) * 100 : 0; 182 + const conditionalPercent = 183 + sourceCount > 0 ? (data.conditionalSourceCount / sourceCount) * 100 : 0; 184 + const delayedPercent = 185 + sourceCount > 0 ? (data.delayedSourceCount / sourceCount) * 100 : 0; 186 + const bouncePercent = 187 + sourceCount > 0 ? (data.bounceSourceCount / sourceCount) * 100 : 0; 188 + 189 + const hasSymbols = data.symbolCount > 0; 190 + const greyedOut = !hasSymbols; 191 + 192 + return ( 193 + <div 194 + className={`flex flex-col items-center gap-1 ${greyedOut ? "opacity-40" : ""}`} 195 + > 196 + <button 197 + type="button" 198 + onClick={() => onSelect("symbol")} 199 + disabled={!hasSymbols} 200 + title={ 201 + hasSymbols 202 + ? `${data.symbolCount} ${colorName.toLowerCase()} pips in card costs` 203 + : `No ${colorName.toLowerCase()} pips in deck` 204 + } 205 + className={`p-1 rounded transition-all ${ 206 + isSelected("symbol") 207 + ? "ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30" 208 + : hasSymbols 209 + ? "hover:bg-gray-100 dark:hover:bg-slate-800" 210 + : "cursor-default" 211 + }`} 212 + > 213 + <div className="rounded-full ring-1 ring-black/20 dark:ring-transparent"> 214 + <CardSymbol symbol={data.color} size="large" /> 215 + </div> 216 + </button> 217 + 218 + <div 219 + className="text-lg font-semibold text-gray-900 dark:text-white cursor-help" 220 + title={ 221 + totalSymbols > 0 222 + ? `${data.symbolCount} of ${totalSymbols} total pips are ${colorName.toLowerCase()}` 223 + : "No pips in deck" 224 + } 225 + > 226 + {Math.round(data.symbolPercent)}% 227 + </div> 228 + <div 229 + className="text-xs text-gray-500 dark:text-gray-400 cursor-help" 230 + title={`${data.symbolCount} ${colorName.toLowerCase()} mana symbols in card costs`} 231 + > 232 + {data.symbolCount} pips 233 + </div> 234 + 235 + <Sparkline distribution={data.symbolDistribution} color={data.color} /> 236 + 237 + {sourceCount > 0 && ( 238 + <div className="w-full mt-2"> 239 + <div 240 + className="flex h-3 rounded-md overflow-hidden bg-gray-200 dark:bg-slate-700 cursor-help" 241 + title={`${sourceCount} sources produce ${colorName.toLowerCase()} mana (${Math.round((sourceCount / totalSources) * 100)}% of all sources)`} 242 + > 243 + {data.immediateSourceCount > 0 && ( 244 + <button 245 + type="button" 246 + className={`bg-emerald-500 transition-opacity ${ 247 + isSelected("immediate") 248 + ? "opacity-100" 249 + : "opacity-80 hover:opacity-100" 250 + }`} 251 + style={{ width: `${immediatePercent}%` }} 252 + title={`Immediate: ${data.immediateSourceCount} of ${sourceCount} ${colorName.toLowerCase()} sources (${Math.round(immediatePercent)}%)\nCan produce mana the turn they enter`} 253 + onClick={() => onSelect("immediate")} 254 + /> 255 + )} 256 + {data.conditionalSourceCount > 0 && ( 257 + <button 258 + type="button" 259 + className={`bg-sky-500 transition-opacity ${ 260 + isSelected("conditional") 261 + ? "opacity-100" 262 + : "opacity-80 hover:opacity-100" 263 + }`} 264 + style={{ width: `${conditionalPercent}%` }} 265 + title={`Conditional: ${data.conditionalSourceCount} of ${sourceCount} ${colorName.toLowerCase()} sources (${Math.round(conditionalPercent)}%)\nMay enter tapped depending on game state`} 266 + onClick={() => onSelect("conditional")} 267 + /> 268 + )} 269 + {data.delayedSourceCount > 0 && ( 270 + <button 271 + type="button" 272 + className={`bg-rose-500 transition-opacity ${ 273 + isSelected("delayed") 274 + ? "opacity-100" 275 + : "opacity-80 hover:opacity-100" 276 + }`} 277 + style={{ width: `${delayedPercent}%` }} 278 + title={`Delayed: ${data.delayedSourceCount} of ${sourceCount} ${colorName.toLowerCase()} sources (${Math.round(delayedPercent)}%)\nEnter tapped or have summoning sickness`} 279 + onClick={() => onSelect("delayed")} 280 + /> 281 + )} 282 + {data.bounceSourceCount > 0 && ( 283 + <button 284 + type="button" 285 + className={`bg-violet-500 transition-opacity ${ 286 + isSelected("bounce") 287 + ? "opacity-100" 288 + : "opacity-80 hover:opacity-100" 289 + }`} 290 + style={{ width: `${bouncePercent}%` }} 291 + title={`Bounce: ${data.bounceSourceCount} of ${sourceCount} ${colorName.toLowerCase()} sources (${Math.round(bouncePercent)}%)\nEnter tapped and return a land`} 292 + onClick={() => onSelect("bounce")} 293 + /> 294 + )} 295 + </div> 296 + <div 297 + className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1 cursor-help" 298 + title={`${sourceCount} cards produce ${colorName.toLowerCase()} mana`} 299 + > 300 + {sourceCount} sources 301 + </div> 302 + </div> 303 + )} 304 + </div> 305 + ); 306 + } 307 + 308 + interface LandBarProps { 309 + data: ManaSymbolsData; 310 + colorName: string; 311 + totalLands: number; 312 + isSelected: boolean; 313 + onSelect: () => void; 314 + } 315 + 316 + const MANA_BAR_COLORS: Record<ManaColorWithColorless, string> = { 317 + W: "bg-amber-200", 318 + U: "bg-blue-400", 319 + B: "bg-gray-600", 320 + R: "bg-red-400", 321 + G: "bg-green-400", 322 + C: "bg-gray-400", 323 + }; 324 + 325 + function LandBar({ 326 + data, 327 + colorName, 328 + totalLands, 329 + isSelected, 330 + onSelect, 331 + }: LandBarProps) { 332 + const greyedOut = data.symbolCount === 0; 333 + const landCount = data.landSourceCount; 334 + 335 + // Calculate tempo percentages for this color's lands 336 + const immediatePercent = 337 + landCount > 0 ? (data.landImmediateCount / landCount) * 100 : 0; 338 + const conditionalPercent = 339 + landCount > 0 ? (data.landConditionalCount / landCount) * 100 : 0; 340 + const delayedPercent = 341 + landCount > 0 ? (data.landDelayedCount / landCount) * 100 : 0; 342 + const bouncePercent = 343 + landCount > 0 ? (data.landBounceCount / landCount) * 100 : 0; 344 + 345 + return ( 346 + <div 347 + className={`flex flex-col items-center gap-1 ${greyedOut ? "opacity-40" : ""}`} 348 + > 349 + {/* Coverage bar - mana colored */} 350 + <button 351 + type="button" 352 + className={`relative w-full h-4 rounded overflow-hidden bg-gray-200 dark:bg-slate-700 transition-opacity ${ 353 + isSelected ? "ring-2 ring-blue-500" : "hover:opacity-90" 354 + }`} 355 + onClick={onSelect} 356 + title={`${landCount} of ${totalLands} lands produce ${colorName.toLowerCase()}`} 357 + > 358 + <div 359 + className={`h-full ${MANA_BAR_COLORS[data.color]}`} 360 + style={{ width: `${data.landSourcePercent}%` }} 361 + /> 362 + <span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300"> 363 + {Math.round(data.landSourcePercent)}% 364 + </span> 365 + </button> 366 + 367 + {/* Tempo bar - smaller, shows quality of those lands */} 368 + {landCount > 0 && ( 369 + <div 370 + className="w-full h-1.5 rounded-full overflow-hidden bg-gray-200 dark:bg-slate-700 flex cursor-help" 371 + title={`Land tempo: ${data.landImmediateCount} immediate, ${data.landConditionalCount} conditional, ${data.landDelayedCount} delayed, ${data.landBounceCount} bounce`} 372 + > 373 + {data.landImmediateCount > 0 && ( 374 + <div 375 + className="h-full bg-emerald-500" 376 + style={{ width: `${immediatePercent}%` }} 377 + /> 378 + )} 379 + {data.landConditionalCount > 0 && ( 380 + <div 381 + className="h-full bg-sky-500" 382 + style={{ width: `${conditionalPercent}%` }} 383 + /> 384 + )} 385 + {data.landDelayedCount > 0 && ( 386 + <div 387 + className="h-full bg-rose-500" 388 + style={{ width: `${delayedPercent}%` }} 389 + /> 390 + )} 391 + {data.landBounceCount > 0 && ( 392 + <div 393 + className="h-full bg-violet-500" 394 + style={{ width: `${bouncePercent}%` }} 395 + /> 396 + )} 397 + </div> 398 + )} 399 + 400 + <div 401 + className="text-xs text-gray-500 dark:text-gray-400 text-center cursor-help" 402 + title={`${Math.round(data.landProductionPercent)}% of your lands' total mana production is ${colorName.toLowerCase()}`} 403 + > 404 + {Math.round(data.landProductionPercent)}% of symbols on lands 405 + </div> 406 + </div> 407 + ); 408 + } 409 + 410 + function Sparkline({ 411 + distribution, 412 + color, 413 + }: { 414 + distribution: { bucket: string; count: number }[]; 415 + color: ManaColorWithColorless; 416 + }) { 417 + const maxCount = Math.max(...distribution.map((d) => d.count), 1); 418 + const total = distribution.reduce((s, d) => s + d.count, 0); 419 + 420 + const barColor = { 421 + W: "bg-amber-200", 422 + U: "bg-blue-400", 423 + B: "bg-gray-600", 424 + R: "bg-red-400", 425 + G: "bg-green-400", 426 + C: "bg-gray-400", 427 + }[color]; 428 + 429 + return ( 430 + <div className="flex items-end h-6 w-full"> 431 + {distribution.map((d, i) => ( 432 + <div 433 + key={d.bucket} 434 + className={`flex-1 ${barColor} rounded-t-sm cursor-help ${i < distribution.length - 1 ? "border-r-2 border-black/30 dark:border-black/50" : ""}`} 435 + style={{ 436 + height: `${(d.count / maxCount) * 100}%`, 437 + minHeight: d.count > 0 ? "2px" : "0", 438 + }} 439 + title={`MV ${d.bucket}: ${d.count} pips${total > 0 ? ` (${Math.round((d.count / total) * 100)}%)` : ""}`} 440 + /> 441 + ))} 442 + </div> 443 + ); 444 + }
+133
src/components/deck/stats/ManaCurveChart.tsx
··· 1 + import { 2 + Bar, 3 + BarChart, 4 + Cell, 5 + ResponsiveContainer, 6 + XAxis, 7 + YAxis, 8 + } from "recharts"; 9 + import type { ManaCurveData } from "@/lib/deck-stats"; 10 + import type { StatsSelection } from "@/lib/stats-selection"; 11 + 12 + interface ManaCurveChartProps { 13 + data: ManaCurveData[]; 14 + selection: StatsSelection; 15 + onSelect: (selection: StatsSelection) => void; 16 + } 17 + 18 + const COLORS = { 19 + permanent: "#22C55E", 20 + spell: "#3B82F6", 21 + permanentHover: "#16A34A", 22 + spellHover: "#2563EB", 23 + }; 24 + 25 + export function ManaCurveChart({ 26 + data, 27 + selection, 28 + onSelect, 29 + }: ManaCurveChartProps) { 30 + // Hide 0-CMC bucket when empty (common after excluding lands) 31 + const filteredData = data.filter( 32 + (d) => d.bucket !== "0" || d.permanents > 0 || d.spells > 0, 33 + ); 34 + 35 + const isSelected = (bucket: string, type: "permanent" | "spell") => 36 + selection?.chart === "curve" && 37 + selection.bucket === bucket && 38 + selection.type === type; 39 + 40 + return ( 41 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700"> 42 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 43 + Mana Curve 44 + </h3> 45 + <div className="h-48"> 46 + <ResponsiveContainer width="100%" height="100%"> 47 + <BarChart data={filteredData} barCategoryGap="15%"> 48 + <XAxis 49 + dataKey="bucket" 50 + tick={{ fill: "#9CA3AF", fontSize: 12 }} 51 + axisLine={{ stroke: "#4B5563" }} 52 + tickLine={{ stroke: "#4B5563" }} 53 + /> 54 + <YAxis 55 + tick={{ fill: "#9CA3AF", fontSize: 12 }} 56 + axisLine={{ stroke: "#4B5563" }} 57 + tickLine={{ stroke: "#4B5563" }} 58 + allowDecimals={false} 59 + /> 60 + <Bar 61 + dataKey="permanents" 62 + stackId="stack" 63 + name="Permanents" 64 + cursor="pointer" 65 + > 66 + {filteredData.map((entry) => ( 67 + <Cell 68 + key={`perm-${entry.bucket}`} 69 + fill={ 70 + isSelected(entry.bucket, "permanent") 71 + ? COLORS.permanentHover 72 + : COLORS.permanent 73 + } 74 + onClick={() => 75 + onSelect({ 76 + chart: "curve", 77 + bucket: entry.bucket, 78 + type: "permanent", 79 + }) 80 + } 81 + /> 82 + ))} 83 + </Bar> 84 + <Bar 85 + dataKey="spells" 86 + stackId="stack" 87 + name="Spells" 88 + cursor="pointer" 89 + > 90 + {filteredData.map((entry) => ( 91 + <Cell 92 + key={`spell-${entry.bucket}`} 93 + fill={ 94 + isSelected(entry.bucket, "spell") 95 + ? COLORS.spellHover 96 + : COLORS.spell 97 + } 98 + onClick={() => 99 + onSelect({ 100 + chart: "curve", 101 + bucket: entry.bucket, 102 + type: "spell", 103 + }) 104 + } 105 + /> 106 + ))} 107 + </Bar> 108 + </BarChart> 109 + </ResponsiveContainer> 110 + </div> 111 + <div className="flex justify-center gap-4 mt-2"> 112 + <div className="flex items-center gap-1"> 113 + <div 114 + className="w-3 h-3 rounded" 115 + style={{ backgroundColor: COLORS.permanent }} 116 + /> 117 + <span className="text-xs text-gray-600 dark:text-gray-400"> 118 + Permanents 119 + </span> 120 + </div> 121 + <div className="flex items-center gap-1"> 122 + <div 123 + className="w-3 h-3 rounded" 124 + style={{ backgroundColor: COLORS.spell }} 125 + /> 126 + <span className="text-xs text-gray-600 dark:text-gray-400"> 127 + Spells 128 + </span> 129 + </div> 130 + </div> 131 + </div> 132 + ); 133 + }
+91
src/components/deck/stats/SpeedPieChart.tsx
··· 1 + import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; 2 + import type { SpeedCategory, SpeedData } from "@/lib/deck-stats"; 3 + import type { StatsSelection } from "@/lib/stats-selection"; 4 + 5 + interface SpeedPieChartProps { 6 + data: SpeedData[]; 7 + selection: StatsSelection; 8 + onSelect: (selection: StatsSelection) => void; 9 + } 10 + 11 + const SPEED_COLORS: Record<SpeedCategory, string> = { 12 + instant: "#3B82F6", 13 + sorcery: "#EF4444", 14 + }; 15 + 16 + const SPEED_LABELS: Record<SpeedCategory, string> = { 17 + instant: "Instant Speed", 18 + sorcery: "Sorcery Speed", 19 + }; 20 + 21 + export function SpeedPieChart({ 22 + data, 23 + selection, 24 + onSelect, 25 + }: SpeedPieChartProps) { 26 + const isSelected = (category: SpeedCategory) => 27 + selection?.chart === "speed" && selection.category === category; 28 + 29 + const total = data.reduce((sum, d) => sum + d.count, 0); 30 + 31 + return ( 32 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700"> 33 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 34 + Speed 35 + </h3> 36 + <div className="h-48 flex"> 37 + <div className="flex-1"> 38 + <ResponsiveContainer width="100%" height="100%"> 39 + <PieChart> 40 + <Pie 41 + data={data as unknown as Record<string, unknown>[]} 42 + dataKey="count" 43 + nameKey="category" 44 + cx="50%" 45 + cy="50%" 46 + innerRadius={40} 47 + outerRadius={70} 48 + cursor="pointer" 49 + onClick={(_, index) => 50 + onSelect({ chart: "speed", category: data[index].category }) 51 + } 52 + > 53 + {data.map((entry) => ( 54 + <Cell 55 + key={entry.category} 56 + fill={SPEED_COLORS[entry.category]} 57 + stroke={isSelected(entry.category) ? "#fff" : "transparent"} 58 + strokeWidth={isSelected(entry.category) ? 2 : 0} 59 + /> 60 + ))} 61 + </Pie> 62 + </PieChart> 63 + </ResponsiveContainer> 64 + </div> 65 + <div className="w-28 flex flex-col justify-center gap-2"> 66 + {data.map((entry) => ( 67 + <button 68 + key={entry.category} 69 + type="button" 70 + className="flex items-center gap-1 text-left hover:opacity-80 transition-opacity" 71 + onClick={() => 72 + onSelect({ chart: "speed", category: entry.category }) 73 + } 74 + > 75 + <div 76 + className="w-2 h-2 rounded-full flex-shrink-0" 77 + style={{ backgroundColor: SPEED_COLORS[entry.category] }} 78 + /> 79 + <span className="text-xs text-gray-600 dark:text-gray-400"> 80 + {SPEED_LABELS[entry.category]} 81 + </span> 82 + <span className="text-xs text-gray-500 dark:text-gray-500 ml-auto"> 83 + {total > 0 ? Math.round((entry.count / total) * 100) : 0}% 84 + </span> 85 + </button> 86 + ))} 87 + </div> 88 + </div> 89 + </div> 90 + ); 91 + }
+89
src/components/deck/stats/StatsCardList.tsx
··· 1 + import { useQueries } from "@tanstack/react-query"; 2 + import { ManaCost } from "@/components/ManaCost"; 3 + import { getCastableFaces } from "@/lib/card-faces"; 4 + import type { FacedCard } from "@/lib/deck-stats"; 5 + import type { DeckCard } from "@/lib/deck-types"; 6 + import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; 7 + import type { ScryfallId } from "@/lib/scryfall-types"; 8 + 9 + interface StatsCardListProps { 10 + title: string; 11 + cards: FacedCard[]; 12 + onCardHover: (cardId: ScryfallId | null) => void; 13 + onCardClick?: (card: DeckCard) => void; 14 + } 15 + 16 + export function StatsCardList({ 17 + title, 18 + cards, 19 + onCardHover, 20 + onCardClick, 21 + }: StatsCardListProps) { 22 + // Deduplicate cards (same card+face may appear multiple times for quantity) 23 + // Use card id + face index as unique key 24 + const uniqueCards = cards.reduce<FacedCard[]>((acc, facedCard) => { 25 + const key = `${facedCard.card.scryfallId}-${facedCard.faceIdx}`; 26 + if (!acc.some((c) => `${c.card.scryfallId}-${c.faceIdx}` === key)) { 27 + acc.push(facedCard); 28 + } 29 + return acc; 30 + }, []); 31 + 32 + // Fetch card data for display 33 + const cardMap = useQueries({ 34 + queries: uniqueCards.map((fc) => 35 + getCardByIdQueryOptions(fc.card.scryfallId), 36 + ), 37 + combine: combineCardQueries, 38 + }); 39 + 40 + return ( 41 + <div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-4 min-w-48"> 42 + <h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 43 + {title} 44 + </h4> 45 + {uniqueCards.length === 0 ? ( 46 + <p className="text-sm text-gray-500 dark:text-gray-400 italic"> 47 + No cards 48 + </p> 49 + ) : ( 50 + <ul className="space-y-1"> 51 + {uniqueCards.map((facedCard) => { 52 + const { card: deckCard, faceIdx } = facedCard; 53 + const card = cardMap?.get(deckCard.scryfallId); 54 + 55 + // Get the specific face that matched 56 + const faces = card ? getCastableFaces(card) : []; 57 + const face = faces[faceIdx] ?? faces[0]; 58 + const faceName = face?.name ?? card?.name ?? "Loading..."; 59 + const faceManaCost = face?.mana_cost ?? card?.mana_cost; 60 + 61 + return ( 62 + <li key={`${deckCard.scryfallId}-${faceIdx}`}> 63 + <button 64 + type="button" 65 + className="w-full text-left px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700 transition-colors flex items-center gap-2" 66 + onMouseEnter={() => onCardHover(deckCard.scryfallId)} 67 + onMouseLeave={() => onCardHover(null)} 68 + onClick={() => onCardClick?.(deckCard)} 69 + > 70 + <span className="text-gray-600 dark:text-gray-400 font-mono text-xs w-4 text-right flex-shrink-0"> 71 + {deckCard.quantity} 72 + </span> 73 + <span className="text-gray-900 dark:text-white text-sm truncate flex-1 min-w-0"> 74 + {faceName} 75 + </span> 76 + <div className="flex-shrink-0 flex items-center ml-auto"> 77 + {faceManaCost && ( 78 + <ManaCost cost={faceManaCost} size="small" /> 79 + )} 80 + </div> 81 + </button> 82 + </li> 83 + ); 84 + })} 85 + </ul> 86 + )} 87 + </div> 88 + ); 89 + }
+114
src/components/deck/stats/SubtypesPieChart.tsx
··· 1 + import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; 2 + import type { TypeData } from "@/lib/deck-stats"; 3 + import type { StatsSelection } from "@/lib/stats-selection"; 4 + 5 + interface SubtypesPieChartProps { 6 + data: TypeData[]; 7 + selection: StatsSelection; 8 + onSelect: (selection: StatsSelection) => void; 9 + } 10 + 11 + const COLORS = [ 12 + "#22C55E", 13 + "#3B82F6", 14 + "#EF4444", 15 + "#A855F7", 16 + "#F59E0B", 17 + "#EC4899", 18 + "#14B8A6", 19 + "#6366F1", 20 + "#F97316", 21 + "#8B5CF6", 22 + "#06B6D4", 23 + "#84CC16", 24 + ]; 25 + 26 + function getSubtypeColor(subtype: string, index: number): string { 27 + if (subtype === "Other") return "#9CA3AF"; 28 + return COLORS[index % COLORS.length]; 29 + } 30 + 31 + export function SubtypesPieChart({ 32 + data, 33 + selection, 34 + onSelect, 35 + }: SubtypesPieChartProps) { 36 + const isSelected = (subtype: string) => 37 + selection?.chart === "subtype" && selection.subtype === subtype; 38 + 39 + const total = data.reduce((sum, d) => sum + d.count, 0); 40 + 41 + if (data.length === 0) { 42 + return ( 43 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700"> 44 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 45 + Subtypes 46 + </h3> 47 + <div className="h-48 flex items-center justify-center text-gray-500 dark:text-gray-400 text-sm"> 48 + No subtypes 49 + </div> 50 + </div> 51 + ); 52 + } 53 + 54 + return ( 55 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700"> 56 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 57 + Subtypes 58 + </h3> 59 + <div className="h-48 flex"> 60 + <div className="flex-1"> 61 + <ResponsiveContainer width="100%" height="100%"> 62 + <PieChart> 63 + <Pie 64 + data={data as unknown as Record<string, unknown>[]} 65 + dataKey="count" 66 + nameKey="type" 67 + cx="50%" 68 + cy="50%" 69 + innerRadius={40} 70 + outerRadius={70} 71 + cursor="pointer" 72 + onClick={(_, index) => 73 + onSelect({ chart: "subtype", subtype: data[index].type }) 74 + } 75 + > 76 + {data.map((entry, index) => ( 77 + <Cell 78 + key={entry.type} 79 + fill={getSubtypeColor(entry.type, index)} 80 + stroke={isSelected(entry.type) ? "#fff" : "transparent"} 81 + strokeWidth={isSelected(entry.type) ? 2 : 0} 82 + /> 83 + ))} 84 + </Pie> 85 + </PieChart> 86 + </ResponsiveContainer> 87 + </div> 88 + <div className="w-24 flex flex-col justify-center gap-1 max-h-48 overflow-y-auto"> 89 + {data.slice(0, 8).map((entry, index) => ( 90 + <button 91 + key={entry.type} 92 + type="button" 93 + className="flex items-center gap-1 text-left hover:opacity-80 transition-opacity" 94 + onClick={() => 95 + onSelect({ chart: "subtype", subtype: entry.type }) 96 + } 97 + > 98 + <div 99 + className="w-2 h-2 rounded-full flex-shrink-0" 100 + style={{ backgroundColor: getSubtypeColor(entry.type, index) }} 101 + /> 102 + <span className="text-xs text-gray-600 dark:text-gray-400 truncate"> 103 + {entry.type} 104 + </span> 105 + <span className="text-xs text-gray-500 dark:text-gray-500 ml-auto"> 106 + {Math.round((entry.count / total) * 100)}% 107 + </span> 108 + </button> 109 + ))} 110 + </div> 111 + </div> 112 + </div> 113 + ); 114 + }
+96
src/components/deck/stats/TypesPieChart.tsx
··· 1 + import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; 2 + import type { TypeData } from "@/lib/deck-stats"; 3 + import type { StatsSelection } from "@/lib/stats-selection"; 4 + 5 + interface TypesPieChartProps { 6 + data: TypeData[]; 7 + selection: StatsSelection; 8 + onSelect: (selection: StatsSelection) => void; 9 + } 10 + 11 + const TYPE_COLORS: Record<string, string> = { 12 + Creature: "#22C55E", 13 + Instant: "#3B82F6", 14 + Sorcery: "#EF4444", 15 + Enchantment: "#A855F7", 16 + Artifact: "#6B7280", 17 + Planeswalker: "#F59E0B", 18 + Land: "#84CC16", 19 + Battle: "#EC4899", 20 + Kindred: "#8B5CF6", 21 + Other: "#9CA3AF", 22 + }; 23 + 24 + function getTypeColor(type: string): string { 25 + return TYPE_COLORS[type] ?? TYPE_COLORS.Other; 26 + } 27 + 28 + export function TypesPieChart({ 29 + data, 30 + selection, 31 + onSelect, 32 + }: TypesPieChartProps) { 33 + const isSelected = (type: string) => 34 + selection?.chart === "type" && selection.type === type; 35 + 36 + const total = data.reduce((sum, d) => sum + d.count, 0); 37 + 38 + return ( 39 + <div className="bg-white dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700"> 40 + <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> 41 + Card Types 42 + </h3> 43 + <div className="h-48 flex"> 44 + <div className="flex-1"> 45 + <ResponsiveContainer width="100%" height="100%"> 46 + <PieChart> 47 + <Pie 48 + data={data as unknown as Record<string, unknown>[]} 49 + dataKey="count" 50 + nameKey="type" 51 + cx="50%" 52 + cy="50%" 53 + innerRadius={40} 54 + outerRadius={70} 55 + cursor="pointer" 56 + onClick={(_, index) => 57 + onSelect({ chart: "type", type: data[index].type }) 58 + } 59 + > 60 + {data.map((entry) => ( 61 + <Cell 62 + key={entry.type} 63 + fill={getTypeColor(entry.type)} 64 + stroke={isSelected(entry.type) ? "#fff" : "transparent"} 65 + strokeWidth={isSelected(entry.type) ? 2 : 0} 66 + /> 67 + ))} 68 + </Pie> 69 + </PieChart> 70 + </ResponsiveContainer> 71 + </div> 72 + <div className="w-24 flex flex-col justify-center gap-1"> 73 + {data.slice(0, 6).map((entry) => ( 74 + <button 75 + key={entry.type} 76 + type="button" 77 + className="flex items-center gap-1 text-left hover:opacity-80 transition-opacity" 78 + onClick={() => onSelect({ chart: "type", type: entry.type })} 79 + > 80 + <div 81 + className="w-2 h-2 rounded-full flex-shrink-0" 82 + style={{ backgroundColor: getTypeColor(entry.type) }} 83 + /> 84 + <span className="text-xs text-gray-600 dark:text-gray-400 truncate"> 85 + {entry.type} 86 + </span> 87 + <span className="text-xs text-gray-500 dark:text-gray-500 ml-auto"> 88 + {Math.round((entry.count / total) * 100)}% 89 + </span> 90 + </button> 91 + ))} 92 + </div> 93 + </div> 94 + </div> 95 + ); 96 + }
+131
src/components/list/DeleteListDialog.tsx
··· 1 + import { AlertTriangle } from "lucide-react"; 2 + import { useEffect, useId, useState } from "react"; 3 + 4 + interface DeleteListDialogProps { 5 + listName: string; 6 + isOpen: boolean; 7 + onClose: () => void; 8 + onConfirm: () => void; 9 + isDeleting?: boolean; 10 + } 11 + 12 + export function DeleteListDialog({ 13 + listName, 14 + isOpen, 15 + onClose, 16 + onConfirm, 17 + isDeleting = false, 18 + }: DeleteListDialogProps) { 19 + const [confirmText, setConfirmText] = useState(""); 20 + const titleId = useId(); 21 + const inputId = useId(); 22 + 23 + const isMatch = confirmText === listName; 24 + 25 + useEffect(() => { 26 + if (!isOpen) { 27 + setConfirmText(""); 28 + } 29 + }, [isOpen]); 30 + 31 + useEffect(() => { 32 + const handleKeyDown = (e: KeyboardEvent) => { 33 + if (e.key === "Escape" && !isDeleting) { 34 + onClose(); 35 + } 36 + }; 37 + 38 + if (isOpen) { 39 + document.addEventListener("keydown", handleKeyDown); 40 + return () => document.removeEventListener("keydown", handleKeyDown); 41 + } 42 + }, [isOpen, isDeleting, onClose]); 43 + 44 + if (!isOpen) return null; 45 + 46 + const handleSubmit = (e: React.FormEvent) => { 47 + e.preventDefault(); 48 + if (isMatch && !isDeleting) { 49 + onConfirm(); 50 + } 51 + }; 52 + 53 + return ( 54 + <> 55 + <div 56 + className="fixed inset-0 bg-black/50 z-40" 57 + onClick={isDeleting ? undefined : onClose} 58 + aria-hidden="true" 59 + /> 60 + 61 + <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"> 62 + <div 63 + role="alertdialog" 64 + aria-modal="true" 65 + aria-labelledby={titleId} 66 + className="bg-white dark:bg-slate-900 rounded-lg shadow-2xl max-w-md w-full pointer-events-auto border border-gray-300 dark:border-slate-700" 67 + > 68 + <div className="flex items-center gap-3 p-6 border-b border-gray-200 dark:border-slate-800"> 69 + <div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full"> 70 + <AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" /> 71 + </div> 72 + <h2 73 + id={titleId} 74 + className="text-xl font-bold text-gray-900 dark:text-white" 75 + > 76 + Delete list 77 + </h2> 78 + </div> 79 + 80 + <form onSubmit={handleSubmit} className="p-6 space-y-4"> 81 + <p className="text-gray-600 dark:text-gray-400"> 82 + This action <strong>cannot</strong> be undone. This will 83 + permanently delete the list. 84 + </p> 85 + 86 + <div> 87 + <label 88 + htmlFor={inputId} 89 + className="block text-sm text-gray-700 dark:text-gray-300 mb-2" 90 + > 91 + Please type{" "} 92 + <span className="font-mono font-semibold text-gray-900 dark:text-white bg-gray-100 dark:bg-slate-800 px-1.5 py-0.5 rounded"> 93 + {listName} 94 + </span>{" "} 95 + to confirm. 96 + </label> 97 + <input 98 + id={inputId} 99 + type="text" 100 + value={confirmText} 101 + onChange={(e) => setConfirmText(e.target.value)} 102 + disabled={isDeleting} 103 + autoComplete="off" 104 + className="w-full px-4 py-2 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" 105 + placeholder="List name" 106 + /> 107 + </div> 108 + 109 + <div className="flex items-center justify-end gap-3 pt-2"> 110 + <button 111 + type="button" 112 + onClick={onClose} 113 + disabled={isDeleting} 114 + className="px-4 py-2 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 115 + > 116 + Cancel 117 + </button> 118 + <button 119 + type="submit" 120 + disabled={!isMatch || isDeleting} 121 + className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-600/50 disabled:cursor-not-allowed text-white rounded-lg transition-colors" 122 + > 123 + {isDeleting ? "Deleting..." : "Delete this list"} 124 + </button> 125 + </div> 126 + </form> 127 + </div> 128 + </div> 129 + </> 130 + ); 131 + }
+69
src/components/list/ListActionsMenu.tsx
··· 1 + import { MoreVertical, Trash2 } from "lucide-react"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import type { Rkey } from "@/lib/atproto-client"; 4 + import { useDeleteCollectionListMutation } from "@/lib/collection-list-queries"; 5 + import { DeleteListDialog } from "./DeleteListDialog"; 6 + 7 + interface ListActionsMenuProps { 8 + listName: string; 9 + rkey: Rkey; 10 + } 11 + 12 + export function ListActionsMenu({ listName, rkey }: ListActionsMenuProps) { 13 + const [isOpen, setIsOpen] = useState(false); 14 + const [showDeleteDialog, setShowDeleteDialog] = useState(false); 15 + const menuRef = useRef<HTMLDivElement>(null); 16 + const deleteMutation = useDeleteCollectionListMutation(rkey); 17 + 18 + useEffect(() => { 19 + const handleClickOutside = (event: MouseEvent) => { 20 + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 21 + setIsOpen(false); 22 + } 23 + }; 24 + 25 + if (isOpen) { 26 + document.addEventListener("mousedown", handleClickOutside); 27 + return () => 28 + document.removeEventListener("mousedown", handleClickOutside); 29 + } 30 + }, [isOpen]); 31 + 32 + return ( 33 + <div className="relative" ref={menuRef}> 34 + <button 35 + type="button" 36 + onClick={() => setIsOpen(!isOpen)} 37 + className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" 38 + aria-label="List actions" 39 + aria-expanded={isOpen} 40 + > 41 + <MoreVertical size={16} /> 42 + </button> 43 + 44 + {isOpen && ( 45 + <div className="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50"> 46 + <button 47 + type="button" 48 + onClick={() => { 49 + setIsOpen(false); 50 + setShowDeleteDialog(true); 51 + }} 52 + className="w-full text-left px-4 py-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400 text-sm flex items-center gap-2" 53 + > 54 + <Trash2 size={14} /> 55 + Delete list 56 + </button> 57 + </div> 58 + )} 59 + 60 + <DeleteListDialog 61 + listName={listName} 62 + isOpen={showDeleteDialog} 63 + onClose={() => setShowDeleteDialog(false)} 64 + onConfirm={() => deleteMutation.mutate()} 65 + isDeleting={deleteMutation.isPending} 66 + /> 67 + </div> 68 + ); 69 + }
+65
src/components/list/SaveToListButton.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { Bookmark } from "lucide-react"; 3 + import { useMemo, useState } from "react"; 4 + import { listUserCollectionListsQueryOptions } from "@/lib/collection-list-queries"; 5 + import { hasCard, hasDeck } from "@/lib/collection-list-types"; 6 + import { useAuth } from "@/lib/useAuth"; 7 + import { type SaveItem, SaveToListDialog } from "./SaveToListDialog"; 8 + 9 + interface SaveToListButtonProps { 10 + item: SaveItem; 11 + itemName?: string; 12 + className?: string; 13 + } 14 + 15 + export function SaveToListButton({ 16 + item, 17 + itemName, 18 + className = "", 19 + }: SaveToListButtonProps) { 20 + const { session } = useAuth(); 21 + const [isOpen, setIsOpen] = useState(false); 22 + 23 + const { data: listsData } = useQuery({ 24 + ...listUserCollectionListsQueryOptions(session?.info.sub ?? ("" as never)), 25 + enabled: !!session, 26 + }); 27 + 28 + const isSaved = useMemo(() => { 29 + if (!listsData?.records) return false; 30 + return listsData.records.some((record) => 31 + item.type === "card" 32 + ? hasCard(record.value, item.scryfallId) 33 + : hasDeck(record.value, item.deckUri), 34 + ); 35 + }, [listsData, item]); 36 + 37 + if (!session) { 38 + return null; 39 + } 40 + 41 + return ( 42 + <> 43 + <button 44 + type="button" 45 + onClick={() => setIsOpen(true)} 46 + className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`} 47 + aria-label={isSaved ? "Saved to list" : "Save to list"} 48 + title={isSaved ? "Saved to list" : "Save to list"} 49 + > 50 + <Bookmark 51 + className={`w-5 h-5 ${isSaved ? "text-blue-500 dark:text-blue-400" : "text-gray-600 dark:text-gray-400"}`} 52 + fill={isSaved ? "currentColor" : "none"} 53 + /> 54 + </button> 55 + 56 + <SaveToListDialog 57 + item={item} 58 + itemName={itemName} 59 + userDid={session.info.sub} 60 + isOpen={isOpen} 61 + onClose={() => setIsOpen(false)} 62 + /> 63 + </> 64 + ); 65 + }
+250
src/components/list/SaveToListDialog.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Bookmark, Loader2, Plus } from "lucide-react"; 4 + import { useEffect, useId, useState } from "react"; 5 + import { toast } from "sonner"; 6 + import type { Rkey } from "@/lib/atproto-client"; 7 + import { 8 + listUserCollectionListsQueryOptions, 9 + useCreateCollectionListMutation, 10 + useUpdateCollectionListMutation, 11 + } from "@/lib/collection-list-queries"; 12 + import { 13 + addCardToList, 14 + addDeckToList, 15 + type CollectionList, 16 + hasCard, 17 + hasDeck, 18 + } from "@/lib/collection-list-types"; 19 + import type { ScryfallId } from "@/lib/scryfall-types"; 20 + 21 + export type SaveItem = 22 + | { type: "card"; scryfallId: ScryfallId } 23 + | { type: "deck"; deckUri: string }; 24 + 25 + interface SaveToListDialogProps { 26 + item: SaveItem; 27 + itemName?: string; 28 + userDid: Did; 29 + isOpen: boolean; 30 + onClose: () => void; 31 + } 32 + 33 + export function SaveToListDialog({ 34 + item, 35 + itemName, 36 + userDid, 37 + isOpen, 38 + onClose, 39 + }: SaveToListDialogProps) { 40 + const titleId = useId(); 41 + const inputId = useId(); 42 + const [newListName, setNewListName] = useState(""); 43 + 44 + const { data: listsData, isLoading } = useQuery({ 45 + ...listUserCollectionListsQueryOptions(userDid), 46 + enabled: isOpen, 47 + }); 48 + 49 + const createMutation = useCreateCollectionListMutation(); 50 + 51 + useEffect(() => { 52 + if (!isOpen) { 53 + setNewListName(""); 54 + } 55 + }, [isOpen]); 56 + 57 + useEffect(() => { 58 + const handleKeyDown = (e: KeyboardEvent) => { 59 + if (e.key === "Escape") { 60 + onClose(); 61 + } 62 + }; 63 + 64 + if (isOpen) { 65 + document.addEventListener("keydown", handleKeyDown); 66 + return () => document.removeEventListener("keydown", handleKeyDown); 67 + } 68 + }, [isOpen, onClose]); 69 + 70 + if (!isOpen) return null; 71 + 72 + const handleCreateList = (e: React.FormEvent) => { 73 + e.preventDefault(); 74 + if (!newListName.trim()) return; 75 + 76 + createMutation.mutate( 77 + { name: newListName.trim() }, 78 + { 79 + onSuccess: () => { 80 + setNewListName(""); 81 + }, 82 + }, 83 + ); 84 + }; 85 + 86 + const lists = listsData?.records ?? []; 87 + 88 + return ( 89 + <> 90 + {/* Backdrop */} 91 + <div 92 + className="fixed inset-0 bg-black/50 z-40" 93 + onClick={onClose} 94 + aria-hidden="true" 95 + /> 96 + 97 + {/* Dialog */} 98 + <div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"> 99 + <div 100 + role="dialog" 101 + aria-modal="true" 102 + aria-labelledby={titleId} 103 + className="bg-white dark:bg-slate-900 rounded-lg shadow-2xl max-w-md w-full pointer-events-auto border border-gray-300 dark:border-slate-700" 104 + > 105 + {/* Header */} 106 + <div className="flex items-center gap-3 p-6 border-b border-gray-200 dark:border-slate-800"> 107 + <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-full"> 108 + <Bookmark className="w-5 h-5 text-blue-600 dark:text-blue-400" /> 109 + </div> 110 + <h2 111 + id={titleId} 112 + className="text-xl font-bold text-gray-900 dark:text-white" 113 + > 114 + Save to list 115 + </h2> 116 + </div> 117 + 118 + {/* Body */} 119 + <div className="p-6 space-y-4"> 120 + {isLoading ? ( 121 + <div className="flex items-center justify-center py-8"> 122 + <Loader2 className="w-6 h-6 animate-spin text-gray-400" /> 123 + </div> 124 + ) : lists.length === 0 ? ( 125 + <p className="text-gray-600 dark:text-gray-400 text-center py-4"> 126 + You don't have any lists yet. Create one below! 127 + </p> 128 + ) : ( 129 + <div className="space-y-2 max-h-64 overflow-y-auto"> 130 + {lists.map((record) => ( 131 + <ListRow 132 + key={record.uri} 133 + list={record.value} 134 + rkey={record.uri.split("/").pop() ?? ""} 135 + item={item} 136 + itemName={itemName} 137 + userDid={userDid} 138 + onClose={onClose} 139 + /> 140 + ))} 141 + </div> 142 + )} 143 + 144 + {/* Create new list */} 145 + <form 146 + onSubmit={handleCreateList} 147 + className="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-slate-800" 148 + > 149 + <input 150 + id={inputId} 151 + type="text" 152 + value={newListName} 153 + onChange={(e) => setNewListName(e.target.value)} 154 + disabled={createMutation.isPending} 155 + placeholder="New list name..." 156 + className="flex-1 px-4 py-2 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" 157 + /> 158 + <button 159 + type="submit" 160 + disabled={!newListName.trim() || createMutation.isPending} 161 + className="px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 disabled:cursor-not-allowed text-white rounded-lg transition-colors" 162 + > 163 + {createMutation.isPending ? ( 164 + <Loader2 className="w-5 h-5 animate-spin" /> 165 + ) : ( 166 + <Plus className="w-5 h-5" /> 167 + )} 168 + </button> 169 + </form> 170 + </div> 171 + 172 + {/* Footer */} 173 + <div className="flex items-center justify-end gap-3 p-6 pt-0"> 174 + <button 175 + type="button" 176 + onClick={onClose} 177 + className="px-4 py-2 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white rounded-lg transition-colors" 178 + > 179 + Done 180 + </button> 181 + </div> 182 + </div> 183 + </div> 184 + </> 185 + ); 186 + } 187 + 188 + interface ListRowProps { 189 + list: CollectionList; 190 + rkey: string; 191 + item: SaveItem; 192 + itemName?: string; 193 + userDid: Did; 194 + onClose: () => void; 195 + } 196 + 197 + function ListRow({ 198 + list, 199 + rkey, 200 + item, 201 + itemName, 202 + userDid, 203 + onClose, 204 + }: ListRowProps) { 205 + const updateMutation = useUpdateCollectionListMutation(userDid, rkey as Rkey); 206 + 207 + const alreadySaved = 208 + item.type === "card" 209 + ? hasCard(list, item.scryfallId) 210 + : hasDeck(list, item.deckUri); 211 + 212 + const handleClick = () => { 213 + if (alreadySaved) return; 214 + 215 + const updatedList = 216 + item.type === "card" 217 + ? addCardToList(list, item.scryfallId) 218 + : addDeckToList(list, item.deckUri); 219 + 220 + updateMutation.mutate(updatedList, { 221 + onSuccess: () => { 222 + const what = itemName ?? (item.type === "card" ? "Card" : "Deck"); 223 + toast.success(`Saved ${what} to ${list.name}`); 224 + onClose(); 225 + }, 226 + }); 227 + }; 228 + 229 + return ( 230 + <button 231 + type="button" 232 + onClick={handleClick} 233 + disabled={alreadySaved || updateMutation.isPending} 234 + className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:hover:bg-gray-50 dark:disabled:hover:bg-slate-800 rounded-lg transition-colors disabled:cursor-not-allowed" 235 + > 236 + <span className="font-medium text-gray-900 dark:text-white"> 237 + {list.name} 238 + </span> 239 + <span className="text-sm text-gray-500 dark:text-gray-400"> 240 + {alreadySaved ? ( 241 + "Already saved" 242 + ) : updateMutation.isPending ? ( 243 + <Loader2 className="w-4 h-4 animate-spin" /> 244 + ) : ( 245 + `${list.items.length} items` 246 + )} 247 + </span> 248 + </button> 249 + ); 250 + }
+99
src/components/richtext/RichTextEditor.tsx
··· 1 + import type { RefObject } from "react"; 2 + import type { ParseResult } from "@/lib/richtext"; 3 + import { RichText } from "@/lib/richtext"; 4 + 5 + type SaveState = "saved" | "dirty" | "saving"; 6 + 7 + function getSaveState({ 8 + isDirty, 9 + isSaving, 10 + }: { 11 + isDirty?: boolean; 12 + isSaving?: boolean; 13 + }): SaveState { 14 + if (isSaving) return "saving"; 15 + if (isDirty) return "dirty"; 16 + return "saved"; 17 + } 18 + 19 + function SaveIndicator({ state }: { state: SaveState }) { 20 + const colors: Record<SaveState, string> = { 21 + saving: "text-blue-400 dark:text-blue-500", 22 + dirty: "text-amber-500 dark:text-amber-400", 23 + saved: "text-green-500 dark:text-green-400", 24 + }; 25 + 26 + const labels: Record<SaveState, string> = { 27 + saving: "Saving", 28 + dirty: "Unsaved", 29 + saved: "Saved", 30 + }; 31 + 32 + return ( 33 + <svg 34 + className={`w-3 h-3 ${colors[state]}`} 35 + viewBox="0 0 24 24" 36 + fill="currentColor" 37 + role="img" 38 + aria-label={labels[state]} 39 + > 40 + <circle cx="12" cy="12" r="6" /> 41 + </svg> 42 + ); 43 + } 44 + 45 + export interface RichTextEditorProps { 46 + inputRef: RefObject<HTMLTextAreaElement | null>; 47 + onInput: () => void; 48 + defaultValue: string; 49 + parsed: ParseResult; 50 + isDirty?: boolean; 51 + isSaving?: boolean; 52 + placeholder?: string; 53 + className?: string; 54 + } 55 + 56 + export function RichTextEditor({ 57 + inputRef, 58 + onInput, 59 + defaultValue, 60 + parsed, 61 + isDirty, 62 + isSaving, 63 + placeholder = "Write something...", 64 + className, 65 + }: RichTextEditorProps) { 66 + const saveState = getSaveState({ isDirty, isSaving }); 67 + 68 + return ( 69 + <div className={className}> 70 + <div className="grid grid-cols-2 gap-4 items-stretch"> 71 + <div className="relative flex flex-col"> 72 + <textarea 73 + ref={inputRef} 74 + defaultValue={defaultValue} 75 + onInput={onInput} 76 + placeholder={placeholder} 77 + className="w-full min-h-64 flex-1 p-3 pr-8 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-y font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 78 + /> 79 + <div className="absolute top-2 right-2"> 80 + <SaveIndicator state={saveState} /> 81 + </div> 82 + </div> 83 + <div className="p-3 border border-gray-300 dark:border-slate-700 rounded-lg bg-gray-50 dark:bg-slate-800/50 min-h-64 overflow-auto"> 84 + {parsed.text ? ( 85 + <RichText 86 + text={parsed.text} 87 + facets={parsed.facets} 88 + className="prose dark:prose-invert prose-sm max-w-none whitespace-pre-wrap" 89 + /> 90 + ) : ( 91 + <span className="text-gray-400 dark:text-gray-500"> 92 + {placeholder} 93 + </span> 94 + )} 95 + </div> 96 + </div> 97 + </div> 98 + ); 99 + }
+31
src/lib/__tests__/add-test-card.sh
··· 1 + #!/usr/bin/env bash 2 + # Add a card to test-cards.json fixture 3 + # Usage: ./add-test-card.sh "Card Name" 4 + 5 + set -euo pipefail 6 + 7 + if [[ $# -lt 1 ]]; then 8 + echo "Usage: $0 \"Card Name\"" >&2 9 + exit 1 10 + fi 11 + 12 + CARD_NAME="$1" 13 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 14 + FIXTURE="$SCRIPT_DIR/test-cards.json" 15 + 16 + # Fetch oracle_id from Scryfall 17 + ORACLE_ID=$(curl -sf "https://api.scryfall.com/cards/named?exact=$(printf '%s' "$CARD_NAME" | jq -sRr @uri)" | jq -r '.oracle_id') 18 + 19 + if [[ -z "$ORACLE_ID" || "$ORACLE_ID" == "null" ]]; then 20 + echo "Card not found: $CARD_NAME" >&2 21 + exit 1 22 + fi 23 + 24 + # Add to fixture and sort keys 25 + jq -S --arg name "$CARD_NAME" --arg id "$ORACLE_ID" '. + {($name): $id}' "$FIXTURE" > "$FIXTURE.tmp" 26 + mv "$FIXTURE.tmp" "$FIXTURE" 27 + 28 + # Format with project settings (tabs) 29 + npx biome format --write "$FIXTURE" >/dev/null 2>&1 || true 30 + 31 + echo "Added: \"$CARD_NAME\": \"$ORACLE_ID\""
+100 -3
src/lib/__tests__/card-data-provider.test.ts
··· 8 8 import type { CardDataProvider } from "../card-data-provider"; 9 9 import { ClientCardProvider } from "../cards-client-provider"; 10 10 import { ServerCardProvider } from "../cards-server-provider"; 11 + import type { ScryfallId } from "../scryfall-types"; 11 12 import { asOracleId, asScryfallId } from "../scryfall-types"; 13 + import { getTestCardOracleId } from "./test-card-lookup"; 12 14 import { mockFetchFromPublicDir } from "./test-helpers"; 13 15 14 16 // Mock cards-worker-client to use real worker code without Comlink/Worker ··· 41 43 }; 42 44 }); 43 45 44 - // Known test IDs from our dataset (using first sample card - Forest from Bloomburrow) 45 - const TEST_CARD_ID = asScryfallId("0000419b-0bba-4488-8f7a-6194544ce91e"); 46 - const TEST_CARD_ORACLE = asOracleId("b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"); 46 + // Test card from fixture 47 47 const TEST_CARD_NAME = "Forest"; 48 + const TEST_CARD_ORACLE = getTestCardOracleId(TEST_CARD_NAME); 49 + 50 + // Resolved at runtime in beforeAll (needs provider) 51 + let TEST_CARD_ID: ScryfallId; 52 + 48 53 const INVALID_ID = asScryfallId("00000000-0000-0000-0000-000000000000"); 49 54 const INVALID_ORACLE = asOracleId("00000000-0000-0000-0000-000000000000"); 50 55 ··· 112 117 serverProvider = new ServerCardProvider(); 113 118 clientProvider = new ClientCardProvider(); 114 119 await clientProvider.initialize(); 120 + 121 + // Resolve test card ID from oracle ID 122 + const cardId = await serverProvider.getCanonicalPrinting(TEST_CARD_ORACLE); 123 + if (!cardId) { 124 + throw new Error(`Failed to resolve test card ID for ${TEST_CARD_NAME}`); 125 + } 126 + TEST_CARD_ID = cardId; 115 127 }, 20_000); 116 128 117 129 describe.each([ ··· 286 298 it("does not support searchCards", () => { 287 299 expect(serverProvider.searchCards).toBeUndefined(); 288 300 }); 301 + 302 + describe("getCardsByIds (batch fetch)", () => { 303 + it("returns cards for valid IDs", async () => { 304 + const ids = SAMPLE_CARD_IDS.slice(0, 5).map(asScryfallId); 305 + const result = await serverProvider.getCardsByIds(ids); 306 + 307 + expect(result.size).toBe(5); 308 + for (const id of ids) { 309 + expect(result.has(id)).toBe(true); 310 + expect(result.get(id)?.id).toBe(id); 311 + } 312 + }); 313 + 314 + it("handles mixed valid and invalid IDs", async () => { 315 + const validIds = SAMPLE_CARD_IDS.slice(0, 3).map(asScryfallId); 316 + const invalidIds = [INVALID_ID]; 317 + const result = await serverProvider.getCardsByIds([ 318 + ...validIds, 319 + ...invalidIds, 320 + ]); 321 + 322 + expect(result.size).toBe(3); 323 + for (const id of validIds) { 324 + expect(result.has(id)).toBe(true); 325 + } 326 + expect(result.has(INVALID_ID)).toBe(false); 327 + }); 328 + 329 + it("returns same data as individual getCardById calls", async () => { 330 + const ids = SAMPLE_CARD_IDS.slice(0, 10).map(asScryfallId); 331 + const batchResult = await serverProvider.getCardsByIds(ids); 332 + const individualResults = await Promise.all( 333 + ids.map((id) => serverProvider.getCardById(id)), 334 + ); 335 + 336 + for (let i = 0; i < ids.length; i++) { 337 + expect(batchResult.get(ids[i])).toEqual(individualResults[i]); 338 + } 339 + }); 340 + 341 + it("handles empty array", async () => { 342 + const result = await serverProvider.getCardsByIds([]); 343 + expect(result.size).toBe(0); 344 + }); 345 + }); 346 + }); 347 + 348 + describe("Volatile data", () => { 349 + describe.each([ 350 + ["ServerCardProvider", () => serverProvider], 351 + ["ClientCardProvider", () => clientProvider], 352 + ])("%s", (_name, getProvider) => { 353 + it("returns volatile data for known card", async () => { 354 + const provider = getProvider(); 355 + const volatileData = await provider.getVolatileData(TEST_CARD_ID); 356 + 357 + expect(volatileData).not.toBeNull(); 358 + expect(volatileData).toHaveProperty("edhrecRank"); 359 + expect(volatileData).toHaveProperty("usd"); 360 + expect(volatileData).toHaveProperty("usdFoil"); 361 + expect(volatileData).toHaveProperty("usdEtched"); 362 + expect(volatileData).toHaveProperty("eur"); 363 + expect(volatileData).toHaveProperty("eurFoil"); 364 + expect(volatileData).toHaveProperty("tix"); 365 + }); 366 + 367 + it("returns null for invalid card ID", async () => { 368 + const provider = getProvider(); 369 + const volatileData = await provider.getVolatileData(INVALID_ID); 370 + expect(volatileData).toBeNull(); 371 + }); 372 + }); 373 + 374 + it.each(SAMPLE_CARD_IDS)( 375 + "returns identical volatile data for card %s", 376 + async (cardId) => { 377 + const id = asScryfallId(cardId); 378 + const [clientData, serverData] = await Promise.all([ 379 + clientProvider.getVolatileData(id), 380 + serverProvider.getVolatileData(id), 381 + ]); 382 + 383 + expect(clientData).toEqual(serverData); 384 + }, 385 + ); 289 386 }); 290 387 });
+145
src/lib/__tests__/card-faces-proptest.test.ts
··· 1 + /** 2 + * Property-based tests for card-faces module 3 + * 4 + * Tests parseManaValue against the full card database to ensure 5 + * our parsing matches Scryfall's authoritative cmc values. 6 + * 7 + * Separated from unit tests because loading the full card database 8 + * takes several seconds. 9 + */ 10 + 11 + import { readFile } from "node:fs/promises"; 12 + import { join } from "node:path"; 13 + import { beforeAll, describe, expect, it } from "vitest"; 14 + import { parseManaValue } from "../card-faces"; 15 + import { CARD_CHUNKS } from "../card-manifest"; 16 + import type { Card } from "../scryfall-types"; 17 + 18 + const PUBLIC_DIR = join(process.cwd(), "public", "data", "cards"); 19 + 20 + interface ChunkData { 21 + cards: Record<string, Card>; 22 + } 23 + 24 + async function loadAllCards(): Promise<Card[]> { 25 + const cards: Card[] = []; 26 + 27 + for (const chunkFile of CARD_CHUNKS) { 28 + const content = await readFile(join(PUBLIC_DIR, chunkFile), "utf-8"); 29 + const chunk = JSON.parse(content) as ChunkData; 30 + cards.push(...Object.values(chunk.cards)); 31 + } 32 + 33 + return cards; 34 + } 35 + 36 + describe("parseManaValue against real cards", () => { 37 + let allCards: Card[]; 38 + 39 + beforeAll(async () => { 40 + allCards = await loadAllCards(); 41 + }, 60_000); 42 + 43 + it("loads a reasonable number of cards", () => { 44 + expect(allCards.length).toBeGreaterThan(100_000); 45 + }); 46 + 47 + it("matches Scryfall cmc for all single-faced cards", () => { 48 + const failures: Array<{ 49 + name: string; 50 + manaCost: string; 51 + expected: number; 52 + got: number; 53 + }> = []; 54 + let testedCount = 0; 55 + 56 + for (const card of allCards) { 57 + // Skip cards without mana cost (lands, etc.) 58 + if (!card.mana_cost) continue; 59 + 60 + // Skip multi-faced cards - their top-level mana_cost can be combined 61 + if (card.card_faces && card.card_faces.length > 1) continue; 62 + 63 + // Skip cards without cmc (shouldn't happen, but be safe) 64 + if (card.cmc === undefined) continue; 65 + 66 + // Skip joke/test sets with intentionally broken data 67 + if (card.set === "unk") continue; 68 + 69 + testedCount++; 70 + const parsed = parseManaValue(card.mana_cost); 71 + if (parsed !== card.cmc) { 72 + failures.push({ 73 + name: card.name, 74 + manaCost: card.mana_cost, 75 + expected: card.cmc, 76 + got: parsed, 77 + }); 78 + } 79 + } 80 + 81 + expect(testedCount).toBeGreaterThan(50_000); 82 + 83 + if (failures.length > 0) { 84 + const sample = failures.slice(0, 10); 85 + const msg = sample 86 + .map( 87 + (f) => 88 + `${f.name}: "${f.manaCost}" expected ${f.expected}, got ${f.got}`, 89 + ) 90 + .join("\n"); 91 + expect.fail( 92 + `${failures.length} cards failed mana value parsing:\n${msg}`, 93 + ); 94 + } 95 + }); 96 + 97 + it("matches Scryfall cmc for front face of DFCs", () => { 98 + const failures: Array<{ 99 + name: string; 100 + manaCost: string; 101 + expected: number; 102 + got: number; 103 + }> = []; 104 + let testedCount = 0; 105 + 106 + // Only test DFCs where card.cmc = front face CMC 107 + // Skip split/adventure cards where card.cmc is combined 108 + const dfcLayouts = ["transform", "modal_dfc", "flip", "meld"]; 109 + 110 + for (const card of allCards) { 111 + if (!card.card_faces || card.card_faces.length < 2) continue; 112 + if (!card.layout || !dfcLayouts.includes(card.layout)) continue; 113 + 114 + const frontFace = card.card_faces[0]; 115 + if (!frontFace.mana_cost) continue; 116 + if (card.cmc === undefined) continue; 117 + 118 + testedCount++; 119 + const parsed = parseManaValue(frontFace.mana_cost); 120 + if (parsed !== card.cmc) { 121 + failures.push({ 122 + name: card.name, 123 + manaCost: frontFace.mana_cost, 124 + expected: card.cmc, 125 + got: parsed, 126 + }); 127 + } 128 + } 129 + 130 + expect(testedCount).toBeGreaterThan(500); 131 + 132 + if (failures.length > 0) { 133 + const sample = failures.slice(0, 10); 134 + const msg = sample 135 + .map( 136 + (f) => 137 + `${f.name}: "${f.manaCost}" expected ${f.expected}, got ${f.got}`, 138 + ) 139 + .join("\n"); 140 + expect.fail( 141 + `${failures.length} DFC cards failed mana value parsing:\n${msg}`, 142 + ); 143 + } 144 + }); 145 + });
+331
src/lib/__tests__/card-faces.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + canFlip, 4 + getCastableFaces, 5 + getFaceManaValue, 6 + getFlipBehavior, 7 + getPrimaryFace, 8 + hasBackImage, 9 + isMultiFaced, 10 + parseManaValue, 11 + } from "../card-faces"; 12 + import type { Card, CardFace } from "../scryfall-types"; 13 + 14 + describe("parseManaValue", () => { 15 + describe("basic costs", () => { 16 + it("handles empty/undefined", () => { 17 + expect(parseManaValue(undefined)).toBe(0); 18 + expect(parseManaValue("")).toBe(0); 19 + }); 20 + 21 + it("handles generic mana", () => { 22 + expect(parseManaValue("{1}")).toBe(1); 23 + expect(parseManaValue("{2}")).toBe(2); 24 + expect(parseManaValue("{10}")).toBe(10); 25 + }); 26 + 27 + it("handles single colors", () => { 28 + expect(parseManaValue("{W}")).toBe(1); 29 + expect(parseManaValue("{U}")).toBe(1); 30 + expect(parseManaValue("{B}")).toBe(1); 31 + expect(parseManaValue("{R}")).toBe(1); 32 + expect(parseManaValue("{G}")).toBe(1); 33 + }); 34 + 35 + it("handles colorless", () => { 36 + expect(parseManaValue("{C}")).toBe(1); 37 + expect(parseManaValue("{C}{C}")).toBe(2); 38 + }); 39 + 40 + it("handles combined costs", () => { 41 + expect(parseManaValue("{2}{U}")).toBe(3); 42 + expect(parseManaValue("{1}{W}{W}")).toBe(3); 43 + expect(parseManaValue("{5}{B}{R}")).toBe(7); 44 + expect(parseManaValue("{W}{U}{B}{R}{G}")).toBe(5); 45 + }); 46 + }); 47 + 48 + describe("special costs", () => { 49 + it("handles X costs (count as 0)", () => { 50 + expect(parseManaValue("{X}")).toBe(0); 51 + expect(parseManaValue("{X}{G}{G}")).toBe(2); 52 + expect(parseManaValue("{X}{X}{R}")).toBe(1); 53 + }); 54 + 55 + it("handles Y and Z costs", () => { 56 + expect(parseManaValue("{X}{Y}")).toBe(0); 57 + expect(parseManaValue("{X}{Y}{Z}")).toBe(0); 58 + }); 59 + 60 + it("handles half mana (costs 0.5)", () => { 61 + expect(parseManaValue("{HW}")).toBe(0.5); 62 + expect(parseManaValue("{HR}")).toBe(0.5); 63 + }); 64 + 65 + it("handles hybrid mana (costs 1)", () => { 66 + expect(parseManaValue("{W/U}")).toBe(1); 67 + expect(parseManaValue("{W/U}{W/U}")).toBe(2); 68 + expect(parseManaValue("{2}{W/B}{W/B}")).toBe(4); 69 + }); 70 + 71 + it("handles phyrexian mana (costs 1)", () => { 72 + expect(parseManaValue("{W/P}")).toBe(1); 73 + expect(parseManaValue("{U/P}{U/P}")).toBe(2); 74 + expect(parseManaValue("{1}{G/P}")).toBe(2); 75 + }); 76 + 77 + it("handles generic hybrid (2/W costs 2)", () => { 78 + expect(parseManaValue("{2/W}")).toBe(2); 79 + expect(parseManaValue("{2/U}{2/U}")).toBe(4); 80 + expect(parseManaValue("{2/W}{2/U}{2/B}{2/R}{2/G}")).toBe(10); 81 + }); 82 + 83 + it("handles snow mana", () => { 84 + expect(parseManaValue("{S}")).toBe(1); 85 + expect(parseManaValue("{S}{S}{S}")).toBe(3); 86 + expect(parseManaValue("{2}{S}")).toBe(3); 87 + }); 88 + }); 89 + 90 + describe("real card costs", () => { 91 + const knownCosts: Array<[string, number]> = [ 92 + ["{0}", 0], 93 + ["{3}", 3], 94 + ["{1}{U}", 2], 95 + ["{2}{U}{U}", 4], 96 + ["{W}{W}{W}{W}", 4], 97 + ["{X}{X}{G}", 1], 98 + ["{2}{W/U}{W/U}", 4], 99 + ["{B/P}{B/P}{B/P}{B/P}", 4], 100 + ]; 101 + 102 + for (const [cost, expected] of knownCosts) { 103 + it(`parses ${cost} as ${expected}`, () => { 104 + expect(parseManaValue(cost)).toBe(expected); 105 + }); 106 + } 107 + }); 108 + }); 109 + 110 + describe("isMultiFaced", () => { 111 + it("returns false for single-faced card", () => { 112 + const card = { name: "Lightning Bolt" } as Card; 113 + expect(isMultiFaced(card)).toBe(false); 114 + }); 115 + 116 + it("returns false for single face in array", () => { 117 + const card = { 118 + name: "Test", 119 + card_faces: [{ name: "Test", object: "card_face" }], 120 + } as Card; 121 + expect(isMultiFaced(card)).toBe(false); 122 + }); 123 + 124 + it("returns true for two faces", () => { 125 + const card = { 126 + name: "Delver // Aberration", 127 + card_faces: [ 128 + { name: "Delver", object: "card_face" }, 129 + { name: "Aberration", object: "card_face" }, 130 + ], 131 + } as Card; 132 + expect(isMultiFaced(card)).toBe(true); 133 + }); 134 + }); 135 + 136 + describe("getPrimaryFace", () => { 137 + it("returns first face for multi-faced card", () => { 138 + const card = { 139 + name: "Valki // Tibalt", 140 + card_faces: [ 141 + { name: "Valki", mana_cost: "{1}{B}", object: "card_face" }, 142 + { name: "Tibalt", mana_cost: "{5}{B}{R}", object: "card_face" }, 143 + ], 144 + } as Card; 145 + const face = getPrimaryFace(card); 146 + expect(face.name).toBe("Valki"); 147 + expect(face.mana_cost).toBe("{1}{B}"); 148 + }); 149 + 150 + it("returns synthetic face for single-faced card", () => { 151 + const card = { 152 + name: "Lightning Bolt", 153 + mana_cost: "{R}", 154 + type_line: "Instant", 155 + } as Card; 156 + const face = getPrimaryFace(card); 157 + expect(face.name).toBe("Lightning Bolt"); 158 + expect(face.mana_cost).toBe("{R}"); 159 + expect(face.type_line).toBe("Instant"); 160 + }); 161 + }); 162 + 163 + describe("getCastableFaces", () => { 164 + it("returns both faces for modal_dfc", () => { 165 + const card = { 166 + name: "Valki // Tibalt", 167 + layout: "modal_dfc", 168 + card_faces: [ 169 + { name: "Valki", object: "card_face" }, 170 + { name: "Tibalt", object: "card_face" }, 171 + ], 172 + } as Card; 173 + const faces = getCastableFaces(card); 174 + expect(faces).toHaveLength(2); 175 + expect(faces[0].name).toBe("Valki"); 176 + expect(faces[1].name).toBe("Tibalt"); 177 + }); 178 + 179 + it("returns both faces for split", () => { 180 + const card = { 181 + name: "Fire // Ice", 182 + layout: "split", 183 + card_faces: [ 184 + { name: "Fire", object: "card_face" }, 185 + { name: "Ice", object: "card_face" }, 186 + ], 187 + } as Card; 188 + const faces = getCastableFaces(card); 189 + expect(faces).toHaveLength(2); 190 + }); 191 + 192 + it("returns both faces for adventure", () => { 193 + const card = { 194 + name: "Bonecrusher Giant", 195 + layout: "adventure", 196 + card_faces: [ 197 + { name: "Bonecrusher Giant", object: "card_face" }, 198 + { name: "Stomp", object: "card_face" }, 199 + ], 200 + } as Card; 201 + const faces = getCastableFaces(card); 202 + expect(faces).toHaveLength(2); 203 + }); 204 + 205 + it("returns only front face for transform", () => { 206 + const card = { 207 + name: "Delver of Secrets", 208 + layout: "transform", 209 + card_faces: [ 210 + { name: "Delver of Secrets", object: "card_face" }, 211 + { name: "Insectile Aberration", object: "card_face" }, 212 + ], 213 + } as Card; 214 + const faces = getCastableFaces(card); 215 + expect(faces).toHaveLength(1); 216 + expect(faces[0].name).toBe("Delver of Secrets"); 217 + }); 218 + 219 + it("returns only front face for flip", () => { 220 + const card = { 221 + name: "Erayo, Soratami Ascendant", 222 + layout: "flip", 223 + card_faces: [ 224 + { name: "Erayo, Soratami Ascendant", object: "card_face" }, 225 + { name: "Erayo's Essence", object: "card_face" }, 226 + ], 227 + } as Card; 228 + const faces = getCastableFaces(card); 229 + expect(faces).toHaveLength(1); 230 + }); 231 + 232 + it("returns only front face for meld", () => { 233 + const card = { 234 + name: "Gisela, the Broken Blade", 235 + layout: "meld", 236 + card_faces: [ 237 + { name: "Gisela, the Broken Blade", object: "card_face" }, 238 + { name: "Brisela", object: "card_face" }, 239 + ], 240 + } as Card; 241 + const faces = getCastableFaces(card); 242 + expect(faces).toHaveLength(1); 243 + }); 244 + 245 + it("returns synthetic face for normal card", () => { 246 + const card = { 247 + name: "Lightning Bolt", 248 + layout: "normal", 249 + mana_cost: "{R}", 250 + } as Card; 251 + const faces = getCastableFaces(card); 252 + expect(faces).toHaveLength(1); 253 + expect(faces[0].name).toBe("Lightning Bolt"); 254 + }); 255 + }); 256 + 257 + describe("getFlipBehavior", () => { 258 + it("returns transform for DFCs", () => { 259 + expect(getFlipBehavior("transform")).toBe("transform"); 260 + expect(getFlipBehavior("modal_dfc")).toBe("transform"); 261 + expect(getFlipBehavior("meld")).toBe("transform"); 262 + expect(getFlipBehavior("reversible_card")).toBe("transform"); 263 + }); 264 + 265 + it("returns rotate90 for split", () => { 266 + expect(getFlipBehavior("split")).toBe("rotate90"); 267 + }); 268 + 269 + it("returns rotate180 for flip", () => { 270 + expect(getFlipBehavior("flip")).toBe("rotate180"); 271 + }); 272 + 273 + it("returns none for normal cards", () => { 274 + expect(getFlipBehavior("normal")).toBe("none"); 275 + expect(getFlipBehavior("adventure")).toBe("none"); 276 + expect(getFlipBehavior("saga")).toBe("none"); 277 + expect(getFlipBehavior(undefined)).toBe("none"); 278 + }); 279 + }); 280 + 281 + describe("canFlip", () => { 282 + it("returns true for flippable layouts", () => { 283 + expect(canFlip({ layout: "transform" } as Card)).toBe(true); 284 + expect(canFlip({ layout: "modal_dfc" } as Card)).toBe(true); 285 + expect(canFlip({ layout: "split" } as Card)).toBe(true); 286 + expect(canFlip({ layout: "flip" } as Card)).toBe(true); 287 + }); 288 + 289 + it("returns false for non-flippable layouts", () => { 290 + expect(canFlip({ layout: "normal" } as Card)).toBe(false); 291 + expect(canFlip({ layout: "adventure" } as Card)).toBe(false); 292 + expect(canFlip({} as Card)).toBe(false); 293 + }); 294 + }); 295 + 296 + describe("hasBackImage", () => { 297 + it("returns true for DFC layouts", () => { 298 + expect(hasBackImage("transform")).toBe(true); 299 + expect(hasBackImage("modal_dfc")).toBe(true); 300 + expect(hasBackImage("meld")).toBe(true); 301 + expect(hasBackImage("reversible_card")).toBe(true); 302 + }); 303 + 304 + it("returns false for single-image layouts", () => { 305 + expect(hasBackImage("normal")).toBe(false); 306 + expect(hasBackImage("split")).toBe(false); 307 + expect(hasBackImage("flip")).toBe(false); 308 + expect(hasBackImage("adventure")).toBe(false); 309 + expect(hasBackImage(undefined)).toBe(false); 310 + }); 311 + }); 312 + 313 + describe("getFaceManaValue", () => { 314 + it("uses card.cmc for primary face", () => { 315 + const card = { cmc: 5 } as Card; 316 + const face = { mana_cost: "{3}{U}{U}" } as CardFace; 317 + expect(getFaceManaValue(face, card, 0)).toBe(5); 318 + }); 319 + 320 + it("parses mana_cost for secondary faces", () => { 321 + const card = { cmc: 2 } as Card; 322 + const face = { mana_cost: "{5}{B}{R}" } as CardFace; 323 + expect(getFaceManaValue(face, card, 1)).toBe(7); 324 + }); 325 + 326 + it("parses mana_cost when card.cmc is undefined", () => { 327 + const card = {} as Card; 328 + const face = { mana_cost: "{2}{G}" } as CardFace; 329 + expect(getFaceManaValue(face, card, 0)).toBe(3); 330 + }); 331 + });
+693
src/lib/__tests__/printing-selection.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import type { CardDataProvider } from "../card-data-provider"; 3 + import type { Deck } from "../deck-types"; 4 + import { 5 + findAllCanonicalPrintings, 6 + findAllCheapestPrintings, 7 + findCheapestPrinting, 8 + getCheapestPrice, 9 + updateDeckPrintings, 10 + } from "../printing-selection"; 11 + import type { 12 + Card, 13 + OracleId, 14 + ScryfallId, 15 + VolatileData, 16 + } from "../scryfall-types"; 17 + import { asOracleId, asScryfallId } from "../scryfall-types"; 18 + 19 + function mockVolatileData(overrides: Partial<VolatileData> = {}): VolatileData { 20 + return { 21 + edhrecRank: null, 22 + usd: null, 23 + usdFoil: null, 24 + usdEtched: null, 25 + eur: null, 26 + eurFoil: null, 27 + tix: null, 28 + ...overrides, 29 + }; 30 + } 31 + 32 + function mockDeck( 33 + cards: Array<{ 34 + scryfallId: ScryfallId; 35 + section?: "mainboard" | "sideboard" | "commander" | "maybeboard"; 36 + }>, 37 + ): Deck { 38 + return { 39 + $type: "com.deckbelcher.deck.list", 40 + name: "Test Deck", 41 + format: "commander", 42 + cards: cards.map((c) => ({ 43 + scryfallId: c.scryfallId, 44 + quantity: 1, 45 + section: c.section ?? "mainboard", 46 + tags: [], 47 + })), 48 + createdAt: new Date().toISOString(), 49 + }; 50 + } 51 + 52 + describe("getCheapestPrice", () => { 53 + it("returns usd when it's the only price", () => { 54 + const v = mockVolatileData({ usd: 1.5 }); 55 + expect(getCheapestPrice(v)).toBe(1.5); 56 + }); 57 + 58 + it("returns cheapest among usd/foil/etched", () => { 59 + const v = mockVolatileData({ usd: 2.0, usdFoil: 1.5, usdEtched: 3.0 }); 60 + expect(getCheapestPrice(v)).toBe(1.5); 61 + }); 62 + 63 + it("returns null when all prices are null", () => { 64 + const v = mockVolatileData(); 65 + expect(getCheapestPrice(v)).toBeNull(); 66 + }); 67 + 68 + it("ignores null values in comparison", () => { 69 + const v = mockVolatileData({ usdFoil: 5.0 }); 70 + expect(getCheapestPrice(v)).toBe(5.0); 71 + }); 72 + 73 + it("returns etched price when it's cheapest", () => { 74 + const v = mockVolatileData({ usd: 10.0, usdFoil: 15.0, usdEtched: 5.0 }); 75 + expect(getCheapestPrice(v)).toBe(5.0); 76 + }); 77 + 78 + it("handles zero price correctly", () => { 79 + const v = mockVolatileData({ usd: 0, usdFoil: 1.0 }); 80 + expect(getCheapestPrice(v)).toBe(0); 81 + }); 82 + }); 83 + 84 + describe("findCheapestPrinting", () => { 85 + const id1 = asScryfallId("00000000-0000-0000-0000-000000000001"); 86 + const id2 = asScryfallId("00000000-0000-0000-0000-000000000002"); 87 + const id3 = asScryfallId("00000000-0000-0000-0000-000000000003"); 88 + 89 + it("returns printing with lowest price", () => { 90 + const volatileData = new Map<ScryfallId, VolatileData | null>([ 91 + [id1, mockVolatileData({ usd: 5.0 })], 92 + [id2, mockVolatileData({ usd: 1.0 })], 93 + [id3, mockVolatileData({ usd: 3.0 })], 94 + ]); 95 + expect(findCheapestPrinting([id1, id2, id3], volatileData)).toBe(id2); 96 + }); 97 + 98 + it("considers foil prices", () => { 99 + const volatileData = new Map<ScryfallId, VolatileData | null>([ 100 + [id1, mockVolatileData({ usd: 5.0 })], 101 + [id2, mockVolatileData({ usdFoil: 0.5 })], 102 + ]); 103 + expect(findCheapestPrinting([id1, id2], volatileData)).toBe(id2); 104 + }); 105 + 106 + it("returns null when no prices available", () => { 107 + const volatileData = new Map<ScryfallId, VolatileData | null>([ 108 + [id1, mockVolatileData()], 109 + ]); 110 + expect(findCheapestPrinting([id1], volatileData)).toBeNull(); 111 + }); 112 + 113 + it("returns null for empty printing list", () => { 114 + expect(findCheapestPrinting([], new Map())).toBeNull(); 115 + }); 116 + 117 + it("skips printings with null volatile data", () => { 118 + const volatileData = new Map<ScryfallId, VolatileData | null>([ 119 + [id1, null], 120 + [id2, mockVolatileData({ usd: 2.0 })], 121 + ]); 122 + expect(findCheapestPrinting([id1, id2], volatileData)).toBe(id2); 123 + }); 124 + 125 + it("skips printings not in volatile data map", () => { 126 + const volatileData = new Map<ScryfallId, VolatileData | null>([ 127 + [id2, mockVolatileData({ usd: 2.0 })], 128 + ]); 129 + expect(findCheapestPrinting([id1, id2], volatileData)).toBe(id2); 130 + }); 131 + }); 132 + 133 + describe("updateDeckPrintings", () => { 134 + const oldId = asScryfallId("00000000-0000-0000-0000-000000000001"); 135 + const newId = asScryfallId("00000000-0000-0000-0000-000000000002"); 136 + const keepId = asScryfallId("00000000-0000-0000-0000-000000000003"); 137 + 138 + it("updates scryfallIds based on mapping", () => { 139 + const deck = mockDeck([{ scryfallId: oldId }]); 140 + const updates = new Map([[oldId, newId]]); 141 + const result = updateDeckPrintings(deck, updates); 142 + 143 + expect(result.cards[0].scryfallId).toBe(newId); 144 + }); 145 + 146 + it("preserves cards not in update map", () => { 147 + const deck = mockDeck([{ scryfallId: keepId }]); 148 + const result = updateDeckPrintings(deck, new Map()); 149 + 150 + expect(result.cards[0].scryfallId).toBe(keepId); 151 + }); 152 + 153 + it("handles empty deck", () => { 154 + const deck = mockDeck([]); 155 + const result = updateDeckPrintings(deck, new Map()); 156 + 157 + expect(result.cards).toEqual([]); 158 + }); 159 + 160 + it("updates only cards in the map", () => { 161 + const deck = mockDeck([{ scryfallId: oldId }, { scryfallId: keepId }]); 162 + const updates = new Map([[oldId, newId]]); 163 + const result = updateDeckPrintings(deck, updates); 164 + 165 + expect(result.cards[0].scryfallId).toBe(newId); 166 + expect(result.cards[1].scryfallId).toBe(keepId); 167 + }); 168 + 169 + it("preserves other card properties", () => { 170 + const deck: Deck = { 171 + $type: "com.deckbelcher.deck.list", 172 + name: "Test Deck", 173 + format: "commander", 174 + cards: [ 175 + { 176 + scryfallId: oldId, 177 + quantity: 4, 178 + section: "sideboard", 179 + tags: ["removal", "instant"], 180 + }, 181 + ], 182 + createdAt: new Date().toISOString(), 183 + }; 184 + const updates = new Map([[oldId, newId]]); 185 + const result = updateDeckPrintings(deck, updates); 186 + 187 + expect(result.cards[0]).toEqual({ 188 + scryfallId: newId, 189 + quantity: 4, 190 + section: "sideboard", 191 + tags: ["removal", "instant"], 192 + }); 193 + }); 194 + 195 + it("returns same deck reference when no updates", () => { 196 + const deck = mockDeck([{ scryfallId: keepId }]); 197 + const result = updateDeckPrintings(deck, new Map()); 198 + 199 + expect(result).toBe(deck); 200 + }); 201 + 202 + it("sets updatedAt when changes are made", () => { 203 + const deck = mockDeck([{ scryfallId: oldId }]); 204 + const originalUpdatedAt = deck.updatedAt; 205 + const updates = new Map([[oldId, newId]]); 206 + 207 + const result = updateDeckPrintings(deck, updates); 208 + 209 + expect(result.updatedAt).not.toBe(originalUpdatedAt); 210 + }); 211 + }); 212 + 213 + describe("findAllCheapestPrintings", () => { 214 + const oracle1 = asOracleId("11111111-1111-1111-1111-111111111111"); 215 + const oracle2 = asOracleId("22222222-2222-2222-2222-222222222222"); 216 + 217 + const card1a = asScryfallId("1a1a1a1a-1a1a-1a1a-1a1a-1a1a1a1a1a1a"); 218 + const card1b = asScryfallId("1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b"); 219 + const card2a = asScryfallId("2a2a2a2a-2a2a-2a2a-2a2a-2a2a2a2a2a2a"); 220 + 221 + function mockProvider(config: { 222 + cards: Record<string, { oracle_id: OracleId }>; 223 + printings: Record<string, ScryfallId[]>; 224 + volatileData: Record<string, VolatileData | null>; 225 + canonical?: Record<string, ScryfallId>; 226 + }): CardDataProvider { 227 + return { 228 + getCardById: vi.fn(async (id: ScryfallId) => { 229 + const data = config.cards[id]; 230 + if (!data) return undefined; 231 + return { id, oracle_id: data.oracle_id, name: "Test Card" } as Card; 232 + }), 233 + getPrintingsByOracleId: vi.fn( 234 + async (oracleId: OracleId) => config.printings[oracleId] ?? [], 235 + ), 236 + getVolatileData: vi.fn( 237 + async (id: ScryfallId) => config.volatileData[id] ?? null, 238 + ), 239 + getCanonicalPrinting: vi.fn( 240 + async (oracleId: OracleId) => config.canonical?.[oracleId], 241 + ), 242 + getMetadata: vi.fn(async () => ({ version: "test", cardCount: 100 })), 243 + }; 244 + } 245 + 246 + it("finds cheapest printing for each card", async () => { 247 + const provider = mockProvider({ 248 + cards: { 249 + [card1a]: { oracle_id: oracle1 }, 250 + }, 251 + printings: { 252 + [oracle1]: [card1a, card1b], 253 + }, 254 + volatileData: { 255 + [card1a]: mockVolatileData({ usd: 10.0 }), 256 + [card1b]: mockVolatileData({ usd: 2.0 }), 257 + }, 258 + }); 259 + 260 + const deck = mockDeck([{ scryfallId: card1a }]); 261 + const updates = await findAllCheapestPrintings(deck, provider); 262 + 263 + expect(updates.get(card1a)).toBe(card1b); 264 + }); 265 + 266 + it("skips cards already at cheapest", async () => { 267 + const provider = mockProvider({ 268 + cards: { 269 + [card1a]: { oracle_id: oracle1 }, 270 + }, 271 + printings: { 272 + [oracle1]: [card1a, card1b], 273 + }, 274 + volatileData: { 275 + [card1a]: mockVolatileData({ usd: 1.0 }), 276 + [card1b]: mockVolatileData({ usd: 10.0 }), 277 + }, 278 + }); 279 + 280 + const deck = mockDeck([{ scryfallId: card1a }]); 281 + const updates = await findAllCheapestPrintings(deck, provider); 282 + 283 + expect(updates.size).toBe(0); 284 + }); 285 + 286 + it("handles multiple cards with same oracle", async () => { 287 + const provider = mockProvider({ 288 + cards: { 289 + [card1a]: { oracle_id: oracle1 }, 290 + [card1b]: { oracle_id: oracle1 }, 291 + }, 292 + printings: { 293 + [oracle1]: [card1a, card1b], 294 + }, 295 + volatileData: { 296 + [card1a]: mockVolatileData({ usd: 10.0 }), 297 + [card1b]: mockVolatileData({ usd: 2.0 }), 298 + }, 299 + }); 300 + 301 + const deck = mockDeck([ 302 + { scryfallId: card1a }, 303 + { scryfallId: card1a, section: "sideboard" }, 304 + ]); 305 + const updates = await findAllCheapestPrintings(deck, provider); 306 + 307 + expect(updates.get(card1a)).toBe(card1b); 308 + }); 309 + 310 + it("handles cards with no price data", async () => { 311 + const provider = mockProvider({ 312 + cards: { 313 + [card1a]: { oracle_id: oracle1 }, 314 + }, 315 + printings: { 316 + [oracle1]: [card1a, card1b], 317 + }, 318 + volatileData: { 319 + [card1a]: mockVolatileData(), 320 + [card1b]: mockVolatileData(), 321 + }, 322 + }); 323 + 324 + const deck = mockDeck([{ scryfallId: card1a }]); 325 + const updates = await findAllCheapestPrintings(deck, provider); 326 + 327 + expect(updates.size).toBe(0); 328 + }); 329 + 330 + it("handles multiple different cards", async () => { 331 + const provider = mockProvider({ 332 + cards: { 333 + [card1a]: { oracle_id: oracle1 }, 334 + [card2a]: { oracle_id: oracle2 }, 335 + }, 336 + printings: { 337 + [oracle1]: [card1a, card1b], 338 + [oracle2]: [card2a], 339 + }, 340 + volatileData: { 341 + [card1a]: mockVolatileData({ usd: 10.0 }), 342 + [card1b]: mockVolatileData({ usd: 2.0 }), 343 + [card2a]: mockVolatileData({ usd: 5.0 }), 344 + }, 345 + }); 346 + 347 + const deck = mockDeck([{ scryfallId: card1a }, { scryfallId: card2a }]); 348 + const updates = await findAllCheapestPrintings(deck, provider); 349 + 350 + expect(updates.get(card1a)).toBe(card1b); 351 + expect(updates.has(card2a)).toBe(false); 352 + }); 353 + }); 354 + 355 + describe("findAllCheapestPrintings edge cases", () => { 356 + const oracle1 = asOracleId("11111111-1111-1111-1111-111111111111"); 357 + 358 + const cardExpensive = asScryfallId("eeee-eeee-eeee-eeee-eeeeeeeeeeee"); 359 + const cardCheap = asScryfallId("cccc-cccc-cccc-cccc-cccccccccccc"); 360 + const cardMid = asScryfallId("mmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"); 361 + 362 + function mockProvider(): CardDataProvider { 363 + return { 364 + getCardById: vi.fn(async (id: ScryfallId) => { 365 + // All cards have the same oracle_id 366 + return { id, oracle_id: oracle1, name: "Lightning Bolt" } as Card; 367 + }), 368 + getPrintingsByOracleId: vi.fn(async () => [ 369 + cardExpensive, 370 + cardCheap, 371 + cardMid, 372 + ]), 373 + getVolatileData: vi.fn(async (id: ScryfallId) => { 374 + if (id === cardExpensive) return mockVolatileData({ usd: 100.0 }); 375 + if (id === cardCheap) return mockVolatileData({ usd: 0.25 }); 376 + if (id === cardMid) return mockVolatileData({ usd: 5.0 }); 377 + return null; 378 + }), 379 + getCanonicalPrinting: vi.fn(async () => cardExpensive), 380 + getMetadata: vi.fn(async () => ({ version: "test", cardCount: 100 })), 381 + }; 382 + } 383 + 384 + it("updates multiple cards with different printings of same oracle to same cheapest", async () => { 385 + const provider = mockProvider(); 386 + const deck: Deck = { 387 + $type: "com.deckbelcher.deck.list", 388 + name: "Test Deck", 389 + format: "modern", 390 + cards: [ 391 + // Same oracle, different printings in same section 392 + { 393 + scryfallId: cardExpensive, 394 + quantity: 2, 395 + section: "mainboard", 396 + tags: [], 397 + }, 398 + { 399 + scryfallId: cardMid, 400 + quantity: 2, 401 + section: "mainboard", 402 + tags: ["burn"], 403 + }, 404 + ], 405 + createdAt: new Date().toISOString(), 406 + }; 407 + 408 + const updates = await findAllCheapestPrintings(deck, provider); 409 + 410 + // Both should update to the cheapest 411 + expect(updates.get(cardExpensive)).toBe(cardCheap); 412 + expect(updates.get(cardMid)).toBe(cardCheap); 413 + }); 414 + 415 + it("handles same card in different sections with different printings", async () => { 416 + const provider = mockProvider(); 417 + const deck: Deck = { 418 + $type: "com.deckbelcher.deck.list", 419 + name: "Test Deck", 420 + format: "modern", 421 + cards: [ 422 + { 423 + scryfallId: cardExpensive, 424 + quantity: 4, 425 + section: "mainboard", 426 + tags: [], 427 + }, 428 + { 429 + scryfallId: cardMid, 430 + quantity: 2, 431 + section: "sideboard", 432 + tags: ["sb"], 433 + }, 434 + ], 435 + createdAt: new Date().toISOString(), 436 + }; 437 + 438 + const updates = await findAllCheapestPrintings(deck, provider); 439 + 440 + // Both should update to cheapest 441 + expect(updates.get(cardExpensive)).toBe(cardCheap); 442 + expect(updates.get(cardMid)).toBe(cardCheap); 443 + }); 444 + 445 + it("handles same printing in same section with different tag entries", async () => { 446 + const provider = mockProvider(); 447 + // Note: This is technically invalid deck state (same scryfallId+section twice) 448 + // but we should handle it gracefully 449 + const deck: Deck = { 450 + $type: "com.deckbelcher.deck.list", 451 + name: "Test Deck", 452 + format: "modern", 453 + cards: [ 454 + { 455 + scryfallId: cardExpensive, 456 + quantity: 2, 457 + section: "mainboard", 458 + tags: ["burn"], 459 + }, 460 + { 461 + scryfallId: cardExpensive, 462 + quantity: 2, 463 + section: "mainboard", 464 + tags: ["removal"], 465 + }, 466 + ], 467 + createdAt: new Date().toISOString(), 468 + }; 469 + 470 + const updates = await findAllCheapestPrintings(deck, provider); 471 + 472 + // Both entries should get the update mapping 473 + expect(updates.get(cardExpensive)).toBe(cardCheap); 474 + }); 475 + 476 + it("preserves card already at cheapest among mixed printings", async () => { 477 + const provider = mockProvider(); 478 + const deck: Deck = { 479 + $type: "com.deckbelcher.deck.list", 480 + name: "Test Deck", 481 + format: "modern", 482 + cards: [ 483 + { scryfallId: cardCheap, quantity: 2, section: "mainboard", tags: [] }, 484 + { 485 + scryfallId: cardExpensive, 486 + quantity: 2, 487 + section: "mainboard", 488 + tags: [], 489 + }, 490 + ], 491 + createdAt: new Date().toISOString(), 492 + }; 493 + 494 + const updates = await findAllCheapestPrintings(deck, provider); 495 + 496 + // cardCheap should NOT be in updates (it's already cheapest) 497 + expect(updates.has(cardCheap)).toBe(false); 498 + // cardExpensive should update to cardCheap 499 + expect(updates.get(cardExpensive)).toBe(cardCheap); 500 + }); 501 + }); 502 + 503 + describe("updateDeckPrintings edge cases", () => { 504 + const oldPrinting = asScryfallId("oooo-oooo-oooo-oooo-oooooooooooo"); 505 + const newPrinting = asScryfallId("nnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn"); 506 + 507 + it("updates all instances of a printing regardless of section", () => { 508 + const deck: Deck = { 509 + $type: "com.deckbelcher.deck.list", 510 + name: "Test Deck", 511 + format: "modern", 512 + cards: [ 513 + { 514 + scryfallId: oldPrinting, 515 + quantity: 4, 516 + section: "mainboard", 517 + tags: [], 518 + }, 519 + { 520 + scryfallId: oldPrinting, 521 + quantity: 2, 522 + section: "sideboard", 523 + tags: ["sb"], 524 + }, 525 + ], 526 + createdAt: new Date().toISOString(), 527 + }; 528 + 529 + const updates = new Map([[oldPrinting, newPrinting]]); 530 + const result = updateDeckPrintings(deck, updates); 531 + 532 + expect(result.cards[0].scryfallId).toBe(newPrinting); 533 + expect(result.cards[1].scryfallId).toBe(newPrinting); 534 + }); 535 + 536 + it("preserves tags when updating printings", () => { 537 + const deck: Deck = { 538 + $type: "com.deckbelcher.deck.list", 539 + name: "Test Deck", 540 + format: "modern", 541 + cards: [ 542 + { 543 + scryfallId: oldPrinting, 544 + quantity: 4, 545 + section: "mainboard", 546 + tags: ["burn", "instant"], 547 + }, 548 + ], 549 + createdAt: new Date().toISOString(), 550 + }; 551 + 552 + const updates = new Map([[oldPrinting, newPrinting]]); 553 + const result = updateDeckPrintings(deck, updates); 554 + 555 + expect(result.cards[0].tags).toEqual(["burn", "instant"]); 556 + }); 557 + 558 + it("preserves quantity when updating printings", () => { 559 + const deck: Deck = { 560 + $type: "com.deckbelcher.deck.list", 561 + name: "Test Deck", 562 + format: "modern", 563 + cards: [ 564 + { 565 + scryfallId: oldPrinting, 566 + quantity: 4, 567 + section: "mainboard", 568 + tags: [], 569 + }, 570 + ], 571 + createdAt: new Date().toISOString(), 572 + }; 573 + 574 + const updates = new Map([[oldPrinting, newPrinting]]); 575 + const result = updateDeckPrintings(deck, updates); 576 + 577 + expect(result.cards[0].quantity).toBe(4); 578 + }); 579 + 580 + it("does NOT merge separate entries that end up with same printing", () => { 581 + const printingA = asScryfallId("aaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); 582 + const printingB = asScryfallId("bbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); 583 + const cheapest = asScryfallId("cccc-cccc-cccc-cccc-cccccccccccc"); 584 + 585 + const deck: Deck = { 586 + $type: "com.deckbelcher.deck.list", 587 + name: "Test Deck", 588 + format: "modern", 589 + cards: [ 590 + { 591 + scryfallId: printingA, 592 + quantity: 2, 593 + section: "mainboard", 594 + tags: ["burn"], 595 + }, 596 + { 597 + scryfallId: printingB, 598 + quantity: 3, 599 + section: "mainboard", 600 + tags: ["removal"], 601 + }, 602 + ], 603 + createdAt: new Date().toISOString(), 604 + }; 605 + 606 + const updates = new Map([ 607 + [printingA, cheapest], 608 + [printingB, cheapest], 609 + ]); 610 + const result = updateDeckPrintings(deck, updates); 611 + 612 + // Should have 2 separate entries, NOT merged into 1 613 + expect(result.cards.length).toBe(2); 614 + expect(result.cards[0].scryfallId).toBe(cheapest); 615 + expect(result.cards[0].quantity).toBe(2); 616 + expect(result.cards[0].tags).toEqual(["burn"]); 617 + expect(result.cards[1].scryfallId).toBe(cheapest); 618 + expect(result.cards[1].quantity).toBe(3); 619 + expect(result.cards[1].tags).toEqual(["removal"]); 620 + }); 621 + }); 622 + 623 + describe("findAllCanonicalPrintings", () => { 624 + const oracle1 = asOracleId("11111111-1111-1111-1111-111111111111"); 625 + 626 + const card1a = asScryfallId("1a1a1a1a-1a1a-1a1a-1a1a-1a1a1a1a1a1a"); 627 + const card1b = asScryfallId("1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b"); 628 + 629 + function mockProvider(config: { 630 + cards: Record<string, { oracle_id: OracleId }>; 631 + canonical: Record<string, ScryfallId>; 632 + }): CardDataProvider { 633 + return { 634 + getCardById: vi.fn(async (id: ScryfallId) => { 635 + const data = config.cards[id]; 636 + if (!data) return undefined; 637 + return { id, oracle_id: data.oracle_id, name: "Test Card" } as Card; 638 + }), 639 + getPrintingsByOracleId: vi.fn(async () => []), 640 + getVolatileData: vi.fn(async () => null), 641 + getCanonicalPrinting: vi.fn( 642 + async (oracleId: OracleId) => config.canonical[oracleId], 643 + ), 644 + getMetadata: vi.fn(async () => ({ version: "test", cardCount: 100 })), 645 + }; 646 + } 647 + 648 + it("finds canonical printing for each card", async () => { 649 + const provider = mockProvider({ 650 + cards: { 651 + [card1a]: { oracle_id: oracle1 }, 652 + }, 653 + canonical: { 654 + [oracle1]: card1b, 655 + }, 656 + }); 657 + 658 + const deck = mockDeck([{ scryfallId: card1a }]); 659 + const updates = await findAllCanonicalPrintings(deck, provider); 660 + 661 + expect(updates.get(card1a)).toBe(card1b); 662 + }); 663 + 664 + it("skips cards already at canonical", async () => { 665 + const provider = mockProvider({ 666 + cards: { 667 + [card1a]: { oracle_id: oracle1 }, 668 + }, 669 + canonical: { 670 + [oracle1]: card1a, 671 + }, 672 + }); 673 + 674 + const deck = mockDeck([{ scryfallId: card1a }]); 675 + const updates = await findAllCanonicalPrintings(deck, provider); 676 + 677 + expect(updates.size).toBe(0); 678 + }); 679 + 680 + it("handles cards with no canonical printing", async () => { 681 + const provider = mockProvider({ 682 + cards: { 683 + [card1a]: { oracle_id: oracle1 }, 684 + }, 685 + canonical: {}, 686 + }); 687 + 688 + const deck = mockDeck([{ scryfallId: card1a }]); 689 + const updates = await findAllCanonicalPrintings(deck, provider); 690 + 691 + expect(updates.size).toBe(0); 692 + }); 693 + });
+89
src/lib/__tests__/test-card-lookup.ts
··· 1 + /** 2 + * Test utility for looking up real card data by name. 3 + * 4 + * Uses a fixture file (test-cards.json) for name → oracle_id mapping, 5 + * then ServerCardProvider for fast binary-search card lookups. 6 + * 7 + * Usage: 8 + * const cards = await setupTestCards(); 9 + * 10 + * const llanowar = await cards.get("Llanowar Elves"); 11 + * expect(getSourceTempo(llanowar)).toBe("delayed"); 12 + * 13 + * To add a new test card: 14 + * ./src/lib/__tests__/add-test-card.sh "Card Name" 15 + */ 16 + 17 + import { ServerCardProvider } from "../cards-server-provider"; 18 + import type { Card, OracleId } from "../scryfall-types"; 19 + import { asOracleId } from "../scryfall-types"; 20 + import testCardsFixture from "./test-cards.json"; 21 + import { mockFetchFromPublicDir } from "./test-helpers"; 22 + 23 + const nameToOracleId: Record<string, string> = testCardsFixture; 24 + 25 + /** 26 + * Get oracle ID for a card name from the test fixture. 27 + * Throws if card not in fixture. 28 + */ 29 + export function getTestCardOracleId(name: string): OracleId { 30 + const oracleIdStr = nameToOracleId[name]; 31 + if (!oracleIdStr) { 32 + const available = Object.keys(nameToOracleId) 33 + .filter((n) => n !== "$comment") 34 + .join(", "); 35 + throw new Error( 36 + `Card "${name}" not in test fixture. Available: ${available}\n` + 37 + `To add: ./src/lib/__tests__/add-test-card.sh "${name}"`, 38 + ); 39 + } 40 + return asOracleId(oracleIdStr); 41 + } 42 + 43 + export class TestCardLookup { 44 + private provider: ServerCardProvider; 45 + 46 + constructor() { 47 + this.provider = new ServerCardProvider(); 48 + } 49 + 50 + async get(name: string): Promise<Card> { 51 + const oracleId = getTestCardOracleId(name); 52 + const scryfallId = await this.provider.getCanonicalPrinting(oracleId); 53 + if (!scryfallId) { 54 + throw new Error( 55 + `No canonical printing found for "${name}" (${oracleId})`, 56 + ); 57 + } 58 + 59 + const card = await this.provider.getCardById(scryfallId); 60 + if (!card) { 61 + throw new Error(`Card data not found for "${name}" (${scryfallId})`); 62 + } 63 + 64 + // For MDFCs, allow matching either the front face or full name 65 + const nameMatches = 66 + card.name === name || card.name.startsWith(`${name} //`); 67 + if (!nameMatches) { 68 + throw new Error( 69 + `Name mismatch: expected "${name}" but got "${card.name}". ` + 70 + `Oracle ID ${oracleId} may be wrong in test-cards.json`, 71 + ); 72 + } 73 + 74 + return card; 75 + } 76 + 77 + async getAll(...names: string[]): Promise<Card[]> { 78 + return Promise.all(names.map((name) => this.get(name))); 79 + } 80 + } 81 + 82 + /** 83 + * Set up test card lookup with mocked fetch. 84 + * Call this in beforeAll. 85 + */ 86 + export async function setupTestCards(): Promise<TestCardLookup> { 87 + mockFetchFromPublicDir(); 88 + return new TestCardLookup(); 89 + }
+118
src/lib/__tests__/test-cards.json
··· 1 + { 2 + "$comment": "Maps card names to oracle IDs for test fixtures. Add cards here as needed for tests.", 3 + "A-Lantern Bearer": "ba790609-7b48-4a96-a21f-5a0cfcf316a3", 4 + "A-Llanowar Greenwidow": "57920afe-61b7-4db1-ad79-dca4f0bc281b", 5 + "Aboroth": "28f70f86-19a9-4811-bc10-423a05842d39", 6 + "Adarkar Wastes": "d5ad26cc-2bdb-46b7-b8bf-dd099d5fa09b", 7 + "Aerathi Berserker": "5cb495a2-c683-4066-b6ee-d0b7d8843cb9", 8 + "Akki Lavarunner": "47795817-73e5-4af6-bd1e-d69b193e8e9e", 9 + "Akki Rockspeaker": "01cd52f9-7fac-4396-bec7-d06bf683e011", 10 + "Akoum Warrior": "afedce7b-0e18-40ad-a26a-1933fddb560d", 11 + "Ancient Tomb": "23467047-6dba-4498-b783-1ebc4f74b8c2", 12 + "Apostle's Blessing": "ea393815-0202-4edc-aa1e-6885fe9b20ab", 13 + "Arcbound Wanderer": "03436524-197a-4941-a2a1-c7c4b71c4709", 14 + "Arcum's Astrolabe": "ec463bde-dadf-4044-8e41-71338fd4d62f", 15 + "Axebane Ferox": "35b3ae1c-4116-49b0-89a9-7332fe9bbff0", 16 + "Azorius Chancery": "189fc8f4-17ac-4f1d-82c8-8401445bdaf4", 17 + "Barbara Wright": "0e61a062-f13a-4958-91de-909650c662a8", 18 + "Barkchannel Pathway": "59d22de5-e310-44d7-89cf-ef3529e40cef", 19 + "Basking Broodscale": "a7c69802-99c4-4778-934e-cc09ba58c267", 20 + "Beastcaller Savant": "e28227eb-b3b5-42fa-b597-1fefd2a70186", 21 + "Beloved Chaplain": "a07406b7-faa1-4ed3-ab84-314c40f3f7f1", 22 + "Bird Admirer": "58bd02ae-2676-4c9c-b24e-2bd51be8bde7", 23 + "Birds of Paradise": "d3a0b660-358c-41bd-9cd2-41fbf3491b1a", 24 + "Black Knight": "9456c5b6-946d-403a-8ed0-dff9f921d98c", 25 + "Blood Knight": "67fba605-9cfa-499c-83e0-4fbd023bcfa0", 26 + "Blood Pet": "e05c6c80-a91a-45e0-b991-0014fd5a6472", 27 + "Bloodbraid Challenger": "6e111622-c7cc-49cf-83e8-3555e06518c1", 28 + "Blossoming Tortoise": "5e1bf23b-7fb0-45ff-8544-fce9fa3eba00", 29 + "Bosh, Iron Golem": "2fb6f65f-a0fb-4d45-be1b-d6405e61d47a", 30 + "Botanical Sanctum": "88f8f683-738e-48f3-afff-c8f73f1033a2", 31 + "Breeding Pool": "20283c4a-f1f0-42f0-bc08-6da87474426b", 32 + "Burning-Tree Emissary": "327d9679-0049-4401-8dab-e0fb362306bd", 33 + "Cabal Coffers": "7358e164-5704-4e78-9b21-6a9bf2a968ce", 34 + "Caldera Lake": "c737d27b-db14-4bd4-8f16-bcbd4401c47b", 35 + "Canopy Vista": "dcb7e046-f01b-497c-88e5-57794eb30ce5", 36 + "Celestial Colonnade": "876ac6f6-74de-4666-84c6-83d81f054723", 37 + "Chrome Mox": "ec3d4466-547c-4e02-b1b5-a156ec4637e9", 38 + "City of Brass": "f25351e3-539b-4bbc-b92d-6480acf4d722", 39 + "Command Tower": "0895c9b7-ae7d-4bb3-af17-3b75deb50a25", 40 + "Concealed Courtyard": "2d899466-b1eb-4901-b626-1f2fb09b786d", 41 + "Cormela, Glamour Thief": "3cd9bac9-0abc-43d8-9f84-94f965f7a2e0", 42 + "Cryptolith Rite": "043f869d-b11c-4c0d-9591-2bf0df7bde55", 43 + "Dark Ritual": "53f7c868-b03e-4fc2-8dcf-a75bbfa3272b", 44 + "Deepcavern Imp": "1f295f2f-969a-4b82-98d1-7fe307ec83a7", 45 + "Delver of Secrets": "edd531b9-f615-4399-8c8c-1c5e18c4acbf", 46 + "Dreamroot Cascade": "dd8538e6-cd5f-4a88-aff5-eb5e76ce8ddb", 47 + "Dryad Arbor": "e996cd67-739c-40f4-b276-0042acf26c71", 48 + "Elvish Mystic": "3f3b2c10-21f8-4e13-be83-4ef3fa36e123", 49 + "Elvish Spirit Guide": "6b0e23cf-7d68-4329-86db-7adc26abd86b", 50 + "Everflowing Chalice": "0a79237e-0811-4a8a-bd4d-db3ca91bff22", 51 + "Evolving Wilds": "a75445d3-1303-4bb5-89ad-26ea93fecd48", 52 + "Exotic Orchard": "27b047e3-0d41-45e2-98e9-9391d7923a1e", 53 + "Fabled Passage": "0c85b8f7-0bd0-4680-9ec5-d4b110460a54", 54 + "Farseek": "495e52e6-4c2b-4574-9474-eadbdcc8b4ac", 55 + "Fireball": "aa7714b0-2bfb-458a-8ebf-37ec2c53383e", 56 + "Flooded Grove": "dc974eb4-72b9-4213-887b-8ee684b93420", 57 + "Forest": "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", 58 + "Fyndhorn Elves": "df317532-7d36-40fd-938f-e972749c8792", 59 + "Gaea's Cradle": "7c427c3d-ecd8-45ef-bebd-8f10f4a311db", 60 + "Gallowbraid": "4a4418fa-7574-4027-aa09-4ca789be15ba", 61 + "Gilded Lotus": "9a02a9a7-39d9-4763-85d3-747a0540b60b", 62 + "Glacial Fortress": "027dd013-baa7-4111-b3c9-f4d1414e9c45", 63 + "Graven Cairns": "5004b84a-33b7-4f6f-b2c2-7086b9087535", 64 + "Grim Monolith": "229d6627-1292-4ae1-8849-b0f956fa6540", 65 + "Hedron Archive": "32263baa-d3f0-463f-92b3-4e9938476add", 66 + "Hinterland Harbor": "fb5a3403-7f0b-406c-8c4f-d693be010ca6", 67 + "Horizon Canopy": "262a5d83-506c-4781-9bc9-1a2b5d83955c", 68 + "Inspiring Vantage": "3f17c60e-923a-4392-9da8-87d9ded009b7", 69 + "Karlach, Raging Tiefling": "f40ddd57-ad75-477c-bfe6-1b0ca68b88b6", 70 + "Ketria Triome": "6bae00e8-06cf-4ac4-a1cc-757e454109fe", 71 + "Lightning Bolt": "4457ed35-7c10-48c8-9776-456485fdf070", 72 + "Lion's Eye Diamond": "ee6099b0-fb1f-42f1-b862-7708c6e36d05", 73 + "Little Girl": "b0d1c34c-30f1-4c07-9527-38b49231eb9f", 74 + "Llanowar Elves": "68954295-54e3-4303-a6bc-fc4547a4e3a3", 75 + "Lotus Petal": "32e5339e-9e4f-46f8-b305-f9d6d3ba8bb5", 76 + "Lunar Hatchling": "cb2c8ea9-c125-4742-a562-b693254152e5", 77 + "Mana Confluence": "d0ee5bdc-2b69-4b73-9a20-ffcc18783b29", 78 + "Mana Crypt": "2c63e4e1-89d2-4bc6-a232-94e75c4b1c8a", 79 + "Mana Vault": "736892cb-a34b-4bb9-b56c-e26e3db207a2", 80 + "Misty Rainforest": "09dd85aa-47bc-4713-a9b9-8b52ff2285ed", 81 + "Mox Diamond": "f3c5978a-70fa-431f-933b-b954bd0db0ea", 82 + "Mystic Gate": "e9f5feb2-2c1a-46ce-885a-4f378d7d10af", 83 + "Nicol Bolas, Planeswalker": "05e9b55e-6329-48ec-b7d7-24c6b9692244", 84 + "Ornithopter": "a3a98bc9-caa0-49b7-951c-fe4e4f54e4ba", 85 + "Phyrexian Tower": "1861e642-21d5-4232-89f3-b5557f2946c1", 86 + "Polluted Delta": "ef86989d-ce80-4e55-aece-7d11710eeffa", 87 + "Prairie Stream": "5330e24a-8568-446e-840a-594cd08bd1bc", 88 + "Priest of Gix": "d93f82ce-0eed-45cc-a7b1-50fd4cbb6152", 89 + "Priest of Urabrask": "e4bd8910-770b-4220-8a26-2673491f4a3e", 90 + "Prismatic Vista": "032b8a0d-491a-4a12-ab9f-689010054d5b", 91 + "Raging Ravine": "8d38194e-b607-4ff4-9c19-0e8636d463bf", 92 + "Scalding Tarn": "cb027150-848c-4a66-88ad-e20222304dd8", 93 + "Seachrome Coast": "9e7a240d-dc33-47ac-9f17-77fab4c1c340", 94 + "Selvala, Heart of the Wilds": "1d725121-e50c-42f0-9128-56802f07c89e", 95 + "Serra Angel": "4b7ac066-e5c7-43e6-9e7e-2739b24a905d", 96 + "Simian Spirit Guide": "44e0ffa3-8915-4c1f-8f1a-4aeea1365f07", 97 + "Simic Growth Chamber": "046f5783-cc7b-416a-8cf6-2bcef9c2cc1a", 98 + "Skirk Prospector": "c18013e4-0b99-44e3-a2b2-027ace68723a", 99 + "Snow-Covered Forest": "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d", 100 + "Sol Ring": "6ad8011d-3471-4369-9d68-b264cc027487", 101 + "Steam Vents": "17039058-822d-409f-938c-b727a366ba63", 102 + "Tarmogoyf": "45900b2f-f6a9-4c42-9642-008f3c1cf6dd", 103 + "Tel-Jilad Chosen": "de6854d1-bdb0-4b9a-b442-08d4d90a538d", 104 + "Temple of Mystery": "7e26f0b7-20e6-46d5-8130-d98c14d6aa29", 105 + "Thran Dynamo": "a699c663-8131-4045-9265-a83e86609374", 106 + "Thran Portal": "926ce6a2-7bdd-4380-ac65-bc902ba0c284", 107 + "Toy Boat": "2baf5c37-6191-4e9b-a080-5e12f735646f", 108 + "Tranquil Cove": "5d641bf6-0f93-4189-8dc1-ec7ea446dade", 109 + "Treasonous Ogre": "826d4279-1576-4898-a1c2-26fd547fb0d0", 110 + "Tropical Island": "74b7fe23-5d3a-4092-8d78-7c0eba8f6f73", 111 + "Undercity Sewers": "08d80efc-9542-4ba2-824c-c8615d8d07f2", 112 + "Underground Sea": "4b22be3a-8ce1-47d1-b82e-6c3ccfb0548b", 113 + "Vault Skirge": "64cf5d59-7bcd-4b0b-a160-c8468d4c0f60", 114 + "Wall of Shards": "8fab68ad-169d-46d3-93c4-5bdee4eea2ce", 115 + "Wastes": "05d24b0c-904a-46b6-b42a-96a4d91a0dd4", 116 + "Worn Powerstone": "b166b670-febc-4821-855e-f8d465644c03", 117 + "Yavimaya Coast": "40b36bc6-c185-4bda-99e7-0118953c2c97" 118 + }
+140 -48
src/lib/atproto-client.ts
··· 1 1 /** 2 - * ATProto client utilities for deck record CRUD operations 2 + * ATProto client utilities for record CRUD operations 3 3 * Reads via Slingshot (cached), writes via PDS (authenticated) 4 4 */ 5 5 ··· 7 7 import { Client } from "@atcute/client"; 8 8 import type { Did } from "@atcute/lexicons"; 9 9 import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 10 - import type { ComDeckbelcherDeckList } from "./lexicons/index"; 10 + import type { 11 + ComDeckbelcherCollectionList, 12 + ComDeckbelcherDeckList, 13 + } from "./lexicons/index"; 11 14 12 15 type AtUri = `at://${string}`; 13 16 14 17 const SLINGSHOT_BASE = "https://slingshot.microcosm.blue"; 15 - const COLLECTION = "com.deckbelcher.deck.list"; 18 + const DECK_COLLECTION = "com.deckbelcher.deck.list" as const; 19 + const LIST_COLLECTION = "com.deckbelcher.collection.list" as const; 20 + 21 + type Collection = typeof DECK_COLLECTION | typeof LIST_COLLECTION; 16 22 17 23 // Branded types for type safety 18 24 declare const PdsUrlBrand: unique symbol; ··· 29 35 return rkey as Rkey; 30 36 } 31 37 32 - export interface DeckRecordResponse { 38 + export type Result<T, E = Error> = 39 + | { success: true; data: T } 40 + | { success: false; error: E }; 41 + 42 + export interface RecordResponse<T> { 33 43 uri: AtUri; 34 44 cid: string; 35 - value: ComDeckbelcherDeckList.Main; 45 + value: T; 36 46 } 37 47 38 - export interface ListRecordsResponse { 39 - records: DeckRecordResponse[]; 48 + export interface ListRecordsResponse<T> { 49 + records: RecordResponse<T>[]; 40 50 cursor?: string; 41 51 } 42 52 43 - export type Result<T, E = Error> = 44 - | { success: true; data: T } 45 - | { success: false; error: E }; 53 + // ============================================================================ 54 + // Generic ATProto Operations 55 + // ============================================================================ 46 56 47 - /** 48 - * Fetch a deck record via Slingshot (cached, public read) 49 - */ 50 - export async function getDeckRecord( 57 + async function getRecord<T>( 51 58 did: Did, 52 59 rkey: Rkey, 53 - ): Promise<Result<DeckRecordResponse>> { 60 + collection: Collection, 61 + entityName: string, 62 + ): Promise<Result<RecordResponse<T>>> { 54 63 try { 55 64 const url = new URL(`${SLINGSHOT_BASE}/xrpc/com.atproto.repo.getRecord`); 56 65 url.searchParams.set("repo", did); 57 - url.searchParams.set("collection", COLLECTION); 66 + url.searchParams.set("collection", collection); 58 67 url.searchParams.set("rkey", rkey); 59 68 60 69 const response = await fetch(url.toString()); ··· 66 75 return { 67 76 success: false, 68 77 error: new Error( 69 - error.message || `Failed to fetch deck: ${response.statusText}`, 78 + error.message || 79 + `Failed to fetch ${entityName}: ${response.statusText}`, 70 80 ), 71 81 }; 72 82 } 73 83 74 - const data = (await response.json()) as DeckRecordResponse; 84 + const data = (await response.json()) as RecordResponse<T>; 75 85 return { success: true, data }; 76 86 } catch (error) { 77 87 return { ··· 81 91 } 82 92 } 83 93 84 - /** 85 - * Create a new deck record (authenticated write to PDS) 86 - */ 87 - export async function createDeckRecord( 94 + async function createRecord<T extends Record<string, unknown>>( 88 95 agent: OAuthUserAgent, 89 - record: ComDeckbelcherDeckList.Main, 96 + record: T, 97 + collection: Collection, 98 + entityName: string, 90 99 ): Promise<Result<{ uri: AtUri; cid: string; rkey: Rkey }>> { 91 100 try { 92 101 const client = new Client({ handler: agent }); 93 102 const response = await client.post("com.atproto.repo.createRecord", { 94 103 input: { 95 104 repo: agent.sub, 96 - collection: COLLECTION, 105 + collection, 97 106 record, 98 107 }, 99 108 }); ··· 102 111 return { 103 112 success: false, 104 113 error: new Error( 105 - response.data.message || "Failed to create deck record", 114 + response.data.message || `Failed to create ${entityName} record`, 106 115 ), 107 116 }; 108 117 } 109 118 110 - // Extract rkey from the URI (at://did:plc:.../collection/rkey) 111 119 const uri = response.data.uri as string; 112 120 const cid = response.data.cid as string; 113 121 const rkey = uri.split("/").pop(); ··· 130 138 } 131 139 } 132 140 133 - /** 134 - * Update an existing deck record (authenticated write to PDS) 135 - */ 136 - export async function updateDeckRecord( 141 + async function updateRecord<T extends Record<string, unknown>>( 137 142 agent: OAuthUserAgent, 138 143 rkey: Rkey, 139 - record: ComDeckbelcherDeckList.Main, 144 + record: T, 145 + collection: Collection, 146 + entityName: string, 140 147 ): Promise<Result<{ uri: AtUri; cid: string }>> { 141 148 try { 142 149 const client = new Client({ handler: agent }); 143 150 const response = await client.post("com.atproto.repo.putRecord", { 144 151 input: { 145 152 repo: agent.sub, 146 - collection: COLLECTION, 153 + collection, 147 154 rkey, 148 155 record, 149 156 }, ··· 153 160 return { 154 161 success: false, 155 162 error: new Error( 156 - response.data.message || "Failed to update deck record", 163 + response.data.message || `Failed to update ${entityName} record`, 157 164 ), 158 165 }; 159 166 } ··· 173 180 } 174 181 } 175 182 176 - /** 177 - * List all deck records for a user (direct PDS call) 178 - * Requires PDS URL for the target user 179 - */ 180 - export async function listUserDecks( 183 + async function listRecords<T>( 181 184 pdsUrl: PdsUrl, 182 185 did: Did, 183 - ): Promise<Result<ListRecordsResponse>> { 186 + collection: Collection, 187 + entityName: string, 188 + ): Promise<Result<ListRecordsResponse<T>>> { 184 189 try { 185 190 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 186 191 url.searchParams.set("repo", did); 187 - url.searchParams.set("collection", COLLECTION); 192 + url.searchParams.set("collection", collection); 188 193 189 194 const response = await fetch(url.toString()); 190 195 ··· 195 200 return { 196 201 success: false, 197 202 error: new Error( 198 - error.message || `Failed to list decks: ${response.statusText}`, 203 + error.message || 204 + `Failed to list ${entityName}s: ${response.statusText}`, 199 205 ), 200 206 }; 201 207 } 202 208 203 - const data = (await response.json()) as ListRecordsResponse; 209 + const data = (await response.json()) as ListRecordsResponse<T>; 204 210 return { success: true, data }; 205 211 } catch (error) { 206 212 return { ··· 210 216 } 211 217 } 212 218 213 - /** 214 - * Delete a deck record (authenticated write to PDS) 215 - */ 216 - export async function deleteDeckRecord( 219 + async function deleteRecord( 217 220 agent: OAuthUserAgent, 218 221 rkey: Rkey, 222 + collection: Collection, 223 + entityName: string, 219 224 ): Promise<Result<void>> { 220 225 try { 221 226 const client = new Client({ handler: agent }); 222 227 const response = await client.post("com.atproto.repo.deleteRecord", { 223 228 input: { 224 229 repo: agent.sub, 225 - collection: COLLECTION, 230 + collection, 226 231 rkey, 227 232 }, 228 233 }); ··· 231 236 return { 232 237 success: false, 233 238 error: new Error( 234 - response.data.message || "Failed to delete deck record", 239 + response.data.message || `Failed to delete ${entityName} record`, 235 240 ), 236 241 }; 237 242 } ··· 244 249 }; 245 250 } 246 251 } 252 + 253 + // ============================================================================ 254 + // Deck Records 255 + // ============================================================================ 256 + 257 + export type DeckRecordResponse = RecordResponse<ComDeckbelcherDeckList.Main>; 258 + 259 + export function getDeckRecord(did: Did, rkey: Rkey) { 260 + return getRecord<ComDeckbelcherDeckList.Main>( 261 + did, 262 + rkey, 263 + DECK_COLLECTION, 264 + "deck", 265 + ); 266 + } 267 + 268 + export function createDeckRecord( 269 + agent: OAuthUserAgent, 270 + record: ComDeckbelcherDeckList.Main, 271 + ) { 272 + return createRecord(agent, record, DECK_COLLECTION, "deck"); 273 + } 274 + 275 + export function updateDeckRecord( 276 + agent: OAuthUserAgent, 277 + rkey: Rkey, 278 + record: ComDeckbelcherDeckList.Main, 279 + ) { 280 + return updateRecord(agent, rkey, record, DECK_COLLECTION, "deck"); 281 + } 282 + 283 + export function listUserDecks(pdsUrl: PdsUrl, did: Did) { 284 + return listRecords<ComDeckbelcherDeckList.Main>( 285 + pdsUrl, 286 + did, 287 + DECK_COLLECTION, 288 + "deck", 289 + ); 290 + } 291 + 292 + export function deleteDeckRecord(agent: OAuthUserAgent, rkey: Rkey) { 293 + return deleteRecord(agent, rkey, DECK_COLLECTION, "deck"); 294 + } 295 + 296 + // ============================================================================ 297 + // Collection List Records 298 + // ============================================================================ 299 + 300 + export type CollectionListRecordResponse = 301 + RecordResponse<ComDeckbelcherCollectionList.Main>; 302 + 303 + export function getCollectionListRecord(did: Did, rkey: Rkey) { 304 + return getRecord<ComDeckbelcherCollectionList.Main>( 305 + did, 306 + rkey, 307 + LIST_COLLECTION, 308 + "list", 309 + ); 310 + } 311 + 312 + export function createCollectionListRecord( 313 + agent: OAuthUserAgent, 314 + record: ComDeckbelcherCollectionList.Main, 315 + ) { 316 + return createRecord(agent, record, LIST_COLLECTION, "list"); 317 + } 318 + 319 + export function updateCollectionListRecord( 320 + agent: OAuthUserAgent, 321 + rkey: Rkey, 322 + record: ComDeckbelcherCollectionList.Main, 323 + ) { 324 + return updateRecord(agent, rkey, record, LIST_COLLECTION, "list"); 325 + } 326 + 327 + export function listUserCollectionLists(pdsUrl: PdsUrl, did: Did) { 328 + return listRecords<ComDeckbelcherCollectionList.Main>( 329 + pdsUrl, 330 + did, 331 + LIST_COLLECTION, 332 + "list", 333 + ); 334 + } 335 + 336 + export function deleteCollectionListRecord(agent: OAuthUserAgent, rkey: Rkey) { 337 + return deleteRecord(agent, rkey, LIST_COLLECTION, "list"); 338 + }
+14 -10
src/lib/card-chunks.ts
··· 1 1 /** 2 2 * Auto-generated by scripts/download-scryfall.ts 3 - * Contains card data chunk filenames for client loading 3 + * 4 + * Files in /data/cards/ have content hashes in filenames for immutable caching. 5 + * This subfolder is configured for aggressive edge caching via Cloudflare rules 6 + * (TanStack Start doesn't support custom cache headers). 4 7 */ 5 8 9 + export const CARD_INDEXES = "indexes-2436e5c6f79396f5.json"; 10 + 6 11 export const CARD_CHUNKS = [ 7 - "cards-000.json", 8 - "cards-001.json", 9 - "cards-002.json", 10 - "cards-003.json", 11 - "cards-004.json", 12 - "cards-005.json", 13 - "cards-006.json", 14 - "cards-007.json", 15 - "cards-008.json", 12 + "cards-000-c370c4bbdf50febf.json", 13 + "cards-001-de071126cc0bff27.json", 14 + "cards-002-cc774c9e7a1875ef.json", 15 + "cards-003-c2a08adf3f90d2e1.json", 16 + "cards-004-e29c370de8d4c8b0.json", 17 + "cards-005-448405e66327dc1e.json", 18 + "cards-006-1a65ecd2f30c3a4e.json", 19 + "cards-007-9043095f08f5e094.json", 16 20 ] as const;
+49 -5
src/lib/card-data-provider.ts
··· 6 6 * - ServerCardProvider: Server-side, reads from filesystem 7 7 */ 8 8 9 - // import { ClientCardProvider } from "./cards-client-provider"; 10 9 import { createIsomorphicFn } from "@tanstack/react-start"; 10 + import type { OracleId, ScryfallId, VolatileData } from "./scryfall-types"; 11 11 import type { 12 12 Card, 13 - OracleId, 14 - ScryfallId, 13 + PaginatedSearchResult, 15 14 SearchRestrictions, 16 - } from "./scryfall-types"; 15 + SortOption, 16 + UnifiedSearchResult, 17 + } from "./search-types"; 18 + 19 + export type { UnifiedSearchResult, PaginatedSearchResult }; 17 20 18 21 export interface CardDataProvider { 19 22 /** ··· 44 47 restrictions?: SearchRestrictions, 45 48 maxResults?: number, 46 49 ): Promise<Card[]>; 50 + 51 + /** 52 + * Search cards using Scryfall-like syntax (e.g., "t:creature cmc<=3 s:lea") 53 + * Returns error info if query is invalid 54 + */ 55 + syntaxSearch?( 56 + query: string, 57 + maxResults?: number, 58 + ): Promise< 59 + | { ok: true; cards: Card[] } 60 + | { ok: false; error: { message: string; start: number; end: number } } 61 + >; 62 + 63 + /** 64 + * Get volatile data (prices, EDHREC rank) for a card 65 + * Waits for volatile data to load if not ready yet 66 + * Returns null if card not found 67 + */ 68 + getVolatileData(id: ScryfallId): Promise<VolatileData | null>; 69 + 70 + /** 71 + * Unified search that automatically routes to fuzzy or syntax search 72 + * based on query complexity. Returns mode indicator and optional description. 73 + */ 74 + unifiedSearch?( 75 + query: string, 76 + restrictions?: SearchRestrictions, 77 + maxResults?: number, 78 + ): Promise<UnifiedSearchResult>; 79 + 80 + /** 81 + * Paginated unified search with caching for virtual scroll 82 + * Caches full result set in LRU cache, returns requested slice 83 + */ 84 + paginatedUnifiedSearch?( 85 + query: string, 86 + restrictions: SearchRestrictions | undefined, 87 + sort: SortOption, 88 + offset: number, 89 + limit: number, 90 + ): Promise<PaginatedSearchResult>; 47 91 } 48 92 49 93 let providerPromise: Promise<CardDataProvider> | null = null; ··· 52 96 * Get the card data provider for the current environment 53 97 * 54 98 * - Client: ClientCardProvider (uses Web Worker with full dataset) 55 - * - Server: TODO - D1 local dev is flaky, disabled for now 99 + * - Server: ServerCardProvider (uses binary searchable index for slice parsing) 56 100 */ 57 101 export const getCardDataProvider = createIsomorphicFn() 58 102 .client(() => {
+235
src/lib/card-faces.ts
··· 1 + /** 2 + * Multi-faced card helpers 3 + * 4 + * Provides utilities for working with transform, MDFC, split, flip, 5 + * adventure, and meld cards. 6 + */ 7 + 8 + import type { Card, CardFace, Layout } from "./scryfall-types"; 9 + 10 + export type FlipBehavior = "transform" | "rotate90" | "rotate180" | "none"; 11 + 12 + /** 13 + * Layouts where both faces can be cast independently from hand. 14 + * Stats should count BOTH faces for these cards. 15 + */ 16 + const MODAL_LAYOUTS: Layout[] = ["modal_dfc", "split", "adventure"]; 17 + 18 + /** 19 + * Layouts where the card transforms/flips on the battlefield. 20 + * Only the front face can be cast - stats should count only front. 21 + */ 22 + const TRANSFORM_IN_PLAY_LAYOUTS: Layout[] = ["transform", "flip", "meld"]; 23 + 24 + /** 25 + * Layouts that have a separate back face image. 26 + */ 27 + const HAS_BACK_IMAGE_LAYOUTS: Layout[] = [ 28 + "transform", 29 + "modal_dfc", 30 + "meld", 31 + "reversible_card", 32 + "double_faced_token", 33 + ]; 34 + 35 + /** 36 + * Parse mana value from a mana cost string. 37 + * 38 + * This is a standalone function so it can be tested independently 39 + * and proptested against cards with known cmc values. 40 + * 41 + * Examples: 42 + * "{2}{U}" => 3 43 + * "{X}{G}{G}" => 2 (X counts as 0) 44 + * "{W/U}{W/U}" => 2 (hybrid costs 1 each) 45 + * "{5}{B}{R}" => 7 46 + */ 47 + export function parseManaValue(manaCost: string | undefined): number { 48 + if (!manaCost) return 0; 49 + 50 + let mv = 0; 51 + const symbols = manaCost.matchAll(/\{([^}]+)\}/g); 52 + 53 + for (const match of symbols) { 54 + const symbol = match[1]; 55 + 56 + // Generic mana (numbers like {2}, {10}) 57 + const numericMatch = symbol.match(/^(\d+)$/); 58 + if (numericMatch) { 59 + mv += Number.parseInt(numericMatch[1], 10); 60 + continue; 61 + } 62 + 63 + // X, Y, Z cost nothing 64 + if (/^[XYZ]$/.test(symbol)) { 65 + continue; 66 + } 67 + 68 + // Half mana (HW, HR, etc.) - costs 0.5 69 + if (symbol.startsWith("H")) { 70 + mv += 0.5; 71 + continue; 72 + } 73 + 74 + // Single color (W, U, B, R, G) or colorless (C) 75 + if (/^[WUBRGC]$/.test(symbol)) { 76 + mv += 1; 77 + continue; 78 + } 79 + 80 + // Hybrid with generic (2/W, 2/U, etc.) - costs 2 (the generic portion) 81 + const twoHybridMatch = symbol.match(/^(\d+)\/[WUBRGC]$/); 82 + if (twoHybridMatch) { 83 + mv += Number.parseInt(twoHybridMatch[1], 10); 84 + continue; 85 + } 86 + 87 + // Regular hybrid mana (W/U, W/P, etc.) - costs 1 88 + if (symbol.includes("/")) { 89 + mv += 1; 90 + continue; 91 + } 92 + 93 + // Snow mana (S) - costs 1 94 + if (symbol === "S") { 95 + mv += 1; 96 + } 97 + } 98 + 99 + return mv; 100 + } 101 + 102 + /** 103 + * Create a synthetic CardFace from top-level Card properties. 104 + * Used for single-faced cards that don't have card_faces array. 105 + */ 106 + function cardToFace(card: Card): CardFace { 107 + return { 108 + object: "card_face", 109 + name: card.name, 110 + mana_cost: card.mana_cost, 111 + type_line: card.type_line, 112 + oracle_text: card.oracle_text, 113 + power: card.power, 114 + toughness: card.toughness, 115 + loyalty: card.loyalty, 116 + defense: card.defense, 117 + colors: card.colors, 118 + }; 119 + } 120 + 121 + /** 122 + * Check if a card has multiple faces. 123 + */ 124 + export function isMultiFaced(card: Card): boolean { 125 + return (card.card_faces?.length ?? 0) > 1; 126 + } 127 + 128 + /** 129 + * Get the primary (front) face of a card. 130 + * For display purposes - what you see in deck lists. 131 + */ 132 + export function getPrimaryFace(card: Card): CardFace { 133 + if (card.card_faces && card.card_faces.length > 0) { 134 + return card.card_faces[0]; 135 + } 136 + return cardToFace(card); 137 + } 138 + 139 + /** 140 + * Get all faces of a card for display purposes. 141 + * Returns all faces regardless of castability. 142 + */ 143 + export function getAllFaces(card: Card): CardFace[] { 144 + if (card.card_faces && card.card_faces.length > 0) { 145 + return card.card_faces; 146 + } 147 + return [cardToFace(card)]; 148 + } 149 + 150 + /** 151 + * Get all independently castable faces of a card. 152 + * 153 + * For stats calculations - each returned face should be counted. 154 + * 155 + * - MDFC/split/adventure: Returns ALL faces (either can be cast from hand) 156 + * - Transform/flip/meld: Returns ONLY front face (back transforms in play) 157 + * - Normal cards: Returns single synthetic face 158 + */ 159 + export function getCastableFaces(card: Card): CardFace[] { 160 + const layout = card.layout; 161 + 162 + // Modal layouts: both faces are independently castable 163 + if (layout && MODAL_LAYOUTS.includes(layout)) { 164 + if (card.card_faces && card.card_faces.length > 0) { 165 + return card.card_faces; 166 + } 167 + } 168 + 169 + // Transform-in-play layouts: only front face is castable 170 + if (layout && TRANSFORM_IN_PLAY_LAYOUTS.includes(layout)) { 171 + if (card.card_faces && card.card_faces.length > 0) { 172 + return [card.card_faces[0]]; 173 + } 174 + } 175 + 176 + // Normal cards or unknown layouts: use top-level card properties 177 + if (card.card_faces && card.card_faces.length > 0) { 178 + return [card.card_faces[0]]; 179 + } 180 + return [cardToFace(card)]; 181 + } 182 + 183 + /** 184 + * Get the flip/rotate behavior for animation purposes. 185 + */ 186 + export function getFlipBehavior(layout: Layout | undefined): FlipBehavior { 187 + if (!layout) return "none"; 188 + 189 + if (HAS_BACK_IMAGE_LAYOUTS.includes(layout)) { 190 + return "transform"; 191 + } 192 + 193 + if (layout === "split") { 194 + return "rotate90"; 195 + } 196 + 197 + if (layout === "flip") { 198 + return "rotate180"; 199 + } 200 + 201 + return "none"; 202 + } 203 + 204 + /** 205 + * Check if a card can be flipped/rotated in the UI. 206 + */ 207 + export function canFlip(card: Card): boolean { 208 + return getFlipBehavior(card.layout) !== "none"; 209 + } 210 + 211 + /** 212 + * Check if a card has a separate back face image. 213 + */ 214 + export function hasBackImage(layout: Layout | undefined): boolean { 215 + if (!layout) return false; 216 + return HAS_BACK_IMAGE_LAYOUTS.includes(layout); 217 + } 218 + 219 + /** 220 + * Get the mana value for a face. 221 + * 222 + * For the primary face, prefer card.cmc if available (it's authoritative). 223 + * For other faces, parse from mana_cost. 224 + */ 225 + export function getFaceManaValue( 226 + face: CardFace, 227 + card: Card, 228 + faceIndex: number, 229 + ): number { 230 + // For the primary face, use card.cmc if available 231 + if (faceIndex === 0 && card.cmc !== undefined) { 232 + return card.cmc; 233 + } 234 + return parseManaValue(face.mana_cost); 235 + }
+44
src/lib/card-prefetch.ts
··· 1 + /** 2 + * Isomorphic card prefetching for route loaders 3 + * 4 + * Design constraints this module addresses: 5 + * 6 + * 1. SERVER CHUNK THRASHING: Cards are stored across multiple chunks with an LRU cache. 7 + * Parallel individual fetches cause cache thrashing when cards span chunks (load 8 + * chunk A, then B, then A again after eviction). Solution: batch lookups that group 9 + * by chunk index before fetching. 10 + * 11 + * 2. CLIENT HAS NO CHUNK ISSUE: The web worker loads all chunks into memory upfront. 12 + * No LRU eviction means parallel fetches are fine and preferred. 13 + * 14 + * 3. AVOID DUPLICATE BINARY SEARCHES: ServerCardProvider.getCardsByIds() does one 15 + * binary search per card to find chunk locations, then groups and fetches. A naive 16 + * "sort by chunk first, then fetch individually" would double the binary searches. 17 + * 18 + * 4. QUERY CACHE INTEGRATION: Both paths populate individual card entries in the 19 + * TanStack Query cache. This function itself is NOT a cached query - it's a prefetch 20 + * utility that warms the cache with individual card entries so useQuery hits cache. 21 + * 22 + * Dynamic import note: `await import()` is cached by the ES module loader - the module 23 + * is evaluated once and subsequent imports return the cached module object. 24 + */ 25 + 26 + import type { QueryClient } from "@tanstack/react-query"; 27 + import { createIsomorphicFn } from "@tanstack/react-start"; 28 + import { getCardByIdQueryOptions } from "./queries"; 29 + import type { ScryfallId } from "./scryfall-types"; 30 + 31 + export const prefetchCards = createIsomorphicFn() 32 + .client(async (queryClient: QueryClient, ids: ScryfallId[]) => { 33 + await Promise.all( 34 + ids.map((id) => queryClient.ensureQueryData(getCardByIdQueryOptions(id))), 35 + ); 36 + }) 37 + .server(async (queryClient: QueryClient, ids: ScryfallId[]) => { 38 + const { ServerCardProvider } = await import("./cards-server-provider"); 39 + const provider = new ServerCardProvider(); 40 + const cards = await provider.getCardsByIds(ids); 41 + for (const [id, card] of cards) { 42 + queryClient.setQueryData(getCardByIdQueryOptions(id).queryKey, card); 43 + } 44 + });
+47 -3
src/lib/cards-client-provider.ts
··· 6 6 7 7 import type { CardDataProvider } from "./card-data-provider"; 8 8 import { getCardsWorker, initializeWorker } from "./cards-worker-client"; 9 + import type { OracleId, ScryfallId, VolatileData } from "./scryfall-types"; 9 10 import type { 10 11 Card, 11 - OracleId, 12 - ScryfallId, 12 + PaginatedSearchResult, 13 13 SearchRestrictions, 14 - } from "./scryfall-types"; 14 + SortOption, 15 + UnifiedSearchResult, 16 + } from "./search-types"; 15 17 16 18 export class ClientCardProvider implements CardDataProvider { 17 19 async initialize(): Promise<void> { ··· 47 49 ): Promise<Card[]> { 48 50 const worker = getCardsWorker(); 49 51 return worker.searchCards(query, restrictions, maxResults); 52 + } 53 + 54 + async syntaxSearch( 55 + query: string, 56 + maxResults = 100, 57 + ): Promise< 58 + | { ok: true; cards: Card[] } 59 + | { ok: false; error: { message: string; start: number; end: number } } 60 + > { 61 + const worker = getCardsWorker(); 62 + return worker.syntaxSearch(query, maxResults); 63 + } 64 + 65 + async getVolatileData(id: ScryfallId): Promise<VolatileData | null> { 66 + const worker = getCardsWorker(); 67 + return worker.getVolatileData(id); 68 + } 69 + 70 + async unifiedSearch( 71 + query: string, 72 + restrictions?: SearchRestrictions, 73 + maxResults = 50, 74 + ): Promise<UnifiedSearchResult> { 75 + const worker = getCardsWorker(); 76 + return worker.unifiedSearch(query, restrictions, maxResults); 77 + } 78 + 79 + async paginatedUnifiedSearch( 80 + query: string, 81 + restrictions: SearchRestrictions | undefined, 82 + sort: SortOption, 83 + offset: number, 84 + limit: number, 85 + ): Promise<PaginatedSearchResult> { 86 + const worker = getCardsWorker(); 87 + return worker.paginatedUnifiedSearch( 88 + query, 89 + restrictions, 90 + sort, 91 + offset, 92 + limit, 93 + ); 50 94 } 51 95 }
+193 -14
src/lib/cards-server-provider.ts
··· 11 11 // In dev: provides stub/mock, In production: actual Workers env 12 12 import { env } from "cloudflare:workers"; 13 13 import type { CardDataProvider } from "./card-data-provider"; 14 + import { CARD_CHUNKS, CARD_INDEXES, CARD_VOLATILE } from "./card-manifest"; 14 15 import { LRUCache } from "./lru-cache"; 15 - import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 16 + import type { 17 + Card, 18 + OracleId, 19 + ScryfallId, 20 + VolatileData, 21 + } from "./scryfall-types"; 16 22 17 23 /** 18 24 * Fetch asset from public/data ··· 40 46 interface IndexData { 41 47 version: string; 42 48 cardCount: number; 49 + // Sorted by canonical order - first element is the canonical printing 43 50 oracleIdToPrintings: Record<OracleId, ScryfallId[]>; 44 - canonicalPrintingByOracleId: Record<OracleId, ScryfallId>; 45 51 } 46 52 47 53 // Binary format: fixed-size records (25 bytes each) 48 54 // Format: UUID (16 bytes) + chunk (1 byte) + offset (4 bytes) + length (4 bytes) 49 55 const RECORD_SIZE = 25; 50 56 51 - // LRU cache for chunk data (keeps memory under 128MB) 52 - const MAX_CHUNK_CACHE_SIZE = 4; 57 + // LRU cache for chunk data (~5MB per chunk with 4096 cards/chunk) 58 + // 12 chunks = ~60MB, same budget as before (3 * 20MB), covers ~43% of data 59 + const MAX_CHUNK_CACHE_SIZE = 12; 60 + 61 + // LRU cache for parsed Card objects — hot cards hit far more often than needing extra chunks 62 + // ~2KB per card (no prices), 10k cards ≈ 20MB 63 + const MAX_CARD_CACHE_SIZE = 10_000; 53 64 54 65 let binaryIndexCache: ArrayBuffer | null = null; 55 66 let indexDataCache: IndexData | null = null; 56 67 const chunkCaches = new LRUCache<number, string>(MAX_CHUNK_CACHE_SIZE); 68 + const cardCache = new LRUCache<ScryfallId, Card>(MAX_CARD_CACHE_SIZE); 69 + 70 + // Volatile data (prices, EDHREC rank) - binary searched, not loaded into memory 71 + const VOLATILE_RECORD_SIZE = 44; // 16 (UUID) + 4 (rank) + 6*4 (prices) 72 + const NULL_VALUE = 0xffffffff; 73 + 74 + let volatileDataPromise: Promise<ArrayBuffer> | null = null; 75 + 76 + async function loadVolatileBuffer(): Promise<ArrayBuffer> { 77 + const response = await fetchAsset(`cards/${CARD_VOLATILE}`); 78 + if (!response.ok) { 79 + throw new Error(`Failed to load volatile data: ${response.statusText}`); 80 + } 81 + return response.arrayBuffer(); 82 + } 83 + 84 + function getVolatileBuffer(): Promise<ArrayBuffer> { 85 + if (!volatileDataPromise) { 86 + volatileDataPromise = loadVolatileBuffer(); 87 + } 88 + return volatileDataPromise; 89 + } 90 + 91 + function parseVolatileRecord(view: DataView, offset: number): VolatileData { 92 + const readValue = (fieldOffset: number): number | null => { 93 + const val = view.getUint32(offset + fieldOffset, true); 94 + return val === NULL_VALUE ? null : val; 95 + }; 96 + 97 + const centsToPrice = (cents: number | null): number | null => 98 + cents === null ? null : cents / 100; 99 + 100 + return { 101 + edhrecRank: readValue(16), 102 + usd: centsToPrice(readValue(20)), 103 + usdFoil: centsToPrice(readValue(24)), 104 + usdEtched: centsToPrice(readValue(28)), 105 + eur: centsToPrice(readValue(32)), 106 + eurFoil: centsToPrice(readValue(36)), 107 + tix: centsToPrice(readValue(40)), 108 + }; 109 + } 110 + 111 + async function findVolatileData( 112 + cardId: ScryfallId, 113 + ): Promise<VolatileData | null> { 114 + const buffer = await getVolatileBuffer(); 115 + const recordCount = buffer.byteLength / VOLATILE_RECORD_SIZE; 116 + 117 + const searchUuid = uuidToBytes(cardId); 118 + const searchView = new DataView(searchUuid.buffer); 119 + const searchHigh = searchView.getBigUint64(0, false); 120 + const searchLow = searchView.getBigUint64(8, false); 121 + 122 + const view = new DataView(buffer); 123 + let left = 0; 124 + let right = recordCount - 1; 125 + 126 + while (left <= right) { 127 + const mid = Math.floor((left + right) / 2); 128 + const offset = mid * VOLATILE_RECORD_SIZE; 129 + 130 + const recordHigh = view.getBigUint64(offset, false); 131 + const recordLow = view.getBigUint64(offset + 8, false); 132 + 133 + if ( 134 + recordHigh < searchHigh || 135 + (recordHigh === searchHigh && recordLow < searchLow) 136 + ) { 137 + left = mid + 1; 138 + } else if (recordHigh === searchHigh && recordLow === searchLow) { 139 + return parseVolatileRecord(view, offset); 140 + } else { 141 + right = mid - 1; 142 + } 143 + } 144 + 145 + return null; 146 + } 57 147 58 148 /** 59 149 * Convert UUID string to Uint8Array for binary comparison ··· 110 200 async function loadIndexData(): Promise<IndexData> { 111 201 if (indexDataCache) return indexDataCache; 112 202 113 - const response = await fetchAsset("cards-indexes.json"); 203 + const response = await fetchAsset(`cards/${CARD_INDEXES}`); 114 204 if (!response.ok) { 115 - throw new Error( 116 - `Failed to load cards-indexes.json: ${response.statusText}`, 117 - ); 205 + throw new Error(`Failed to load ${CARD_INDEXES}: ${response.statusText}`); 118 206 } 119 207 indexDataCache = (await response.json()) as IndexData; 120 208 return indexDataCache; ··· 171 259 */ 172 260 async function loadChunk(chunkIndex: number): Promise<string> { 173 261 return chunkCaches.getOrSet(chunkIndex, async () => { 174 - const chunkFilename = `cards-${String(chunkIndex).padStart(3, "0")}.json`; 175 - const response = await fetchAsset(chunkFilename); 262 + const chunkFilename = CARD_CHUNKS[chunkIndex]; 263 + if (!chunkFilename) { 264 + throw new Error(`Invalid chunk index: ${chunkIndex}`); 265 + } 266 + const response = await fetchAsset(`cards/${chunkFilename}`); 176 267 if (!response.ok) { 177 268 throw new Error( 178 269 `Failed to load ${chunkFilename}: ${response.statusText}`, ··· 184 275 185 276 export class ServerCardProvider implements CardDataProvider { 186 277 async getCardById(id: ScryfallId): Promise<Card | undefined> { 278 + const cached = cardCache.get(id); 279 + if (cached) { 280 + return cached; 281 + } 282 + 187 283 try { 284 + // Binary search is cheap — only cache hits that require chunk loads 188 285 const entry = await findCardInIndex(id); 189 - 190 286 if (!entry) { 191 287 return undefined; 192 288 } 193 289 194 - // Load chunk and extract card JSON 195 290 const chunkContent = await loadChunk(entry.chunkIndex); 196 291 const cardJSON = chunkContent.slice( 197 292 entry.offset, 198 293 entry.offset + entry.length, 199 294 ); 200 295 201 - return JSON.parse(cardJSON) as Card; 296 + const card = JSON.parse(cardJSON) as Card; 297 + cardCache.set(id, card); 298 + return card; 202 299 } catch (error) { 203 300 console.error(`[ServerCardProvider] Error loading card ${id}:`, error); 204 301 return undefined; ··· 231 328 ): Promise<ScryfallId | undefined> { 232 329 try { 233 330 const indexData = await loadIndexData(); 234 - return indexData.canonicalPrintingByOracleId[oracleId]; 331 + // First element of oracleIdToPrintings is the canonical printing 332 + return indexData.oracleIdToPrintings[oracleId]?.[0]; 235 333 } catch { 236 334 return undefined; 237 335 } 336 + } 337 + 338 + /** 339 + * Batch fetch multiple cards, grouped by chunk to avoid cache thrashing. 340 + * 341 + * Instead of fetching cards one-by-one (which bounces between chunks), 342 + * this groups all IDs by their chunk index and processes chunk-by-chunk. 343 + */ 344 + async getCardsByIds(ids: ScryfallId[]): Promise<Map<ScryfallId, Card>> { 345 + const result = new Map<ScryfallId, Card>(); 346 + const uncached: Array<{ id: ScryfallId; entry: ByteIndexEntry }> = []; 347 + 348 + // Phase 1: Check cache and collect index entries for misses 349 + for (const id of ids) { 350 + const cached = cardCache.get(id); 351 + if (cached) { 352 + result.set(id, cached); 353 + } else { 354 + const entry = await findCardInIndex(id); 355 + if (entry) { 356 + uncached.push({ id, entry }); 357 + } 358 + } 359 + } 360 + 361 + // Phase 2: Group by chunk index 362 + const byChunk = new Map< 363 + number, 364 + Array<{ id: ScryfallId; entry: ByteIndexEntry }> 365 + >(); 366 + for (const item of uncached) { 367 + const group = byChunk.get(item.entry.chunkIndex); 368 + if (group) { 369 + group.push(item); 370 + } else { 371 + byChunk.set(item.entry.chunkIndex, [item]); 372 + } 373 + } 374 + 375 + // Phase 3: Process each chunk (load once, extract all cards) 376 + // Sort to process cached chunks first, avoiding unnecessary evictions 377 + const sortedChunks = [...byChunk.entries()].sort(([a], [b]) => { 378 + const aInCache = chunkCaches.has(a); 379 + const bInCache = chunkCaches.has(b); 380 + if (aInCache && !bInCache) return -1; 381 + if (!aInCache && bInCache) return 1; 382 + return 0; 383 + }); 384 + 385 + for (const [chunkIndex, items] of sortedChunks) { 386 + try { 387 + const chunkContent = await loadChunk(chunkIndex); 388 + for (const { id, entry } of items) { 389 + try { 390 + const cardJSON = chunkContent.slice( 391 + entry.offset, 392 + entry.offset + entry.length, 393 + ); 394 + const card = JSON.parse(cardJSON) as Card; 395 + cardCache.set(id, card); 396 + result.set(id, card); 397 + } catch (error) { 398 + console.error( 399 + `[ServerCardProvider] Error parsing card ${id}:`, 400 + error, 401 + ); 402 + } 403 + } 404 + } catch (error) { 405 + console.error( 406 + `[ServerCardProvider] Error loading chunk ${chunkIndex}:`, 407 + error, 408 + ); 409 + } 410 + } 411 + 412 + return result; 413 + } 414 + 415 + async getVolatileData(id: ScryfallId): Promise<VolatileData | null> { 416 + return findVolatileData(id); 238 417 } 239 418 240 419 // Search not implemented server-side (client-only feature for now)
+228
src/lib/collection-list-queries.ts
··· 1 + /** 2 + * TanStack Query integration for collection list operations 3 + * Provides query options and mutations for saving cards/decks to lists 4 + */ 5 + 6 + import type { Did } from "@atcute/lexicons"; 7 + import { queryOptions, useQueryClient } from "@tanstack/react-query"; 8 + import { useNavigate } from "@tanstack/react-router"; 9 + import { toast } from "sonner"; 10 + import { 11 + asPdsUrl, 12 + createCollectionListRecord, 13 + deleteCollectionListRecord, 14 + getCollectionListRecord, 15 + type ListRecordsResponse, 16 + listUserCollectionLists, 17 + type Rkey, 18 + updateCollectionListRecord, 19 + } from "./atproto-client"; 20 + import type { CollectionList } from "./collection-list-types"; 21 + import { getPdsForDid } from "./identity"; 22 + import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 23 + import { useAuth } from "./useAuth"; 24 + import { useMutationWithToast } from "./useMutationWithToast"; 25 + 26 + /** 27 + * Query options for fetching a single collection list 28 + */ 29 + export const getCollectionListQueryOptions = (did: Did, rkey: Rkey) => 30 + queryOptions({ 31 + queryKey: ["collection-list", did, rkey] as const, 32 + queryFn: async (): Promise<CollectionList> => { 33 + const result = await getCollectionListRecord(did, rkey); 34 + if (!result.success) { 35 + throw result.error; 36 + } 37 + return result.data.value as CollectionList; 38 + }, 39 + staleTime: 30 * 1000, 40 + }); 41 + 42 + /** 43 + * Query options for listing all collection lists for a user 44 + */ 45 + export const listUserCollectionListsQueryOptions = (did: Did) => 46 + queryOptions({ 47 + queryKey: ["collection-lists", did] as const, 48 + queryFn: async (): Promise<ListRecordsResponse<CollectionList>> => { 49 + const pds = await getPdsForDid(did); 50 + const result = await listUserCollectionLists(asPdsUrl(pds), did); 51 + if (!result.success) { 52 + throw result.error; 53 + } 54 + return result.data as ListRecordsResponse<CollectionList>; 55 + }, 56 + staleTime: 60 * 1000, 57 + }); 58 + 59 + /** 60 + * Mutation for creating a new collection list 61 + */ 62 + export function useCreateCollectionListMutation() { 63 + const { agent, session } = useAuth(); 64 + const queryClient = useQueryClient(); 65 + 66 + return useMutationWithToast({ 67 + mutationFn: async (list: { name: string }) => { 68 + if (!agent || !session) { 69 + throw new Error("Must be authenticated to create a list"); 70 + } 71 + 72 + const result = await createCollectionListRecord(agent, { 73 + $type: "com.deckbelcher.collection.list", 74 + name: list.name, 75 + items: [], 76 + createdAt: new Date().toISOString(), 77 + }); 78 + 79 + if (!result.success) { 80 + throw result.error; 81 + } 82 + 83 + return result.data; 84 + }, 85 + onSuccess: () => { 86 + toast.success("List created"); 87 + 88 + if (!session) return; 89 + 90 + queryClient.invalidateQueries({ 91 + queryKey: ["collection-lists", session.info.sub], 92 + }); 93 + 94 + // TODO: Navigate to list detail page once route exists 95 + }, 96 + }); 97 + } 98 + 99 + /** 100 + * Mutation for updating a collection list 101 + * Caller provides full new list state (with items array already updated) 102 + */ 103 + export function useUpdateCollectionListMutation(did: Did, rkey: Rkey) { 104 + const { agent } = useAuth(); 105 + const queryClient = useQueryClient(); 106 + 107 + return useMutationWithToast({ 108 + mutationFn: async (list: CollectionList) => { 109 + if (!agent) { 110 + throw new Error("Must be authenticated to update a list"); 111 + } 112 + 113 + const result = await updateCollectionListRecord(agent, rkey, { 114 + $type: "com.deckbelcher.collection.list", 115 + name: list.name, 116 + description: list.description, 117 + items: list.items as ComDeckbelcherCollectionList.Main["items"], 118 + createdAt: list.createdAt, 119 + updatedAt: new Date().toISOString(), 120 + }); 121 + 122 + if (!result.success) { 123 + throw result.error; 124 + } 125 + 126 + return result.data; 127 + }, 128 + onMutate: async (newList) => { 129 + await queryClient.cancelQueries({ 130 + queryKey: ["collection-list", did, rkey], 131 + }); 132 + await queryClient.cancelQueries({ 133 + queryKey: ["collection-lists", did], 134 + }); 135 + 136 + const previousList = queryClient.getQueryData<CollectionList>([ 137 + "collection-list", 138 + did, 139 + rkey, 140 + ]); 141 + 142 + const previousLists = queryClient.getQueryData< 143 + ListRecordsResponse<CollectionList> 144 + >(["collection-lists", did]); 145 + 146 + queryClient.setQueryData<CollectionList>( 147 + ["collection-list", did, rkey], 148 + newList, 149 + ); 150 + 151 + if (previousLists) { 152 + queryClient.setQueryData<ListRecordsResponse<CollectionList>>( 153 + ["collection-lists", did], 154 + { 155 + ...previousLists, 156 + records: previousLists.records.map((record) => 157 + record.uri.endsWith(`/${rkey}`) 158 + ? { ...record, value: newList } 159 + : record, 160 + ), 161 + }, 162 + ); 163 + } 164 + 165 + return { previousList, previousLists }; 166 + }, 167 + onError: (_err, _newList, context) => { 168 + if (context?.previousList) { 169 + queryClient.setQueryData<CollectionList>( 170 + ["collection-list", did, rkey], 171 + context.previousList, 172 + ); 173 + } 174 + if (context?.previousLists) { 175 + queryClient.setQueryData<ListRecordsResponse<CollectionList>>( 176 + ["collection-lists", did], 177 + context.previousLists, 178 + ); 179 + } 180 + queryClient.invalidateQueries({ 181 + queryKey: ["collection-list", did, rkey], 182 + }); 183 + queryClient.invalidateQueries({ 184 + queryKey: ["collection-lists", did], 185 + }); 186 + }, 187 + }); 188 + } 189 + 190 + /** 191 + * Mutation for deleting a collection list 192 + */ 193 + export function useDeleteCollectionListMutation(rkey: Rkey) { 194 + const { agent, session } = useAuth(); 195 + const queryClient = useQueryClient(); 196 + const navigate = useNavigate(); 197 + 198 + return useMutationWithToast({ 199 + mutationFn: async () => { 200 + if (!agent || !session) { 201 + throw new Error("Must be authenticated to delete a list"); 202 + } 203 + 204 + const result = await deleteCollectionListRecord(agent, rkey); 205 + 206 + if (!result.success) { 207 + throw result.error; 208 + } 209 + 210 + return result.data; 211 + }, 212 + onSuccess: () => { 213 + toast.success("List deleted"); 214 + 215 + if (!session) return; 216 + 217 + queryClient.invalidateQueries({ 218 + queryKey: ["collection-lists", session.info.sub], 219 + }); 220 + 221 + navigate({ 222 + to: "/profile/$did", 223 + params: { did: session.info.sub }, 224 + }); 225 + }, 226 + errorMessage: "Failed to delete list", 227 + }); 228 + }
+114
src/lib/collection-list-types.ts
··· 1 + /** 2 + * Type definitions for collection lists 3 + * Based on generated lexicon types from com.deckbelcher.collection.list 4 + */ 5 + 6 + import type { ResourceUri } from "@atcute/lexicons"; 7 + import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 8 + import type { ScryfallId } from "./scryfall-types"; 9 + 10 + export type ListCardItem = Omit< 11 + ComDeckbelcherCollectionList.CardItem, 12 + "scryfallId" 13 + > & { 14 + scryfallId: ScryfallId; 15 + }; 16 + 17 + export type ListDeckItem = ComDeckbelcherCollectionList.DeckItem; 18 + 19 + export type ListItem = ListCardItem | ListDeckItem; 20 + 21 + export type CollectionList = Omit< 22 + ComDeckbelcherCollectionList.Main, 23 + "items" 24 + > & { 25 + items: ListItem[]; 26 + }; 27 + 28 + export function isCardItem(item: ListItem): item is ListCardItem { 29 + return item.$type === "com.deckbelcher.collection.list#cardItem"; 30 + } 31 + 32 + export function isDeckItem(item: ListItem): item is ListDeckItem { 33 + return item.$type === "com.deckbelcher.collection.list#deckItem"; 34 + } 35 + 36 + export function hasCard(list: CollectionList, scryfallId: ScryfallId): boolean { 37 + return list.items.some( 38 + (item) => isCardItem(item) && item.scryfallId === scryfallId, 39 + ); 40 + } 41 + 42 + export function hasDeck(list: CollectionList, deckUri: string): boolean { 43 + return list.items.some( 44 + (item) => isDeckItem(item) && item.deckUri === deckUri, 45 + ); 46 + } 47 + 48 + export function addCardToList( 49 + list: CollectionList, 50 + scryfallId: ScryfallId, 51 + ): CollectionList { 52 + if (hasCard(list, scryfallId)) { 53 + return list; 54 + } 55 + 56 + const newItem: ListCardItem = { 57 + $type: "com.deckbelcher.collection.list#cardItem", 58 + scryfallId, 59 + addedAt: new Date().toISOString(), 60 + }; 61 + 62 + return { 63 + ...list, 64 + items: [...list.items, newItem], 65 + updatedAt: new Date().toISOString(), 66 + }; 67 + } 68 + 69 + export function addDeckToList( 70 + list: CollectionList, 71 + deckUri: string, 72 + ): CollectionList { 73 + if (hasDeck(list, deckUri)) { 74 + return list; 75 + } 76 + 77 + const newItem: ListDeckItem = { 78 + $type: "com.deckbelcher.collection.list#deckItem", 79 + deckUri: deckUri as ResourceUri, 80 + addedAt: new Date().toISOString(), 81 + }; 82 + 83 + return { 84 + ...list, 85 + items: [...list.items, newItem], 86 + updatedAt: new Date().toISOString(), 87 + }; 88 + } 89 + 90 + export function removeCardFromList( 91 + list: CollectionList, 92 + scryfallId: ScryfallId, 93 + ): CollectionList { 94 + return { 95 + ...list, 96 + items: list.items.filter( 97 + (item) => !(isCardItem(item) && item.scryfallId === scryfallId), 98 + ), 99 + updatedAt: new Date().toISOString(), 100 + }; 101 + } 102 + 103 + export function removeDeckFromList( 104 + list: CollectionList, 105 + deckUri: string, 106 + ): CollectionList { 107 + return { 108 + ...list, 109 + items: list.items.filter( 110 + (item) => !(isDeckItem(item) && item.deckUri === deckUri), 111 + ), 112 + updatedAt: new Date().toISOString(), 113 + }; 114 + }
+8 -3
src/lib/deck-grouping.test.ts
··· 144 144 }); 145 145 146 146 it("handles high CMCs", () => { 147 - expect(getManaValueBucket(7)).toBe("7+"); 148 - expect(getManaValueBucket(8)).toBe("7+"); 149 - expect(getManaValueBucket(12)).toBe("7+"); 147 + expect(getManaValueBucket(7)).toBe("7"); 148 + expect(getManaValueBucket(8)).toBe("8"); 149 + expect(getManaValueBucket(12)).toBe("12"); 150 + }); 151 + 152 + it("rounds up fractional CMCs", () => { 153 + expect(getManaValueBucket(0.5)).toBe("1"); 154 + expect(getManaValueBucket(2.5)).toBe("3"); 150 155 }); 151 156 152 157 it("handles undefined CMC", () => {
+9 -6
src/lib/deck-grouping.ts
··· 1 1 import type { Card } from "@/lib/scryfall-types"; 2 + import { getPrimaryFace } from "./card-faces"; 2 3 import type { DeckCard, GroupBy, SortBy } from "./deck-types"; 3 4 4 5 /** ··· 130 131 131 132 /** 132 133 * Get mana value bucket for grouping 133 - * Example: 0 → "0", 3 → "3", 8 → "7+" 134 + * Example: 0 → "0", 0.5 → "1", 3 → "3", 8 → "8" 134 135 */ 135 136 export function getManaValueBucket(cmc: number | undefined): string { 136 137 if (cmc === undefined || cmc === 0) return "0"; 137 - if (cmc >= 7) return "7+"; 138 - return cmc.toString(); 138 + return Math.ceil(cmc).toString(); 139 139 } 140 140 141 141 /** ··· 234 234 case "type": { 235 235 for (const card of cards) { 236 236 const cardData = cardLookup(card); 237 - const type = extractPrimaryType(cardData?.type_line); 237 + const face = cardData ? getPrimaryFace(cardData) : undefined; 238 + const type = extractPrimaryType(face?.type_line); 238 239 const group = groups.get(type) ?? { cards: [], forTag: false }; 239 240 group.cards.push(card); 240 241 groups.set(type, group); ··· 247 248 if (!card.tags || card.tags.length === 0) { 248 249 // No tags → group by type 249 250 const cardData = cardLookup(card); 250 - const type = extractPrimaryType(cardData?.type_line); 251 + const face = cardData ? getPrimaryFace(cardData) : undefined; 252 + const type = extractPrimaryType(face?.type_line); 251 253 const group = groups.get(type) ?? { cards: [], forTag: false }; 252 254 group.cards.push(card); 253 255 groups.set(type, group); ··· 267 269 case "subtype": { 268 270 for (const card of cards) { 269 271 const cardData = cardLookup(card); 270 - const subtypes = extractSubtypes(cardData?.type_line); 272 + const face = cardData ? getPrimaryFace(cardData) : undefined; 273 + const subtypes = extractSubtypes(face?.type_line); 271 274 272 275 if (subtypes.length === 0) { 273 276 const group = groups.get("(No Subtype)") ?? {
+46 -3
src/lib/deck-queries.ts
··· 6 6 import type { Did } from "@atcute/lexicons"; 7 7 import { queryOptions, useQueryClient } from "@tanstack/react-query"; 8 8 import { useNavigate } from "@tanstack/react-router"; 9 + import { toast } from "sonner"; 9 10 import { 10 11 asPdsUrl, 11 12 createDeckRecord, 13 + deleteDeckRecord, 12 14 getDeckRecord, 13 - type ListRecordsResponse, 14 15 listUserDecks, 15 16 type Rkey, 16 17 updateDeckRecord, ··· 53 54 export const listUserDecksQueryOptions = (did: Did) => 54 55 queryOptions({ 55 56 queryKey: ["decks", did] as const, 56 - queryFn: async (): Promise<ListRecordsResponse> => { 57 + queryFn: async () => { 57 58 const pds = await getPdsForDid(did); 58 59 const result = await listUserDecks(asPdsUrl(pds), did); 59 60 if (!result.success) { ··· 139 140 scryfallId: card.scryfallId as string, 140 141 })), 141 142 primer: deck.primer, 142 - primerFacets: deck.primerFacets, 143 143 createdAt: deck.createdAt, 144 144 updatedAt: new Date().toISOString(), 145 145 }); ··· 174 174 // Slingshot (cache) might be stale anyway 175 175 }); 176 176 } 177 + 178 + /** 179 + * Mutation for deleting a deck 180 + * Invalidates deck list and navigates to profile on success 181 + */ 182 + export function useDeleteDeckMutation(rkey: Rkey) { 183 + const { agent, session } = useAuth(); 184 + const queryClient = useQueryClient(); 185 + const navigate = useNavigate(); 186 + 187 + return useMutationWithToast({ 188 + mutationFn: async () => { 189 + if (!agent || !session) { 190 + throw new Error("Must be authenticated to delete a deck"); 191 + } 192 + 193 + const result = await deleteDeckRecord(agent, rkey); 194 + 195 + if (!result.success) { 196 + throw result.error; 197 + } 198 + 199 + return result.data; 200 + }, 201 + onSuccess: () => { 202 + toast.success("Deck deleted"); 203 + 204 + if (!session) return; 205 + 206 + // Invalidate deck list for current user 207 + queryClient.invalidateQueries({ 208 + queryKey: ["decks", session.info.sub], 209 + }); 210 + 211 + // Navigate back to profile 212 + navigate({ 213 + to: "/profile/$did", 214 + params: { did: session.info.sub }, 215 + }); 216 + }, 217 + errorMessage: "Failed to delete deck", 218 + }); 219 + }
+553
src/lib/deck-stats.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import type { Card } from "@/lib/scryfall-types"; 3 + import { 4 + setupTestCards, 5 + type TestCardLookup, 6 + } from "./__tests__/test-card-lookup"; 7 + import { 8 + type CardLookup, 9 + computeManaCurve, 10 + computeSpeedDistribution, 11 + computeTypeDistribution, 12 + countManaSymbols, 13 + getSourceTempo, 14 + getSpeedCategory, 15 + isPermanent, 16 + } from "./deck-stats"; 17 + import type { DeckCard } from "./deck-types"; 18 + 19 + function makeCard(overrides: Partial<Card>): Card { 20 + return { 21 + id: "test-id" as Card["id"], 22 + oracle_id: "test-oracle" as Card["oracle_id"], 23 + name: "Test Card", 24 + ...overrides, 25 + }; 26 + } 27 + 28 + function makeDeckCard(overrides: Partial<DeckCard> = {}): DeckCard { 29 + return { 30 + scryfallId: "test-id" as DeckCard["scryfallId"], 31 + quantity: 1, 32 + section: "mainboard", 33 + ...overrides, 34 + }; 35 + } 36 + 37 + function createTestData( 38 + items: Array<{ card: Partial<Card>; deckCard?: Partial<DeckCard> }>, 39 + ): { cards: DeckCard[]; lookup: CardLookup } { 40 + const cardMap = new Map<string, Card>(); 41 + const deckCards: DeckCard[] = []; 42 + 43 + items.forEach((item, i) => { 44 + const id = `test-id-${i}` as DeckCard["scryfallId"]; 45 + const card = makeCard({ ...item.card, id: id as Card["id"] }); 46 + const deckCard = makeDeckCard({ ...item.deckCard, scryfallId: id }); 47 + 48 + cardMap.set(id, card); 49 + deckCards.push(deckCard); 50 + }); 51 + 52 + const lookup: CardLookup = (dc) => cardMap.get(dc.scryfallId); 53 + 54 + return { cards: deckCards, lookup }; 55 + } 56 + 57 + describe("countManaSymbols", () => { 58 + it("counts basic mana symbols", () => { 59 + expect(countManaSymbols("{2}{U}{U}{B}")).toEqual({ 60 + W: 0, 61 + U: 2, 62 + B: 1, 63 + R: 0, 64 + G: 0, 65 + C: 0, 66 + }); 67 + }); 68 + 69 + it("handles hybrid mana", () => { 70 + expect(countManaSymbols("{W/U}{W/U}")).toEqual({ 71 + W: 2, 72 + U: 2, 73 + B: 0, 74 + R: 0, 75 + G: 0, 76 + C: 0, 77 + }); 78 + }); 79 + 80 + it("handles phyrexian mana", () => { 81 + expect(countManaSymbols("{W/P}{B/P}")).toEqual({ 82 + W: 1, 83 + U: 0, 84 + B: 1, 85 + R: 0, 86 + G: 0, 87 + C: 0, 88 + }); 89 + }); 90 + 91 + it("handles hybrid phyrexian (2/W)", () => { 92 + expect(countManaSymbols("{2/W}{2/U}")).toEqual({ 93 + W: 1, 94 + U: 1, 95 + B: 0, 96 + R: 0, 97 + G: 0, 98 + C: 0, 99 + }); 100 + }); 101 + 102 + it("ignores generic and X costs", () => { 103 + expect(countManaSymbols("{X}{X}{2}{R}")).toEqual({ 104 + W: 0, 105 + U: 0, 106 + B: 0, 107 + R: 1, 108 + G: 0, 109 + C: 0, 110 + }); 111 + }); 112 + 113 + it("counts colorless mana requirements", () => { 114 + expect(countManaSymbols("{C}{C}{U}")).toEqual({ 115 + W: 0, 116 + U: 1, 117 + B: 0, 118 + R: 0, 119 + G: 0, 120 + C: 2, 121 + }); 122 + }); 123 + 124 + it("handles empty/undefined", () => { 125 + expect(countManaSymbols("")).toEqual({ 126 + W: 0, 127 + U: 0, 128 + B: 0, 129 + R: 0, 130 + G: 0, 131 + C: 0, 132 + }); 133 + expect(countManaSymbols(undefined)).toEqual({ 134 + W: 0, 135 + U: 0, 136 + B: 0, 137 + R: 0, 138 + G: 0, 139 + C: 0, 140 + }); 141 + }); 142 + 143 + it("counts all five colors plus colorless", () => { 144 + expect(countManaSymbols("{W}{U}{B}{R}{G}{C}")).toEqual({ 145 + W: 1, 146 + U: 1, 147 + B: 1, 148 + R: 1, 149 + G: 1, 150 + C: 1, 151 + }); 152 + }); 153 + }); 154 + 155 + describe("getSourceTempo", () => { 156 + let cards: TestCardLookup; 157 + 158 + beforeAll(async () => { 159 + cards = await setupTestCards(); 160 + }); 161 + 162 + describe("immediate sources", () => { 163 + it.each([ 164 + // === LANDS === 165 + // Basic lands 166 + ["Forest", "basic land"], 167 + ["Wastes", "colorless basic"], 168 + // Dual lands (ABUR) 169 + ["Tropical Island", "original dual"], 170 + // Fetch lands 171 + ["Misty Rainforest", "fetch land"], 172 + // Shock lands (you can always pay 2 life = immediate) 173 + ["Breeding Pool", "shockland"], 174 + // Pain lands 175 + ["Yavimaya Coast", "pain land"], 176 + // Filter lands 177 + ["Flooded Grove", "filter land"], 178 + // Pathway lands (MDFCs) 179 + ["Barkchannel Pathway", "pathway land"], 180 + // Rainbow lands 181 + ["Command Tower", "commander rainbow"], 182 + ["Exotic Orchard", "conditional rainbow"], 183 + ["Mana Confluence", "pain rainbow"], 184 + ["City of Brass", "pain rainbow"], 185 + // Utility lands 186 + ["Ancient Tomb", "colorless + life loss"], 187 + ["Gaea's Cradle", "creature-count mana"], 188 + ["Phyrexian Tower", "sacrifice land"], 189 + ["Cabal Coffers", "swamp-count mana"], 190 + // === ARTIFACTS === 191 + // Fast mana 192 + ["Mana Crypt", "free mana rock"], 193 + ["Mana Vault", "burst mana rock"], 194 + ["Grim Monolith", "burst mana rock"], 195 + ["Sol Ring", "classic mana rock"], 196 + // 0-cost rocks 197 + ["Chrome Mox", "imprint rock"], 198 + ["Mox Diamond", "discard rock"], 199 + ["Lotus Petal", "sac rock"], 200 + ["Lion's Eye Diamond", "discard-hand rock"], 201 + // Larger rocks 202 + ["Gilded Lotus", "5-mana rock"], 203 + ["Thran Dynamo", "4-mana rock"], 204 + ["Hedron Archive", "4-mana rock"], 205 + ["Arcum's Astrolabe", "cantrip rock"], 206 + ["Everflowing Chalice", "scaling rock"], 207 + // === CREATURES === 208 + // Haste creatures 209 + ["Beastcaller Savant", "hasty mana dork"], 210 + ["Cormela, Glamour Thief", "hasty mana creature"], 211 + // Sacrifice self (no tap) 212 + ["Blood Pet", "sac self for mana"], 213 + ["Skirk Prospector", "sac by creature type"], 214 + // Exile from hand 215 + ["Simian Spirit Guide", "exile from hand"], 216 + ["Elvish Spirit Guide", "exile from hand"], 217 + // ETB triggers 218 + ["Akki Rockspeaker", "ETB add mana"], 219 + ["Burning-Tree Emissary", "ETB add mana"], 220 + ["Priest of Gix", "ETB add mana"], 221 + ["Priest of Urabrask", "ETB add mana"], 222 + // Creates sacrifice tokens 223 + ["Basking Broodscale", "creates Eldrazi Spawn"], 224 + // Pay life for mana 225 + ["Treasonous Ogre", "pay life for mana (no tap)"], 226 + // === ENCHANTMENTS === 227 + ["Cryptolith Rite", "grants tap abilities"], 228 + // === SPELLS === 229 + ["Dark Ritual", "ritual"], 230 + ])("%s → immediate (%s)", async (name) => { 231 + const card = await cards.get(name); 232 + expect(getSourceTempo(card)).toBe("immediate"); 233 + }); 234 + }); 235 + 236 + describe("conditional sources", () => { 237 + it.each([ 238 + // Game-state dependent - you might not meet the condition 239 + // Check lands (need a land of the right type) 240 + ["Hinterland Harbor", "checkland"], 241 + // Fast lands (need 2 or fewer lands - bad late game) 242 + ["Botanical Sanctum", "fastland"], 243 + // Battle/Tango lands (need 2+ basics) 244 + ["Canopy Vista", "battle land"], 245 + ])("%s → conditional (%s)", async (name) => { 246 + const card = await cards.get(name); 247 + expect(getSourceTempo(card)).toBe("conditional"); 248 + }); 249 + }); 250 + 251 + describe("delayed sources", () => { 252 + it.each([ 253 + // === LANDS === 254 + // Tap lands (unconditional) 255 + ["Temple of Mystery", "scry land"], 256 + ["Ketria Triome", "triome"], 257 + ["Undercity Sewers", "surveil land"], 258 + // === ARTIFACTS === 259 + ["Worn Powerstone", "ETB tapped rock"], 260 + // === CREATURES (summoning sickness) === 261 + ["Llanowar Elves", "classic mana dork"], 262 + ["Elvish Mystic", "classic mana dork"], 263 + ["Fyndhorn Elves", "classic mana dork"], 264 + ["Birds of Paradise", "flying rainbow dork"], 265 + ["Selvala, Heart of the Wilds", "big mana dork"], 266 + // Land-creature 267 + ["Dryad Arbor", "land creature (summoning sickness)"], 268 + ])("%s → delayed (%s)", async (name) => { 269 + const card = await cards.get(name); 270 + expect(getSourceTempo(card)).toBe("delayed"); 271 + }); 272 + }); 273 + 274 + describe("bounce sources", () => { 275 + it.each([ 276 + ["Simic Growth Chamber", "simic bounceland"], 277 + ["Azorius Chancery", "azorius bounceland"], 278 + ])("%s → bounce (%s)", async (name) => { 279 + const card = await cards.get(name); 280 + expect(getSourceTempo(card)).toBe("bounce"); 281 + }); 282 + }); 283 + }); 284 + 285 + describe("getSpeedCategory", () => { 286 + it("returns instant for Instant type", () => { 287 + const card = makeCard({ type_line: "Instant", keywords: [] }); 288 + expect(getSpeedCategory(card)).toBe("instant"); 289 + }); 290 + 291 + it("returns instant for Flash keyword", () => { 292 + const card = makeCard({ 293 + type_line: "Creature — Human Wizard", 294 + keywords: ["Flash"], 295 + }); 296 + expect(getSpeedCategory(card)).toBe("instant"); 297 + }); 298 + 299 + it("returns sorcery for regular creatures", () => { 300 + const card = makeCard({ 301 + type_line: "Creature — Human Wizard", 302 + keywords: [], 303 + }); 304 + expect(getSpeedCategory(card)).toBe("sorcery"); 305 + }); 306 + 307 + it("returns sorcery for Sorcery type", () => { 308 + const card = makeCard({ type_line: "Sorcery", keywords: [] }); 309 + expect(getSpeedCategory(card)).toBe("sorcery"); 310 + }); 311 + 312 + it("returns sorcery for creatures without keywords", () => { 313 + const card = makeCard({ 314 + type_line: "Creature — Dragon", 315 + keywords: undefined, 316 + }); 317 + expect(getSpeedCategory(card)).toBe("sorcery"); 318 + }); 319 + 320 + it("returns instant for Instant with Flash (edge case)", () => { 321 + const card = makeCard({ 322 + type_line: "Instant", 323 + keywords: ["Flash"], 324 + }); 325 + expect(getSpeedCategory(card)).toBe("instant"); 326 + }); 327 + }); 328 + 329 + describe("isPermanent", () => { 330 + it("returns true for creatures", () => { 331 + expect(isPermanent("Legendary Creature — Human Wizard")).toBe(true); 332 + }); 333 + 334 + it("returns true for artifacts", () => { 335 + expect(isPermanent("Artifact — Equipment")).toBe(true); 336 + }); 337 + 338 + it("returns true for enchantments", () => { 339 + expect(isPermanent("Enchantment — Aura")).toBe(true); 340 + }); 341 + 342 + it("returns true for planeswalkers", () => { 343 + expect(isPermanent("Legendary Planeswalker — Jace")).toBe(true); 344 + }); 345 + 346 + it("returns true for lands", () => { 347 + expect(isPermanent("Basic Land — Island")).toBe(true); 348 + }); 349 + 350 + it("returns true for battles", () => { 351 + expect(isPermanent("Battle — Siege")).toBe(true); 352 + }); 353 + 354 + it("returns false for instants", () => { 355 + expect(isPermanent("Instant")).toBe(false); 356 + }); 357 + 358 + it("returns false for sorceries", () => { 359 + expect(isPermanent("Sorcery")).toBe(false); 360 + }); 361 + 362 + it("returns false for undefined", () => { 363 + expect(isPermanent(undefined)).toBe(false); 364 + }); 365 + 366 + it("returns true for artifact creatures", () => { 367 + expect(isPermanent("Artifact Creature — Golem")).toBe(true); 368 + }); 369 + 370 + it("returns true for enchantment creatures", () => { 371 + expect(isPermanent("Enchantment Creature — God")).toBe(true); 372 + }); 373 + }); 374 + 375 + describe("computeManaCurve", () => { 376 + it("groups cards by CMC bucket", () => { 377 + const { cards, lookup } = createTestData([ 378 + { card: { cmc: 1, type_line: "Creature" } }, 379 + { card: { cmc: 2, type_line: "Creature" } }, 380 + { card: { cmc: 2, type_line: "Creature" } }, 381 + { card: { cmc: 3, type_line: "Creature" } }, 382 + ]); 383 + 384 + const curve = computeManaCurve(cards, lookup); 385 + 386 + expect(curve.find((b) => b.bucket === "1")?.permanents).toBe(1); 387 + expect(curve.find((b) => b.bucket === "2")?.permanents).toBe(2); 388 + expect(curve.find((b) => b.bucket === "3")?.permanents).toBe(1); 389 + }); 390 + 391 + it("separates permanents from spells", () => { 392 + const { cards, lookup } = createTestData([ 393 + { card: { cmc: 2, type_line: "Creature — Elf" } }, 394 + { card: { cmc: 2, type_line: "Instant" } }, 395 + ]); 396 + 397 + const curve = computeManaCurve(cards, lookup); 398 + const bucket2 = curve.find((b) => b.bucket === "2"); 399 + 400 + expect(bucket2?.permanents).toBe(1); 401 + expect(bucket2?.spells).toBe(1); 402 + }); 403 + 404 + it("handles high CMC buckets individually", () => { 405 + const { cards, lookup } = createTestData([ 406 + { card: { cmc: 7, type_line: "Creature" } }, 407 + { card: { cmc: 8, type_line: "Creature" } }, 408 + { card: { cmc: 10, type_line: "Sorcery" } }, 409 + ]); 410 + 411 + const curve = computeManaCurve(cards, lookup); 412 + 413 + expect(curve.find((b) => b.bucket === "7")?.permanents).toBe(1); 414 + expect(curve.find((b) => b.bucket === "8")?.permanents).toBe(1); 415 + expect(curve.find((b) => b.bucket === "10")?.spells).toBe(1); 416 + // Should have buckets 0-10 (with gaps filled) 417 + expect(curve.find((b) => b.bucket === "9")?.permanents).toBe(0); 418 + }); 419 + 420 + it("multiplies by quantity", () => { 421 + const { cards, lookup } = createTestData([ 422 + { card: { cmc: 1, type_line: "Instant" }, deckCard: { quantity: 4 } }, 423 + ]); 424 + 425 + const curve = computeManaCurve(cards, lookup); 426 + expect(curve.find((b) => b.bucket === "1")?.spells).toBe(4); 427 + }); 428 + 429 + it("returns empty buckets for missing CMCs", () => { 430 + const { cards, lookup } = createTestData([ 431 + { card: { cmc: 5, type_line: "Creature" } }, 432 + ]); 433 + 434 + const curve = computeManaCurve(cards, lookup); 435 + 436 + expect(curve.find((b) => b.bucket === "0")?.permanents).toBe(0); 437 + expect(curve.find((b) => b.bucket === "1")?.permanents).toBe(0); 438 + expect(curve.find((b) => b.bucket === "5")?.permanents).toBe(1); 439 + }); 440 + 441 + it("includes card references in buckets", () => { 442 + const { cards, lookup } = createTestData([ 443 + { card: { name: "Llanowar Elves", cmc: 1, type_line: "Creature" } }, 444 + { card: { name: "Lightning Bolt", cmc: 1, type_line: "Instant" } }, 445 + ]); 446 + 447 + const curve = computeManaCurve(cards, lookup); 448 + const bucket1 = curve.find((b) => b.bucket === "1"); 449 + 450 + expect(bucket1?.permanentCards).toHaveLength(1); 451 + expect(bucket1?.spellCards).toHaveLength(1); 452 + }); 453 + }); 454 + 455 + describe("computeTypeDistribution", () => { 456 + it("counts cards by primary type", () => { 457 + const { cards, lookup } = createTestData([ 458 + { card: { type_line: "Creature — Elf" } }, 459 + { card: { type_line: "Creature — Human" } }, 460 + { card: { type_line: "Instant" } }, 461 + { card: { type_line: "Sorcery" } }, 462 + ]); 463 + 464 + const types = computeTypeDistribution(cards, lookup); 465 + 466 + expect(types.find((t) => t.type === "Creature")?.count).toBe(2); 467 + expect(types.find((t) => t.type === "Instant")?.count).toBe(1); 468 + expect(types.find((t) => t.type === "Sorcery")?.count).toBe(1); 469 + }); 470 + 471 + it("multiplies by quantity", () => { 472 + const { cards, lookup } = createTestData([ 473 + { card: { type_line: "Instant" }, deckCard: { quantity: 4 } }, 474 + ]); 475 + 476 + const types = computeTypeDistribution(cards, lookup); 477 + expect(types.find((t) => t.type === "Instant")?.count).toBe(4); 478 + }); 479 + 480 + it("sorts by count descending", () => { 481 + const { cards, lookup } = createTestData([ 482 + { card: { type_line: "Instant" } }, 483 + { card: { type_line: "Creature" } }, 484 + { card: { type_line: "Creature" } }, 485 + { card: { type_line: "Creature" } }, 486 + ]); 487 + 488 + const types = computeTypeDistribution(cards, lookup); 489 + 490 + expect(types[0].type).toBe("Creature"); 491 + expect(types[0].count).toBe(3); 492 + expect(types[1].type).toBe("Instant"); 493 + expect(types[1].count).toBe(1); 494 + }); 495 + 496 + it("includes card references", () => { 497 + const { cards, lookup } = createTestData([ 498 + { card: { name: "Bolt", type_line: "Instant" } }, 499 + { card: { name: "Shock", type_line: "Instant" } }, 500 + ]); 501 + 502 + const types = computeTypeDistribution(cards, lookup); 503 + const instants = types.find((t) => t.type === "Instant"); 504 + 505 + expect(instants?.cards).toHaveLength(2); 506 + }); 507 + }); 508 + 509 + describe("computeSpeedDistribution", () => { 510 + it("separates instant and sorcery speed", () => { 511 + const { cards, lookup } = createTestData([ 512 + { card: { type_line: "Instant", keywords: [] } }, 513 + { card: { type_line: "Creature", keywords: ["Flash"] } }, 514 + { card: { type_line: "Creature", keywords: [] } }, 515 + { card: { type_line: "Sorcery", keywords: [] } }, 516 + ]); 517 + 518 + const speed = computeSpeedDistribution(cards, lookup); 519 + 520 + expect(speed.find((s) => s.category === "instant")?.count).toBe(2); 521 + expect(speed.find((s) => s.category === "sorcery")?.count).toBe(2); 522 + }); 523 + 524 + it("multiplies by quantity", () => { 525 + const { cards, lookup } = createTestData([ 526 + { 527 + card: { type_line: "Instant", keywords: [] }, 528 + deckCard: { quantity: 4 }, 529 + }, 530 + ]); 531 + 532 + const speed = computeSpeedDistribution(cards, lookup); 533 + expect(speed.find((s) => s.category === "instant")?.count).toBe(4); 534 + }); 535 + 536 + it("includes card references", () => { 537 + const { cards, lookup } = createTestData([ 538 + { card: { name: "Bolt", type_line: "Instant", keywords: [] } }, 539 + { 540 + card: { 541 + name: "Snapcaster", 542 + type_line: "Creature", 543 + keywords: ["Flash"], 544 + }, 545 + }, 546 + ]); 547 + 548 + const speed = computeSpeedDistribution(cards, lookup); 549 + const instant = speed.find((s) => s.category === "instant"); 550 + 551 + expect(instant?.cards).toHaveLength(2); 552 + }); 553 + });
+713
src/lib/deck-stats.ts
··· 1 + import type { 2 + Card, 3 + ManaColor, 4 + ManaColorWithColorless, 5 + } from "@/lib/scryfall-types"; 6 + import { 7 + getCastableFaces, 8 + getFaceManaValue, 9 + getPrimaryFace, 10 + } from "./card-faces"; 11 + import { 12 + type CardLookup, 13 + extractPrimaryType, 14 + extractSubtypes, 15 + getManaValueBucket, 16 + } from "./deck-grouping"; 17 + import type { DeckCard } from "./deck-types"; 18 + 19 + export type SpeedCategory = "instant" | "sorcery"; 20 + export type SourceTempo = "immediate" | "conditional" | "delayed" | "bounce"; 21 + 22 + export type { CardLookup }; 23 + 24 + /** 25 + * A card reference with the specific face that matched the stat criteria. 26 + * faceIdx 0 = front/primary face, 1 = back/secondary face. 27 + */ 28 + export interface FacedCard { 29 + card: DeckCard; 30 + faceIdx: number; 31 + } 32 + 33 + export interface ManaCurveData { 34 + bucket: string; 35 + permanents: number; 36 + spells: number; 37 + permanentCards: FacedCard[]; 38 + spellCards: FacedCard[]; 39 + } 40 + 41 + export interface ManaSymbolsData { 42 + color: ManaColorWithColorless; 43 + symbolCount: number; 44 + symbolPercent: number; 45 + immediateSourceCount: number; 46 + conditionalSourceCount: number; 47 + delayedSourceCount: number; 48 + bounceSourceCount: number; 49 + sourceCount: number; 50 + sourcePercent: number; 51 + // Land-specific stats (for moxfield-style breakdown) 52 + landSourceCount: number; 53 + landSourcePercent: number; // % of lands that produce this color 54 + landProductionPercent: number; // % of total land production that is this color 55 + totalLandCount: number; // actual count of mana-producing lands (for display) 56 + // Land tempo breakdown 57 + landImmediateCount: number; 58 + landConditionalCount: number; 59 + landDelayedCount: number; 60 + landBounceCount: number; 61 + symbolCards: FacedCard[]; 62 + immediateSourceCards: FacedCard[]; 63 + conditionalSourceCards: FacedCard[]; 64 + delayedSourceCards: FacedCard[]; 65 + bounceSourceCards: FacedCard[]; 66 + landSourceCards: FacedCard[]; 67 + symbolDistribution: { bucket: string; count: number }[]; 68 + } 69 + 70 + export interface TypeData { 71 + type: string; 72 + count: number; 73 + cards: FacedCard[]; 74 + } 75 + 76 + export interface SpeedData { 77 + category: SpeedCategory; 78 + count: number; 79 + cards: FacedCard[]; 80 + } 81 + 82 + const MANA_COLORS: ManaColor[] = ["W", "U", "B", "R", "G"]; 83 + const MANA_COLORS_WITH_COLORLESS: ManaColorWithColorless[] = [ 84 + "W", 85 + "U", 86 + "B", 87 + "R", 88 + "G", 89 + "C", 90 + ]; 91 + 92 + /** 93 + * Count mana symbols in a mana cost string. 94 + * Handles hybrid mana (counts both colors), phyrexian mana, colorless, and ignores generic/X costs. 95 + */ 96 + export function countManaSymbols( 97 + manaCost: string | undefined, 98 + ): Record<ManaColorWithColorless, number> { 99 + const counts: Record<ManaColorWithColorless, number> = { 100 + W: 0, 101 + U: 0, 102 + B: 0, 103 + R: 0, 104 + G: 0, 105 + C: 0, 106 + }; 107 + 108 + if (!manaCost) return counts; 109 + 110 + // Match all symbols in braces 111 + const matches = manaCost.matchAll(/\{([^}]+)\}/g); 112 + 113 + for (const match of matches) { 114 + const symbol = match[1]; 115 + 116 + // Single color symbol (W, U, B, R, G) 117 + if (MANA_COLORS.includes(symbol as ManaColor)) { 118 + counts[symbol as ManaColor]++; 119 + continue; 120 + } 121 + 122 + // Colorless mana requirement (C) 123 + if (symbol === "C") { 124 + counts.C++; 125 + continue; 126 + } 127 + 128 + // Hybrid mana (W/U, U/B, etc.) - count both colors 129 + const hybridMatch = symbol.match(/^([WUBRG])\/([WUBRG])$/); 130 + if (hybridMatch) { 131 + counts[hybridMatch[1] as ManaColor]++; 132 + counts[hybridMatch[2] as ManaColor]++; 133 + continue; 134 + } 135 + 136 + // Phyrexian mana (W/P, U/P, etc.) - count the color 137 + const phyrexianMatch = symbol.match(/^([WUBRG])\/P$/); 138 + if (phyrexianMatch) { 139 + counts[phyrexianMatch[1] as ManaColor]++; 140 + continue; 141 + } 142 + 143 + // Hybrid phyrexian (2/W, 2/U, etc.) - count the color 144 + const hybridPhyrexianMatch = symbol.match(/^2\/([WUBRG])$/); 145 + if (hybridPhyrexianMatch) { 146 + counts[hybridPhyrexianMatch[1] as ManaColor]++; 147 + } 148 + 149 + // Ignore generic mana (numbers), X, S (snow), etc. 150 + } 151 + 152 + return counts; 153 + } 154 + 155 + /** 156 + * Determine how quickly a mana-producing card can produce mana. 157 + * - immediate: can tap right away (untapped lands, hasty dorks, normal artifacts) 158 + * - conditional: might enter tapped depending on game state (battle lands, check lands, fast lands) 159 + * - delayed: needs a turn (taplands, summoning sick creatures, ETB-tapped artifacts) 160 + * - bounce: bouncelands (enters tapped + returns a land) 161 + */ 162 + export function getSourceTempo(card: Card): SourceTempo { 163 + const typeLine = card.type_line ?? ""; 164 + const oracleText = card.oracle_text ?? ""; 165 + 166 + // Detect enters-tapped patterns 167 + // "This X enters tapped" without "unless" = unconditional (always delayed) 168 + // "enters tapped unless" = conditional (checklands/fastlands/battlelands - depends on game state) 169 + // "As X enters, you may pay" + "If you don't, it enters tapped" = immediate (shocklands - you can always pay life) 170 + const entersTappedUnconditional = 171 + /this (land|artifact|creature) enters tapped(?! unless)/i.test(oracleText); 172 + // Shocklands let you pay life (a choice you always have) - these are immediate 173 + const isPayLifeChoice = /as .* enters.*you may pay.*life/i.test(oracleText); 174 + // Game-state conditional: battle lands (2+ basics), check lands (land types), fast lands (2 or fewer lands) 175 + const entersTappedConditional = 176 + !isPayLifeChoice && 177 + /this (land|artifact|creature) enters tapped unless/i.test(oracleText); 178 + const returnsLand = /return a land/i.test(oracleText); 179 + 180 + // Bouncelands: enters tapped AND returns a land 181 + if (entersTappedUnconditional && returnsLand) { 182 + return "bounce"; 183 + } 184 + 185 + // Creatures first (before Land check) - handles Land Creatures like Dryad Arbor 186 + if (typeLine.includes("Creature")) { 187 + if (card.keywords?.includes("Haste")) { 188 + return "immediate"; 189 + } 190 + // Exile from hand doesn't require being on battlefield 191 + if (/exile this (card|creature) from your hand/i.test(oracleText)) { 192 + return "immediate"; 193 + } 194 + // ETB triggers fire immediately 195 + if (/when (this creature|it) enters.*add/i.test(oracleText)) { 196 + return "immediate"; 197 + } 198 + // Sacrifice without tap bypasses summoning sickness 199 + if (/sacrifice this creature: add/i.test(oracleText)) { 200 + return "immediate"; 201 + } 202 + // Sacrifice by creature type (e.g., "Sacrifice a Goblin: Add") 203 + // Check if any of the creature's types appear in a sacrifice pattern 204 + const subtypes = typeLine.split("—")[1]?.trim().split(" ") ?? []; 205 + for (const subtype of subtypes) { 206 + if (new RegExp(`sacrifice a ${subtype}: add`, "i").test(oracleText)) { 207 + return "immediate"; 208 + } 209 + } 210 + // Creates tokens with sacrifice-for-mana abilities (e.g., Eldrazi Spawn/Scion, Treasure) 211 + if ( 212 + /create.*token.*sacrifice this (creature|token): add/is.test(oracleText) 213 + ) { 214 + return "immediate"; 215 + } 216 + // Pay life for mana (no tap required, bypasses summoning sickness) 217 + if (/pay \d+ life: add/i.test(oracleText)) { 218 + return "immediate"; 219 + } 220 + return "delayed"; 221 + } 222 + 223 + // Lands (non-creature) 224 + if (typeLine.includes("Land")) { 225 + if (entersTappedUnconditional) return "delayed"; 226 + if (entersTappedConditional) return "conditional"; 227 + return "immediate"; 228 + } 229 + 230 + // Artifacts can enter tapped 231 + if (typeLine.includes("Artifact")) { 232 + if (entersTappedUnconditional) return "delayed"; 233 + if (entersTappedConditional) return "conditional"; 234 + return "immediate"; 235 + } 236 + 237 + // Everything else (enchantments, instants, sorceries) 238 + return "immediate"; 239 + } 240 + 241 + /** 242 + * Determine if a card can be cast at instant speed. 243 + */ 244 + export function getSpeedCategory(card: Card): SpeedCategory { 245 + // Instants are instant speed 246 + if (card.type_line?.includes("Instant")) { 247 + return "instant"; 248 + } 249 + 250 + // Cards with Flash keyword are instant speed 251 + if (card.keywords?.includes("Flash")) { 252 + return "instant"; 253 + } 254 + 255 + // Everything else is sorcery speed 256 + return "sorcery"; 257 + } 258 + 259 + /** 260 + * Check if a card type line represents a permanent. 261 + */ 262 + export function isPermanent(typeLine: string | undefined): boolean { 263 + if (!typeLine) return false; 264 + 265 + const permanentTypes = [ 266 + "Creature", 267 + "Artifact", 268 + "Enchantment", 269 + "Planeswalker", 270 + "Battle", 271 + "Land", 272 + ]; 273 + 274 + return permanentTypes.some((type) => typeLine.includes(type)); 275 + } 276 + 277 + /** 278 + * Compute mana curve data for a set of cards. 279 + * Groups cards by CMC bucket and separates permanents from spells. 280 + * 281 + * For multi-faced cards (MDFC, split, adventure), each castable face 282 + * appears at its own mana value. Land faces are skipped. 283 + */ 284 + export function computeManaCurve( 285 + cards: DeckCard[], 286 + lookup: CardLookup, 287 + ): ManaCurveData[] { 288 + const buckets = new Map< 289 + string, 290 + { 291 + permanentCards: FacedCard[]; 292 + spellCards: FacedCard[]; 293 + } 294 + >(); 295 + 296 + let maxCmc = 0; 297 + 298 + for (const deckCard of cards) { 299 + const card = lookup(deckCard); 300 + if (!card) continue; 301 + 302 + const faces = getCastableFaces(card); 303 + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { 304 + const face = faces[faceIdx]; 305 + const typeLine = face.type_line ?? ""; 306 + 307 + // Skip pure lands (but keep land creatures like Dryad Arbor) 308 + if (typeLine.includes("Land") && !typeLine.includes("Creature")) continue; 309 + 310 + const mv = getFaceManaValue(face, card, faceIdx); 311 + const bucket = getManaValueBucket(mv); 312 + const cmcNum = Number.parseInt(bucket, 10); 313 + if (cmcNum > maxCmc) maxCmc = cmcNum; 314 + 315 + const data = buckets.get(bucket) ?? { 316 + permanentCards: [], 317 + spellCards: [], 318 + }; 319 + 320 + const facedCard: FacedCard = { card: deckCard, faceIdx }; 321 + 322 + // Add card quantity times (each copy counts) 323 + for (let i = 0; i < deckCard.quantity; i++) { 324 + if (isPermanent(typeLine)) { 325 + data.permanentCards.push(facedCard); 326 + } else { 327 + data.spellCards.push(facedCard); 328 + } 329 + } 330 + 331 + buckets.set(bucket, data); 332 + } 333 + } 334 + 335 + // Build contiguous array from 0 to maxCmc 336 + const result: ManaCurveData[] = []; 337 + for (let i = 0; i <= maxCmc; i++) { 338 + const bucket = i.toString(); 339 + const data = buckets.get(bucket) ?? { permanentCards: [], spellCards: [] }; 340 + result.push({ 341 + bucket, 342 + permanents: data.permanentCards.length, 343 + spells: data.spellCards.length, 344 + permanentCards: data.permanentCards, 345 + spellCards: data.spellCards, 346 + }); 347 + } 348 + 349 + return result; 350 + } 351 + 352 + /** 353 + * Compute mana symbols vs mana sources breakdown with tempo analysis. 354 + */ 355 + export function computeManaSymbolsVsSources( 356 + cards: DeckCard[], 357 + lookup: CardLookup, 358 + ): ManaSymbolsData[] { 359 + type ColorKey = ManaColorWithColorless; 360 + const makeColorRecord = <T>(init: () => T): Record<ColorKey, T> => ({ 361 + W: init(), 362 + U: init(), 363 + B: init(), 364 + R: init(), 365 + G: init(), 366 + C: init(), 367 + }); 368 + 369 + const symbolCounts = makeColorRecord(() => 0); 370 + const immediateCounts = makeColorRecord(() => 0); 371 + const conditionalCounts = makeColorRecord(() => 0); 372 + const delayedCounts = makeColorRecord(() => 0); 373 + const bounceCounts = makeColorRecord(() => 0); 374 + const landCounts = makeColorRecord(() => 0); 375 + const landImmediateCounts = makeColorRecord(() => 0); 376 + const landConditionalCounts = makeColorRecord(() => 0); 377 + const landDelayedCounts = makeColorRecord(() => 0); 378 + const landBounceCounts = makeColorRecord(() => 0); 379 + const symbolCards = makeColorRecord<FacedCard[]>(() => []); 380 + const immediateCards = makeColorRecord<FacedCard[]>(() => []); 381 + const conditionalCards = makeColorRecord<FacedCard[]>(() => []); 382 + const delayedCards = makeColorRecord<FacedCard[]>(() => []); 383 + const bounceCards = makeColorRecord<FacedCard[]>(() => []); 384 + const landCards = makeColorRecord<FacedCard[]>(() => []); 385 + const symbolDistributions = makeColorRecord<Map<string, number>>( 386 + () => new Map(), 387 + ); 388 + let totalLandCount = 0; 389 + 390 + for (const deckCard of cards) { 391 + const card = lookup(deckCard); 392 + if (!card) continue; 393 + 394 + // Count mana symbols from all castable faces 395 + const faces = getCastableFaces(card); 396 + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { 397 + const face = faces[faceIdx]; 398 + const symbols = countManaSymbols(face.mana_cost); 399 + const mv = getFaceManaValue(face, card, faceIdx); 400 + const bucket = getManaValueBucket(mv); 401 + 402 + const facedCard: FacedCard = { card: deckCard, faceIdx }; 403 + 404 + for (const color of MANA_COLORS_WITH_COLORLESS) { 405 + if (symbols[color] > 0) { 406 + symbolCounts[color] += symbols[color] * deckCard.quantity; 407 + symbolCards[color].push(facedCard); 408 + // Track distribution by MV 409 + const dist = symbolDistributions[color]; 410 + dist.set( 411 + bucket, 412 + (dist.get(bucket) ?? 0) + symbols[color] * deckCard.quantity, 413 + ); 414 + } 415 + } 416 + } 417 + 418 + // Count mana sources by tempo (card-level property, not face-level) 419 + // For sources, we use faceIdx 0 since produced_mana is card-level 420 + const producedColors = (card.produced_mana ?? []) as ColorKey[]; 421 + const primaryFace = getPrimaryFace(card); 422 + const isLand = (primaryFace.type_line ?? "").includes("Land"); 423 + const sourceFacedCard: FacedCard = { card: deckCard, faceIdx: 0 }; 424 + 425 + if (producedColors.length > 0) { 426 + const tempo = getSourceTempo(card); 427 + 428 + // Track land sources separately (count each land once, not per color) 429 + if (isLand) { 430 + totalLandCount += deckCard.quantity; 431 + } 432 + 433 + for (const color of producedColors) { 434 + if (!MANA_COLORS_WITH_COLORLESS.includes(color)) continue; 435 + const qty = deckCard.quantity; 436 + 437 + // Track land sources per color with tempo 438 + if (isLand) { 439 + landCounts[color] += qty; 440 + landCards[color].push(sourceFacedCard); 441 + switch (tempo) { 442 + case "immediate": 443 + landImmediateCounts[color] += qty; 444 + break; 445 + case "conditional": 446 + landConditionalCounts[color] += qty; 447 + break; 448 + case "delayed": 449 + landDelayedCounts[color] += qty; 450 + break; 451 + case "bounce": 452 + landBounceCounts[color] += qty; 453 + break; 454 + } 455 + } 456 + 457 + switch (tempo) { 458 + case "immediate": 459 + immediateCounts[color] += qty; 460 + immediateCards[color].push(sourceFacedCard); 461 + break; 462 + case "conditional": 463 + conditionalCounts[color] += qty; 464 + conditionalCards[color].push(sourceFacedCard); 465 + break; 466 + case "delayed": 467 + delayedCounts[color] += qty; 468 + delayedCards[color].push(sourceFacedCard); 469 + break; 470 + case "bounce": 471 + bounceCounts[color] += qty; 472 + bounceCards[color].push(sourceFacedCard); 473 + break; 474 + } 475 + } 476 + } 477 + } 478 + 479 + // Calculate totals for percentages 480 + const totalSymbols = Object.values(symbolCounts).reduce((a, b) => a + b, 0); 481 + const totalSources = MANA_COLORS_WITH_COLORLESS.reduce( 482 + (sum, c) => 483 + sum + 484 + immediateCounts[c] + 485 + conditionalCounts[c] + 486 + delayedCounts[c] + 487 + bounceCounts[c], 488 + 0, 489 + ); 490 + // Total land production = sum of all land-color pairs (duals count for each color) 491 + const totalLandProduction = MANA_COLORS_WITH_COLORLESS.reduce( 492 + (sum, c) => sum + landCounts[c], 493 + 0, 494 + ); 495 + 496 + return MANA_COLORS_WITH_COLORLESS.map((color) => { 497 + const sourceCount = 498 + immediateCounts[color] + 499 + conditionalCounts[color] + 500 + delayedCounts[color] + 501 + bounceCounts[color]; 502 + 503 + // Convert distribution map to array 504 + const dist = symbolDistributions[color]; 505 + const symbolDistribution = ["0", "1", "2", "3", "4", "5", "6", "7+"].map( 506 + (bucket) => ({ bucket, count: dist.get(bucket) ?? 0 }), 507 + ); 508 + 509 + return { 510 + color, 511 + symbolCount: symbolCounts[color], 512 + symbolPercent: 513 + totalSymbols > 0 ? (symbolCounts[color] / totalSymbols) * 100 : 0, 514 + immediateSourceCount: immediateCounts[color], 515 + conditionalSourceCount: conditionalCounts[color], 516 + delayedSourceCount: delayedCounts[color], 517 + bounceSourceCount: bounceCounts[color], 518 + sourceCount, 519 + sourcePercent: totalSources > 0 ? (sourceCount / totalSources) * 100 : 0, 520 + landSourceCount: landCounts[color], 521 + landSourcePercent: 522 + totalLandCount > 0 ? (landCounts[color] / totalLandCount) * 100 : 0, 523 + landProductionPercent: 524 + totalLandProduction > 0 525 + ? (landCounts[color] / totalLandProduction) * 100 526 + : 0, 527 + totalLandCount, 528 + landImmediateCount: landImmediateCounts[color], 529 + landConditionalCount: landConditionalCounts[color], 530 + landDelayedCount: landDelayedCounts[color], 531 + landBounceCount: landBounceCounts[color], 532 + symbolCards: symbolCards[color], 533 + immediateSourceCards: immediateCards[color], 534 + conditionalSourceCards: conditionalCards[color], 535 + delayedSourceCards: delayedCards[color], 536 + bounceSourceCards: bounceCards[color], 537 + landSourceCards: landCards[color], 538 + symbolDistribution, 539 + }; 540 + }); 541 + } 542 + 543 + /** 544 + * Compute card type distribution. 545 + * 546 + * For multi-faced cards (MDFC, split, adventure), each castable face's 547 + * type is counted. An MDFC land//creature counts as both Land and Creature. 548 + */ 549 + export function computeTypeDistribution( 550 + cards: DeckCard[], 551 + lookup: CardLookup, 552 + ): TypeData[] { 553 + const types = new Map<string, { count: number; cards: FacedCard[] }>(); 554 + 555 + for (const deckCard of cards) { 556 + const card = lookup(deckCard); 557 + if (!card) continue; 558 + 559 + // Track which types we've already counted for this card 560 + // (avoid double-counting if both faces have same type) 561 + const countedTypes = new Map<string, number>(); 562 + 563 + const faces = getCastableFaces(card); 564 + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { 565 + const face = faces[faceIdx]; 566 + const type = extractPrimaryType(face.type_line); 567 + 568 + if (!countedTypes.has(type)) { 569 + countedTypes.set(type, faceIdx); 570 + const data = types.get(type) ?? { count: 0, cards: [] }; 571 + data.count += deckCard.quantity; 572 + data.cards.push({ card: deckCard, faceIdx }); 573 + types.set(type, data); 574 + } 575 + } 576 + } 577 + 578 + // Sort by count descending 579 + return [...types.entries()] 580 + .map(([type, data]) => ({ 581 + type, 582 + count: data.count, 583 + cards: data.cards, 584 + })) 585 + .sort((a, b) => b.count - a.count); 586 + } 587 + 588 + /** 589 + * Compute subtype distribution. 590 + * Limits to top 10 subtypes with "Other" bucket. 591 + * 592 + * For multi-faced cards, subtypes from all castable faces are counted. 593 + */ 594 + export function computeSubtypeDistribution( 595 + cards: DeckCard[], 596 + lookup: CardLookup, 597 + ): TypeData[] { 598 + const subtypes = new Map<string, { count: number; cards: FacedCard[] }>(); 599 + 600 + for (const deckCard of cards) { 601 + const card = lookup(deckCard); 602 + if (!card) continue; 603 + 604 + // Track which subtypes we've counted for this card 605 + const countedSubtypes = new Map<string, number>(); 606 + 607 + const faces = getCastableFaces(card); 608 + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { 609 + const face = faces[faceIdx]; 610 + const faceSubtypes = extractSubtypes(face.type_line); 611 + 612 + for (const subtype of faceSubtypes) { 613 + if (!countedSubtypes.has(subtype)) { 614 + countedSubtypes.set(subtype, faceIdx); 615 + const data = subtypes.get(subtype) ?? { count: 0, cards: [] }; 616 + data.count += deckCard.quantity; 617 + data.cards.push({ card: deckCard, faceIdx }); 618 + subtypes.set(subtype, data); 619 + } 620 + } 621 + } 622 + } 623 + 624 + // Sort by count descending 625 + const sorted = [...subtypes.entries()] 626 + .map(([type, data]) => ({ 627 + type, 628 + count: data.count, 629 + cards: data.cards, 630 + })) 631 + .sort((a, b) => b.count - a.count); 632 + 633 + // Limit to top 10, rest goes to "Other" 634 + if (sorted.length <= 10) { 635 + return sorted; 636 + } 637 + 638 + const top10 = sorted.slice(0, 10); 639 + const rest = sorted.slice(10); 640 + 641 + const otherCount = rest.reduce((sum, item) => sum + item.count, 0); 642 + const otherCards = rest.flatMap((item) => item.cards); 643 + 644 + if (otherCount > 0) { 645 + top10.push({ 646 + type: "Other", 647 + count: otherCount, 648 + cards: otherCards, 649 + }); 650 + } 651 + 652 + return top10; 653 + } 654 + 655 + /** 656 + * Compute speed distribution (instant vs sorcery speed). 657 + * 658 + * For multi-faced cards, each face is checked for its speed. 659 + * A card with an instant adventure and creature main is counted 660 + * as having both instant and sorcery options. 661 + */ 662 + export function computeSpeedDistribution( 663 + cards: DeckCard[], 664 + lookup: CardLookup, 665 + ): SpeedData[] { 666 + const instant: FacedCard[] = []; 667 + const sorcery: FacedCard[] = []; 668 + let instantCount = 0; 669 + let sorceryCount = 0; 670 + 671 + for (const deckCard of cards) { 672 + const card = lookup(deckCard); 673 + if (!card) continue; 674 + 675 + // Track which speeds we've counted for this card 676 + let hasInstant = false; 677 + let hasSorcery = false; 678 + let instantFaceIdx = 0; 679 + let sorceryFaceIdx = 0; 680 + 681 + const faces = getCastableFaces(card); 682 + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { 683 + const face = faces[faceIdx]; 684 + const typeLine = face.type_line ?? ""; 685 + 686 + // Check if this face is instant speed 687 + const isInstant = 688 + typeLine.includes("Instant") || card.keywords?.includes("Flash"); 689 + 690 + if (isInstant && !hasInstant) { 691 + hasInstant = true; 692 + instantFaceIdx = faceIdx; 693 + } else if (!isInstant && !hasSorcery) { 694 + hasSorcery = true; 695 + sorceryFaceIdx = faceIdx; 696 + } 697 + } 698 + 699 + if (hasInstant) { 700 + instant.push({ card: deckCard, faceIdx: instantFaceIdx }); 701 + instantCount += deckCard.quantity; 702 + } 703 + if (hasSorcery) { 704 + sorcery.push({ card: deckCard, faceIdx: sorceryFaceIdx }); 705 + sorceryCount += deckCard.quantity; 706 + } 707 + } 708 + 709 + return [ 710 + { category: "instant", count: instantCount, cards: instant }, 711 + { category: "sorcery", count: sorceryCount, cards: sorcery }, 712 + ]; 713 + }
+69
src/lib/format-utils.ts
··· 1 + export interface FormatGroup { 2 + label: string; 3 + formats: { value: string; label: string }[]; 4 + } 5 + 6 + export const FORMAT_GROUPS: FormatGroup[] = [ 7 + { 8 + label: "Constructed", 9 + formats: [ 10 + { value: "standard", label: "Standard" }, 11 + { value: "pioneer", label: "Pioneer" }, 12 + { value: "modern", label: "Modern" }, 13 + { value: "legacy", label: "Legacy" }, 14 + { value: "vintage", label: "Vintage" }, 15 + { value: "pauper", label: "Pauper" }, 16 + ], 17 + }, 18 + { 19 + label: "Commander", 20 + formats: [ 21 + { value: "commander", label: "Commander" }, 22 + { value: "duel", label: "Duel Commander" }, 23 + { value: "paupercommander", label: "Pauper Commander" }, 24 + { value: "predh", label: "PreDH" }, 25 + { value: "oathbreaker", label: "Oathbreaker" }, 26 + ], 27 + }, 28 + { 29 + label: "Brawl", 30 + formats: [ 31 + { value: "brawl", label: "Brawl" }, 32 + { value: "standardbrawl", label: "Standard Brawl" }, 33 + ], 34 + }, 35 + { 36 + label: "Arena", 37 + formats: [ 38 + { value: "historic", label: "Historic" }, 39 + { value: "timeless", label: "Timeless" }, 40 + { value: "alchemy", label: "Alchemy" }, 41 + { value: "gladiator", label: "Gladiator" }, 42 + ], 43 + }, 44 + { 45 + label: "Retro", 46 + formats: [ 47 + { value: "premodern", label: "Premodern" }, 48 + { value: "oldschool", label: "Old School" }, 49 + ], 50 + }, 51 + { 52 + label: "Other", 53 + formats: [ 54 + { value: "penny", label: "Penny Dreadful" }, 55 + { value: "cube", label: "Cube" }, 56 + ], 57 + }, 58 + ]; 59 + 60 + const FORMAT_DISPLAY_NAMES: Record<string, string> = Object.fromEntries( 61 + FORMAT_GROUPS.flatMap((group) => 62 + group.formats.map((fmt) => [fmt.value, fmt.label]), 63 + ), 64 + ); 65 + 66 + export function formatDisplayName(format: string | undefined): string { 67 + if (!format) return ""; 68 + return FORMAT_DISPLAY_NAMES[format] ?? format; 69 + }
+545
src/lib/goldfish/__tests__/engine.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import type { DeckCard } from "@/lib/deck-types"; 3 + import { asScryfallId } from "@/lib/scryfall-types"; 4 + import { createSeededRng } from "@/lib/useSeededRandom"; 5 + import { 6 + addCounter, 7 + adjustLife, 8 + adjustPoison, 9 + clearCounters, 10 + createInitialState, 11 + cycleFace, 12 + draw, 13 + flipCard, 14 + moveCard, 15 + mulligan, 16 + removeCounter, 17 + setCounter, 18 + setHoveredCard, 19 + tap, 20 + toggleFaceDown, 21 + toggleTap, 22 + untap, 23 + untapAll, 24 + } from "../engine"; 25 + import type { GameState } from "../types"; 26 + 27 + function mockDeck(count: number): DeckCard[] { 28 + return Array.from({ length: count }, (_, i) => ({ 29 + scryfallId: asScryfallId(`card-${i}`), 30 + quantity: 1, 31 + section: "mainboard" as const, 32 + tags: [], 33 + })); 34 + } 35 + 36 + function createTestRng(seed = 42): () => number { 37 + const stateRef = { current: seed }; 38 + return createSeededRng(stateRef); 39 + } 40 + 41 + describe("createInitialState", () => { 42 + it("creates state with 7 cards in hand", () => { 43 + const deck = mockDeck(60); 44 + const state = createInitialState(deck, createTestRng()); 45 + 46 + expect(state.hand).toHaveLength(7); 47 + }); 48 + 49 + it("puts remaining cards in library", () => { 50 + const deck = mockDeck(60); 51 + const state = createInitialState(deck, createTestRng()); 52 + 53 + expect(state.library).toHaveLength(53); 54 + }); 55 + 56 + it("starts with empty battlefield, graveyard, and exile", () => { 57 + const deck = mockDeck(60); 58 + const state = createInitialState(deck, createTestRng()); 59 + 60 + expect(state.battlefield).toHaveLength(0); 61 + expect(state.graveyard).toHaveLength(0); 62 + expect(state.exile).toHaveLength(0); 63 + }); 64 + 65 + it("assigns unique instanceIds to all cards", () => { 66 + const deck = mockDeck(60); 67 + const state = createInitialState(deck, createTestRng()); 68 + 69 + const allCards = [...state.hand, ...state.library]; 70 + const ids = allCards.map((c) => c.instanceId); 71 + const uniqueIds = new Set(ids); 72 + 73 + expect(uniqueIds.size).toBe(60); 74 + }); 75 + 76 + it("uses custom starting life", () => { 77 + const deck = mockDeck(60); 78 + const state = createInitialState(deck, createTestRng(), 40); 79 + 80 + expect(state.player.life).toBe(40); 81 + }); 82 + 83 + it("defaults to 20 life", () => { 84 + const deck = mockDeck(60); 85 + const state = createInitialState(deck, createTestRng()); 86 + 87 + expect(state.player.life).toBe(20); 88 + }); 89 + }); 90 + 91 + describe("draw", () => { 92 + it("moves top card from library to hand", () => { 93 + const deck = mockDeck(60); 94 + const state = createInitialState(deck, createTestRng()); 95 + const topCard = state.library[0]; 96 + 97 + const newState = draw(state); 98 + 99 + expect(newState.hand).toHaveLength(8); 100 + expect(newState.library).toHaveLength(52); 101 + expect(newState.hand[7].instanceId).toBe(topCard.instanceId); 102 + expect(newState.hand[7].cardId).toBe(topCard.cardId); 103 + }); 104 + 105 + it("reveals drawn card (sets isFaceDown to false)", () => { 106 + const deck = mockDeck(60); 107 + const state = createInitialState(deck, createTestRng()); 108 + 109 + // Library cards start face-down 110 + expect(state.library[0].isFaceDown).toBe(true); 111 + 112 + const newState = draw(state); 113 + 114 + // Drawn card should be face-up 115 + expect(newState.hand[7].isFaceDown).toBe(false); 116 + }); 117 + 118 + it("returns same state if library is empty", () => { 119 + const state: GameState = { 120 + hand: [], 121 + library: [], 122 + battlefield: [], 123 + graveyard: [], 124 + exile: [], 125 + hoveredId: null, 126 + nextZIndex: 1, 127 + player: { life: 20, poison: 0, counters: {} }, 128 + }; 129 + 130 + const newState = draw(state); 131 + 132 + expect(newState).toBe(state); 133 + }); 134 + }); 135 + 136 + describe("mulligan", () => { 137 + it("reshuffles all cards and deals new hand", () => { 138 + const deck = mockDeck(60); 139 + const state = createInitialState(deck, createTestRng()); 140 + 141 + const newState = mulligan(state, createTestRng(123)); 142 + 143 + expect(newState.hand).toHaveLength(7); 144 + expect(newState.library).toHaveLength(53); 145 + expect(newState.battlefield).toHaveLength(0); 146 + }); 147 + 148 + it("includes cards from all zones in reshuffle", () => { 149 + const deck = mockDeck(10); 150 + let state = createInitialState(deck, createTestRng()); 151 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 152 + state = moveCard(state, state.hand[0].instanceId, "graveyard"); 153 + 154 + const newState = mulligan(state, createTestRng(456)); 155 + 156 + const totalCards = 157 + newState.hand.length + 158 + newState.library.length + 159 + newState.battlefield.length + 160 + newState.graveyard.length + 161 + newState.exile.length; 162 + expect(totalCards).toBe(10); 163 + }); 164 + 165 + it("resets card state on mulligan", () => { 166 + const deck = mockDeck(10); 167 + let state = createInitialState(deck, createTestRng()); 168 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 169 + state = tap(state, state.battlefield[0].instanceId); 170 + state = addCounter(state, state.battlefield[0].instanceId, "+1/+1", 3); 171 + 172 + const newState = mulligan(state, createTestRng(789)); 173 + 174 + const allCards = [...newState.hand, ...newState.library]; 175 + for (const card of allCards) { 176 + expect(card.isTapped).toBe(false); 177 + expect(card.counters).toEqual({}); 178 + } 179 + }); 180 + }); 181 + 182 + describe("tap/untap", () => { 183 + it("tap sets isTapped to true", () => { 184 + const deck = mockDeck(10); 185 + let state = createInitialState(deck, createTestRng()); 186 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 187 + const cardId = state.battlefield[0].instanceId; 188 + 189 + const newState = tap(state, cardId); 190 + 191 + expect(newState.battlefield[0].isTapped).toBe(true); 192 + }); 193 + 194 + it("untap sets isTapped to false", () => { 195 + const deck = mockDeck(10); 196 + let state = createInitialState(deck, createTestRng()); 197 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 198 + state = tap(state, state.battlefield[0].instanceId); 199 + const cardId = state.battlefield[0].instanceId; 200 + 201 + const newState = untap(state, cardId); 202 + 203 + expect(newState.battlefield[0].isTapped).toBe(false); 204 + }); 205 + 206 + it("toggleTap flips isTapped", () => { 207 + const deck = mockDeck(10); 208 + let state = createInitialState(deck, createTestRng()); 209 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 210 + const cardId = state.battlefield[0].instanceId; 211 + 212 + expect(state.battlefield[0].isTapped).toBe(false); 213 + 214 + state = toggleTap(state, cardId); 215 + expect(state.battlefield[0].isTapped).toBe(true); 216 + 217 + state = toggleTap(state, cardId); 218 + expect(state.battlefield[0].isTapped).toBe(false); 219 + }); 220 + 221 + it("untapAll untaps all battlefield cards", () => { 222 + const deck = mockDeck(10); 223 + let state = createInitialState(deck, createTestRng()); 224 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 225 + state = moveCard(state, state.hand[0].instanceId, "battlefield"); 226 + state = tap(state, state.battlefield[0].instanceId); 227 + state = tap(state, state.battlefield[1].instanceId); 228 + 229 + const newState = untapAll(state); 230 + 231 + expect(newState.battlefield[0].isTapped).toBe(false); 232 + expect(newState.battlefield[1].isTapped).toBe(false); 233 + }); 234 + }); 235 + 236 + describe("cycleFace", () => { 237 + it("increments faceIndex", () => { 238 + const deck = mockDeck(10); 239 + let state = createInitialState(deck, createTestRng()); 240 + const cardId = state.hand[0].instanceId; 241 + 242 + state = cycleFace(state, cardId, 2); 243 + 244 + expect(state.hand[0].faceIndex).toBe(1); 245 + }); 246 + 247 + it("wraps around at maxFaces", () => { 248 + const deck = mockDeck(10); 249 + let state = createInitialState(deck, createTestRng()); 250 + const cardId = state.hand[0].instanceId; 251 + 252 + state = cycleFace(state, cardId, 2); 253 + state = cycleFace(state, cardId, 2); 254 + 255 + expect(state.hand[0].faceIndex).toBe(0); 256 + }); 257 + 258 + it("does nothing for single-faced cards", () => { 259 + const deck = mockDeck(10); 260 + const state = createInitialState(deck, createTestRng()); 261 + const cardId = state.hand[0].instanceId; 262 + 263 + const newState = cycleFace(state, cardId, 1); 264 + 265 + expect(newState).toBe(state); 266 + }); 267 + }); 268 + 269 + describe("toggleFaceDown", () => { 270 + it("toggles isFaceDown for morph/manifest", () => { 271 + const deck = mockDeck(10); 272 + let state = createInitialState(deck, createTestRng()); 273 + const cardId = state.hand[0].instanceId; 274 + 275 + expect(state.hand[0].isFaceDown).toBe(false); 276 + 277 + state = toggleFaceDown(state, cardId); 278 + expect(state.hand[0].isFaceDown).toBe(true); 279 + 280 + state = toggleFaceDown(state, cardId); 281 + expect(state.hand[0].isFaceDown).toBe(false); 282 + }); 283 + }); 284 + 285 + describe("flipCard", () => { 286 + it("reveals face-down card", () => { 287 + const deck = mockDeck(10); 288 + let state = createInitialState(deck, createTestRng()); 289 + const cardId = state.hand[0].instanceId; 290 + 291 + state = toggleFaceDown(state, cardId); 292 + expect(state.hand[0].isFaceDown).toBe(true); 293 + 294 + state = flipCard(state, cardId, 1); 295 + 296 + expect(state.hand[0].isFaceDown).toBe(false); 297 + }); 298 + 299 + it("cycles face for DFC when face-up", () => { 300 + const deck = mockDeck(10); 301 + let state = createInitialState(deck, createTestRng()); 302 + const cardId = state.hand[0].instanceId; 303 + 304 + expect(state.hand[0].faceIndex).toBe(0); 305 + 306 + state = flipCard(state, cardId, 2); 307 + 308 + expect(state.hand[0].faceIndex).toBe(1); 309 + expect(state.hand[0].isFaceDown).toBe(false); 310 + }); 311 + 312 + it("wraps around when cycling DFC faces", () => { 313 + const deck = mockDeck(10); 314 + let state = createInitialState(deck, createTestRng()); 315 + const cardId = state.hand[0].instanceId; 316 + 317 + state = flipCard(state, cardId, 2); 318 + state = flipCard(state, cardId, 2); 319 + 320 + expect(state.hand[0].faceIndex).toBe(0); 321 + }); 322 + 323 + it("morphs single-faced card when face-up", () => { 324 + const deck = mockDeck(10); 325 + let state = createInitialState(deck, createTestRng()); 326 + const cardId = state.hand[0].instanceId; 327 + 328 + expect(state.hand[0].isFaceDown).toBe(false); 329 + 330 + state = flipCard(state, cardId, 1); 331 + 332 + expect(state.hand[0].isFaceDown).toBe(true); 333 + }); 334 + 335 + it("reveals face-down DFC to front face", () => { 336 + const deck = mockDeck(10); 337 + let state = createInitialState(deck, createTestRng()); 338 + const cardId = state.hand[0].instanceId; 339 + 340 + state = toggleFaceDown(state, cardId); 341 + expect(state.hand[0].isFaceDown).toBe(true); 342 + 343 + state = flipCard(state, cardId, 2); 344 + 345 + expect(state.hand[0].isFaceDown).toBe(false); 346 + expect(state.hand[0].faceIndex).toBe(0); 347 + }); 348 + 349 + it("toggles between morph and reveal for single-faced card", () => { 350 + const deck = mockDeck(10); 351 + let state = createInitialState(deck, createTestRng()); 352 + const cardId = state.hand[0].instanceId; 353 + 354 + state = flipCard(state, cardId, 1); 355 + expect(state.hand[0].isFaceDown).toBe(true); 356 + 357 + state = flipCard(state, cardId, 1); 358 + expect(state.hand[0].isFaceDown).toBe(false); 359 + 360 + state = flipCard(state, cardId, 1); 361 + expect(state.hand[0].isFaceDown).toBe(true); 362 + }); 363 + }); 364 + 365 + describe("moveCard", () => { 366 + it("moves card between zones", () => { 367 + const deck = mockDeck(10); 368 + let state = createInitialState(deck, createTestRng()); 369 + const cardId = state.hand[0].instanceId; 370 + 371 + state = moveCard(state, cardId, "battlefield"); 372 + 373 + expect(state.hand).toHaveLength(6); 374 + expect(state.battlefield).toHaveLength(1); 375 + expect(state.battlefield[0].instanceId).toBe(cardId); 376 + }); 377 + 378 + it("sets position when moving to battlefield", () => { 379 + const deck = mockDeck(10); 380 + let state = createInitialState(deck, createTestRng()); 381 + const cardId = state.hand[0].instanceId; 382 + 383 + state = moveCard(state, cardId, "battlefield", { 384 + position: { x: 200, y: 300 }, 385 + }); 386 + 387 + expect(state.battlefield[0].position).toEqual({ x: 200, y: 300 }); 388 + }); 389 + 390 + it("uses default position if none provided", () => { 391 + const deck = mockDeck(10); 392 + let state = createInitialState(deck, createTestRng()); 393 + const cardId = state.hand[0].instanceId; 394 + 395 + state = moveCard(state, cardId, "battlefield"); 396 + 397 + expect(state.battlefield[0].position).toEqual({ x: 100, y: 100 }); 398 + }); 399 + 400 + it("clears position when leaving battlefield", () => { 401 + const deck = mockDeck(10); 402 + let state = createInitialState(deck, createTestRng()); 403 + state = moveCard(state, state.hand[0].instanceId, "battlefield", { 404 + position: { x: 200, y: 300 }, 405 + }); 406 + const cardId = state.battlefield[0].instanceId; 407 + 408 + state = moveCard(state, cardId, "graveyard"); 409 + 410 + expect(state.graveyard[0].position).toBeUndefined(); 411 + }); 412 + 413 + it("updates position when moving within battlefield", () => { 414 + const deck = mockDeck(10); 415 + let state = createInitialState(deck, createTestRng()); 416 + state = moveCard(state, state.hand[0].instanceId, "battlefield", { 417 + position: { x: 100, y: 100 }, 418 + }); 419 + const cardId = state.battlefield[0].instanceId; 420 + 421 + state = moveCard(state, cardId, "battlefield", { 422 + position: { x: 500, y: 600 }, 423 + }); 424 + 425 + expect(state.battlefield[0].position).toEqual({ x: 500, y: 600 }); 426 + }); 427 + }); 428 + 429 + describe("setHoveredCard", () => { 430 + it("sets hoveredId", () => { 431 + const deck = mockDeck(10); 432 + let state = createInitialState(deck, createTestRng()); 433 + 434 + state = setHoveredCard(state, 5); 435 + 436 + expect(state.hoveredId).toBe(5); 437 + }); 438 + 439 + it("returns same state if hoveredId unchanged", () => { 440 + const deck = mockDeck(10); 441 + let state = createInitialState(deck, createTestRng()); 442 + state = setHoveredCard(state, 5); 443 + 444 + const newState = setHoveredCard(state, 5); 445 + 446 + expect(newState).toBe(state); 447 + }); 448 + }); 449 + 450 + describe("counters", () => { 451 + it("addCounter adds counters to a card", () => { 452 + const deck = mockDeck(10); 453 + let state = createInitialState(deck, createTestRng()); 454 + const cardId = state.hand[0].instanceId; 455 + 456 + state = addCounter(state, cardId, "+1/+1", 3); 457 + 458 + expect(state.hand[0].counters["+1/+1"]).toBe(3); 459 + }); 460 + 461 + it("addCounter stacks with existing counters", () => { 462 + const deck = mockDeck(10); 463 + let state = createInitialState(deck, createTestRng()); 464 + const cardId = state.hand[0].instanceId; 465 + 466 + state = addCounter(state, cardId, "+1/+1", 3); 467 + state = addCounter(state, cardId, "+1/+1", 2); 468 + 469 + expect(state.hand[0].counters["+1/+1"]).toBe(5); 470 + }); 471 + 472 + it("removeCounter removes counters", () => { 473 + const deck = mockDeck(10); 474 + let state = createInitialState(deck, createTestRng()); 475 + const cardId = state.hand[0].instanceId; 476 + 477 + state = addCounter(state, cardId, "+1/+1", 5); 478 + state = removeCounter(state, cardId, "+1/+1", 2); 479 + 480 + expect(state.hand[0].counters["+1/+1"]).toBe(3); 481 + }); 482 + 483 + it("removeCounter deletes counter type when reaching zero", () => { 484 + const deck = mockDeck(10); 485 + let state = createInitialState(deck, createTestRng()); 486 + const cardId = state.hand[0].instanceId; 487 + 488 + state = addCounter(state, cardId, "+1/+1", 3); 489 + state = removeCounter(state, cardId, "+1/+1", 3); 490 + 491 + expect(state.hand[0].counters["+1/+1"]).toBeUndefined(); 492 + }); 493 + 494 + it("setCounter sets counter to specific value", () => { 495 + const deck = mockDeck(10); 496 + let state = createInitialState(deck, createTestRng()); 497 + const cardId = state.hand[0].instanceId; 498 + 499 + state = setCounter(state, cardId, "loyalty", 4); 500 + 501 + expect(state.hand[0].counters.loyalty).toBe(4); 502 + }); 503 + 504 + it("clearCounters removes all counters", () => { 505 + const deck = mockDeck(10); 506 + let state = createInitialState(deck, createTestRng()); 507 + const cardId = state.hand[0].instanceId; 508 + 509 + state = addCounter(state, cardId, "+1/+1", 3); 510 + state = addCounter(state, cardId, "charge", 5); 511 + state = clearCounters(state, cardId); 512 + 513 + expect(state.hand[0].counters).toEqual({}); 514 + }); 515 + }); 516 + 517 + describe("player state", () => { 518 + it("adjustLife changes life total", () => { 519 + const deck = mockDeck(10); 520 + let state = createInitialState(deck, createTestRng()); 521 + 522 + state = adjustLife(state, -5); 523 + 524 + expect(state.player.life).toBe(15); 525 + }); 526 + 527 + it("adjustPoison changes poison counters", () => { 528 + const deck = mockDeck(10); 529 + let state = createInitialState(deck, createTestRng()); 530 + 531 + state = adjustPoison(state, 3); 532 + 533 + expect(state.player.poison).toBe(3); 534 + }); 535 + 536 + it("adjustPoison cannot go below zero", () => { 537 + const deck = mockDeck(10); 538 + let state = createInitialState(deck, createTestRng()); 539 + state = adjustPoison(state, 3); 540 + 541 + state = adjustPoison(state, -10); 542 + 543 + expect(state.player.poison).toBe(0); 544 + }); 545 + });
+387
src/lib/goldfish/engine.ts
··· 1 + import type { DeckCard } from "@/lib/deck-types"; 2 + import { seededShuffle } from "@/lib/useSeededRandom"; 3 + import { 4 + type CardInstance, 5 + createCardInstance, 6 + createEmptyGameState, 7 + type GameState, 8 + type Zone, 9 + } from "./types"; 10 + 11 + function findCard( 12 + state: GameState, 13 + instanceId: number, 14 + ): { card: CardInstance; zone: Zone } | null { 15 + const zones: Zone[] = [ 16 + "hand", 17 + "battlefield", 18 + "graveyard", 19 + "exile", 20 + "library", 21 + ]; 22 + for (const zone of zones) { 23 + const card = state[zone].find((c) => c.instanceId === instanceId); 24 + if (card) return { card, zone }; 25 + } 26 + return null; 27 + } 28 + 29 + function updateCardInState( 30 + state: GameState, 31 + instanceId: number, 32 + updater: (card: CardInstance) => CardInstance, 33 + ): GameState { 34 + const found = findCard(state, instanceId); 35 + if (!found) return state; 36 + 37 + return { 38 + ...state, 39 + [found.zone]: state[found.zone].map((c) => 40 + c.instanceId === instanceId ? updater(c) : c, 41 + ), 42 + }; 43 + } 44 + 45 + export function createInitialState( 46 + deck: DeckCard[], 47 + rng: () => number, 48 + startingLife = 20, 49 + ): GameState { 50 + const instances: CardInstance[] = []; 51 + let instanceId = 0; 52 + 53 + for (const card of deck) { 54 + for (let i = 0; i < card.quantity; i++) { 55 + instances.push(createCardInstance(card.scryfallId, instanceId++)); 56 + } 57 + } 58 + 59 + const shuffled = seededShuffle(instances, rng); 60 + 61 + return { 62 + ...createEmptyGameState(startingLife), 63 + hand: shuffled.slice(0, 7), 64 + // Library cards are face-down by default (can be revealed with F) 65 + library: shuffled.slice(7).map((c) => ({ ...c, isFaceDown: true })), 66 + }; 67 + } 68 + 69 + export function draw(state: GameState): GameState { 70 + if (state.library.length === 0) return state; 71 + 72 + const [drawn, ...rest] = state.library; 73 + return { 74 + ...state, 75 + hand: [...state.hand, { ...drawn, isFaceDown: false }], 76 + library: rest, 77 + }; 78 + } 79 + 80 + export function mulligan( 81 + state: GameState, 82 + rng: () => number, 83 + startingLife = 20, 84 + ): GameState { 85 + const allCards = [ 86 + ...state.hand, 87 + ...state.library, 88 + ...state.battlefield, 89 + ...state.graveyard, 90 + ...state.exile, 91 + ].map((card) => ({ 92 + ...card, 93 + isTapped: false, 94 + isFaceDown: false, 95 + faceIndex: 0, 96 + zIndex: 0, 97 + counters: {}, 98 + position: undefined, 99 + })); 100 + 101 + const shuffled = seededShuffle(allCards, rng); 102 + 103 + return { 104 + ...createEmptyGameState(startingLife), 105 + hand: shuffled.slice(0, 7), 106 + library: shuffled.slice(7).map((c) => ({ ...c, isFaceDown: true })), 107 + }; 108 + } 109 + 110 + export function tap(state: GameState, instanceId: number): GameState { 111 + return updateCardInState(state, instanceId, (card) => ({ 112 + ...card, 113 + isTapped: true, 114 + })); 115 + } 116 + 117 + export function untap(state: GameState, instanceId: number): GameState { 118 + return updateCardInState(state, instanceId, (card) => ({ 119 + ...card, 120 + isTapped: false, 121 + })); 122 + } 123 + 124 + export function toggleTap(state: GameState, instanceId: number): GameState { 125 + return updateCardInState(state, instanceId, (card) => ({ 126 + ...card, 127 + isTapped: !card.isTapped, 128 + })); 129 + } 130 + 131 + export function untapAll(state: GameState): GameState { 132 + return { 133 + ...state, 134 + battlefield: state.battlefield.map((card) => ({ 135 + ...card, 136 + isTapped: false, 137 + })), 138 + }; 139 + } 140 + 141 + export function cycleFace( 142 + state: GameState, 143 + instanceId: number, 144 + maxFaces: number, 145 + ): GameState { 146 + if (maxFaces <= 1) return state; 147 + 148 + return updateCardInState(state, instanceId, (card) => ({ 149 + ...card, 150 + faceIndex: (card.faceIndex + 1) % maxFaces, 151 + })); 152 + } 153 + 154 + export function toggleFaceDown( 155 + state: GameState, 156 + instanceId: number, 157 + ): GameState { 158 + return updateCardInState(state, instanceId, (card) => ({ 159 + ...card, 160 + isFaceDown: !card.isFaceDown, 161 + })); 162 + } 163 + 164 + /** 165 + * Smart flip - combines morph/manifest and DFC flipping into one action: 166 + * 1. If face-down: reveal (set isFaceDown: false) 167 + * 2. Else if DFC (maxFaces > 1): cycle to next face 168 + * 3. Else (single-faced, face-up): morph (set isFaceDown: true) 169 + */ 170 + export function flipCard( 171 + state: GameState, 172 + instanceId: number, 173 + maxFaces: number, 174 + ): GameState { 175 + const found = findCard(state, instanceId); 176 + if (!found) return state; 177 + 178 + const { card } = found; 179 + 180 + if (card.isFaceDown) { 181 + return updateCardInState(state, instanceId, (c) => ({ 182 + ...c, 183 + isFaceDown: false, 184 + })); 185 + } 186 + 187 + if (maxFaces > 1) { 188 + return updateCardInState(state, instanceId, (c) => ({ 189 + ...c, 190 + faceIndex: (c.faceIndex + 1) % maxFaces, 191 + })); 192 + } 193 + 194 + return updateCardInState(state, instanceId, (c) => ({ 195 + ...c, 196 + isFaceDown: true, 197 + })); 198 + } 199 + 200 + export interface MoveCardOptions { 201 + position?: { x: number; y: number }; 202 + faceDown?: boolean; 203 + } 204 + 205 + export function moveCard( 206 + state: GameState, 207 + instanceId: number, 208 + toZone: Zone, 209 + options: MoveCardOptions = {}, 210 + ): GameState { 211 + const { position, faceDown } = options; 212 + const found = findCard(state, instanceId); 213 + if (!found) return state; 214 + 215 + const { card, zone: fromZone } = found; 216 + 217 + if (fromZone === toZone) { 218 + if (toZone === "battlefield" && position) { 219 + // Moving within battlefield - bring to front 220 + return { 221 + ...updateCardInState(state, instanceId, (c) => ({ 222 + ...c, 223 + position, 224 + zIndex: state.nextZIndex, 225 + })), 226 + nextZIndex: state.nextZIndex + 1, 227 + }; 228 + } 229 + return state; 230 + } 231 + 232 + const movedCard: CardInstance = 233 + toZone === "battlefield" 234 + ? { 235 + ...card, 236 + position: position ?? { x: 100, y: 100 }, 237 + zIndex: state.nextZIndex, 238 + isFaceDown: faceDown ?? card.isFaceDown, 239 + } 240 + : toZone === "library" 241 + ? { 242 + ...card, 243 + position: undefined, 244 + isFaceDown: true, // Always face-down when going to library 245 + } 246 + : { 247 + ...card, 248 + position: undefined, 249 + isFaceDown: faceDown ?? card.isFaceDown, 250 + }; 251 + 252 + // Library: add to front (top of deck), others: add to end 253 + const newZoneCards = 254 + toZone === "library" 255 + ? [movedCard, ...state[toZone]] 256 + : [...state[toZone], movedCard]; 257 + 258 + return { 259 + ...state, 260 + [fromZone]: state[fromZone].filter((c) => c.instanceId !== instanceId), 261 + [toZone]: newZoneCards, 262 + nextZIndex: 263 + toZone === "battlefield" ? state.nextZIndex + 1 : state.nextZIndex, 264 + }; 265 + } 266 + 267 + export function setHoveredCard( 268 + state: GameState, 269 + instanceId: number | null, 270 + ): GameState { 271 + if (state.hoveredId === instanceId) return state; 272 + return { 273 + ...state, 274 + hoveredId: instanceId, 275 + }; 276 + } 277 + 278 + export function resetGame( 279 + deck: DeckCard[], 280 + rng: () => number, 281 + startingLife = 20, 282 + ): GameState { 283 + return createInitialState(deck, rng, startingLife); 284 + } 285 + 286 + // Counter manipulation 287 + 288 + export function addCounter( 289 + state: GameState, 290 + instanceId: number, 291 + counterType: string, 292 + amount = 1, 293 + ): GameState { 294 + return updateCardInState(state, instanceId, (card) => { 295 + const current = card.counters[counterType] ?? 0; 296 + const newValue = current + amount; 297 + if (newValue <= 0) { 298 + const { [counterType]: _, ...rest } = card.counters; 299 + return { ...card, counters: rest }; 300 + } 301 + return { 302 + ...card, 303 + counters: { ...card.counters, [counterType]: newValue }, 304 + }; 305 + }); 306 + } 307 + 308 + export function removeCounter( 309 + state: GameState, 310 + instanceId: number, 311 + counterType: string, 312 + amount = 1, 313 + ): GameState { 314 + return addCounter(state, instanceId, counterType, -amount); 315 + } 316 + 317 + export function setCounter( 318 + state: GameState, 319 + instanceId: number, 320 + counterType: string, 321 + value: number, 322 + ): GameState { 323 + return updateCardInState(state, instanceId, (card) => { 324 + if (value <= 0) { 325 + const { [counterType]: _, ...rest } = card.counters; 326 + return { ...card, counters: rest }; 327 + } 328 + return { 329 + ...card, 330 + counters: { ...card.counters, [counterType]: value }, 331 + }; 332 + }); 333 + } 334 + 335 + export function clearCounters(state: GameState, instanceId: number): GameState { 336 + return updateCardInState(state, instanceId, (card) => ({ 337 + ...card, 338 + counters: {}, 339 + })); 340 + } 341 + 342 + // Player state manipulation 343 + 344 + export function setLife(state: GameState, life: number): GameState { 345 + return { 346 + ...state, 347 + player: { ...state.player, life }, 348 + }; 349 + } 350 + 351 + export function adjustLife(state: GameState, amount: number): GameState { 352 + return setLife(state, state.player.life + amount); 353 + } 354 + 355 + export function setPoison(state: GameState, poison: number): GameState { 356 + return { 357 + ...state, 358 + player: { ...state.player, poison: Math.max(0, poison) }, 359 + }; 360 + } 361 + 362 + export function adjustPoison(state: GameState, amount: number): GameState { 363 + return setPoison(state, state.player.poison + amount); 364 + } 365 + 366 + export function addPlayerCounter( 367 + state: GameState, 368 + counterType: string, 369 + amount = 1, 370 + ): GameState { 371 + const current = state.player.counters[counterType] ?? 0; 372 + const newValue = current + amount; 373 + if (newValue <= 0) { 374 + const { [counterType]: _, ...rest } = state.player.counters; 375 + return { 376 + ...state, 377 + player: { ...state.player, counters: rest }, 378 + }; 379 + } 380 + return { 381 + ...state, 382 + player: { 383 + ...state.player, 384 + counters: { ...state.player.counters, [counterType]: newValue }, 385 + }, 386 + }; 387 + }
+3
src/lib/goldfish/index.ts
··· 1 + export * from "./engine"; 2 + export * from "./types"; 3 + export * from "./useGoldfishGame";
+67
src/lib/goldfish/types.ts
··· 1 + import type { ScryfallId } from "@/lib/scryfall-types"; 2 + 3 + export interface CardInstance { 4 + cardId: ScryfallId; 5 + instanceId: number; 6 + isTapped: boolean; 7 + isFaceDown: boolean; 8 + faceIndex: number; 9 + position?: { x: number; y: number }; 10 + zIndex: number; 11 + counters: Record<string, number>; 12 + } 13 + 14 + export type Zone = "hand" | "battlefield" | "graveyard" | "exile" | "library"; 15 + 16 + export interface PlayerState { 17 + life: number; 18 + poison: number; 19 + counters: Record<string, number>; 20 + } 21 + 22 + export interface GameState { 23 + hand: CardInstance[]; 24 + library: CardInstance[]; 25 + battlefield: CardInstance[]; 26 + graveyard: CardInstance[]; 27 + exile: CardInstance[]; 28 + hoveredId: number | null; 29 + nextZIndex: number; 30 + player: PlayerState; 31 + } 32 + 33 + export function createCardInstance( 34 + cardId: ScryfallId, 35 + instanceId: number, 36 + ): CardInstance { 37 + return { 38 + cardId, 39 + instanceId, 40 + isTapped: false, 41 + isFaceDown: false, 42 + faceIndex: 0, 43 + zIndex: 0, 44 + counters: {}, 45 + }; 46 + } 47 + 48 + export function createPlayerState(startingLife = 20): PlayerState { 49 + return { 50 + life: startingLife, 51 + poison: 0, 52 + counters: {}, 53 + }; 54 + } 55 + 56 + export function createEmptyGameState(startingLife = 20): GameState { 57 + return { 58 + hand: [], 59 + library: [], 60 + battlefield: [], 61 + graveyard: [], 62 + exile: [], 63 + hoveredId: null, 64 + nextZIndex: 1, 65 + player: createPlayerState(startingLife), 66 + }; 67 + }
+187
src/lib/goldfish/useGoldfishGame.ts
··· 1 + import { useCallback, useEffect, useMemo, useState } from "react"; 2 + import type { DeckCard } from "@/lib/deck-types"; 3 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 4 + import { useSeededRandom } from "@/lib/useSeededRandom"; 5 + import * as engine from "./engine"; 6 + import type { GameState, Zone } from "./types"; 7 + 8 + export interface GoldfishActions { 9 + draw: () => void; 10 + mulligan: () => void; 11 + reset: () => void; 12 + tap: (instanceId: number) => void; 13 + untap: (instanceId: number) => void; 14 + toggleTap: (instanceId: number) => void; 15 + untapAll: () => void; 16 + cycleFace: (instanceId: number, maxFaces: number) => void; 17 + toggleFaceDown: (instanceId: number) => void; 18 + flipCard: (instanceId: number, maxFaces: number) => void; 19 + moveCard: ( 20 + instanceId: number, 21 + toZone: Zone, 22 + options?: { position?: { x: number; y: number }; faceDown?: boolean }, 23 + ) => void; 24 + setHoveredCard: (instanceId: number | null) => void; 25 + addCounter: ( 26 + instanceId: number, 27 + counterType: string, 28 + amount?: number, 29 + ) => void; 30 + removeCounter: ( 31 + instanceId: number, 32 + counterType: string, 33 + amount?: number, 34 + ) => void; 35 + setCounter: (instanceId: number, counterType: string, value: number) => void; 36 + clearCounters: (instanceId: number) => void; 37 + adjustLife: (amount: number) => void; 38 + adjustPoison: (amount: number) => void; 39 + } 40 + 41 + export interface UseGoldfishGameOptions { 42 + startingLife?: number; 43 + cardLookup?: (id: ScryfallId) => Card | undefined; 44 + } 45 + 46 + export interface UseGoldfishGameResult { 47 + state: GameState; 48 + actions: GoldfishActions; 49 + SeedEmbed: () => React.ReactElement; 50 + } 51 + 52 + export function useGoldfishGame( 53 + deck: DeckCard[], 54 + options: UseGoldfishGameOptions = {}, 55 + ): UseGoldfishGameResult { 56 + const { startingLife = 20, cardLookup } = options; 57 + const { rng, SeedEmbed } = useSeededRandom(); 58 + 59 + const [state, setState] = useState<GameState>(() => 60 + engine.createInitialState(deck, rng, startingLife), 61 + ); 62 + 63 + const actions: GoldfishActions = useMemo( 64 + () => ({ 65 + draw: () => setState((s) => engine.draw(s)), 66 + mulligan: () => setState((s) => engine.mulligan(s, rng, startingLife)), 67 + reset: () => setState(engine.createInitialState(deck, rng, startingLife)), 68 + tap: (id) => setState((s) => engine.tap(s, id)), 69 + untap: (id) => setState((s) => engine.untap(s, id)), 70 + toggleTap: (id) => setState((s) => engine.toggleTap(s, id)), 71 + untapAll: () => setState((s) => engine.untapAll(s)), 72 + cycleFace: (id, maxFaces) => 73 + setState((s) => engine.cycleFace(s, id, maxFaces)), 74 + toggleFaceDown: (id) => setState((s) => engine.toggleFaceDown(s, id)), 75 + flipCard: (id, maxFaces) => 76 + setState((s) => engine.flipCard(s, id, maxFaces)), 77 + moveCard: (id, zone, opts) => 78 + setState((s) => engine.moveCard(s, id, zone, opts)), 79 + setHoveredCard: (id) => setState((s) => engine.setHoveredCard(s, id)), 80 + addCounter: (id, type, amount) => 81 + setState((s) => engine.addCounter(s, id, type, amount)), 82 + removeCounter: (id, type, amount) => 83 + setState((s) => engine.removeCounter(s, id, type, amount)), 84 + setCounter: (id, type, value) => 85 + setState((s) => engine.setCounter(s, id, type, value)), 86 + clearCounters: (id) => setState((s) => engine.clearCounters(s, id)), 87 + adjustLife: (amount) => setState((s) => engine.adjustLife(s, amount)), 88 + adjustPoison: (amount) => setState((s) => engine.adjustPoison(s, amount)), 89 + }), 90 + [deck, rng, startingLife], 91 + ); 92 + 93 + const getFaceCount = useCallback( 94 + (cardId: ScryfallId): number => { 95 + if (!cardLookup) return 1; 96 + const card = cardLookup(cardId); 97 + if (!card) return 1; 98 + return card.card_faces?.length ?? 1; 99 + }, 100 + [cardLookup], 101 + ); 102 + 103 + useEffect(() => { 104 + function handleKeyDown(e: KeyboardEvent) { 105 + if ( 106 + e.target instanceof HTMLInputElement || 107 + e.target instanceof HTMLTextAreaElement 108 + ) { 109 + return; 110 + } 111 + 112 + const hoveredId = state.hoveredId; 113 + 114 + switch (e.key.toLowerCase()) { 115 + case "d": 116 + e.preventDefault(); 117 + actions.draw(); 118 + break; 119 + case "u": 120 + e.preventDefault(); 121 + actions.untapAll(); 122 + break; 123 + case "t": 124 + case " ": 125 + if (hoveredId !== null) { 126 + e.preventDefault(); 127 + actions.toggleTap(hoveredId); 128 + } 129 + break; 130 + case "f": 131 + if (hoveredId !== null) { 132 + e.preventDefault(); 133 + // Include library top so it can be revealed 134 + const card = [ 135 + ...state.hand, 136 + ...state.battlefield, 137 + ...state.graveyard, 138 + ...state.exile, 139 + ...state.library.slice(0, 1), 140 + ].find((c) => c.instanceId === hoveredId); 141 + if (card) { 142 + actions.flipCard(hoveredId, getFaceCount(card.cardId)); 143 + } 144 + } 145 + break; 146 + case "g": 147 + if (hoveredId !== null) { 148 + e.preventDefault(); 149 + actions.moveCard(hoveredId, "graveyard"); 150 + } 151 + break; 152 + case "e": 153 + if (hoveredId !== null) { 154 + e.preventDefault(); 155 + actions.moveCard(hoveredId, "exile"); 156 + } 157 + break; 158 + case "h": 159 + if (hoveredId !== null) { 160 + e.preventDefault(); 161 + actions.moveCard(hoveredId, "hand"); 162 + } 163 + break; 164 + case "b": 165 + if (hoveredId !== null) { 166 + e.preventDefault(); 167 + actions.moveCard(hoveredId, "battlefield"); 168 + } 169 + break; 170 + } 171 + } 172 + 173 + window.addEventListener("keydown", handleKeyDown); 174 + return () => window.removeEventListener("keydown", handleKeyDown); 175 + }, [ 176 + state.hoveredId, 177 + state.hand, 178 + state.library, 179 + state.battlefield, 180 + state.graveyard, 181 + state.exile, 182 + actions, 183 + getFaceCount, 184 + ]); 185 + 186 + return { state, actions, SeedEmbed }; 187 + }
+2
src/lib/lexicons/index.ts
··· 1 1 export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 2 2 export * as ComDeckbelcherActorProfile from "./types/com/deckbelcher/actor/profile.js"; 3 + export * as ComDeckbelcherCollectionList from "./types/com/deckbelcher/collection/list.js"; 3 4 export * as ComDeckbelcherDeckList from "./types/com/deckbelcher/deck/list.js"; 4 5 export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js"; 6 + export * as ComDeckbelcherRichtext from "./types/com/deckbelcher/richtext.js"; 5 7 export * as ComDeckbelcherSocialLike from "./types/com/deckbelcher/social/like.js";
+90
src/lib/lexicons/types/com/deckbelcher/collection/list.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import type {} from "@atcute/lexicons/ambient"; 3 + import * as v from "@atcute/lexicons/validations"; 4 + import * as ComDeckbelcherRichtext from "../richtext.js"; 5 + 6 + const _cardItemSchema = /*#__PURE__*/ v.object({ 7 + $type: /*#__PURE__*/ v.optional( 8 + /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#cardItem"), 9 + ), 10 + /** 11 + * Timestamp when this item was added to the list. 12 + */ 13 + addedAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Scryfall UUID for the card. 16 + */ 17 + scryfallId: /*#__PURE__*/ v.string(), 18 + }); 19 + const _deckItemSchema = /*#__PURE__*/ v.object({ 20 + $type: /*#__PURE__*/ v.optional( 21 + /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#deckItem"), 22 + ), 23 + /** 24 + * Timestamp when this item was added to the list. 25 + */ 26 + addedAt: /*#__PURE__*/ v.datetimeString(), 27 + /** 28 + * AT-URI of the deck record. 29 + */ 30 + deckUri: /*#__PURE__*/ v.resourceUriString(), 31 + }); 32 + const _mainSchema = /*#__PURE__*/ v.record( 33 + /*#__PURE__*/ v.tidString(), 34 + /*#__PURE__*/ v.object({ 35 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.collection.list"), 36 + /** 37 + * Timestamp when the list was created. 38 + */ 39 + createdAt: /*#__PURE__*/ v.datetimeString(), 40 + /** 41 + * Description of the list. 42 + */ 43 + get description() { 44 + return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.mainSchema); 45 + }, 46 + /** 47 + * Items in the list. 48 + */ 49 + get items() { 50 + return /*#__PURE__*/ v.array( 51 + /*#__PURE__*/ v.variant([cardItemSchema, deckItemSchema]), 52 + ); 53 + }, 54 + /** 55 + * Name of the list. 56 + * @maxLength 1280 57 + * @maxGraphemes 128 58 + */ 59 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 60 + /*#__PURE__*/ v.stringLength(0, 1280), 61 + /*#__PURE__*/ v.stringGraphemes(0, 128), 62 + ]), 63 + /** 64 + * Timestamp when the list was last updated. 65 + */ 66 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 67 + }), 68 + ); 69 + 70 + type cardItem$schematype = typeof _cardItemSchema; 71 + type deckItem$schematype = typeof _deckItemSchema; 72 + type main$schematype = typeof _mainSchema; 73 + 74 + export interface cardItemSchema extends cardItem$schematype {} 75 + export interface deckItemSchema extends deckItem$schematype {} 76 + export interface mainSchema extends main$schematype {} 77 + 78 + export const cardItemSchema = _cardItemSchema as cardItemSchema; 79 + export const deckItemSchema = _deckItemSchema as deckItemSchema; 80 + export const mainSchema = _mainSchema as mainSchema; 81 + 82 + export interface CardItem extends v.InferInput<typeof cardItemSchema> {} 83 + export interface DeckItem extends v.InferInput<typeof deckItemSchema> {} 84 + export interface Main extends v.InferInput<typeof mainSchema> {} 85 + 86 + declare module "@atcute/lexicons/ambient" { 87 + interface Records { 88 + "com.deckbelcher.collection.list": mainSchema; 89 + } 90 + }
+3 -16
src/lib/lexicons/types/com/deckbelcher/deck/list.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import type {} from "@atcute/lexicons/ambient"; 3 3 import * as v from "@atcute/lexicons/validations"; 4 - import * as ComDeckbelcherRichtextFacet from "../richtext/facet.js"; 4 + import * as ComDeckbelcherRichtext from "../richtext.js"; 5 5 6 6 const _cardSchema = /*#__PURE__*/ v.object({ 7 7 $type: /*#__PURE__*/ v.optional( ··· 76 76 ]), 77 77 /** 78 78 * Deck primer with strategy, combos, and card choices. 79 - * @maxLength 100000 80 - * @maxGraphemes 10000 81 79 */ 82 - primer: /*#__PURE__*/ v.optional( 83 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 84 - /*#__PURE__*/ v.stringLength(0, 100000), 85 - /*#__PURE__*/ v.stringGraphemes(0, 10000), 86 - ]), 87 - ), 88 - /** 89 - * Annotations of text in the primer (mentions, URLs, hashtags, card references, etc). 90 - */ 91 - get primerFacets() { 92 - return /*#__PURE__*/ v.optional( 93 - /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 94 - ); 80 + get primer() { 81 + return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.mainSchema); 95 82 }, 96 83 /** 97 84 * Timestamp when the decklist was last updated.
+45 -1
src/lib/lexicons/types/com/deckbelcher/richtext/facet.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 4 + const _boldSchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#bold"), 7 + ), 8 + }); 4 9 const _byteSliceSchema = /*#__PURE__*/ v.object({ 5 10 $type: /*#__PURE__*/ v.optional( 6 11 /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#byteSlice"), ··· 14 19 */ 15 20 byteStart: /*#__PURE__*/ v.integer(), 16 21 }); 22 + const _codeSchema = /*#__PURE__*/ v.object({ 23 + $type: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#code"), 25 + ), 26 + }); 27 + const _codeBlockSchema = /*#__PURE__*/ v.object({ 28 + $type: /*#__PURE__*/ v.optional( 29 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#codeBlock"), 30 + ), 31 + }); 32 + const _italicSchema = /*#__PURE__*/ v.object({ 33 + $type: /*#__PURE__*/ v.optional( 34 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#italic"), 35 + ), 36 + }); 17 37 const _linkSchema = /*#__PURE__*/ v.object({ 18 38 $type: /*#__PURE__*/ v.optional( 19 39 /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#link"), ··· 26 46 ), 27 47 get features() { 28 48 return /*#__PURE__*/ v.array( 29 - /*#__PURE__*/ v.variant([linkSchema, mentionSchema, tagSchema]), 49 + /*#__PURE__*/ v.variant([ 50 + boldSchema, 51 + codeSchema, 52 + codeBlockSchema, 53 + italicSchema, 54 + linkSchema, 55 + mentionSchema, 56 + tagSchema, 57 + ]), 30 58 ); 31 59 }, 32 60 get index() { ··· 53 81 ]), 54 82 }); 55 83 84 + type bold$schematype = typeof _boldSchema; 56 85 type byteSlice$schematype = typeof _byteSliceSchema; 86 + type code$schematype = typeof _codeSchema; 87 + type codeBlock$schematype = typeof _codeBlockSchema; 88 + type italic$schematype = typeof _italicSchema; 57 89 type link$schematype = typeof _linkSchema; 58 90 type main$schematype = typeof _mainSchema; 59 91 type mention$schematype = typeof _mentionSchema; 60 92 type tag$schematype = typeof _tagSchema; 61 93 94 + export interface boldSchema extends bold$schematype {} 62 95 export interface byteSliceSchema extends byteSlice$schematype {} 96 + export interface codeSchema extends code$schematype {} 97 + export interface codeBlockSchema extends codeBlock$schematype {} 98 + export interface italicSchema extends italic$schematype {} 63 99 export interface linkSchema extends link$schematype {} 64 100 export interface mainSchema extends main$schematype {} 65 101 export interface mentionSchema extends mention$schematype {} 66 102 export interface tagSchema extends tag$schematype {} 67 103 104 + export const boldSchema = _boldSchema as boldSchema; 68 105 export const byteSliceSchema = _byteSliceSchema as byteSliceSchema; 106 + export const codeSchema = _codeSchema as codeSchema; 107 + export const codeBlockSchema = _codeBlockSchema as codeBlockSchema; 108 + export const italicSchema = _italicSchema as italicSchema; 69 109 export const linkSchema = _linkSchema as linkSchema; 70 110 export const mainSchema = _mainSchema as mainSchema; 71 111 export const mentionSchema = _mentionSchema as mentionSchema; 72 112 export const tagSchema = _tagSchema as tagSchema; 73 113 114 + export interface Bold extends v.InferInput<typeof boldSchema> {} 74 115 export interface ByteSlice extends v.InferInput<typeof byteSliceSchema> {} 116 + export interface Code extends v.InferInput<typeof codeSchema> {} 117 + export interface CodeBlock extends v.InferInput<typeof codeBlockSchema> {} 118 + export interface Italic extends v.InferInput<typeof italicSchema> {} 75 119 export interface Link extends v.InferInput<typeof linkSchema> {} 76 120 export interface Main extends v.InferInput<typeof mainSchema> {} 77 121 export interface Mention extends v.InferInput<typeof mentionSchema> {}
+36
src/lib/lexicons/types/com/deckbelcher/richtext.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import * as ComDeckbelcherRichtextFacet from "./richtext/facet.js"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext"), 8 + ), 9 + /** 10 + * Annotations of text (mentions, URLs, hashtags, card references, etc). 11 + */ 12 + get facets() { 13 + return /*#__PURE__*/ v.optional( 14 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 15 + ); 16 + }, 17 + /** 18 + * The text content. 19 + * @maxLength 500000 20 + * @maxGraphemes 50000 21 + */ 22 + text: /*#__PURE__*/ v.optional( 23 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 24 + /*#__PURE__*/ v.stringLength(0, 500000), 25 + /*#__PURE__*/ v.stringGraphemes(0, 50000), 26 + ]), 27 + ), 28 + }); 29 + 30 + type main$schematype = typeof _mainSchema; 31 + 32 + export interface mainSchema extends main$schematype {} 33 + 34 + export const mainSchema = _mainSchema as mainSchema; 35 + 36 + export interface Main extends v.InferInput<typeof mainSchema> {}
+7
src/lib/lru-cache.ts
··· 53 53 get size(): number { 54 54 return this.cache.size; 55 55 } 56 + 57 + /** 58 + * Check if key exists without affecting LRU order 59 + */ 60 + has(key: K): boolean { 61 + return this.cache.has(key); 62 + } 56 63 }
+156
src/lib/printing-selection.ts
··· 1 + /** 2 + * Pure functions for bulk printing selection 3 + * 4 + * Allows updating all cards in a deck to their cheapest or canonical printings. 5 + */ 6 + 7 + import type { CardDataProvider } from "./card-data-provider"; 8 + import type { Deck, DeckCard } from "./deck-types"; 9 + import type { OracleId, ScryfallId, VolatileData } from "./scryfall-types"; 10 + 11 + /** 12 + * Get the cheapest USD price from volatile data. 13 + * Considers usd, usdFoil, and usdEtched. 14 + * Returns null if no prices are available. 15 + */ 16 + export function getCheapestPrice(v: VolatileData): number | null { 17 + const prices = [v.usd, v.usdFoil, v.usdEtched].filter( 18 + (p): p is number => p !== null, 19 + ); 20 + return prices.length > 0 ? Math.min(...prices) : null; 21 + } 22 + 23 + /** 24 + * Find the cheapest printing from a list of printing IDs. 25 + * Returns null if no prices are available for any printing. 26 + */ 27 + export function findCheapestPrinting( 28 + printingIds: ScryfallId[], 29 + volatileData: Map<ScryfallId, VolatileData | null>, 30 + ): ScryfallId | null { 31 + let cheapestId: ScryfallId | null = null; 32 + let cheapestPrice = Infinity; 33 + 34 + for (const id of printingIds) { 35 + const v = volatileData.get(id); 36 + if (!v) continue; 37 + 38 + const price = getCheapestPrice(v); 39 + if (price !== null && price < cheapestPrice) { 40 + cheapestPrice = price; 41 + cheapestId = id; 42 + } 43 + } 44 + 45 + return cheapestId; 46 + } 47 + 48 + /** 49 + * Apply printing updates to a deck. 50 + * Returns a new deck with updated scryfallIds. 51 + */ 52 + export function updateDeckPrintings( 53 + deck: Deck, 54 + updates: Map<ScryfallId, ScryfallId>, 55 + ): Deck { 56 + if (updates.size === 0) { 57 + return deck; 58 + } 59 + 60 + return { 61 + ...deck, 62 + cards: deck.cards.map((card) => ({ 63 + ...card, 64 + scryfallId: updates.get(card.scryfallId) ?? card.scryfallId, 65 + })), 66 + updatedAt: new Date().toISOString(), 67 + }; 68 + } 69 + 70 + /** 71 + * Group deck cards by oracle ID. 72 + * Returns a map of oracle ID to deck cards with that oracle. 73 + */ 74 + async function groupCardsByOracle( 75 + deck: Deck, 76 + provider: CardDataProvider, 77 + ): Promise<Map<OracleId, DeckCard[]>> { 78 + const byOracle = new Map<OracleId, DeckCard[]>(); 79 + 80 + for (const card of deck.cards) { 81 + const cardData = await provider.getCardById(card.scryfallId); 82 + if (!cardData) continue; 83 + 84 + const existing = byOracle.get(cardData.oracle_id) ?? []; 85 + byOracle.set(cardData.oracle_id, [...existing, card]); 86 + } 87 + 88 + return byOracle; 89 + } 90 + 91 + /** 92 + * Find cheapest printing for all cards in a deck. 93 + * Returns a map of current scryfallId -> cheapest scryfallId. 94 + * Only includes cards that need to change. 95 + */ 96 + export async function findAllCheapestPrintings( 97 + deck: Deck, 98 + provider: CardDataProvider, 99 + ): Promise<Map<ScryfallId, ScryfallId>> { 100 + const updates = new Map<ScryfallId, ScryfallId>(); 101 + const byOracle = await groupCardsByOracle(deck, provider); 102 + 103 + for (const [oracleId, cards] of byOracle) { 104 + const printingIds = await provider.getPrintingsByOracleId(oracleId); 105 + 106 + // Get volatile data for all printings in parallel 107 + const volatileDataArray = await Promise.all( 108 + printingIds.map((id) => provider.getVolatileData(id)), 109 + ); 110 + 111 + // Build map for findCheapestPrinting 112 + const volatileData = new Map<ScryfallId, VolatileData | null>(); 113 + for (let i = 0; i < printingIds.length; i++) { 114 + volatileData.set(printingIds[i], volatileDataArray[i]); 115 + } 116 + 117 + const cheapestId = findCheapestPrinting(printingIds, volatileData); 118 + if (!cheapestId) continue; 119 + 120 + // Map all cards with this oracle to the cheapest 121 + for (const card of cards) { 122 + if (card.scryfallId !== cheapestId) { 123 + updates.set(card.scryfallId, cheapestId); 124 + } 125 + } 126 + } 127 + 128 + return updates; 129 + } 130 + 131 + /** 132 + * Find canonical printing for all cards in a deck. 133 + * Returns a map of current scryfallId -> canonical scryfallId. 134 + * Only includes cards that need to change. 135 + */ 136 + export async function findAllCanonicalPrintings( 137 + deck: Deck, 138 + provider: CardDataProvider, 139 + ): Promise<Map<ScryfallId, ScryfallId>> { 140 + const updates = new Map<ScryfallId, ScryfallId>(); 141 + const byOracle = await groupCardsByOracle(deck, provider); 142 + 143 + for (const [oracleId, cards] of byOracle) { 144 + const canonicalId = await provider.getCanonicalPrinting(oracleId); 145 + if (!canonicalId) continue; 146 + 147 + // Map all cards with this oracle to the canonical 148 + for (const card of cards) { 149 + if (card.scryfallId !== canonicalId) { 150 + updates.set(card.scryfallId, canonicalId); 151 + } 152 + } 153 + } 154 + 155 + return updates; 156 + }
+162 -3
src/lib/queries.ts
··· 4 4 5 5 import { queryOptions } from "@tanstack/react-query"; 6 6 import { getCardDataProvider } from "./card-data-provider"; 7 + import type { OracleId, ScryfallId, VolatileData } from "./scryfall-types"; 7 8 import type { 8 9 Card, 9 - OracleId, 10 - ScryfallId, 10 + PaginatedSearchResult, 11 11 SearchRestrictions, 12 - } from "./scryfall-types"; 12 + SortOption, 13 + UnifiedSearchResult, 14 + } from "./search-types"; 15 + 16 + /** 17 + * Combine function for useQueries - converts query results into a Map. 18 + * Returns undefined until all cards are loaded. 19 + */ 20 + export function combineCardQueries( 21 + results: Array<{ data?: Card | undefined }>, 22 + ): Map<ScryfallId, Card> | undefined { 23 + const map = new Map<ScryfallId, Card>(); 24 + for (const result of results) { 25 + if (result.data) { 26 + map.set(result.data.id, result.data); 27 + } 28 + } 29 + return results.every((r) => r.data) ? map : undefined; 30 + } 13 31 14 32 /** 15 33 * Search cards by name with optional restrictions ··· 97 115 }, 98 116 staleTime: Number.POSITIVE_INFINITY, 99 117 }); 118 + 119 + export type SyntaxSearchResult = 120 + | { ok: true; cards: Card[] } 121 + | { ok: false; error: { message: string; start: number; end: number } }; 122 + 123 + /** 124 + * Search cards using Scryfall-like syntax (e.g., "t:creature cmc<=3 s:lea") 125 + */ 126 + export const syntaxSearchQueryOptions = (query: string, maxResults = 100) => 127 + queryOptions({ 128 + queryKey: ["cards", "syntaxSearch", query, maxResults] as const, 129 + queryFn: async (): Promise<SyntaxSearchResult> => { 130 + const provider = await getCardDataProvider(); 131 + 132 + if (!query.trim()) { 133 + return { ok: true, cards: [] }; 134 + } 135 + 136 + if (!provider.syntaxSearch) { 137 + return { 138 + ok: false, 139 + error: { message: "Syntax search not available", start: 0, end: 0 }, 140 + }; 141 + } 142 + 143 + return provider.syntaxSearch(query, maxResults); 144 + }, 145 + staleTime: 5 * 60 * 1000, 146 + }); 147 + 148 + /** 149 + * Get volatile data (prices, EDHREC rank) for a card 150 + */ 151 + export const getVolatileDataQueryOptions = (id: ScryfallId) => 152 + queryOptions({ 153 + queryKey: ["cards", "volatile", id] as const, 154 + queryFn: async (): Promise<VolatileData | null> => { 155 + const provider = await getCardDataProvider(); 156 + return provider.getVolatileData(id); 157 + }, 158 + staleTime: 5 * 60 * 1000, // 5 minutes - prices change frequently 159 + }); 160 + 161 + export type { UnifiedSearchResult }; 162 + 163 + /** 164 + * Unified search that automatically routes to fuzzy or syntax search 165 + * based on query complexity. Returns mode indicator and description for syntax queries. 166 + */ 167 + export const unifiedSearchQueryOptions = ( 168 + query: string, 169 + restrictions?: SearchRestrictions, 170 + maxResults = 50, 171 + ) => 172 + queryOptions({ 173 + queryKey: [ 174 + "cards", 175 + "unifiedSearch", 176 + query, 177 + restrictions, 178 + maxResults, 179 + ] as const, 180 + queryFn: async (): Promise<UnifiedSearchResult> => { 181 + const provider = await getCardDataProvider(); 182 + 183 + if (!query.trim()) { 184 + return { mode: "fuzzy", cards: [], description: null, error: null }; 185 + } 186 + 187 + if (!provider.unifiedSearch) { 188 + return { 189 + mode: "fuzzy", 190 + cards: [], 191 + description: null, 192 + error: { message: "Unified search not available", start: 0, end: 0 }, 193 + }; 194 + } 195 + 196 + return provider.unifiedSearch(query, restrictions, maxResults); 197 + }, 198 + staleTime: 5 * 60 * 1000, 199 + }); 200 + 201 + export const PAGE_SIZE = 50; 202 + 203 + /** 204 + * Single page query for visibility-based fetching. 205 + * Used with useQueries to load pages based on scroll position. 206 + */ 207 + export const searchPageQueryOptions = ( 208 + query: string, 209 + offset: number, 210 + restrictions?: SearchRestrictions, 211 + sort: SortOption = { field: "name", direction: "auto" }, 212 + ) => 213 + queryOptions({ 214 + queryKey: [ 215 + "cards", 216 + "searchPage", 217 + query, 218 + offset, 219 + restrictions, 220 + sort, 221 + ] as const, 222 + queryFn: async (): Promise<PaginatedSearchResult> => { 223 + const provider = await getCardDataProvider(); 224 + 225 + if (!query.trim()) { 226 + return { 227 + mode: "fuzzy", 228 + cards: [], 229 + totalCount: 0, 230 + description: null, 231 + error: null, 232 + }; 233 + } 234 + 235 + if (!provider.paginatedUnifiedSearch) { 236 + return { 237 + mode: "fuzzy", 238 + cards: [], 239 + totalCount: 0, 240 + description: null, 241 + error: { 242 + message: "Paginated search not available", 243 + start: 0, 244 + end: 0, 245 + }, 246 + }; 247 + } 248 + 249 + return provider.paginatedUnifiedSearch( 250 + query, 251 + restrictions, 252 + sort, 253 + offset, 254 + PAGE_SIZE, 255 + ); 256 + }, 257 + staleTime: 5 * 60 * 1000, 258 + });
+515
src/lib/richtext/__tests__/__snapshots__/parser.test.ts.snap
··· 1 + // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 + 3 + exports[`parseMarkdown > snapshot: > parses correctly 1`] = ` 4 + { 5 + "facets": [], 6 + "text": "", 7 + } 8 + `; 9 + 10 + exports[`parseMarkdown > snapshot: * > parses correctly 1`] = ` 11 + { 12 + "facets": [], 13 + "text": "*", 14 + } 15 + `; 16 + 17 + exports[`parseMarkdown > snapshot: ** > parses correctly 1`] = ` 18 + { 19 + "facets": [], 20 + "text": "**", 21 + } 22 + `; 23 + 24 + exports[`parseMarkdown > snapshot: *** > parses correctly 1`] = ` 25 + { 26 + "facets": [], 27 + "text": "***", 28 + } 29 + `; 30 + 31 + exports[`parseMarkdown > snapshot: **** > parses correctly 1`] = ` 32 + { 33 + "facets": [], 34 + "text": "****", 35 + } 36 + `; 37 + 38 + exports[`parseMarkdown > snapshot: ***bold and italic*** > parses correctly 1`] = ` 39 + { 40 + "facets": [ 41 + { 42 + "features": [ 43 + { 44 + "$type": "com.deckbelcher.richtext.facet#bold", 45 + }, 46 + ], 47 + "index": { 48 + "byteEnd": 15, 49 + "byteStart": 0, 50 + }, 51 + }, 52 + { 53 + "features": [ 54 + { 55 + "$type": "com.deckbelcher.richtext.facet#italic", 56 + }, 57 + ], 58 + "index": { 59 + "byteEnd": 15, 60 + "byteStart": 0, 61 + }, 62 + }, 63 + ], 64 + "text": "bold and italic", 65 + } 66 + `; 67 + 68 + exports[`parseMarkdown > snapshot: **a**b**c** > parses correctly 1`] = ` 69 + { 70 + "facets": [ 71 + { 72 + "features": [ 73 + { 74 + "$type": "com.deckbelcher.richtext.facet#bold", 75 + }, 76 + ], 77 + "index": { 78 + "byteEnd": 1, 79 + "byteStart": 0, 80 + }, 81 + }, 82 + { 83 + "features": [ 84 + { 85 + "$type": "com.deckbelcher.richtext.facet#bold", 86 + }, 87 + ], 88 + "index": { 89 + "byteEnd": 3, 90 + "byteStart": 2, 91 + }, 92 + }, 93 + ], 94 + "text": "abc", 95 + } 96 + `; 97 + 98 + exports[`parseMarkdown > snapshot: **a日b** > parses correctly 1`] = ` 99 + { 100 + "facets": [ 101 + { 102 + "features": [ 103 + { 104 + "$type": "com.deckbelcher.richtext.facet#bold", 105 + }, 106 + ], 107 + "index": { 108 + "byteEnd": 5, 109 + "byteStart": 0, 110 + }, 111 + }, 112 + ], 113 + "text": "a日b", 114 + } 115 + `; 116 + 117 + exports[`parseMarkdown > snapshot: **bold** and *italic* together > parses correctly 1`] = ` 118 + { 119 + "facets": [ 120 + { 121 + "features": [ 122 + { 123 + "$type": "com.deckbelcher.richtext.facet#bold", 124 + }, 125 + ], 126 + "index": { 127 + "byteEnd": 4, 128 + "byteStart": 0, 129 + }, 130 + }, 131 + { 132 + "features": [ 133 + { 134 + "$type": "com.deckbelcher.richtext.facet#italic", 135 + }, 136 + ], 137 + "index": { 138 + "byteEnd": 15, 139 + "byteStart": 9, 140 + }, 141 + }, 142 + ], 143 + "text": "bold and italic together", 144 + } 145 + `; 146 + 147 + exports[`parseMarkdown > snapshot: **nested *italic* in bold** > parses correctly 1`] = ` 148 + { 149 + "facets": [ 150 + { 151 + "features": [ 152 + { 153 + "$type": "com.deckbelcher.richtext.facet#bold", 154 + }, 155 + ], 156 + "index": { 157 + "byteEnd": 21, 158 + "byteStart": 0, 159 + }, 160 + }, 161 + { 162 + "features": [ 163 + { 164 + "$type": "com.deckbelcher.richtext.facet#italic", 165 + }, 166 + ], 167 + "index": { 168 + "byteEnd": 13, 169 + "byteStart": 7, 170 + }, 171 + }, 172 + ], 173 + "text": "nested italic in bold", 174 + } 175 + `; 176 + 177 + exports[`parseMarkdown > snapshot: **日本語** > parses correctly 1`] = ` 178 + { 179 + "facets": [ 180 + { 181 + "features": [ 182 + { 183 + "$type": "com.deckbelcher.richtext.facet#bold", 184 + }, 185 + ], 186 + "index": { 187 + "byteEnd": 9, 188 + "byteStart": 0, 189 + }, 190 + }, 191 + ], 192 + "text": "日本語", 193 + } 194 + `; 195 + 196 + exports[`parseMarkdown > snapshot: **🔥** > parses correctly 1`] = ` 197 + { 198 + "facets": [ 199 + { 200 + "features": [ 201 + { 202 + "$type": "com.deckbelcher.richtext.facet#bold", 203 + }, 204 + ], 205 + "index": { 206 + "byteEnd": 4, 207 + "byteStart": 0, 208 + }, 209 + }, 210 + ], 211 + "text": "🔥", 212 + } 213 + `; 214 + 215 + exports[`parseMarkdown > snapshot: *a*b*c* > parses correctly 1`] = ` 216 + { 217 + "facets": [ 218 + { 219 + "features": [ 220 + { 221 + "$type": "com.deckbelcher.richtext.facet#italic", 222 + }, 223 + ], 224 + "index": { 225 + "byteEnd": 1, 226 + "byteStart": 0, 227 + }, 228 + }, 229 + { 230 + "features": [ 231 + { 232 + "$type": "com.deckbelcher.richtext.facet#italic", 233 + }, 234 + ], 235 + "index": { 236 + "byteEnd": 3, 237 + "byteStart": 2, 238 + }, 239 + }, 240 + ], 241 + "text": "abc", 242 + } 243 + `; 244 + 245 + exports[`parseMarkdown > snapshot: *🔥* and **🔥** > parses correctly 1`] = ` 246 + { 247 + "facets": [ 248 + { 249 + "features": [ 250 + { 251 + "$type": "com.deckbelcher.richtext.facet#italic", 252 + }, 253 + ], 254 + "index": { 255 + "byteEnd": 4, 256 + "byteStart": 0, 257 + }, 258 + }, 259 + { 260 + "features": [ 261 + { 262 + "$type": "com.deckbelcher.richtext.facet#bold", 263 + }, 264 + ], 265 + "index": { 266 + "byteEnd": 13, 267 + "byteStart": 9, 268 + }, 269 + }, 270 + ], 271 + "text": "🔥 and 🔥", 272 + } 273 + `; 274 + 275 + exports[`parseMarkdown > snapshot: @user.bsky.social > parses correctly 1`] = ` 276 + { 277 + "facets": [ 278 + { 279 + "features": [ 280 + { 281 + "$type": "com.deckbelcher.richtext.facet#mention", 282 + "did": "user.bsky.social", 283 + }, 284 + ], 285 + "index": { 286 + "byteEnd": 17, 287 + "byteStart": 0, 288 + }, 289 + }, 290 + ], 291 + "text": "@user.bsky.social", 292 + } 293 + `; 294 + 295 + exports[`parseMarkdown > snapshot: [link](https://example.com) > parses correctly 1`] = ` 296 + { 297 + "facets": [ 298 + { 299 + "features": [ 300 + { 301 + "$type": "com.deckbelcher.richtext.facet#link", 302 + "uri": "https://example.com", 303 + }, 304 + ], 305 + "index": { 306 + "byteEnd": 4, 307 + "byteStart": 0, 308 + }, 309 + }, 310 + ], 311 + "text": "link", 312 + } 313 + `; 314 + 315 + exports[`parseMarkdown > snapshot: \`\` > parses correctly 1`] = ` 316 + { 317 + "facets": [], 318 + "text": "\`\`", 319 + } 320 + `; 321 + 322 + exports[`parseMarkdown > snapshot: \`code\` > parses correctly 1`] = ` 323 + { 324 + "facets": [ 325 + { 326 + "features": [ 327 + { 328 + "$type": "com.deckbelcher.richtext.facet#code", 329 + }, 330 + ], 331 + "index": { 332 + "byteEnd": 4, 333 + "byteStart": 0, 334 + }, 335 + }, 336 + ], 337 + "text": "code", 338 + } 339 + `; 340 + 341 + exports[`parseMarkdown > snapshot: Hello **world**! > parses correctly 1`] = ` 342 + { 343 + "facets": [ 344 + { 345 + "features": [ 346 + { 347 + "$type": "com.deckbelcher.richtext.facet#bold", 348 + }, 349 + ], 350 + "index": { 351 + "byteEnd": 11, 352 + "byteStart": 6, 353 + }, 354 + }, 355 + ], 356 + "text": "Hello world!", 357 + } 358 + `; 359 + 360 + exports[`parseMarkdown > snapshot: Multi-byte: **日本語** emoji **🔥** > parses correctly 1`] = ` 361 + { 362 + "facets": [ 363 + { 364 + "features": [ 365 + { 366 + "$type": "com.deckbelcher.richtext.facet#bold", 367 + }, 368 + ], 369 + "index": { 370 + "byteEnd": 21, 371 + "byteStart": 12, 372 + }, 373 + }, 374 + { 375 + "features": [ 376 + { 377 + "$type": "com.deckbelcher.richtext.facet#bold", 378 + }, 379 + ], 380 + "index": { 381 + "byteEnd": 32, 382 + "byteStart": 28, 383 + }, 384 + }, 385 + ], 386 + "text": "Multi-byte: 日本語 emoji 🔥", 387 + } 388 + `; 389 + 390 + exports[`parseMarkdown > snapshot: This is *italic* text > parses correctly 1`] = ` 391 + { 392 + "facets": [ 393 + { 394 + "features": [ 395 + { 396 + "$type": "com.deckbelcher.richtext.facet#italic", 397 + }, 398 + ], 399 + "index": { 400 + "byteEnd": 14, 401 + "byteStart": 8, 402 + }, 403 + }, 404 + ], 405 + "text": "This is italic text", 406 + } 407 + `; 408 + 409 + exports[`parseMarkdown > snapshot: Unclosed **bold > parses correctly 1`] = ` 410 + { 411 + "facets": [], 412 + "text": "Unclosed **bold", 413 + } 414 + `; 415 + 416 + exports[`parseMarkdown > snapshot: Unclosed *italic > parses correctly 1`] = ` 417 + { 418 + "facets": [], 419 + "text": "Unclosed *italic", 420 + } 421 + `; 422 + 423 + exports[`parseMarkdown > snapshot: a**b**c**d**e > parses correctly 1`] = ` 424 + { 425 + "facets": [ 426 + { 427 + "features": [ 428 + { 429 + "$type": "com.deckbelcher.richtext.facet#bold", 430 + }, 431 + ], 432 + "index": { 433 + "byteEnd": 2, 434 + "byteStart": 1, 435 + }, 436 + }, 437 + { 438 + "features": [ 439 + { 440 + "$type": "com.deckbelcher.richtext.facet#bold", 441 + }, 442 + ], 443 + "index": { 444 + "byteEnd": 4, 445 + "byteStart": 3, 446 + }, 447 + }, 448 + ], 449 + "text": "abcde", 450 + } 451 + `; 452 + 453 + exports[`parseMarkdown > snapshot: no formatting here > parses correctly 1`] = ` 454 + { 455 + "facets": [], 456 + "text": "no formatting here", 457 + } 458 + `; 459 + 460 + exports[`parseMarkdown > snapshot: prefix **日本語** suffix > parses correctly 1`] = ` 461 + { 462 + "facets": [ 463 + { 464 + "features": [ 465 + { 466 + "$type": "com.deckbelcher.richtext.facet#bold", 467 + }, 468 + ], 469 + "index": { 470 + "byteEnd": 16, 471 + "byteStart": 7, 472 + }, 473 + }, 474 + ], 475 + "text": "prefix 日本語 suffix", 476 + } 477 + `; 478 + 479 + exports[`parseMarkdown > snapshot: 👨‍👩‍👧‍👧 **text** 🔥 > parses correctly 1`] = ` 480 + { 481 + "facets": [ 482 + { 483 + "features": [ 484 + { 485 + "$type": "com.deckbelcher.richtext.facet#bold", 486 + }, 487 + ], 488 + "index": { 489 + "byteEnd": 30, 490 + "byteStart": 26, 491 + }, 492 + }, 493 + ], 494 + "text": "👨‍👩‍👧‍👧 text 🔥", 495 + } 496 + `; 497 + 498 + exports[`parseMarkdown > snapshot: 🔥 **bold** > parses correctly 1`] = ` 499 + { 500 + "facets": [ 501 + { 502 + "features": [ 503 + { 504 + "$type": "com.deckbelcher.richtext.facet#bold", 505 + }, 506 + ], 507 + "index": { 508 + "byteEnd": 9, 509 + "byteStart": 5, 510 + }, 511 + }, 512 + ], 513 + "text": "🔥 bold", 514 + } 515 + `;
+150
src/lib/richtext/__tests__/adversarial.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { parseMarkdown } from "../parser"; 3 + import { serializeToMarkdown } from "../serializer"; 4 + import { BOLD, type Facet, ITALIC } from "../types"; 5 + 6 + describe("adversarial inputs", () => { 7 + describe("parser handles edge cases", () => { 8 + const ADVERSARIAL_INPUTS = [ 9 + "", 10 + "*", 11 + "**", 12 + "***", 13 + "****", 14 + "*****", 15 + "[[]]", 16 + "[[", 17 + "]]", 18 + "[[[[nested]]]]", 19 + "@".repeat(1000), 20 + "*".repeat(1000), 21 + "**".repeat(500), 22 + "\u0000\u0001\u0002", 23 + "**\u200b**", 24 + "*\u200b*", 25 + "**a\nb**", 26 + "*a\nb*", 27 + "**🔥**", 28 + "*🔥*", 29 + "[[🔥]]", 30 + "\u202EtloB gninthgiL", 31 + "a**b*c**d*e", 32 + "**a*b*c**", 33 + "*a**b**c*", 34 + "\\*not italic\\*", 35 + "\\**not bold\\**", 36 + " ** spaced ** ", 37 + "** no close", 38 + "no open **", 39 + "a]b[c", 40 + "🔥".repeat(100), 41 + "日本語".repeat(100), 42 + "\t\n\r **bold** \t\n\r", 43 + ]; 44 + 45 + it.each(ADVERSARIAL_INPUTS)("never crashes on: %j", (input) => { 46 + expect(() => parseMarkdown(input)).not.toThrow(); 47 + }); 48 + 49 + it.each(ADVERSARIAL_INPUTS)("produces valid facets for: %j", (input) => { 50 + const { text, facets } = parseMarkdown(input); 51 + const encoder = new TextEncoder(); 52 + const bytes = encoder.encode(text); 53 + 54 + for (const facet of facets) { 55 + expect(facet.index.byteStart).toBeGreaterThanOrEqual(0); 56 + expect(facet.index.byteEnd).toBeLessThanOrEqual(bytes.length); 57 + expect(facet.index.byteStart).toBeLessThan(facet.index.byteEnd); 58 + expect(facet.features.length).toBeGreaterThan(0); 59 + } 60 + }); 61 + }); 62 + 63 + describe("serializer handles malformed facets", () => { 64 + const text = "hello world"; 65 + 66 + it.each([ 67 + [[{ index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }]], 68 + [[{ index: { byteStart: 0, byteEnd: -1 }, features: [BOLD] }]], 69 + [[{ index: { byteStart: 5, byteEnd: 5 }, features: [BOLD] }]], 70 + [[{ index: { byteStart: 10, byteEnd: 5 }, features: [BOLD] }]], 71 + [[{ index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }]], 72 + [[{ index: { byteStart: 1000, byteEnd: 2000 }, features: [BOLD] }]], 73 + [[{ index: { byteStart: 0, byteEnd: 5 }, features: [] }]], 74 + [ 75 + [ 76 + { index: { byteStart: 0, byteEnd: 5 }, features: [BOLD] }, 77 + { index: { byteStart: -10, byteEnd: 100 }, features: [ITALIC] }, 78 + ], 79 + ], 80 + ])("handles invalid facets gracefully: %j", (facets) => { 81 + expect(() => serializeToMarkdown(text, facets as Facet[])).not.toThrow(); 82 + }); 83 + 84 + it("skips facets with negative byteStart", () => { 85 + const facets: Facet[] = [ 86 + { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 87 + ]; 88 + expect(serializeToMarkdown(text, facets)).toBe(text); 89 + }); 90 + 91 + it("skips facets with byteEnd > text length", () => { 92 + const facets: Facet[] = [ 93 + { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 94 + ]; 95 + expect(serializeToMarkdown(text, facets)).toBe(text); 96 + }); 97 + 98 + it("skips facets with byteStart >= byteEnd", () => { 99 + const facets: Facet[] = [ 100 + { index: { byteStart: 5, byteEnd: 5 }, features: [BOLD] }, 101 + { index: { byteStart: 8, byteEnd: 3 }, features: [ITALIC] }, 102 + ]; 103 + expect(serializeToMarkdown(text, facets)).toBe(text); 104 + }); 105 + 106 + it("skips facets with empty features", () => { 107 + const facets: Facet[] = [ 108 + { index: { byteStart: 0, byteEnd: 5 }, features: [] }, 109 + ]; 110 + expect(serializeToMarkdown(text, facets)).toBe(text); 111 + }); 112 + 113 + it("processes valid facets while skipping invalid ones", () => { 114 + const facets: Facet[] = [ 115 + { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 116 + { index: { byteStart: 0, byteEnd: 5 }, features: [ITALIC] }, 117 + { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 118 + ]; 119 + expect(serializeToMarkdown(text, facets)).toBe("*hello* world"); 120 + }); 121 + }); 122 + 123 + describe("unicode edge cases", () => { 124 + it("handles zero-width characters", () => { 125 + const input = "**\u200b**"; 126 + const result = parseMarkdown(input); 127 + expect(result.text).toBe("\u200b"); 128 + expect(result.facets).toHaveLength(1); 129 + }); 130 + 131 + it("handles RTL override characters", () => { 132 + const input = "**\u202Etext\u202C**"; 133 + const result = parseMarkdown(input); 134 + expect(result.facets).toHaveLength(1); 135 + }); 136 + 137 + it("handles combining characters", () => { 138 + const input = "**e\u0301**"; // é as e + combining acute 139 + const result = parseMarkdown(input); 140 + expect(result.facets).toHaveLength(1); 141 + }); 142 + 143 + it("handles surrogate pairs (emoji)", () => { 144 + const input = "**\u{1F600}**"; // 😀 145 + const result = parseMarkdown(input); 146 + expect(result.text).toBe("😀"); 147 + expect(result.facets).toHaveLength(1); 148 + }); 149 + }); 150 + });
+146
src/lib/richtext/__tests__/byte-offsets.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { ByteString } from "../byte-string"; 3 + import { parseMarkdown } from "../parser"; 4 + import { serializeToMarkdown } from "../serializer"; 5 + import { BOLD, type Facet, ITALIC } from "../types"; 6 + 7 + describe("byte offset handling (Bluesky-inspired)", () => { 8 + describe("grapheme vs byte length", () => { 9 + it("ASCII has equal byte and character length", () => { 10 + const bs = new ByteString("Hello!"); 11 + expect(bs.length).toBe(6); 12 + expect(bs.text.length).toBe(6); 13 + }); 14 + 15 + it("family emoji is 25 bytes but 1 grapheme cluster", () => { 16 + const bs = new ByteString("👨‍👩‍👧‍👧"); 17 + expect(bs.length).toBe(25); 18 + }); 19 + 20 + it("mixed emoji and text", () => { 21 + const bs = new ByteString("👨‍👩‍👧‍👧🔥 good!✅"); 22 + expect(bs.length).toBe(38); 23 + }); 24 + 25 + it("CJK characters are 3 bytes each", () => { 26 + const bs = new ByteString("日本語"); 27 + expect(bs.length).toBe(9); 28 + }); 29 + }); 30 + 31 + describe("facet byte indices with unicode", () => { 32 + it("bold emoji has correct byte indices", () => { 33 + const { text, facets } = parseMarkdown("**🔥**"); 34 + expect(text).toBe("🔥"); 35 + expect(facets).toHaveLength(1); 36 + expect(facets[0].index.byteStart).toBe(0); 37 + expect(facets[0].index.byteEnd).toBe(4); // 🔥 is 4 bytes 38 + }); 39 + 40 + it("bold CJK has correct byte indices", () => { 41 + const { text, facets } = parseMarkdown("**日本語**"); 42 + expect(text).toBe("日本語"); 43 + expect(facets).toHaveLength(1); 44 + expect(facets[0].index.byteStart).toBe(0); 45 + expect(facets[0].index.byteEnd).toBe(9); // 3 chars × 3 bytes 46 + }); 47 + 48 + it("facet after emoji starts at correct byte offset", () => { 49 + const { text, facets } = parseMarkdown("🔥 **bold**"); 50 + expect(text).toBe("🔥 bold"); 51 + expect(facets).toHaveLength(1); 52 + // 🔥 = 4 bytes, space = 1 byte 53 + expect(facets[0].index.byteStart).toBe(5); 54 + expect(facets[0].index.byteEnd).toBe(9); 55 + }); 56 + 57 + it("facet between emojis", () => { 58 + const { text, facets } = parseMarkdown("👨‍👩‍👧‍👧 **text** 🔥"); 59 + expect(text).toBe("👨‍👩‍👧‍👧 text 🔥"); 60 + expect(facets).toHaveLength(1); 61 + // family emoji = 25 bytes, space = 1 byte 62 + expect(facets[0].index.byteStart).toBe(26); 63 + expect(facets[0].index.byteEnd).toBe(30); 64 + }); 65 + }); 66 + 67 + describe("serializer respects byte boundaries", () => { 68 + it("serializes facet at unicode boundary correctly", () => { 69 + const text = "🔥 hello"; 70 + const facets: Facet[] = [ 71 + { index: { byteStart: 5, byteEnd: 10 }, features: [BOLD] }, 72 + ]; 73 + expect(serializeToMarkdown(text, facets)).toBe("🔥 **hello**"); 74 + }); 75 + 76 + it("serializes facet spanning emoji correctly", () => { 77 + const text = "🔥"; 78 + const facets: Facet[] = [ 79 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 80 + ]; 81 + expect(serializeToMarkdown(text, facets)).toBe("**🔥**"); 82 + }); 83 + 84 + it("serializes multiple facets around unicode", () => { 85 + const text = "日本語 text 日本語"; 86 + const facets: Facet[] = [ 87 + { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 88 + { index: { byteStart: 15, byteEnd: 24 }, features: [ITALIC] }, 89 + ]; 90 + expect(serializeToMarkdown(text, facets)).toBe( 91 + "**日本語** text *日本語*", 92 + ); 93 + }); 94 + }); 95 + 96 + describe("slicing at byte boundaries", () => { 97 + it("sliceByBytes handles emoji boundaries", () => { 98 + const bs = new ByteString("🔥hello"); 99 + expect(bs.sliceByBytes(0, 4)).toBe("🔥"); 100 + expect(bs.sliceByBytes(4, 9)).toBe("hello"); 101 + }); 102 + 103 + it("sliceByBytes handles CJK boundaries", () => { 104 + const bs = new ByteString("日本語"); 105 + expect(bs.sliceByBytes(0, 3)).toBe("日"); 106 + expect(bs.sliceByBytes(3, 6)).toBe("本"); 107 + expect(bs.sliceByBytes(6, 9)).toBe("語"); 108 + }); 109 + 110 + it("sliceByBytes handles mixed content", () => { 111 + const bs = new ByteString("a日🔥b"); 112 + expect(bs.sliceByBytes(0, 1)).toBe("a"); 113 + expect(bs.sliceByBytes(1, 4)).toBe("日"); 114 + expect(bs.sliceByBytes(4, 8)).toBe("🔥"); 115 + expect(bs.sliceByBytes(8, 9)).toBe("b"); 116 + }); 117 + }); 118 + 119 + describe("edge cases from real-world scenarios", () => { 120 + it("empty input", () => { 121 + const { text, facets } = parseMarkdown(""); 122 + expect(text).toBe(""); 123 + expect(facets).toHaveLength(0); 124 + expect(serializeToMarkdown(text, facets)).toBe(""); 125 + }); 126 + 127 + it("text with no formatting preserves unicode", () => { 128 + const input = "👨‍👩‍👧‍👧 family emoji and 日本語 text"; 129 + const { text, facets } = parseMarkdown(input); 130 + expect(text).toBe(input); 131 + expect(facets).toHaveLength(0); 132 + }); 133 + 134 + it("overlapping facets serialize correctly", () => { 135 + const text = "hello world"; 136 + const facets: Facet[] = [ 137 + { index: { byteStart: 0, byteEnd: 11 }, features: [BOLD] }, 138 + { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 139 + ]; 140 + // Bold wraps everything, italic starts at "world" 141 + // At byte 6: close nothing, open italic → * 142 + // At byte 11: close italic, close bold → *** 143 + expect(serializeToMarkdown(text, facets)).toBe("**hello *world***"); 144 + }); 145 + }); 146 + });
+82
src/lib/richtext/__tests__/byte-string.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { ByteString } from "../byte-string"; 3 + 4 + describe("ByteString", () => { 5 + describe("construction", () => { 6 + it("handles empty string", () => { 7 + const bs = new ByteString(""); 8 + expect(bs.text).toBe(""); 9 + expect(bs.length).toBe(0); 10 + }); 11 + 12 + it("handles ASCII text", () => { 13 + const bs = new ByteString("hello"); 14 + expect(bs.text).toBe("hello"); 15 + expect(bs.length).toBe(5); 16 + }); 17 + 18 + it("handles multi-byte UTF-8 characters", () => { 19 + const bs = new ByteString("日本語"); 20 + expect(bs.text).toBe("日本語"); 21 + expect(bs.length).toBe(9); // 3 chars * 3 bytes each 22 + }); 23 + 24 + it("handles emoji (4-byte UTF-8)", () => { 25 + const bs = new ByteString("🔥"); 26 + expect(bs.text).toBe("🔥"); 27 + expect(bs.length).toBe(4); 28 + }); 29 + 30 + it("handles mixed ASCII and multi-byte", () => { 31 + const bs = new ByteString("a日b"); 32 + expect(bs.text).toBe("a日b"); 33 + expect(bs.length).toBe(5); // 1 + 3 + 1 34 + }); 35 + }); 36 + 37 + describe("sliceByBytes", () => { 38 + it("slices ASCII correctly", () => { 39 + const bs = new ByteString("hello world"); 40 + expect(bs.sliceByBytes(0, 5)).toBe("hello"); 41 + expect(bs.sliceByBytes(6, 11)).toBe("world"); 42 + expect(bs.sliceByBytes(0, 11)).toBe("hello world"); 43 + }); 44 + 45 + it("slices multi-byte characters correctly", () => { 46 + const bs = new ByteString("日本語"); 47 + expect(bs.sliceByBytes(0, 3)).toBe("日"); 48 + expect(bs.sliceByBytes(3, 6)).toBe("本"); 49 + expect(bs.sliceByBytes(6, 9)).toBe("語"); 50 + expect(bs.sliceByBytes(0, 9)).toBe("日本語"); 51 + }); 52 + 53 + it("slices mixed content correctly", () => { 54 + const bs = new ByteString("a日b"); 55 + expect(bs.sliceByBytes(0, 1)).toBe("a"); 56 + expect(bs.sliceByBytes(1, 4)).toBe("日"); 57 + expect(bs.sliceByBytes(4, 5)).toBe("b"); 58 + }); 59 + 60 + it("slices emoji correctly", () => { 61 + const bs = new ByteString("hi🔥bye"); 62 + expect(bs.sliceByBytes(0, 2)).toBe("hi"); 63 + expect(bs.sliceByBytes(2, 6)).toBe("🔥"); 64 + expect(bs.sliceByBytes(6, 9)).toBe("bye"); 65 + }); 66 + 67 + it("handles empty slice", () => { 68 + const bs = new ByteString("hello"); 69 + expect(bs.sliceByBytes(2, 2)).toBe(""); 70 + }); 71 + 72 + it("handles slice at start", () => { 73 + const bs = new ByteString("hello"); 74 + expect(bs.sliceByBytes(0, 0)).toBe(""); 75 + }); 76 + 77 + it("handles slice at end", () => { 78 + const bs = new ByteString("hello"); 79 + expect(bs.sliceByBytes(5, 5)).toBe(""); 80 + }); 81 + }); 82 + });
+298
src/lib/richtext/__tests__/parser.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { parseMarkdown } from "../parser"; 3 + import { BOLD, ITALIC } from "../types"; 4 + 5 + describe("parseMarkdown", () => { 6 + describe("plain text", () => { 7 + it("returns empty for empty input", () => { 8 + const result = parseMarkdown(""); 9 + expect(result.text).toBe(""); 10 + expect(result.facets).toEqual([]); 11 + }); 12 + 13 + it("returns text unchanged with no markers", () => { 14 + const result = parseMarkdown("hello world"); 15 + expect(result.text).toBe("hello world"); 16 + expect(result.facets).toEqual([]); 17 + }); 18 + }); 19 + 20 + describe("bold", () => { 21 + it("parses simple bold", () => { 22 + const result = parseMarkdown("**bold**"); 23 + expect(result.text).toBe("bold"); 24 + expect(result.facets).toEqual([ 25 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 26 + ]); 27 + }); 28 + 29 + it("parses bold in middle of text", () => { 30 + const result = parseMarkdown("hello **world** there"); 31 + expect(result.text).toBe("hello world there"); 32 + expect(result.facets).toEqual([ 33 + { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 34 + ]); 35 + }); 36 + 37 + it("parses multiple bold spans", () => { 38 + const result = parseMarkdown("**a**b**c**"); 39 + expect(result.text).toBe("abc"); 40 + expect(result.facets).toEqual([ 41 + { index: { byteStart: 0, byteEnd: 1 }, features: [BOLD] }, 42 + { index: { byteStart: 2, byteEnd: 3 }, features: [BOLD] }, 43 + ]); 44 + }); 45 + }); 46 + 47 + describe("italic", () => { 48 + it("parses simple italic", () => { 49 + const result = parseMarkdown("*italic*"); 50 + expect(result.text).toBe("italic"); 51 + expect(result.facets).toEqual([ 52 + { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 53 + ]); 54 + }); 55 + 56 + it("parses italic in middle of text", () => { 57 + const result = parseMarkdown("hello *world* there"); 58 + expect(result.text).toBe("hello world there"); 59 + expect(result.facets).toEqual([ 60 + { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 61 + ]); 62 + }); 63 + }); 64 + 65 + describe("bold and italic", () => { 66 + it("parses ***bold and italic***", () => { 67 + const result = parseMarkdown("***both***"); 68 + expect(result.text).toBe("both"); 69 + // Should have both bold and italic facets covering the same range 70 + expect(result.facets).toHaveLength(2); 71 + expect(result.facets).toContainEqual({ 72 + index: { byteStart: 0, byteEnd: 4 }, 73 + features: [BOLD], 74 + }); 75 + expect(result.facets).toContainEqual({ 76 + index: { byteStart: 0, byteEnd: 4 }, 77 + features: [ITALIC], 78 + }); 79 + }); 80 + 81 + it("parses nested bold in italic", () => { 82 + const result = parseMarkdown("*italic **bold** italic*"); 83 + expect(result.text).toBe("italic bold italic"); 84 + expect(result.facets).toContainEqual({ 85 + index: { byteStart: 0, byteEnd: 18 }, 86 + features: [ITALIC], 87 + }); 88 + expect(result.facets).toContainEqual({ 89 + index: { byteStart: 7, byteEnd: 11 }, 90 + features: [BOLD], 91 + }); 92 + }); 93 + }); 94 + 95 + describe("unclosed markers", () => { 96 + it("treats unclosed ** as literal", () => { 97 + const result = parseMarkdown("hello **world"); 98 + expect(result.text).toBe("hello **world"); 99 + expect(result.facets).toEqual([]); 100 + }); 101 + 102 + it("treats unclosed * as literal", () => { 103 + const result = parseMarkdown("hello *world"); 104 + expect(result.text).toBe("hello *world"); 105 + expect(result.facets).toEqual([]); 106 + }); 107 + }); 108 + 109 + describe("empty spans", () => { 110 + it("preserves empty bold **** as literal text", () => { 111 + const result = parseMarkdown("a****b"); 112 + expect(result.text).toBe("a****b"); 113 + expect(result.facets).toEqual([]); 114 + }); 115 + 116 + it("treats unpaired ** as literal", () => { 117 + const result = parseMarkdown("a**b"); 118 + expect(result.text).toBe("a**b"); 119 + expect(result.facets).toEqual([]); 120 + }); 121 + }); 122 + 123 + describe("unicode", () => { 124 + it("handles multi-byte characters", () => { 125 + const result = parseMarkdown("**日本語**"); 126 + expect(result.text).toBe("日本語"); 127 + expect(result.facets).toEqual([ 128 + { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 129 + ]); 130 + }); 131 + 132 + it("handles emoji", () => { 133 + const result = parseMarkdown("**🔥**"); 134 + expect(result.text).toBe("🔥"); 135 + expect(result.facets).toEqual([ 136 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 137 + ]); 138 + }); 139 + 140 + it("handles mixed ASCII and unicode", () => { 141 + const result = parseMarkdown("hi **日本語** bye"); 142 + expect(result.text).toBe("hi 日本語 bye"); 143 + expect(result.facets).toEqual([ 144 + { index: { byteStart: 3, byteEnd: 12 }, features: [BOLD] }, 145 + ]); 146 + }); 147 + }); 148 + 149 + describe("inline code", () => { 150 + it("parses inline code", () => { 151 + const result = parseMarkdown("`code`"); 152 + expect(result.text).toBe("code"); 153 + expect(result.facets).toHaveLength(1); 154 + expect(result.facets[0].features[0].$type).toBe( 155 + "com.deckbelcher.richtext.facet#code", 156 + ); 157 + }); 158 + 159 + it("parses code in text", () => { 160 + const result = parseMarkdown("run `npm install` now"); 161 + expect(result.text).toBe("run npm install now"); 162 + expect(result.facets[0].index).toEqual({ byteStart: 4, byteEnd: 15 }); 163 + }); 164 + 165 + it("preserves empty code `` as literal", () => { 166 + const result = parseMarkdown("a``b"); 167 + expect(result.text).toBe("a``b"); 168 + expect(result.facets).toEqual([]); 169 + }); 170 + }); 171 + 172 + describe("code blocks", () => { 173 + it("parses code block", () => { 174 + const result = parseMarkdown("```\nconst x = 1;\n```"); 175 + expect(result.text).toBe("const x = 1;"); 176 + expect(result.facets).toHaveLength(1); 177 + expect(result.facets[0].features[0].$type).toBe( 178 + "com.deckbelcher.richtext.facet#codeBlock", 179 + ); 180 + }); 181 + 182 + it("parses code block with language", () => { 183 + const result = parseMarkdown("```typescript\nconst x = 1;\n```"); 184 + expect(result.text).toBe("const x = 1;"); 185 + }); 186 + 187 + it("parses multi-line code block", () => { 188 + const result = parseMarkdown("```\nline1\nline2\n```"); 189 + expect(result.text).toBe("line1\nline2"); 190 + }); 191 + 192 + it("ignores ``` in middle of line", () => { 193 + const result = parseMarkdown("text ``` more"); 194 + expect(result.text).toBe("text ``` more"); 195 + }); 196 + 197 + it("preserves newline between code block and content after", () => { 198 + const result = parseMarkdown("```\ncode\n```\ncontent after"); 199 + expect(result.text).toBe("code\ncontent after"); 200 + expect(result.facets).toHaveLength(1); 201 + expect(result.facets[0].index).toEqual({ byteStart: 0, byteEnd: 4 }); 202 + }); 203 + }); 204 + 205 + describe("links", () => { 206 + it("parses markdown link", () => { 207 + const result = parseMarkdown("[click here](https://example.com)"); 208 + expect(result.text).toBe("click here"); 209 + expect(result.facets).toHaveLength(1); 210 + expect(result.facets[0].features[0]).toEqual({ 211 + $type: "com.deckbelcher.richtext.facet#link", 212 + uri: "https://example.com", 213 + }); 214 + }); 215 + 216 + it("parses link in text", () => { 217 + const result = parseMarkdown("check [this](https://x.com) out"); 218 + expect(result.text).toBe("check this out"); 219 + }); 220 + 221 + it("treats incomplete link as literal", () => { 222 + const result = parseMarkdown("[text]"); 223 + expect(result.text).toBe("[text]"); 224 + }); 225 + 226 + it("treats link without url as literal", () => { 227 + const result = parseMarkdown("[text]()"); 228 + expect(result.text).toBe("[text]()"); 229 + }); 230 + }); 231 + 232 + describe("mentions", () => { 233 + it("parses valid handle", () => { 234 + const result = parseMarkdown("@user.bsky.social"); 235 + expect(result.text).toBe("@user.bsky.social"); 236 + expect(result.facets).toHaveLength(1); 237 + expect(result.facets[0].features[0]).toEqual({ 238 + $type: "com.deckbelcher.richtext.facet#mention", 239 + did: "user.bsky.social", 240 + }); 241 + }); 242 + 243 + it("ignores @ without valid handle", () => { 244 + const result = parseMarkdown("email@"); 245 + expect(result.text).toBe("email@"); 246 + expect(result.facets).toEqual([]); 247 + }); 248 + 249 + it("ignores @ without dot", () => { 250 + const result = parseMarkdown("@username"); 251 + expect(result.text).toBe("@username"); 252 + expect(result.facets).toEqual([]); 253 + }); 254 + 255 + it("parses mention in sentence", () => { 256 + const result = parseMarkdown("hello @alice.dev!"); 257 + expect(result.text).toBe("hello @alice.dev!"); 258 + expect(result.facets[0].index).toEqual({ byteStart: 6, byteEnd: 16 }); 259 + }); 260 + }); 261 + 262 + describe.each([ 263 + ["Hello **world**!"], 264 + ["This is *italic* text"], 265 + ["***bold and italic***"], 266 + ["**nested *italic* in bold**"], 267 + ["Multi-byte: **日本語** emoji **🔥**"], 268 + ["Unclosed **bold"], 269 + ["Unclosed *italic"], 270 + ["**a**b**c**"], 271 + ["*a*b*c*"], 272 + ["****"], 273 + ["***"], 274 + ["**"], 275 + ["*"], 276 + [""], 277 + ["no formatting here"], 278 + ["**bold** and *italic* together"], 279 + ["a**b**c**d**e"], 280 + // Unicode byte offset edge cases 281 + ["**🔥**"], 282 + ["🔥 **bold**"], 283 + ["**日本語**"], 284 + ["👨‍👩‍👧‍👧 **text** 🔥"], 285 + ["prefix **日本語** suffix"], 286 + ["*🔥* and **🔥**"], 287 + ["**a日b**"], 288 + // New features 289 + ["`code`"], 290 + ["``"], 291 + ["[link](https://example.com)"], 292 + ["@user.bsky.social"], 293 + ])("snapshot: %s", (input) => { 294 + it("parses correctly", () => { 295 + expect(parseMarkdown(input)).toMatchSnapshot(); 296 + }); 297 + }); 298 + });
+172
src/lib/richtext/__tests__/renderer.test.tsx
··· 1 + import { render } from "@testing-library/react"; 2 + import { describe, expect, it } from "vitest"; 3 + import { RichText } from "../renderer"; 4 + import { BOLD, type Facet, ITALIC } from "../types"; 5 + 6 + describe("RichText renderer", () => { 7 + describe("basic rendering", () => { 8 + it("renders null for empty text", () => { 9 + const { container } = render(<RichText text="" />); 10 + expect(container.innerHTML).toBe(""); 11 + }); 12 + 13 + it("renders plain text without formatting", () => { 14 + const { container } = render(<RichText text="hello world" />); 15 + expect(container.textContent).toBe("hello world"); 16 + expect(container.querySelector("strong")).toBeNull(); 17 + expect(container.querySelector("em")).toBeNull(); 18 + }); 19 + 20 + it("applies className to wrapper span", () => { 21 + const { container } = render( 22 + <RichText text="test" className="my-class" />, 23 + ); 24 + expect(container.querySelector("span.my-class")).not.toBeNull(); 25 + }); 26 + }); 27 + 28 + describe("bold formatting", () => { 29 + it("renders bold text", () => { 30 + const facets: Facet[] = [ 31 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 32 + ]; 33 + const { container } = render(<RichText text="bold" facets={facets} />); 34 + expect(container.querySelector("strong")?.textContent).toBe("bold"); 35 + }); 36 + 37 + it("renders bold in middle of text", () => { 38 + const facets: Facet[] = [ 39 + { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 40 + ]; 41 + const { container } = render( 42 + <RichText text="hello world there" facets={facets} />, 43 + ); 44 + expect(container.textContent).toBe("hello world there"); 45 + expect(container.querySelector("strong")?.textContent).toBe("world"); 46 + }); 47 + }); 48 + 49 + describe("italic formatting", () => { 50 + it("renders italic text", () => { 51 + const facets: Facet[] = [ 52 + { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 53 + ]; 54 + const { container } = render(<RichText text="italic" facets={facets} />); 55 + expect(container.querySelector("em")?.textContent).toBe("italic"); 56 + }); 57 + }); 58 + 59 + describe("combined formatting", () => { 60 + it("renders bold and italic on same range", () => { 61 + const facets: Facet[] = [ 62 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 63 + { index: { byteStart: 0, byteEnd: 4 }, features: [ITALIC] }, 64 + ]; 65 + const { container } = render(<RichText text="both" facets={facets} />); 66 + const strong = container.querySelector("strong"); 67 + const em = container.querySelector("em"); 68 + expect(strong).not.toBeNull(); 69 + expect(em).not.toBeNull(); 70 + expect(container.textContent).toBe("both"); 71 + }); 72 + 73 + it("renders nested formatting", () => { 74 + // "hello world" with bold on all, italic on "world" 75 + const facets: Facet[] = [ 76 + { index: { byteStart: 0, byteEnd: 11 }, features: [BOLD] }, 77 + { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 78 + ]; 79 + const { container } = render( 80 + <RichText text="hello world" facets={facets} />, 81 + ); 82 + expect(container.textContent).toBe("hello world"); 83 + // Should have strong elements 84 + expect(container.querySelectorAll("strong").length).toBeGreaterThan(0); 85 + // Should have em element for "world" 86 + expect(container.querySelector("em")?.textContent).toBe("world"); 87 + }); 88 + }); 89 + 90 + describe("unicode handling", () => { 91 + it("renders bold emoji", () => { 92 + const facets: Facet[] = [ 93 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 94 + ]; 95 + const { container } = render(<RichText text="🔥" facets={facets} />); 96 + expect(container.querySelector("strong")?.textContent).toBe("🔥"); 97 + }); 98 + 99 + it("renders bold CJK characters", () => { 100 + const facets: Facet[] = [ 101 + { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 102 + ]; 103 + const { container } = render(<RichText text="日本語" facets={facets} />); 104 + expect(container.querySelector("strong")?.textContent).toBe("日本語"); 105 + }); 106 + 107 + it("handles facet after emoji", () => { 108 + // "🔥 bold" - emoji is 4 bytes, space is 1 109 + const facets: Facet[] = [ 110 + { index: { byteStart: 5, byteEnd: 9 }, features: [BOLD] }, 111 + ]; 112 + const { container } = render(<RichText text="🔥 bold" facets={facets} />); 113 + expect(container.textContent).toBe("🔥 bold"); 114 + expect(container.querySelector("strong")?.textContent).toBe("bold"); 115 + }); 116 + 117 + it("renders family emoji correctly", () => { 118 + const facets: Facet[] = [ 119 + { index: { byteStart: 0, byteEnd: 25 }, features: [BOLD] }, 120 + ]; 121 + const { container } = render( 122 + <RichText text="👨‍👩‍👧‍👧" facets={facets} />, 123 + ); 124 + expect(container.querySelector("strong")?.textContent).toBe("👨‍👩‍👧‍👧"); 125 + }); 126 + }); 127 + 128 + describe("invalid facets", () => { 129 + it("skips facets with negative byteStart", () => { 130 + const facets: Facet[] = [ 131 + { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 132 + ]; 133 + const { container } = render( 134 + <RichText text="hello world" facets={facets} />, 135 + ); 136 + expect(container.querySelector("strong")).toBeNull(); 137 + }); 138 + 139 + it("skips facets with byteEnd > text length", () => { 140 + const facets: Facet[] = [ 141 + { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 142 + ]; 143 + const { container } = render( 144 + <RichText text="hello world" facets={facets} />, 145 + ); 146 + expect(container.querySelector("strong")).toBeNull(); 147 + }); 148 + 149 + it("skips facets with empty features", () => { 150 + const facets: Facet[] = [ 151 + { index: { byteStart: 0, byteEnd: 5 }, features: [] }, 152 + ]; 153 + const { container } = render( 154 + <RichText text="hello world" facets={facets} />, 155 + ); 156 + expect(container.querySelector("strong")).toBeNull(); 157 + expect(container.querySelector("em")).toBeNull(); 158 + }); 159 + 160 + it("processes valid facets while skipping invalid", () => { 161 + const facets: Facet[] = [ 162 + { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 163 + { index: { byteStart: 0, byteEnd: 5 }, features: [ITALIC] }, 164 + ]; 165 + const { container } = render( 166 + <RichText text="hello world" facets={facets} />, 167 + ); 168 + expect(container.querySelector("strong")).toBeNull(); 169 + expect(container.querySelector("em")?.textContent).toBe("hello"); 170 + }); 171 + }); 172 + });
+141
src/lib/richtext/__tests__/roundtrip.test.ts
··· 1 + import fc from "fast-check"; 2 + import { describe, expect, it } from "vitest"; 3 + import { ByteString } from "../byte-string"; 4 + import { parseMarkdown } from "../parser"; 5 + import { serializeToMarkdown } from "../serializer"; 6 + 7 + describe("roundtrip property tests", () => { 8 + it("parse → serialize roundtrips valid markdown", () => { 9 + // Generate markdown with valid formatting 10 + const wordArb = fc.stringMatching(/^[a-zA-Z0-9 ]{1,10}$/); 11 + 12 + const boldArb = wordArb.map((w) => `**${w}**`); 13 + const italicArb = wordArb.map((w) => `*${w}*`); 14 + const plainArb = wordArb; 15 + 16 + const segmentArb = fc.oneof(boldArb, italicArb, plainArb); 17 + const markdownArb = fc 18 + .array(segmentArb, { minLength: 1, maxLength: 5 }) 19 + .map((segs) => segs.join(" ")); 20 + 21 + fc.assert( 22 + fc.property(markdownArb, (md) => { 23 + const { text, facets } = parseMarkdown(md); 24 + const roundtripped = serializeToMarkdown(text, facets); 25 + expect(roundtripped).toBe(md); 26 + }), 27 + { numRuns: 200 }, 28 + ); 29 + }); 30 + 31 + it("facet byte indices are always valid after parsing", () => { 32 + fc.assert( 33 + fc.property(fc.string(), (input) => { 34 + const { text, facets } = parseMarkdown(input); 35 + const bs = new ByteString(text); 36 + 37 + for (const facet of facets) { 38 + expect(facet.index.byteStart).toBeGreaterThanOrEqual(0); 39 + expect(facet.index.byteEnd).toBeLessThanOrEqual(bs.length); 40 + expect(facet.index.byteStart).toBeLessThan(facet.index.byteEnd); 41 + } 42 + }), 43 + { numRuns: 200 }, 44 + ); 45 + }); 46 + 47 + it("parser never crashes on arbitrary input", () => { 48 + fc.assert( 49 + fc.property(fc.string(), (input) => { 50 + expect(() => parseMarkdown(input)).not.toThrow(); 51 + }), 52 + { numRuns: 500 }, 53 + ); 54 + }); 55 + 56 + it("serializer never crashes on arbitrary facets (adversarial input)", () => { 57 + // Facets from untrusted ATProto records could have any values 58 + const facetArb = fc.record({ 59 + index: fc.record({ 60 + byteStart: fc.integer({ min: -100, max: 1000 }), 61 + byteEnd: fc.integer({ min: -100, max: 1000 }), 62 + }), 63 + features: fc.array( 64 + fc.oneof( 65 + fc.constant({ 66 + $type: "com.deckbelcher.richtext.facet#bold" as const, 67 + }), 68 + fc.constant({ 69 + $type: "com.deckbelcher.richtext.facet#italic" as const, 70 + }), 71 + ), 72 + { minLength: 0, maxLength: 3 }, 73 + ), 74 + }); 75 + 76 + fc.assert( 77 + fc.property( 78 + fc.string(), 79 + fc.array(facetArb, { maxLength: 20 }), 80 + (text, facets) => { 81 + // Serializer must handle ANY input without crashing 82 + expect(() => serializeToMarkdown(text, facets)).not.toThrow(); 83 + }, 84 + ), 85 + { numRuns: 500 }, 86 + ); 87 + }); 88 + 89 + it("handles unicode in roundtrip", () => { 90 + // CJK, emoji, and ASCII mixed 91 + const unicodeWordArb = fc.stringMatching( 92 + /^[\u4e00-\u9fff\u{1F600}-\u{1F64F}a-z]{1,5}$/u, 93 + ); 94 + 95 + const boldArb = unicodeWordArb.map((w) => `**${w}**`); 96 + const italicArb = unicodeWordArb.map((w) => `*${w}*`); 97 + const plainArb = unicodeWordArb; 98 + 99 + const segmentArb = fc.oneof(boldArb, italicArb, plainArb); 100 + const markdownArb = fc 101 + .array(segmentArb, { minLength: 1, maxLength: 5 }) 102 + .map((segs) => segs.join(" ")); 103 + 104 + fc.assert( 105 + fc.property(markdownArb, (md) => { 106 + const { text, facets } = parseMarkdown(md); 107 + const roundtripped = serializeToMarkdown(text, facets); 108 + expect(roundtripped).toBe(md); 109 + }), 110 + { numRuns: 200 }, 111 + ); 112 + }); 113 + 114 + it("handles family emoji (25 bytes) in formatted spans", () => { 115 + const familyEmoji = "👨‍👩‍👧‍👧"; 116 + const inputs = [ 117 + `**${familyEmoji}**`, 118 + `*${familyEmoji}*`, 119 + `${familyEmoji} **text**`, 120 + `**text** ${familyEmoji}`, 121 + `${familyEmoji} **${familyEmoji}** ${familyEmoji}`, 122 + ]; 123 + 124 + for (const input of inputs) { 125 + const { text, facets } = parseMarkdown(input); 126 + const output = serializeToMarkdown(text, facets); 127 + expect(output).toBe(input); 128 + } 129 + }); 130 + 131 + it("arbitrary UTF-8 roundtrips through parse→serialize", () => { 132 + fc.assert( 133 + fc.property(fc.string(), (input) => { 134 + const { text, facets } = parseMarkdown(input); 135 + const output = serializeToMarkdown(text, facets); 136 + expect(output).toBe(input); 137 + }), 138 + { numRuns: 10_000 }, 139 + ); 140 + }); 141 + });
+73
src/lib/richtext/__tests__/serializer.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { serializeToMarkdown } from "../serializer"; 3 + import { BOLD, type Facet, ITALIC } from "../types"; 4 + 5 + describe("serializeToMarkdown", () => { 6 + it("returns text unchanged with no facets", () => { 7 + expect(serializeToMarkdown("hello world", [])).toBe("hello world"); 8 + }); 9 + 10 + it("serializes simple bold", () => { 11 + const facets: Facet[] = [ 12 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 13 + ]; 14 + expect(serializeToMarkdown("bold", facets)).toBe("**bold**"); 15 + }); 16 + 17 + it("serializes simple italic", () => { 18 + const facets: Facet[] = [ 19 + { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 20 + ]; 21 + expect(serializeToMarkdown("italic", facets)).toBe("*italic*"); 22 + }); 23 + 24 + it("serializes bold in middle of text", () => { 25 + const facets: Facet[] = [ 26 + { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 27 + ]; 28 + expect(serializeToMarkdown("hello world there", facets)).toBe( 29 + "hello **world** there", 30 + ); 31 + }); 32 + 33 + it("serializes multiple bold spans", () => { 34 + const facets: Facet[] = [ 35 + { index: { byteStart: 0, byteEnd: 1 }, features: [BOLD] }, 36 + { index: { byteStart: 2, byteEnd: 3 }, features: [BOLD] }, 37 + ]; 38 + expect(serializeToMarkdown("abc", facets)).toBe("**a**b**c**"); 39 + }); 40 + 41 + it("serializes overlapping bold and italic (same range)", () => { 42 + const facets: Facet[] = [ 43 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 44 + { index: { byteStart: 0, byteEnd: 4 }, features: [ITALIC] }, 45 + ]; 46 + expect(serializeToMarkdown("both", facets)).toBe("***both***"); 47 + }); 48 + 49 + it("serializes nested italic in bold", () => { 50 + // "before bold after" = 17 bytes 51 + const facets: Facet[] = [ 52 + { index: { byteStart: 0, byteEnd: 17 }, features: [BOLD] }, 53 + { index: { byteStart: 7, byteEnd: 11 }, features: [ITALIC] }, 54 + ]; 55 + expect(serializeToMarkdown("before bold after", facets)).toBe( 56 + "**before *bold* after**", 57 + ); 58 + }); 59 + 60 + it("handles unicode", () => { 61 + const facets: Facet[] = [ 62 + { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 63 + ]; 64 + expect(serializeToMarkdown("日本語", facets)).toBe("**日本語**"); 65 + }); 66 + 67 + it("handles emoji", () => { 68 + const facets: Facet[] = [ 69 + { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 70 + ]; 71 + expect(serializeToMarkdown("🔥", facets)).toBe("**🔥**"); 72 + }); 73 + });
+20
src/lib/richtext/byte-string.ts
··· 1 + const encoder = new TextEncoder(); 2 + const decoder = new TextDecoder(); 3 + 4 + export class ByteString { 5 + readonly text: string; 6 + readonly bytes: Uint8Array; 7 + 8 + constructor(text: string) { 9 + this.text = text; 10 + this.bytes = encoder.encode(text); 11 + } 12 + 13 + get length(): number { 14 + return this.bytes.length; 15 + } 16 + 17 + sliceByBytes(byteStart: number, byteEnd: number): string { 18 + return decoder.decode(this.bytes.slice(byteStart, byteEnd)); 19 + } 20 + }
+28
src/lib/richtext/index.ts
··· 1 + export { ByteString } from "./byte-string"; 2 + export { parseMarkdown } from "./parser"; 3 + export { RichText } from "./renderer"; 4 + export { serializeToMarkdown } from "./serializer"; 5 + export { 6 + BOLD, 7 + type BoldFeature, 8 + type ByteSlice, 9 + CODE, 10 + CODE_BLOCK, 11 + type CodeBlockFeature, 12 + type CodeFeature, 13 + type Facet, 14 + type FormatFeature, 15 + ITALIC, 16 + type ItalicFeature, 17 + isBold, 18 + isCode, 19 + isCodeBlock, 20 + isItalic, 21 + isLink, 22 + isMention, 23 + type LinkFeature, 24 + link, 25 + type MentionFeature, 26 + mention, 27 + type ParseResult, 28 + } from "./types";
+280
src/lib/richtext/lexer.ts
··· 1 + const encoder = new TextEncoder(); 2 + const decoder = new TextDecoder(); 3 + 4 + const ASTERISK = 0x2a; // '*' 5 + const BACKTICK = 0x60; // '`' 6 + const AT = 0x40; // '@' 7 + const LBRACKET = 0x5b; // '[' 8 + const RBRACKET = 0x5d; // ']' 9 + const LPAREN = 0x28; // '(' 10 + const RPAREN = 0x29; // ')' 11 + const NEWLINE = 0x0a; // '\n' 12 + const PERIOD = 0x2e; // '.' 13 + const HYPHEN = 0x2d; // '-' 14 + 15 + export type TokenType = 16 + | "BOLD_MARKER" 17 + | "ITALIC_MARKER" 18 + | "CODE_MARKER" 19 + | "CODE_BLOCK" 20 + | "LINK" 21 + | "MENTION" 22 + | "TEXT"; 23 + 24 + export interface Token { 25 + type: TokenType; 26 + byteStart: number; 27 + byteEnd: number; 28 + } 29 + 30 + export interface LinkToken extends Token { 31 + type: "LINK"; 32 + textStart: number; 33 + textEnd: number; 34 + uriStart: number; 35 + uriEnd: number; 36 + } 37 + 38 + export interface MentionToken extends Token { 39 + type: "MENTION"; 40 + handleStart: number; 41 + handleEnd: number; 42 + } 43 + 44 + export interface CodeBlockToken extends Token { 45 + type: "CODE_BLOCK"; 46 + contentStart: number; 47 + contentEnd: number; 48 + } 49 + 50 + export function isLinkToken(token: Token): token is LinkToken { 51 + return token.type === "LINK"; 52 + } 53 + 54 + export function isMentionToken(token: Token): token is MentionToken { 55 + return token.type === "MENTION"; 56 + } 57 + 58 + export function isCodeBlockToken(token: Token): token is CodeBlockToken { 59 + return token.type === "CODE_BLOCK"; 60 + } 61 + 62 + export function tokenize(input: string): Token[] { 63 + const bytes = encoder.encode(input); 64 + const tokens: Token[] = []; 65 + let i = 0; 66 + let textStart: number | null = null; 67 + 68 + const flushText = () => { 69 + if (textStart !== null && textStart < i) { 70 + tokens.push({ 71 + type: "TEXT", 72 + byteStart: textStart, 73 + byteEnd: i, 74 + }); 75 + textStart = null; 76 + } 77 + }; 78 + 79 + while (i < bytes.length) { 80 + // Check for ``` (code block) - must be at start of line or start of input 81 + if ( 82 + bytes[i] === BACKTICK && 83 + bytes[i + 1] === BACKTICK && 84 + bytes[i + 2] === BACKTICK 85 + ) { 86 + const isStartOfLine = i === 0 || bytes[i - 1] === NEWLINE; 87 + if (isStartOfLine) { 88 + // Find end of opening line (skip optional language identifier) 89 + let contentStart = i + 3; 90 + while (contentStart < bytes.length && bytes[contentStart] !== NEWLINE) { 91 + contentStart++; 92 + } 93 + if (contentStart < bytes.length) { 94 + contentStart++; // skip the newline 95 + } 96 + 97 + // Find closing ``` 98 + let contentEnd = contentStart; 99 + let found = false; 100 + while (contentEnd < bytes.length) { 101 + if ( 102 + bytes[contentEnd] === NEWLINE && 103 + bytes[contentEnd + 1] === BACKTICK && 104 + bytes[contentEnd + 2] === BACKTICK && 105 + bytes[contentEnd + 3] === BACKTICK 106 + ) { 107 + found = true; 108 + break; 109 + } 110 + contentEnd++; 111 + } 112 + 113 + if (found) { 114 + flushText(); 115 + const blockEnd = contentEnd + 4; // newline + ``` 116 + tokens.push({ 117 + type: "CODE_BLOCK", 118 + byteStart: i, 119 + byteEnd: blockEnd, 120 + contentStart, 121 + contentEnd, 122 + } as CodeBlockToken); 123 + i = blockEnd; 124 + continue; 125 + } 126 + } 127 + } 128 + 129 + // Check for ** (bold marker) 130 + if (bytes[i] === ASTERISK && bytes[i + 1] === ASTERISK) { 131 + flushText(); 132 + tokens.push({ type: "BOLD_MARKER", byteStart: i, byteEnd: i + 2 }); 133 + i += 2; 134 + continue; 135 + } 136 + 137 + // Check for * (italic marker) 138 + if (bytes[i] === ASTERISK) { 139 + flushText(); 140 + tokens.push({ type: "ITALIC_MARKER", byteStart: i, byteEnd: i + 1 }); 141 + i += 1; 142 + continue; 143 + } 144 + 145 + // Check for ` (inline code marker) 146 + if (bytes[i] === BACKTICK) { 147 + flushText(); 148 + tokens.push({ type: "CODE_MARKER", byteStart: i, byteEnd: i + 1 }); 149 + i += 1; 150 + continue; 151 + } 152 + 153 + // Check for [text](url) (link) 154 + if (bytes[i] === LBRACKET) { 155 + const linkResult = tryParseLink(bytes, i); 156 + if (linkResult) { 157 + flushText(); 158 + tokens.push(linkResult); 159 + i = linkResult.byteEnd; 160 + continue; 161 + } 162 + } 163 + 164 + // Check for @handle (mention) 165 + if (bytes[i] === AT) { 166 + const mentionResult = tryParseMention(bytes, i); 167 + if (mentionResult) { 168 + flushText(); 169 + tokens.push(mentionResult); 170 + i = mentionResult.byteEnd; 171 + continue; 172 + } 173 + } 174 + 175 + // Regular byte - accumulate into text 176 + if (textStart === null) { 177 + textStart = i; 178 + } 179 + i += 1; 180 + } 181 + 182 + flushText(); 183 + return tokens; 184 + } 185 + 186 + function tryParseLink(bytes: Uint8Array, start: number): LinkToken | null { 187 + // [text](url) 188 + let i = start + 1; // skip [ 189 + const textStart = i; 190 + 191 + // Find ] 192 + while (i < bytes.length && bytes[i] !== RBRACKET && bytes[i] !== NEWLINE) { 193 + i++; 194 + } 195 + if (i >= bytes.length || bytes[i] !== RBRACKET) return null; 196 + const textEnd = i; 197 + i++; // skip ] 198 + 199 + // Must be followed by ( 200 + if (bytes[i] !== LPAREN) return null; 201 + i++; // skip ( 202 + const uriStart = i; 203 + 204 + // Find ) 205 + while (i < bytes.length && bytes[i] !== RPAREN && bytes[i] !== NEWLINE) { 206 + i++; 207 + } 208 + if (i >= bytes.length || bytes[i] !== RPAREN) return null; 209 + const uriEnd = i; 210 + i++; // skip ) 211 + 212 + // Must have non-empty text and uri 213 + if (textEnd <= textStart || uriEnd <= uriStart) return null; 214 + 215 + return { 216 + type: "LINK", 217 + byteStart: start, 218 + byteEnd: i, 219 + textStart, 220 + textEnd, 221 + uriStart, 222 + uriEnd, 223 + }; 224 + } 225 + 226 + function isHandleChar(b: number): boolean { 227 + // a-z, A-Z, 0-9, -, . 228 + return ( 229 + (b >= 0x61 && b <= 0x7a) || 230 + (b >= 0x41 && b <= 0x5a) || 231 + (b >= 0x30 && b <= 0x39) || 232 + b === HYPHEN || 233 + b === PERIOD 234 + ); 235 + } 236 + 237 + function tryParseMention( 238 + bytes: Uint8Array, 239 + start: number, 240 + ): MentionToken | null { 241 + // @handle - ATProto handles: a-z, 0-9, -, . with at least one dot 242 + let i = start + 1; // skip @ 243 + const handleStart = i; 244 + let dotCount = 0; 245 + 246 + while (i < bytes.length && isHandleChar(bytes[i])) { 247 + if (bytes[i] === PERIOD) { 248 + // Can't have consecutive dots or start with dot 249 + if (i === handleStart || bytes[i - 1] === PERIOD) { 250 + return null; 251 + } 252 + dotCount++; 253 + } 254 + i++; 255 + } 256 + 257 + const handleEnd = i; 258 + 259 + // Must have at least one dot (two segments) 260 + if (dotCount < 1) return null; 261 + 262 + // Can't end with dot or hyphen 263 + const lastChar = bytes[handleEnd - 1]; 264 + if (lastChar === PERIOD || lastChar === HYPHEN) return null; 265 + 266 + if (handleEnd <= handleStart) return null; 267 + 268 + return { 269 + type: "MENTION", 270 + byteStart: start, 271 + byteEnd: handleEnd, 272 + handleStart, 273 + handleEnd, 274 + }; 275 + } 276 + 277 + export function getTokenText(input: string, token: Token): string { 278 + const bytes = encoder.encode(input); 279 + return decoder.decode(bytes.slice(token.byteStart, token.byteEnd)); 280 + }
+195
src/lib/richtext/parser.ts
··· 1 + import { 2 + type CodeBlockToken, 3 + isCodeBlockToken, 4 + isLinkToken, 5 + isMentionToken, 6 + type LinkToken, 7 + type MentionToken, 8 + type Token, 9 + tokenize, 10 + } from "./lexer"; 11 + import { 12 + BOLD, 13 + CODE, 14 + CODE_BLOCK, 15 + type Facet, 16 + type FormatFeature, 17 + ITALIC, 18 + link, 19 + mention, 20 + type ParseResult, 21 + } from "./types"; 22 + 23 + const encoder = new TextEncoder(); 24 + const decoder = new TextDecoder(); 25 + 26 + export function parseMarkdown(input: string): ParseResult { 27 + const tokens = tokenize(input); 28 + const bytes = encoder.encode(input); 29 + 30 + // Find matching pairs for bold, italic, and code markers 31 + const boldPairs = findPairs(tokens, "BOLD_MARKER"); 32 + const italicPairs = findPairs(tokens, "ITALIC_MARKER"); 33 + const codePairs = findPairs(tokens, "CODE_MARKER"); 34 + 35 + // Build output: collect text tokens and paired markers' content 36 + const outputBytes: number[] = []; 37 + const facets: Facet[] = []; 38 + 39 + // Track byte position mapping: original byte -> output byte 40 + let outputBytePos = 0; 41 + 42 + const emitBytes = (start: number, end: number) => { 43 + for (let b = start; b < end; b++) { 44 + outputBytes.push(bytes[b]); 45 + } 46 + outputBytePos += end - start; 47 + }; 48 + 49 + const handlePairedMarker = ( 50 + token: Token, 51 + pairs: Map<Token, PairInfo>, 52 + feature: FormatFeature, 53 + ) => { 54 + const pair = pairs.get(token); 55 + if (pair) { 56 + if (pair.isOpen) { 57 + pair.outputByteStart = outputBytePos; 58 + } else { 59 + const openPair = pair.partner; 60 + if ( 61 + openPair?.outputByteStart !== undefined && 62 + outputBytePos > openPair.outputByteStart 63 + ) { 64 + // Non-empty span: create facet 65 + facets.push({ 66 + index: { 67 + byteStart: openPair.outputByteStart, 68 + byteEnd: outputBytePos, 69 + }, 70 + features: [feature], 71 + }); 72 + } else if (openPair?.openToken) { 73 + // Empty span: emit both markers as literal text 74 + emitBytes(openPair.openToken.byteStart, openPair.openToken.byteEnd); 75 + emitBytes(token.byteStart, token.byteEnd); 76 + } 77 + } 78 + } else { 79 + // Unpaired marker - emit as literal text 80 + emitBytes(token.byteStart, token.byteEnd); 81 + } 82 + }; 83 + 84 + for (const token of tokens) { 85 + if (token.type === "TEXT") { 86 + emitBytes(token.byteStart, token.byteEnd); 87 + } else if (token.type === "BOLD_MARKER") { 88 + handlePairedMarker(token, boldPairs, BOLD); 89 + } else if (token.type === "ITALIC_MARKER") { 90 + handlePairedMarker(token, italicPairs, ITALIC); 91 + } else if (token.type === "CODE_MARKER") { 92 + handlePairedMarker(token, codePairs, CODE); 93 + } else if (token.type === "CODE_BLOCK" && isCodeBlockToken(token)) { 94 + const codeBlock = token as CodeBlockToken; 95 + const contentStart = outputBytePos; 96 + 97 + // Copy content bytes 98 + for (let b = codeBlock.contentStart; b < codeBlock.contentEnd; b++) { 99 + outputBytes.push(bytes[b]); 100 + } 101 + outputBytePos += codeBlock.contentEnd - codeBlock.contentStart; 102 + 103 + facets.push({ 104 + index: { byteStart: contentStart, byteEnd: outputBytePos }, 105 + features: [CODE_BLOCK], 106 + }); 107 + } else if (token.type === "LINK" && isLinkToken(token)) { 108 + const linkToken = token as LinkToken; 109 + const textStart = outputBytePos; 110 + 111 + // Copy link text bytes 112 + for (let b = linkToken.textStart; b < linkToken.textEnd; b++) { 113 + outputBytes.push(bytes[b]); 114 + } 115 + outputBytePos += linkToken.textEnd - linkToken.textStart; 116 + 117 + // Extract URI 118 + const uri = decoder.decode( 119 + bytes.slice(linkToken.uriStart, linkToken.uriEnd), 120 + ); 121 + 122 + facets.push({ 123 + index: { byteStart: textStart, byteEnd: outputBytePos }, 124 + features: [link(uri)], 125 + }); 126 + } else if (token.type === "MENTION" && isMentionToken(token)) { 127 + const mentionToken = token as MentionToken; 128 + const mentionStart = outputBytePos; 129 + 130 + // Copy full @handle to output 131 + for (let b = token.byteStart; b < token.byteEnd; b++) { 132 + outputBytes.push(bytes[b]); 133 + } 134 + outputBytePos += token.byteEnd - token.byteStart; 135 + 136 + // Extract handle (without @) 137 + const handle = decoder.decode( 138 + bytes.slice(mentionToken.handleStart, mentionToken.handleEnd), 139 + ); 140 + 141 + facets.push({ 142 + index: { byteStart: mentionStart, byteEnd: outputBytePos }, 143 + features: [mention(handle)], 144 + }); 145 + } 146 + } 147 + 148 + const text = decoder.decode(new Uint8Array(outputBytes)); 149 + return { 150 + text, 151 + facets: facets.sort((a, b) => a.index.byteStart - b.index.byteStart), 152 + }; 153 + } 154 + 155 + interface PairInfo { 156 + isOpen: boolean; 157 + partner?: PairInfo; 158 + openToken?: Token; 159 + outputByteStart?: number; 160 + } 161 + 162 + function findPairs( 163 + tokens: Token[], 164 + markerType: "BOLD_MARKER" | "ITALIC_MARKER" | "CODE_MARKER", 165 + ): Map<Token, PairInfo> { 166 + const pairs = new Map<Token, PairInfo>(); 167 + const stack: { token: Token; info: PairInfo }[] = []; 168 + 169 + for (const token of tokens) { 170 + if (token.type === markerType) { 171 + const open = stack.pop(); 172 + if (open) { 173 + const closeInfo: PairInfo = { 174 + isOpen: false, 175 + partner: open.info, 176 + openToken: open.token, 177 + }; 178 + open.info.partner = closeInfo; 179 + pairs.set(token, closeInfo); 180 + } else { 181 + const openInfo: PairInfo = { isOpen: true, openToken: token }; 182 + stack.push({ token, info: openInfo }); 183 + pairs.set(token, openInfo); 184 + } 185 + } 186 + } 187 + 188 + for (const { token, info } of stack) { 189 + if (!info.partner) { 190 + pairs.delete(token); 191 + } 192 + } 193 + 194 + return pairs; 195 + }
+181
src/lib/richtext/renderer.tsx
··· 1 + import { memo, type ReactNode } from "react"; 2 + import { ByteString } from "./byte-string"; 3 + import { 4 + type Facet, 5 + type FormatFeature, 6 + isBold, 7 + isCode, 8 + isCodeBlock, 9 + isItalic, 10 + isLink, 11 + isMention, 12 + type LinkFeature, 13 + type MentionFeature, 14 + } from "./types"; 15 + 16 + interface RichTextProps { 17 + text: string; 18 + facets?: Facet[]; 19 + className?: string; 20 + } 21 + 22 + interface Segment { 23 + text: string; 24 + bold: boolean; 25 + italic: boolean; 26 + code: boolean; 27 + codeBlock: boolean; 28 + link: LinkFeature | null; 29 + mention: MentionFeature | null; 30 + } 31 + 32 + export const RichText = memo(function RichText({ 33 + text, 34 + facets, 35 + className, 36 + }: RichTextProps): ReactNode { 37 + if (!text) { 38 + return null; 39 + } 40 + 41 + const segments = segmentText(text, facets ?? []); 42 + 43 + return ( 44 + <span className={className}> 45 + {segments.map((segment, i) => renderSegment(segment, i))} 46 + </span> 47 + ); 48 + }); 49 + 50 + function renderSegment(segment: Segment, key: number): ReactNode { 51 + if (segment.codeBlock) { 52 + return ( 53 + <pre 54 + key={key} 55 + className="bg-gray-100 dark:bg-slate-800 p-2 rounded my-2 overflow-x-auto" 56 + > 57 + <code>{segment.text}</code> 58 + </pre> 59 + ); 60 + } 61 + 62 + // Plain text - no wrapper needed 63 + if ( 64 + !segment.bold && 65 + !segment.italic && 66 + !segment.code && 67 + !segment.link && 68 + !segment.mention 69 + ) { 70 + return segment.text; 71 + } 72 + 73 + let content: ReactNode = segment.text; 74 + 75 + // Wrap in formatting elements (innermost to outermost) 76 + if (segment.code) { 77 + content = ( 78 + <code className="bg-gray-100 dark:bg-slate-800 px-1 rounded font-mono text-sm"> 79 + {content} 80 + </code> 81 + ); 82 + } 83 + if (segment.italic) { 84 + content = <em>{content}</em>; 85 + } 86 + if (segment.bold) { 87 + content = <strong>{content}</strong>; 88 + } 89 + 90 + // Links and mentions wrap the formatted content 91 + if (segment.link) { 92 + return ( 93 + <a 94 + key={key} 95 + href={segment.link.uri} 96 + className="text-blue-600 dark:text-blue-400 hover:underline" 97 + target="_blank" 98 + rel="noopener noreferrer" 99 + > 100 + {content} 101 + </a> 102 + ); 103 + } 104 + 105 + if (segment.mention) { 106 + return ( 107 + <span 108 + key={key} 109 + className="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer" 110 + data-did={segment.mention.did} 111 + > 112 + {content} 113 + </span> 114 + ); 115 + } 116 + 117 + // Non-link/mention formatted content needs a keyed wrapper 118 + return <span key={key}>{content}</span>; 119 + } 120 + 121 + function collectFeatures( 122 + start: number, 123 + end: number, 124 + facets: Facet[], 125 + ): FormatFeature[] { 126 + return facets.flatMap((facet) => { 127 + const { byteStart, byteEnd } = facet.index; 128 + if (start >= byteStart && end <= byteEnd) { 129 + return facet.features; 130 + } 131 + return []; 132 + }); 133 + } 134 + 135 + function buildSegment(text: string, features: FormatFeature[]): Segment { 136 + return { 137 + text, 138 + bold: features.some(isBold), 139 + italic: features.some(isItalic), 140 + code: features.some(isCode), 141 + codeBlock: features.some(isCodeBlock), 142 + link: features.find(isLink) ?? null, 143 + mention: features.find(isMention) ?? null, 144 + }; 145 + } 146 + 147 + function segmentText(text: string, facets: Facet[]): Segment[] { 148 + if (facets.length === 0) { 149 + return [buildSegment(text, [])]; 150 + } 151 + 152 + const bs = new ByteString(text); 153 + 154 + const validFacets = facets.filter((f) => { 155 + const { byteStart, byteEnd } = f.index; 156 + return ( 157 + byteStart >= 0 && 158 + byteEnd > byteStart && 159 + byteEnd <= bs.length && 160 + f.features.length > 0 161 + ); 162 + }); 163 + 164 + const boundaries = new Set([0, bs.length]); 165 + for (const facet of validFacets) { 166 + boundaries.add(facet.index.byteStart); 167 + boundaries.add(facet.index.byteEnd); 168 + } 169 + 170 + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 171 + 172 + return sortedBoundaries 173 + .slice(0, -1) 174 + .map((start, i) => { 175 + const end = sortedBoundaries[i + 1]; 176 + const segText = bs.sliceByBytes(start, end); 177 + const features = collectFeatures(start, end, validFacets); 178 + return buildSegment(segText, features); 179 + }) 180 + .filter((seg) => seg.text.length > 0); 181 + }
+185
src/lib/richtext/serializer.ts
··· 1 + import { ByteString } from "./byte-string"; 2 + import { 3 + type Facet, 4 + isBold, 5 + isCode, 6 + isCodeBlock, 7 + isItalic, 8 + isLink, 9 + isMention, 10 + } from "./types"; 11 + 12 + type BoundaryType = "bold" | "italic" | "code"; 13 + 14 + interface Boundary { 15 + bytePos: number; 16 + type: BoundaryType; 17 + isOpen: boolean; 18 + } 19 + 20 + interface Replacement { 21 + byteStart: number; 22 + byteEnd: number; 23 + output: string; 24 + } 25 + 26 + export function serializeToMarkdown(text: string, facets: Facet[]): string { 27 + if (facets.length === 0) { 28 + return text; 29 + } 30 + 31 + const bs = new ByteString(text); 32 + const boundaries: Boundary[] = []; 33 + const replacements: Replacement[] = []; 34 + 35 + for (const facet of facets) { 36 + const { byteStart, byteEnd } = facet.index; 37 + 38 + // Validate facet bounds 39 + if ( 40 + byteStart < 0 || 41 + byteEnd < 0 || 42 + byteStart >= byteEnd || 43 + byteEnd > bs.length 44 + ) { 45 + continue; 46 + } 47 + 48 + for (const feature of facet.features) { 49 + if (isBold(feature)) { 50 + boundaries.push({ bytePos: byteStart, type: "bold", isOpen: true }); 51 + boundaries.push({ bytePos: byteEnd, type: "bold", isOpen: false }); 52 + } else if (isItalic(feature)) { 53 + boundaries.push({ bytePos: byteStart, type: "italic", isOpen: true }); 54 + boundaries.push({ bytePos: byteEnd, type: "italic", isOpen: false }); 55 + } else if (isCode(feature)) { 56 + boundaries.push({ bytePos: byteStart, type: "code", isOpen: true }); 57 + boundaries.push({ bytePos: byteEnd, type: "code", isOpen: false }); 58 + } else if (isCodeBlock(feature)) { 59 + const content = bs.sliceByBytes(byteStart, byteEnd); 60 + replacements.push({ 61 + byteStart, 62 + byteEnd, 63 + output: `\`\`\`\n${content}\n\`\`\``, 64 + }); 65 + } else if (isLink(feature)) { 66 + const linkText = bs.sliceByBytes(byteStart, byteEnd); 67 + replacements.push({ 68 + byteStart, 69 + byteEnd, 70 + output: `[${linkText}](${feature.uri})`, 71 + }); 72 + } else if (isMention(feature)) { 73 + // Mentions keep @handle in text, so just output as-is 74 + // (the text already includes @handle) 75 + } 76 + } 77 + } 78 + 79 + // If we have replacements, handle them separately 80 + // (they replace entire ranges rather than wrapping) 81 + if (replacements.length > 0) { 82 + return serializeWithReplacements(bs, boundaries, replacements); 83 + } 84 + 85 + // Sort boundaries by position, closes before opens at same position 86 + boundaries.sort((a, b) => { 87 + if (a.bytePos !== b.bytePos) { 88 + return a.bytePos - b.bytePos; 89 + } 90 + if (a.isOpen !== b.isOpen) { 91 + return a.isOpen ? 1 : -1; 92 + } 93 + return 0; 94 + }); 95 + 96 + const parts: string[] = []; 97 + let lastPos = 0; 98 + 99 + for (const boundary of boundaries) { 100 + if (boundary.bytePos > lastPos) { 101 + parts.push(bs.sliceByBytes(lastPos, boundary.bytePos)); 102 + } 103 + 104 + const marker = getMarker(boundary.type); 105 + parts.push(marker); 106 + 107 + lastPos = boundary.bytePos; 108 + } 109 + 110 + if (lastPos < bs.length) { 111 + parts.push(bs.sliceByBytes(lastPos, bs.length)); 112 + } 113 + 114 + return parts.join(""); 115 + } 116 + 117 + function getMarker(type: BoundaryType): string { 118 + switch (type) { 119 + case "bold": 120 + return "**"; 121 + case "italic": 122 + return "*"; 123 + case "code": 124 + return "`"; 125 + } 126 + } 127 + 128 + function serializeWithReplacements( 129 + bs: ByteString, 130 + boundaries: Boundary[], 131 + replacements: Replacement[], 132 + ): string { 133 + // Combine boundaries and replacements into a unified event stream 134 + type Event = 135 + | { type: "boundary"; pos: number; boundary: Boundary } 136 + | { type: "replacement"; pos: number; replacement: Replacement }; 137 + 138 + const events: Event[] = []; 139 + 140 + for (const b of boundaries) { 141 + events.push({ type: "boundary", pos: b.bytePos, boundary: b }); 142 + } 143 + 144 + for (const r of replacements) { 145 + events.push({ type: "replacement", pos: r.byteStart, replacement: r }); 146 + } 147 + 148 + // Sort events by position 149 + events.sort((a, b) => { 150 + if (a.pos !== b.pos) return a.pos - b.pos; 151 + // Replacements come after boundaries at same position 152 + if (a.type !== b.type) return a.type === "boundary" ? -1 : 1; 153 + if (a.type === "boundary" && b.type === "boundary") { 154 + return a.boundary.isOpen ? 1 : -1; 155 + } 156 + return 0; 157 + }); 158 + 159 + const parts: string[] = []; 160 + let lastPos = 0; 161 + 162 + for (const event of events) { 163 + if (event.type === "boundary") { 164 + if (event.pos > lastPos) { 165 + parts.push(bs.sliceByBytes(lastPos, event.pos)); 166 + lastPos = event.pos; 167 + } 168 + parts.push(getMarker(event.boundary.type)); 169 + } else { 170 + // Replacement 171 + const r = event.replacement; 172 + if (r.byteStart > lastPos) { 173 + parts.push(bs.sliceByBytes(lastPos, r.byteStart)); 174 + } 175 + parts.push(r.output); 176 + lastPos = r.byteEnd; 177 + } 178 + } 179 + 180 + if (lastPos < bs.length) { 181 + parts.push(bs.sliceByBytes(lastPos, bs.length)); 182 + } 183 + 184 + return parts.join(""); 185 + }
+134
src/lib/richtext/types.ts
··· 1 + import type { Main as LexiconRichText } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 2 + import type { 3 + ByteSlice as LexiconByteSlice, 4 + Link as LexiconLink, 5 + Mention as LexiconMention, 6 + } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 7 + 8 + // ByteSlice matches lexicon exactly 9 + export interface ByteSlice { 10 + byteStart: number; 11 + byteEnd: number; 12 + } 13 + 14 + // Feature types with required $type for our internal use 15 + export interface BoldFeature { 16 + $type: "com.deckbelcher.richtext.facet#bold"; 17 + } 18 + 19 + export interface ItalicFeature { 20 + $type: "com.deckbelcher.richtext.facet#italic"; 21 + } 22 + 23 + export interface CodeFeature { 24 + $type: "com.deckbelcher.richtext.facet#code"; 25 + } 26 + 27 + export interface CodeBlockFeature { 28 + $type: "com.deckbelcher.richtext.facet#codeBlock"; 29 + } 30 + 31 + export interface LinkFeature { 32 + $type: "com.deckbelcher.richtext.facet#link"; 33 + uri: LexiconLink["uri"]; 34 + } 35 + 36 + export interface MentionFeature { 37 + $type: "com.deckbelcher.richtext.facet#mention"; 38 + did: LexiconMention["did"]; 39 + } 40 + 41 + export interface TagFeature { 42 + $type: "com.deckbelcher.richtext.facet#tag"; 43 + tag: string; 44 + } 45 + 46 + export type FormatFeature = 47 + | BoldFeature 48 + | ItalicFeature 49 + | CodeFeature 50 + | CodeBlockFeature 51 + | LinkFeature 52 + | MentionFeature 53 + | TagFeature; 54 + 55 + export interface Facet { 56 + index: ByteSlice; 57 + features: FormatFeature[]; 58 + } 59 + 60 + export interface ParseResult { 61 + text: string; 62 + facets: Facet[]; 63 + } 64 + 65 + // Type alias for lexicon compatibility 66 + export type RichText = LexiconRichText; 67 + export type { LexiconByteSlice }; 68 + 69 + export function isBold(feature: FormatFeature): feature is BoldFeature { 70 + return feature.$type === "com.deckbelcher.richtext.facet#bold"; 71 + } 72 + 73 + export function isItalic(feature: FormatFeature): feature is ItalicFeature { 74 + return feature.$type === "com.deckbelcher.richtext.facet#italic"; 75 + } 76 + 77 + export function isCode(feature: FormatFeature): feature is CodeFeature { 78 + return feature.$type === "com.deckbelcher.richtext.facet#code"; 79 + } 80 + 81 + export function isCodeBlock( 82 + feature: FormatFeature, 83 + ): feature is CodeBlockFeature { 84 + return feature.$type === "com.deckbelcher.richtext.facet#codeBlock"; 85 + } 86 + 87 + export function isLink(feature: FormatFeature): feature is LinkFeature { 88 + return feature.$type === "com.deckbelcher.richtext.facet#link"; 89 + } 90 + 91 + export function isMention(feature: FormatFeature): feature is MentionFeature { 92 + return feature.$type === "com.deckbelcher.richtext.facet#mention"; 93 + } 94 + 95 + export function isTag(feature: FormatFeature): feature is TagFeature { 96 + return feature.$type === "com.deckbelcher.richtext.facet#tag"; 97 + } 98 + 99 + export const BOLD: BoldFeature = { 100 + $type: "com.deckbelcher.richtext.facet#bold", 101 + }; 102 + 103 + export const ITALIC: ItalicFeature = { 104 + $type: "com.deckbelcher.richtext.facet#italic", 105 + }; 106 + 107 + export const CODE: CodeFeature = { 108 + $type: "com.deckbelcher.richtext.facet#code", 109 + }; 110 + 111 + export const CODE_BLOCK: CodeBlockFeature = { 112 + $type: "com.deckbelcher.richtext.facet#codeBlock", 113 + }; 114 + 115 + export function link(uri: string): LinkFeature { 116 + return { 117 + $type: "com.deckbelcher.richtext.facet#link", 118 + uri: uri as LinkFeature["uri"], 119 + }; 120 + } 121 + 122 + export function mention(did: string): MentionFeature { 123 + return { 124 + $type: "com.deckbelcher.richtext.facet#mention", 125 + did: did as MentionFeature["did"], 126 + }; 127 + } 128 + 129 + export function tag(value: string): TagFeature { 130 + return { 131 + $type: "com.deckbelcher.richtext.facet#tag", 132 + tag: value, 133 + }; 134 + }
+45 -3
src/lib/scryfall-types.ts
··· 104 104 | "reversible_card" 105 105 | string; 106 106 107 + export type SetType = 108 + | "alchemy" 109 + | "archenemy" 110 + | "arsenal" 111 + | "box" 112 + | "commander" 113 + | "core" 114 + | "draft_innovation" 115 + | "duel_deck" 116 + | "eternal" 117 + | "expansion" 118 + | "from_the_vault" 119 + | "funny" 120 + | "masterpiece" 121 + | "masters" 122 + | "memorabilia" 123 + | "minigame" 124 + | "planechase" 125 + | "premium_deck" 126 + | "promo" 127 + | "spellbook" 128 + | "starter" 129 + | "token" 130 + | "treasure_chest" 131 + | "vanguard" 132 + | string; 133 + 107 134 export type ImageStatus = 108 135 | "missing" 109 136 | "placeholder" ··· 118 145 export type Legality = "legal" | "not_legal" | "restricted" | "banned" | string; 119 146 120 147 export type ManaColor = "W" | "U" | "B" | "R" | "G"; 148 + export type ManaColorWithColorless = ManaColor | "C"; 121 149 122 150 export type ImageSize = 123 151 | "small" ··· 166 194 toughness?: string; 167 195 loyalty?: string; 168 196 defense?: string; 197 + produced_mana?: string[]; 169 198 170 199 // Legalities & formats 171 200 legalities?: Record<string, Legality>; ··· 174 203 175 204 // Search & filtering 176 205 set?: string; 206 + set_type?: SetType; 177 207 set_name?: string; 178 208 collector_number?: string; 179 209 rarity?: Rarity; 180 210 released_at?: string; 181 - prices?: Record<string, string | null>; 182 211 artist?: string; 183 212 184 213 // Printing selection (image_uris omitted - can reconstruct from ID) ··· 196 225 layout?: Layout; 197 226 198 227 // Nice-to-have 199 - edhrec_rank?: number; 200 228 reprint?: boolean; 201 229 variation?: boolean; 202 230 lang?: string; ··· 207 235 version: string; 208 236 cardCount: number; 209 237 cards: Record<ScryfallId, Card>; 238 + // Sorted by canonical order - first element is the canonical printing 210 239 oracleIdToPrintings: Record<OracleId, ScryfallId[]>; 211 - canonicalPrintingByOracleId: Record<OracleId, ScryfallId>; 240 + } 241 + 242 + /** 243 + * Volatile data for a card (prices, EDHREC rank) 244 + * Stored separately in volatile.bin to avoid cache busting card data 245 + */ 246 + export interface VolatileData { 247 + edhrecRank: number | null; 248 + usd: number | null; 249 + usdFoil: number | null; 250 + usdEtched: number | null; 251 + eur: number | null; 252 + eurFoil: number | null; 253 + tix: number | null; 212 254 }
+4 -1
src/lib/scryfall-utils.ts
··· 1 1 import type { ImageSize, ScryfallId } from "./scryfall-types"; 2 2 3 + export type CardFaceType = "front" | "back"; 4 + 3 5 /** 4 6 * Reconstruct Scryfall image URI from card ID 5 7 * ··· 11 13 export function getImageUri( 12 14 scryfallId: ScryfallId, 13 15 size: ImageSize = "normal", 16 + face: CardFaceType = "front", 14 17 ): string { 15 - return `https://cards.scryfall.io/${size}/front/${scryfallId[0]}/${scryfallId[1]}/${scryfallId}.jpg`; 18 + return `https://cards.scryfall.io/${size}/${face}/${scryfallId[0]}/${scryfallId[1]}/${scryfallId}.jpg`; 16 19 }
+269
src/lib/search/__tests__/ambiguous.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "../../__tests__/test-card-lookup"; 6 + import { search } from "../index"; 7 + import { tokenize } from "../lexer"; 8 + 9 + describe("Ambiguous query parsing", () => { 10 + let cards: TestCardLookup; 11 + 12 + beforeAll(async () => { 13 + cards = await setupTestCards(); 14 + }, 30_000); 15 + 16 + describe("nested field-like patterns", () => { 17 + it("o:o:flash fails - use quotes for literal colons", async () => { 18 + // Lexer splits on :, so this becomes o, :, o, :, flash - invalid structure 19 + // Scryfall parses it but finds no matches 20 + // We fail to parse - use o:"o:flash" instead 21 + const result = search("o:o:flash"); 22 + expect(result.ok).toBe(false); 23 + 24 + // Quoted version works 25 + const quoted = search('o:"o:flash"'); 26 + expect(quoted.ok).toBe(true); 27 + }); 28 + 29 + it('o:"t:creature" searches for literal "t:creature" in oracle text', async () => { 30 + const result = search('o:"t:creature"'); 31 + expect(result.ok).toBe(true); 32 + }); 33 + }); 34 + 35 + describe("empty and missing values", () => { 36 + it("t: with no value fails - Scryfall allows this, we don't", () => { 37 + // Scryfall treats t: as "has any type", we require a value 38 + const result = search("t:"); 39 + expect(result.ok).toBe(false); 40 + }); 41 + 42 + it('empty quoted string o:"" should parse', () => { 43 + const result = search('o:""'); 44 + expect(result.ok).toBe(true); 45 + }); 46 + }); 47 + 48 + describe("double negation", () => { 49 + it("--t:creature is double negative", async () => { 50 + const result = search("--t:creature"); 51 + expect(result.ok).toBe(true); 52 + 53 + const elves = await cards.get("Llanowar Elves"); 54 + const bolt = await cards.get("Lightning Bolt"); 55 + 56 + if (result.ok) { 57 + // Double negative should mean "is creature" 58 + expect(result.value.match(elves)).toBe(true); 59 + expect(result.value.match(bolt)).toBe(false); 60 + } 61 + }); 62 + 63 + it("-(t:creature) negates grouped expression", async () => { 64 + const result = search("-(t:creature)"); 65 + expect(result.ok).toBe(true); 66 + 67 + const elves = await cards.get("Llanowar Elves"); 68 + const bolt = await cards.get("Lightning Bolt"); 69 + 70 + if (result.ok) { 71 + expect(result.value.match(elves)).toBe(false); 72 + expect(result.value.match(bolt)).toBe(true); 73 + } 74 + }); 75 + }); 76 + 77 + describe("negative numbers", () => { 78 + it("cmc>=-1 should match all cards", async () => { 79 + const result = search("cmc>=-1"); 80 + expect(result.ok).toBe(true); 81 + 82 + const bolt = await cards.get("Lightning Bolt"); 83 + if (result.ok) { 84 + expect(result.value.match(bolt)).toBe(true); 85 + } 86 + }); 87 + 88 + it("cmc<0 should match nothing (no negative CMC cards)", async () => { 89 + const result = search("cmc<0"); 90 + expect(result.ok).toBe(true); 91 + 92 + const bolt = await cards.get("Lightning Bolt"); 93 + if (result.ok) { 94 + expect(result.value.match(bolt)).toBe(false); 95 + } 96 + }); 97 + 98 + it("pow>-5 matches creatures with power > -5", async () => { 99 + const result = search("pow>-5"); 100 + expect(result.ok).toBe(true); 101 + 102 + const elves = await cards.get("Llanowar Elves"); 103 + if (result.ok) { 104 + expect(result.value.match(elves)).toBe(true); 105 + } 106 + }); 107 + }); 108 + 109 + describe("operator spacing", () => { 110 + it("cmc>=3 parses correctly", () => { 111 + const result = search("cmc>=3"); 112 + expect(result.ok).toBe(true); 113 + if (result.ok) { 114 + expect(result.value.ast.type).toBe("FIELD"); 115 + } 116 + }); 117 + 118 + it("cmc> =3 fails - space breaks the operator", () => { 119 + // Space between > and = means > is operator, then =3 is the value 120 + // This fails since =3 isn't a valid value 121 + const result = search("cmc> =3"); 122 + expect(result.ok).toBe(false); 123 + }); 124 + }); 125 + 126 + describe("case sensitivity", () => { 127 + it("OR keyword is case-insensitive", async () => { 128 + const lower = search("t:creature or t:instant"); 129 + const upper = search("t:creature OR t:instant"); 130 + const mixed = search("t:creature Or t:instant"); 131 + 132 + expect(lower.ok).toBe(true); 133 + expect(upper.ok).toBe(true); 134 + expect(mixed.ok).toBe(true); 135 + 136 + if (lower.ok && upper.ok && mixed.ok) { 137 + expect(lower.value.ast.type).toBe("OR"); 138 + expect(upper.value.ast.type).toBe("OR"); 139 + expect(mixed.value.ast.type).toBe("OR"); 140 + } 141 + }); 142 + 143 + it("field names are case-insensitive", async () => { 144 + const lower = search("t:creature"); 145 + const upper = search("T:creature"); 146 + const mixed = search("Type:creature"); 147 + 148 + expect(lower.ok).toBe(true); 149 + expect(upper.ok).toBe(true); 150 + expect(mixed.ok).toBe(true); 151 + }); 152 + }); 153 + 154 + describe("parentheses edge cases", () => { 155 + it("empty parens () should fail or be handled", () => { 156 + const result = search("()"); 157 + // Empty group is probably an error 158 + expect(result.ok).toBe(false); 159 + }); 160 + 161 + it("deeply nested parens work", async () => { 162 + const result = search("(((t:creature)))"); 163 + expect(result.ok).toBe(true); 164 + 165 + const elves = await cards.get("Llanowar Elves"); 166 + if (result.ok) { 167 + expect(result.value.match(elves)).toBe(true); 168 + } 169 + }); 170 + 171 + it("unmatched open paren fails", () => { 172 + const result = search("(t:creature"); 173 + expect(result.ok).toBe(false); 174 + }); 175 + 176 + it("unmatched close paren fails", () => { 177 + const result = search("t:creature)"); 178 + expect(result.ok).toBe(false); 179 + }); 180 + }); 181 + 182 + describe("trailing/leading operators", () => { 183 + it("trailing OR should fail or be handled", () => { 184 + const result = search("t:creature or"); 185 + // Trailing OR with no right operand 186 + expect(result.ok).toBe(false); 187 + }); 188 + 189 + it("leading OR fails - OR is keyword, not word", () => { 190 + // "or" is always the OR keyword, can't search for card named "or" 191 + const result = search("or t:creature"); 192 + expect(result.ok).toBe(false); 193 + }); 194 + }); 195 + 196 + describe("regex edge cases", () => { 197 + it("regex with escaped slash works", async () => { 198 + // Looking for "1/1" in oracle text 199 + const result = search("o:/1\\/1/"); 200 + expect(result.ok).toBe(true); 201 + }); 202 + 203 + it("empty regex // is valid (matches everything)", () => { 204 + // Empty regex pattern matches any string 205 + const result = search("//"); 206 + expect(result.ok).toBe(true); 207 + }); 208 + 209 + it("regex with special chars needs escaping", async () => { 210 + // Looking for "(this" literally - parens need escaping in regex 211 + const result = search("o:/\\(this/"); 212 + expect(result.ok).toBe(true); 213 + }); 214 + }); 215 + 216 + describe("split card names", () => { 217 + it("exact match with // in name", async () => { 218 + // Fire // Ice style names 219 + const result = search('!"Fire // Ice"'); 220 + expect(result.ok).toBe(true); 221 + }); 222 + }); 223 + 224 + describe("whitespace handling", () => { 225 + it("multiple spaces between terms", async () => { 226 + const result = search("t:creature c:g"); 227 + expect(result.ok).toBe(true); 228 + 229 + const elves = await cards.get("Llanowar Elves"); 230 + if (result.ok) { 231 + expect(result.value.match(elves)).toBe(true); 232 + } 233 + }); 234 + 235 + it("leading/trailing whitespace", async () => { 236 + const result = search(" t:creature "); 237 + expect(result.ok).toBe(true); 238 + }); 239 + 240 + it("tabs work like spaces", async () => { 241 + const result = search("t:creature\tc:g"); 242 + expect(result.ok).toBe(true); 243 + }); 244 + }); 245 + 246 + describe("lexer token inspection", () => { 247 + it("o:o:flash tokenizes as WORD COLON WORD", () => { 248 + const result = tokenize("o:o:flash"); 249 + expect(result.ok).toBe(true); 250 + if (result.ok) { 251 + const types = result.value.map((t) => t.type); 252 + // Should be: WORD("o") COLON WORD("o") COLON WORD("flash") EOF 253 + // or: WORD("o") COLON WORD("o:flash") EOF depending on lexer 254 + expect(types).toContain("WORD"); 255 + expect(types).toContain("COLON"); 256 + } 257 + }); 258 + 259 + it("negative number tokenizes correctly", () => { 260 + const result = tokenize("cmc>=-1"); 261 + expect(result.ok).toBe(true); 262 + if (result.ok) { 263 + const values = result.value.map((t) => `${t.type}:${t.value}`); 264 + // Should have GTE and then -1 as part of the value 265 + expect(values.some((v) => v.includes("GTE"))).toBe(true); 266 + } 267 + }); 268 + }); 269 + });
+213
src/lib/search/__tests__/colors.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + compareColors, 4 + isStrictSubset, 5 + isStrictSuperset, 6 + isSubset, 7 + isSuperset, 8 + parseColors, 9 + setsEqual, 10 + } from "../colors"; 11 + 12 + describe("set operations", () => { 13 + describe("isSubset", () => { 14 + it("empty set is subset of everything", () => { 15 + expect(isSubset(new Set(), new Set(["A", "B"]))).toBe(true); 16 + }); 17 + 18 + it("set is subset of itself", () => { 19 + expect(isSubset(new Set(["A", "B"]), new Set(["A", "B"]))).toBe(true); 20 + }); 21 + 22 + it("smaller set is subset of larger", () => { 23 + expect(isSubset(new Set(["A"]), new Set(["A", "B"]))).toBe(true); 24 + }); 25 + 26 + it("larger set is not subset of smaller", () => { 27 + expect(isSubset(new Set(["A", "B"]), new Set(["A"]))).toBe(false); 28 + }); 29 + 30 + it("disjoint sets are not subsets", () => { 31 + expect(isSubset(new Set(["A"]), new Set(["B"]))).toBe(false); 32 + }); 33 + }); 34 + 35 + describe("isSuperset", () => { 36 + it("everything is superset of empty set", () => { 37 + expect(isSuperset(new Set(["A", "B"]), new Set())).toBe(true); 38 + }); 39 + 40 + it("set is superset of itself", () => { 41 + expect(isSuperset(new Set(["A", "B"]), new Set(["A", "B"]))).toBe(true); 42 + }); 43 + 44 + it("larger set is superset of smaller", () => { 45 + expect(isSuperset(new Set(["A", "B"]), new Set(["A"]))).toBe(true); 46 + }); 47 + }); 48 + 49 + describe("setsEqual", () => { 50 + it("empty sets are equal", () => { 51 + expect(setsEqual(new Set(), new Set())).toBe(true); 52 + }); 53 + 54 + it("same elements are equal", () => { 55 + expect(setsEqual(new Set(["A", "B"]), new Set(["B", "A"]))).toBe(true); 56 + }); 57 + 58 + it("different sizes are not equal", () => { 59 + expect(setsEqual(new Set(["A"]), new Set(["A", "B"]))).toBe(false); 60 + }); 61 + }); 62 + 63 + describe("isStrictSubset", () => { 64 + it("set is not strict subset of itself", () => { 65 + expect(isStrictSubset(new Set(["A"]), new Set(["A"]))).toBe(false); 66 + }); 67 + 68 + it("smaller set is strict subset", () => { 69 + expect(isStrictSubset(new Set(["A"]), new Set(["A", "B"]))).toBe(true); 70 + }); 71 + }); 72 + 73 + describe("isStrictSuperset", () => { 74 + it("set is not strict superset of itself", () => { 75 + expect(isStrictSuperset(new Set(["A"]), new Set(["A"]))).toBe(false); 76 + }); 77 + 78 + it("larger set is strict superset", () => { 79 + expect(isStrictSuperset(new Set(["A", "B"]), new Set(["A"]))).toBe(true); 80 + }); 81 + }); 82 + }); 83 + 84 + describe("parseColors", () => { 85 + it("parses single letters", () => { 86 + expect(parseColors("W")).toEqual(new Set(["W"])); 87 + expect(parseColors("u")).toEqual(new Set(["U"])); 88 + expect(parseColors("C")).toEqual(new Set(["C"])); 89 + }); 90 + 91 + it("parses combined colors", () => { 92 + expect(parseColors("wubrg")).toEqual(new Set(["W", "U", "B", "R", "G"])); 93 + expect(parseColors("bg")).toEqual(new Set(["B", "G"])); 94 + expect(parseColors("UR")).toEqual(new Set(["U", "R"])); 95 + }); 96 + 97 + it("parses full names", () => { 98 + expect(parseColors("white")).toEqual(new Set(["W"])); 99 + expect(parseColors("blue")).toEqual(new Set(["U"])); 100 + expect(parseColors("black")).toEqual(new Set(["B"])); 101 + expect(parseColors("red")).toEqual(new Set(["R"])); 102 + expect(parseColors("green")).toEqual(new Set(["G"])); 103 + expect(parseColors("colorless")).toEqual(new Set(["C"])); 104 + }); 105 + 106 + it("ignores invalid characters", () => { 107 + expect(parseColors("wx")).toEqual(new Set(["W"])); 108 + expect(parseColors("123")).toEqual(new Set()); 109 + }); 110 + }); 111 + 112 + describe("compareColors", () => { 113 + describe("commander deckbuilding (id<=)", () => { 114 + it("colorless card fits in any deck", () => { 115 + expect(compareColors([], new Set(["B", "G"]), "<=")).toBe(true); 116 + }); 117 + 118 + it("mono-color fits in matching deck", () => { 119 + expect(compareColors(["G"], new Set(["B", "G"]), "<=")).toBe(true); 120 + }); 121 + 122 + it("exact match fits", () => { 123 + expect(compareColors(["B", "G"], new Set(["B", "G"]), "<=")).toBe(true); 124 + }); 125 + 126 + it("off-color doesn't fit", () => { 127 + expect(compareColors(["R"], new Set(["B", "G"]), "<=")).toBe(false); 128 + }); 129 + 130 + it("more colors doesn't fit", () => { 131 + expect(compareColors(["B", "G", "U"], new Set(["B", "G"]), "<=")).toBe( 132 + false, 133 + ); 134 + }); 135 + }); 136 + 137 + describe("superset (: and >=)", () => { 138 + it(": matches superset", () => { 139 + expect(compareColors(["U", "R", "G"], new Set(["U", "R"]), ":")).toBe( 140 + true, 141 + ); 142 + }); 143 + 144 + it(">= matches superset", () => { 145 + expect(compareColors(["U", "R", "G"], new Set(["U", "R"]), ">=")).toBe( 146 + true, 147 + ); 148 + }); 149 + 150 + it("exact match is superset", () => { 151 + expect(compareColors(["U", "R"], new Set(["U", "R"]), ":")).toBe(true); 152 + }); 153 + 154 + it("subset doesn't match superset", () => { 155 + expect(compareColors(["U"], new Set(["U", "R"]), ":")).toBe(false); 156 + }); 157 + }); 158 + 159 + describe("exact match (=)", () => { 160 + it("matches exact colors", () => { 161 + expect(compareColors(["U", "R"], new Set(["U", "R"]), "=")).toBe(true); 162 + }); 163 + 164 + it("doesn't match with extra color", () => { 165 + expect(compareColors(["U", "R", "G"], new Set(["U", "R"]), "=")).toBe( 166 + false, 167 + ); 168 + }); 169 + 170 + it("doesn't match with missing color", () => { 171 + expect(compareColors(["U"], new Set(["U", "R"]), "=")).toBe(false); 172 + }); 173 + }); 174 + 175 + describe("not equal (!=)", () => { 176 + it("matches different colors", () => { 177 + expect(compareColors(["U"], new Set(["U", "R"]), "!=")).toBe(true); 178 + }); 179 + 180 + it("doesn't match exact colors", () => { 181 + expect(compareColors(["U", "R"], new Set(["U", "R"]), "!=")).toBe(false); 182 + }); 183 + }); 184 + 185 + describe("strict subset (<)", () => { 186 + it("matches strict subset", () => { 187 + expect(compareColors(["U"], new Set(["U", "R"]), "<")).toBe(true); 188 + }); 189 + 190 + it("doesn't match equal sets", () => { 191 + expect(compareColors(["U", "R"], new Set(["U", "R"]), "<")).toBe(false); 192 + }); 193 + }); 194 + 195 + describe("strict superset (>)", () => { 196 + it("matches strict superset", () => { 197 + expect(compareColors(["U", "R", "G"], new Set(["U", "R"]), ">")).toBe( 198 + true, 199 + ); 200 + }); 201 + 202 + it("doesn't match equal sets", () => { 203 + expect(compareColors(["U", "R"], new Set(["U", "R"]), ">")).toBe(false); 204 + }); 205 + }); 206 + 207 + describe("undefined card colors", () => { 208 + it("treats undefined as empty", () => { 209 + expect(compareColors(undefined, new Set(["U"]), "<=")).toBe(true); 210 + expect(compareColors(undefined, new Set(), "=")).toBe(true); 211 + }); 212 + }); 213 + });
+223
src/lib/search/__tests__/describe.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { describeQuery } from "../describe"; 3 + import { parse } from "../parser"; 4 + 5 + function desc(query: string): string { 6 + const result = parse(query); 7 + if (!result.ok) throw new Error(result.error.message); 8 + return describeQuery(result.value); 9 + } 10 + 11 + describe("describeQuery", () => { 12 + it.each([ 13 + ["bolt", 'name includes "bolt"'], 14 + ["Bolt", 'name includes "bolt"'], 15 + ['"lightning bolt"', 'name includes "lightning bolt"'], 16 + ["!Lightning", 'name is exactly "lightning"'], 17 + ['!"Lightning Bolt"', 'name is exactly "lightning bolt"'], 18 + // !foo bar is exact "foo" AND includes "bar" 19 + ["!Lightning Bolt", 'name is exactly "lightning" AND name includes "bolt"'], 20 + ])("name search: `%s` → %s", (query, expected) => { 21 + expect(desc(query)).toBe(expected); 22 + }); 23 + 24 + it.each([ 25 + ["t:creature", 'type includes "creature"'], 26 + ["o:flying", 'oracle text includes "flying"'], 27 + ["kw:trample", 'keyword includes "trample"'], 28 + ["a:Guay", 'artist includes "guay"'], 29 + ])("text field: `%s` → %s", (query, expected) => { 30 + expect(desc(query)).toBe(expected); 31 + }); 32 + 33 + it.each([ 34 + ["s:lea", 'set is "lea"'], 35 + ["st:core", 'set type is "core"'], 36 + ["layout:token", 'layout is "token"'], 37 + ["lang:en", 'language is "en"'], 38 + ["game:paper", 'game is "paper"'], 39 + ["f:commander", 'format is "commander"'], 40 + ])("discrete field uses 'is': `%s` → %s", (query, expected) => { 41 + expect(desc(query)).toBe(expected); 42 + }); 43 + 44 + it.each([ 45 + ["layout:/dfc/", "layout includes /dfc/"], 46 + ["s:/^m2/", "set includes /^m2/"], 47 + ])( 48 + "discrete field with regex still shows regex: `%s` → %s", 49 + (query, expected) => { 50 + expect(desc(query)).toBe(expected); 51 + }, 52 + ); 53 + 54 + it.each([ 55 + ["r:c", "rarity is common"], 56 + ["r:common", "rarity is common"], 57 + ["r:u", "rarity is uncommon"], 58 + ["r:r", "rarity is rare"], 59 + ["r:m", "rarity is mythic"], 60 + ["r>=u", "rarity ≥ uncommon"], 61 + ["r<=r", "rarity ≤ rare"], 62 + ["r>c", "rarity > common"], 63 + ])("rarity field: `%s` → %s", (query, expected) => { 64 + expect(desc(query)).toBe(expected); 65 + }); 66 + 67 + it.each([ 68 + ["cmc=3", "mana value = 3"], 69 + ["cmc>2", "mana value > 2"], 70 + ["cmc<5", "mana value < 5"], 71 + ["cmc>=3", "mana value ≥ 3"], 72 + ["cmc<=4", "mana value ≤ 4"], 73 + ["pow>=4", "power ≥ 4"], 74 + ["tou<=2", "toughness ≤ 2"], 75 + ["loy=3", "loyalty = 3"], 76 + ])("numeric comparison: `%s` → %s", (query, expected) => { 77 + expect(desc(query)).toBe(expected); 78 + }); 79 + 80 + it.each([ 81 + ["c:r", "color includes {R}"], 82 + ["id:bg", "color identity includes {B}{G}"], 83 + ["id<=bg", "color identity is within {B}{G}"], 84 + ["id>=bg", "color identity includes at least {B}{G}"], 85 + ["id=wubrg", "color identity is exactly {W}{U}{B}{R}{G}"], 86 + ["id=c", "color identity is exactly {C}"], 87 + // Use -c:u for "not blue" since != isn't supported for colors 88 + ["-c:u", "NOT color includes {U}"], 89 + ])("color field: `%s` → %s", (query, expected) => { 90 + expect(desc(query)).toBe(expected); 91 + }); 92 + 93 + it.each([ 94 + ["id=0", "cards with exactly 0 identity colors"], 95 + ["id=1", "cards with exactly 1 identity color"], 96 + ["id=2", "cards with exactly 2 identity colors"], 97 + ["id>1", "cards with more than 1 identity color"], 98 + ["id>0", "cards with more than 0 identity colors"], 99 + ["id<3", "cards with fewer than 3 identity colors"], 100 + ["id>=2", "cards with 2 or more identity colors"], 101 + ["id<=1", "cards with 1 or fewer identity colors"], 102 + ["id!=1", "cards without exactly 1 identity color"], 103 + ])("identity count: `%s` → %s", (query, expected) => { 104 + expect(desc(query)).toBe(expected); 105 + }); 106 + 107 + it.each([ 108 + [ 109 + "fire id>=g", 110 + 'name includes "fire" AND color identity includes at least {G}', 111 + ], 112 + ["t:creature cmc<=3", 'type includes "creature" AND mana value ≤ 3'], 113 + ])("AND: `%s` → %s", (query, expected) => { 114 + expect(desc(query)).toBe(expected); 115 + }); 116 + 117 + it.each([ 118 + ["bolt OR shock", '(name includes "bolt" OR name includes "shock")'], 119 + [ 120 + "t:creature OR t:artifact", 121 + '(type includes "creature" OR type includes "artifact")', 122 + ], 123 + ])("OR: `%s` → %s", (query, expected) => { 124 + expect(desc(query)).toBe(expected); 125 + }); 126 + 127 + it.each([ 128 + ["-blue", 'NOT name includes "blue"'], 129 + ["-t:land", 'NOT type includes "land"'], 130 + [ 131 + "creature -human", 132 + 'name includes "creature" AND NOT name includes "human"', 133 + ], 134 + ])("NOT: `%s` → %s", (query, expected) => { 135 + expect(desc(query)).toBe(expected); 136 + }); 137 + 138 + it.each([ 139 + ["o:/draw/", "oracle text includes /draw/"], 140 + ["o:/draw/i", "oracle text includes /draw/"], 141 + ["o:/^Whenever/", "oracle text includes /^Whenever/"], 142 + ["o:/target.*creature/g", "oracle text includes /target.*creature/g"], 143 + ])("regex with flags: `%s` → %s", (query, expected) => { 144 + expect(desc(query)).toBe(expected); 145 + }); 146 + 147 + describe("is: predicate descriptions", () => { 148 + it.each([ 149 + // Land cycles 150 + ["is:fetchland", "fetch lands (sacrifice, pay 1 life, search)"], 151 + ["is:shockland", "shock lands (pay 2 life or enter tapped)"], 152 + ["is:dual", "original dual lands"], 153 + ["is:checkland", "check lands (enter tapped unless you control...)"], 154 + ["is:fastland", "fast lands (enter tapped unless ≤2 other lands)"], 155 + ["is:slowland", "slow lands (enter tapped unless ≥2 other lands)"], 156 + ["is:painland", "pain lands (deal 1 damage for colored mana)"], 157 + ["is:filterland", "filter lands (hybrid mana activation)"], 158 + ["is:bounceland", "bounce lands (return a land when entering)"], 159 + ["is:tangoland", "battle lands (enter tapped unless ≥2 basics)"], 160 + ["is:scryland", "scry lands (enter tapped, scry 1)"], 161 + ["is:gainland", "gain lands (enter tapped, gain 1 life)"], 162 + [ 163 + "is:canopyland", 164 + "horizon lands (pay 1 life for mana, sacrifice to draw)", 165 + ], 166 + ["is:triome", "triomes (three basic land types)"], 167 + 168 + // Archetypes 169 + ["is:vanilla", "vanilla creatures (no abilities)"], 170 + ["is:frenchvanilla", "French vanilla creatures (only keyword abilities)"], 171 + ["is:bear", "bears (2/2 creatures for 2 mana)"], 172 + ["is:modal", "modal spells (choose one or more)"], 173 + ["is:commander", "cards that can be commanders"], 174 + 175 + // Card types 176 + ["is:creature", "creatures"], 177 + ["is:instant", "instants"], 178 + ["is:legendary", "legendary cards"], 179 + ["is:permanent", "permanents"], 180 + ["is:spell", "spells (instants and sorceries)"], 181 + 182 + // Layouts 183 + ["is:mdfc", "modal double-faced cards"], 184 + ["is:saga", "sagas"], 185 + ["is:adventure", "adventure cards"], 186 + 187 + // Printing characteristics 188 + ["is:reserved", "reserved list cards"], 189 + ["is:promo", "promos"], 190 + ["is:foil", "available in foil"], 191 + 192 + // Frame effects 193 + ["is:borderless", "borderless cards"], 194 + ["is:showcase", "showcase frame cards"], 195 + ["is:retro", "retro frame cards (1993/1997)"], 196 + ])("is: predicate `%s` → %s", (query, expected) => { 197 + expect(desc(query)).toBe(expected); 198 + }); 199 + 200 + it.each([ 201 + ["not:creature", "not creatures"], 202 + ["not:legendary", "not legendary cards"], 203 + ["not:promo", "not promos"], 204 + ["not:fetchland", "not fetch lands (sacrifice, pay 1 life, search)"], 205 + ])("not: predicate `%s` → %s", (query, expected) => { 206 + expect(desc(query)).toBe(expected); 207 + }); 208 + 209 + it("unknown predicate uses fallback", () => { 210 + expect(desc("is:unknownpredicate")).toBe('"unknownpredicate" cards'); 211 + expect(desc("not:unknownpredicate")).toBe('not "unknownpredicate"'); 212 + }); 213 + 214 + it("combined with other fields", () => { 215 + expect(desc("is:fetchland c:ug")).toBe( 216 + "fetch lands (sacrifice, pay 1 life, search) AND color includes {U}{G}", 217 + ); 218 + expect(desc("t:creature is:legendary")).toBe( 219 + 'type includes "creature" AND legendary cards', 220 + ); 221 + }); 222 + }); 223 + });
+347
src/lib/search/__tests__/edge-cases.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "../../__tests__/test-card-lookup"; 6 + import { search } from "../index"; 7 + 8 + describe("Scryfall search edge cases", () => { 9 + let cards: TestCardLookup; 10 + 11 + beforeAll(async () => { 12 + cards = await setupTestCards(); 13 + }, 30_000); 14 + 15 + describe("power/toughness edge cases", () => { 16 + it("matches * power exactly", async () => { 17 + const tarmogoyf = await cards.get("Tarmogoyf"); 18 + expect(tarmogoyf.power).toBe("*"); 19 + 20 + const result = search("pow=*"); 21 + expect(result.ok).toBe(true); 22 + if (result.ok) { 23 + expect(result.value.match(tarmogoyf)).toBe(true); 24 + } 25 + }); 26 + 27 + it("matches 1+* toughness (contains *)", async () => { 28 + const tarmogoyf = await cards.get("Tarmogoyf"); 29 + expect(tarmogoyf.toughness).toBe("1+*"); 30 + 31 + const result = search("tou=*"); 32 + expect(result.ok).toBe(true); 33 + if (result.ok) { 34 + // Should match because toughness contains * 35 + expect(result.value.match(tarmogoyf)).toBe(true); 36 + } 37 + }); 38 + 39 + it("treats * as 0 for numeric comparisons", async () => { 40 + const tarmogoyf = await cards.get("Tarmogoyf"); 41 + 42 + const result = search("pow<=0"); 43 + expect(result.ok).toBe(true); 44 + if (result.ok) { 45 + // * is treated as 0 46 + expect(result.value.match(tarmogoyf)).toBe(true); 47 + } 48 + 49 + const gtZero = search("pow>0"); 50 + expect(gtZero.ok).toBe(true); 51 + if (gtZero.ok) { 52 + expect(gtZero.value.match(tarmogoyf)).toBe(false); 53 + } 54 + }); 55 + 56 + it("handles fractional power/toughness", async () => { 57 + const littleGirl = await cards.get("Little Girl"); 58 + // Scryfall stores ".5" not "0.5" 59 + expect(littleGirl.power).toBe(".5"); 60 + expect(littleGirl.toughness).toBe(".5"); 61 + 62 + // parseFloat handles ".5" correctly as 0.5 63 + const exactHalf = search("pow=0.5"); 64 + expect(exactHalf.ok).toBe(true); 65 + if (exactHalf.ok) { 66 + expect(exactHalf.value.match(littleGirl)).toBe(true); 67 + } 68 + 69 + // Comparison with decimal 70 + const ltOne = search("pow<1"); 71 + expect(ltOne.ok).toBe(true); 72 + if (ltOne.ok) { 73 + expect(ltOne.value.match(littleGirl)).toBe(true); 74 + } 75 + 76 + const gtZero = search("pow>0"); 77 + expect(gtZero.ok).toBe(true); 78 + if (gtZero.ok) { 79 + expect(gtZero.value.match(littleGirl)).toBe(true); 80 + } 81 + }); 82 + }); 83 + 84 + describe("mana value edge cases", () => { 85 + it("handles fractional CMC", async () => { 86 + const littleGirl = await cards.get("Little Girl"); 87 + expect(littleGirl.cmc).toBe(0.5); 88 + 89 + const exactHalf = search("cmc=0.5"); 90 + expect(exactHalf.ok).toBe(true); 91 + if (exactHalf.ok) { 92 + expect(exactHalf.value.match(littleGirl)).toBe(true); 93 + } 94 + 95 + const ltOne = search("cmc<1"); 96 + expect(ltOne.ok).toBe(true); 97 + if (ltOne.ok) { 98 + expect(ltOne.value.match(littleGirl)).toBe(true); 99 + } 100 + }); 101 + 102 + it("handles X spells (X doesn't add to CMC)", async () => { 103 + const fireball = await cards.get("Fireball"); 104 + expect(fireball.mana_cost).toContain("X"); 105 + 106 + // Fireball is {X}{R}, CMC = 1 107 + const cmcOne = search("cmc=1"); 108 + expect(cmcOne.ok).toBe(true); 109 + if (cmcOne.ok) { 110 + expect(cmcOne.value.match(fireball)).toBe(true); 111 + } 112 + }); 113 + }); 114 + 115 + describe("mana cost matching", () => { 116 + it("m: matches mana cost substring", async () => { 117 + const apostle = await cards.get("Apostle's Blessing"); 118 + expect(apostle.mana_cost).toBe("{1}{W/P}"); 119 + 120 + // Phyrexian mana symbol matching - braces included like Scryfall 121 + const result = search("m:{W/P}"); 122 + expect(result.ok).toBe(true); 123 + if (result.ok) { 124 + expect(result.value.match(apostle)).toBe(true); 125 + } 126 + }); 127 + 128 + it("m: matches snow mana", async () => { 129 + const astrolabe = await cards.get("Arcum's Astrolabe"); 130 + expect(astrolabe.mana_cost).toBe("{S}"); 131 + 132 + const result = search("m:S"); 133 + expect(result.ok).toBe(true); 134 + if (result.ok) { 135 + expect(result.value.match(astrolabe)).toBe(true); 136 + } 137 + }); 138 + 139 + it("m: matches X in cost", async () => { 140 + const fireball = await cards.get("Fireball"); 141 + 142 + const result = search("m:X"); 143 + expect(result.ok).toBe(true); 144 + if (result.ok) { 145 + expect(result.value.match(fireball)).toBe(true); 146 + } 147 + }); 148 + }); 149 + 150 + describe("is:snow predicate", () => { 151 + it("matches snow permanents", async () => { 152 + const snowForest = await cards.get("Snow-Covered Forest"); 153 + expect(snowForest.type_line).toContain("Snow"); 154 + 155 + const result = search("is:snow"); 156 + expect(result.ok).toBe(true); 157 + if (result.ok) { 158 + expect(result.value.match(snowForest)).toBe(true); 159 + } 160 + }); 161 + 162 + it("does not match non-snow cards", async () => { 163 + const bolt = await cards.get("Lightning Bolt"); 164 + 165 + const result = search("is:snow"); 166 + expect(result.ok).toBe(true); 167 + if (result.ok) { 168 + expect(result.value.match(bolt)).toBe(false); 169 + } 170 + }); 171 + }); 172 + 173 + describe("colorless identity", () => { 174 + it("id:c matches colorless identity cards", async () => { 175 + const ornithopter = await cards.get("Ornithopter"); 176 + expect(ornithopter.color_identity).toEqual([]); 177 + 178 + const result = search("id:c"); 179 + expect(result.ok).toBe(true); 180 + if (result.ok) { 181 + expect(result.value.match(ornithopter)).toBe(true); 182 + } 183 + }); 184 + 185 + it("id<=c matches only colorless cards", async () => { 186 + const ornithopter = await cards.get("Ornithopter"); 187 + const bolt = await cards.get("Lightning Bolt"); 188 + 189 + const result = search("id<=c"); 190 + expect(result.ok).toBe(true); 191 + if (result.ok) { 192 + expect(result.value.match(ornithopter)).toBe(true); 193 + expect(result.value.match(bolt)).toBe(false); 194 + } 195 + }); 196 + }); 197 + 198 + describe("is:historic predicate", () => { 199 + it("matches legendary permanents", async () => { 200 + const bosh = await cards.get("Bosh, Iron Golem"); 201 + expect(bosh.type_line).toContain("Legendary"); 202 + 203 + const result = search("is:historic"); 204 + expect(result.ok).toBe(true); 205 + if (result.ok) { 206 + expect(result.value.match(bosh)).toBe(true); 207 + } 208 + }); 209 + 210 + it("matches artifacts", async () => { 211 + const solRing = await cards.get("Sol Ring"); 212 + expect(solRing.type_line).toContain("Artifact"); 213 + 214 + const result = search("is:historic"); 215 + expect(result.ok).toBe(true); 216 + if (result.ok) { 217 + expect(result.value.match(solRing)).toBe(true); 218 + } 219 + }); 220 + 221 + it("does not match non-historic cards", async () => { 222 + const bolt = await cards.get("Lightning Bolt"); 223 + 224 + const result = search("is:historic"); 225 + expect(result.ok).toBe(true); 226 + if (result.ok) { 227 + expect(result.value.match(bolt)).toBe(false); 228 + } 229 + }); 230 + }); 231 + 232 + describe("complex mana and color queries", () => { 233 + it("Phyrexian mana cards are in the color's identity", async () => { 234 + const apostle = await cards.get("Apostle's Blessing"); 235 + // W/P adds W to color identity even though it can be paid with life 236 + expect(apostle.color_identity).toContain("W"); 237 + 238 + const whiteId = search("id:w"); 239 + expect(whiteId.ok).toBe(true); 240 + if (whiteId.ok) { 241 + expect(whiteId.value.match(apostle)).toBe(true); 242 + } 243 + }); 244 + }); 245 + 246 + describe("loyalty matching", () => { 247 + it("matches planeswalker loyalty", async () => { 248 + const bolas = await cards.get("Nicol Bolas, Planeswalker"); 249 + expect(bolas.loyalty).toBeDefined(); 250 + 251 + const result = search("loy>=5"); 252 + expect(result.ok).toBe(true); 253 + if (result.ok) { 254 + expect(result.value.match(bolas)).toBe(true); 255 + } 256 + }); 257 + 258 + it("non-planeswalkers have no loyalty", async () => { 259 + const bolt = await cards.get("Lightning Bolt"); 260 + 261 + const result = search("loy>0"); 262 + expect(result.ok).toBe(true); 263 + if (result.ok) { 264 + expect(result.value.match(bolt)).toBe(false); 265 + } 266 + }); 267 + }); 268 + 269 + describe("multi-face cards", () => { 270 + it("searches oracle text across faces", async () => { 271 + const delver = await cards.get("Delver of Secrets"); 272 + // Delver transforms into Insectile Aberration 273 + 274 + // Should match front face 275 + const front = search('o:"Look at the top card"'); 276 + expect(front.ok).toBe(true); 277 + if (front.ok) { 278 + expect(front.value.match(delver)).toBe(true); 279 + } 280 + }); 281 + 282 + it("is:transform matches transform cards", async () => { 283 + const delver = await cards.get("Delver of Secrets"); 284 + const bolt = await cards.get("Lightning Bolt"); 285 + 286 + const result = search("is:transform"); 287 + expect(result.ok).toBe(true); 288 + if (result.ok) { 289 + expect(result.value.match(delver)).toBe(true); 290 + expect(result.value.match(bolt)).toBe(false); 291 + } 292 + }); 293 + }); 294 + 295 + describe("regex edge cases", () => { 296 + it("regex at start of query works", async () => { 297 + const bolt = await cards.get("Lightning Bolt"); 298 + 299 + const result = search("/^lightning/i"); 300 + expect(result.ok).toBe(true); 301 + if (result.ok) { 302 + expect(result.value.match(bolt)).toBe(true); 303 + } 304 + }); 305 + 306 + it("regex after field works", async () => { 307 + const bolt = await cards.get("Lightning Bolt"); 308 + 309 + const result = search("o:/\\d+ damage/"); 310 + expect(result.ok).toBe(true); 311 + if (result.ok) { 312 + expect(result.value.match(bolt)).toBe(true); 313 + } 314 + }); 315 + 316 + it("invalid regex returns error", () => { 317 + const result = search("/[invalid/"); 318 + expect(result.ok).toBe(false); 319 + if (!result.ok) { 320 + expect(result.error.message).toContain("Invalid regex"); 321 + } 322 + }); 323 + }); 324 + 325 + describe("produces: mana production", () => { 326 + it("matches cards that produce mana", async () => { 327 + const solRing = await cards.get("Sol Ring"); 328 + expect(solRing.produced_mana).toContain("C"); 329 + 330 + const result = search("produces:c"); 331 + expect(result.ok).toBe(true); 332 + if (result.ok) { 333 + expect(result.value.match(solRing)).toBe(true); 334 + } 335 + }); 336 + 337 + it("non-mana-producers don't match", async () => { 338 + const bolt = await cards.get("Lightning Bolt"); 339 + 340 + const result = search("produces:r"); 341 + expect(result.ok).toBe(true); 342 + if (result.ok) { 343 + expect(result.value.match(bolt)).toBe(false); 344 + } 345 + }); 346 + }); 347 + });
+638
src/lib/search/__tests__/integration.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "../../__tests__/test-card-lookup"; 6 + import type { Card } from "../../scryfall-types"; 7 + import { search } from "../index"; 8 + 9 + describe("Scryfall search integration", () => { 10 + let cards: TestCardLookup; 11 + 12 + beforeAll(async () => { 13 + cards = await setupTestCards(); 14 + }, 30_000); 15 + 16 + describe("name matching", () => { 17 + it("matches bare word against name", async () => { 18 + const bolt = await cards.get("Lightning Bolt"); 19 + const result = search("bolt"); 20 + expect(result.ok).toBe(true); 21 + if (result.ok) { 22 + expect(result.value.match(bolt)).toBe(true); 23 + } 24 + }); 25 + 26 + it("matches quoted phrase", async () => { 27 + const bolt = await cards.get("Lightning Bolt"); 28 + const result = search('"Lightning Bolt"'); 29 + expect(result.ok).toBe(true); 30 + if (result.ok) { 31 + expect(result.value.match(bolt)).toBe(true); 32 + } 33 + }); 34 + 35 + it("matches exact name with !", async () => { 36 + const bolt = await cards.get("Lightning Bolt"); 37 + const result = search('!"Lightning Bolt"'); 38 + expect(result.ok).toBe(true); 39 + if (result.ok) { 40 + expect(result.value.match(bolt)).toBe(true); 41 + } 42 + 43 + // Partial shouldn't match 44 + const partial = search("!Lightning"); 45 + expect(partial.ok).toBe(true); 46 + if (partial.ok) { 47 + expect(partial.value.match(bolt)).toBe(false); 48 + } 49 + }); 50 + 51 + it("matches regex against name", async () => { 52 + const bolt = await cards.get("Lightning Bolt"); 53 + const result = search("/^lightning/i"); 54 + expect(result.ok).toBe(true); 55 + if (result.ok) { 56 + expect(result.value.match(bolt)).toBe(true); 57 + } 58 + }); 59 + }); 60 + 61 + describe("type matching", () => { 62 + it("t: matches type line", async () => { 63 + const bolt = await cards.get("Lightning Bolt"); 64 + const elves = await cards.get("Llanowar Elves"); 65 + 66 + const instant = search("t:instant"); 67 + expect(instant.ok).toBe(true); 68 + if (instant.ok) { 69 + expect(instant.value.match(bolt)).toBe(true); 70 + expect(instant.value.match(elves)).toBe(false); 71 + } 72 + 73 + const creature = search("t:creature"); 74 + expect(creature.ok).toBe(true); 75 + if (creature.ok) { 76 + expect(creature.value.match(elves)).toBe(true); 77 + expect(creature.value.match(bolt)).toBe(false); 78 + } 79 + }); 80 + 81 + it("t: matches subtypes", async () => { 82 + const elves = await cards.get("Llanowar Elves"); 83 + const result = search("t:elf"); 84 + expect(result.ok).toBe(true); 85 + if (result.ok) { 86 + expect(result.value.match(elves)).toBe(true); 87 + } 88 + }); 89 + }); 90 + 91 + describe("oracle text matching", () => { 92 + it("o: matches oracle text", async () => { 93 + const bolt = await cards.get("Lightning Bolt"); 94 + const result = search("o:damage"); 95 + expect(result.ok).toBe(true); 96 + if (result.ok) { 97 + expect(result.value.match(bolt)).toBe(true); 98 + } 99 + }); 100 + 101 + it("o: with regex", async () => { 102 + const bolt = await cards.get("Lightning Bolt"); 103 + const result = search("o:/deals? \\d+ damage/i"); 104 + expect(result.ok).toBe(true); 105 + if (result.ok) { 106 + expect(result.value.match(bolt)).toBe(true); 107 + } 108 + }); 109 + }); 110 + 111 + describe("color matching", () => { 112 + it("c: matches colors", async () => { 113 + const bolt = await cards.get("Lightning Bolt"); 114 + const result = search("c:r"); 115 + expect(result.ok).toBe(true); 116 + if (result.ok) { 117 + expect(result.value.match(bolt)).toBe(true); 118 + } 119 + }); 120 + 121 + it("c= matches exact colors", async () => { 122 + const bolt = await cards.get("Lightning Bolt"); 123 + const exact = search("c=r"); 124 + expect(exact.ok).toBe(true); 125 + if (exact.ok) { 126 + expect(exact.value.match(bolt)).toBe(true); 127 + } 128 + 129 + // Bolt shouldn't match multicolor 130 + const multi = search("c=rg"); 131 + expect(multi.ok).toBe(true); 132 + if (multi.ok) { 133 + expect(multi.value.match(bolt)).toBe(false); 134 + } 135 + }); 136 + 137 + it("c!= excludes exact color", async () => { 138 + const bolt = await cards.get("Lightning Bolt"); 139 + const elves = await cards.get("Llanowar Elves"); 140 + 141 + // Exclude mono-red 142 + const notRed = search("c!=r"); 143 + expect(notRed.ok).toBe(true); 144 + if (notRed.ok) { 145 + expect(notRed.value.match(bolt)).toBe(false); // R = R, excluded 146 + expect(notRed.value.match(elves)).toBe(true); // G != R, included 147 + } 148 + }); 149 + 150 + it("c: differs from id: (color vs color identity)", async () => { 151 + const forest = await cards.get("Forest"); 152 + 153 + // Forest is colorless (no colored mana in cost) 154 + const colorless = search("c:c"); 155 + expect(colorless.ok).toBe(true); 156 + if (colorless.ok) { 157 + expect(colorless.value.match(forest)).toBe(true); 158 + } 159 + 160 + // But Forest has green color identity (produces green mana) 161 + const greenIdentity = search("id:g"); 162 + expect(greenIdentity.ok).toBe(true); 163 + if (greenIdentity.ok) { 164 + expect(greenIdentity.value.match(forest)).toBe(true); 165 + } 166 + 167 + // Forest is NOT green by color 168 + const greenColor = search("c:g"); 169 + expect(greenColor.ok).toBe(true); 170 + if (greenColor.ok) { 171 + expect(greenColor.value.match(forest)).toBe(false); 172 + } 173 + }); 174 + }); 175 + 176 + describe("color identity matching", () => { 177 + it("id<= matches subset (commander deckbuilding)", async () => { 178 + const bolt = await cards.get("Lightning Bolt"); 179 + const elves = await cards.get("Llanowar Elves"); 180 + const forest = await cards.get("Forest"); 181 + 182 + // Gruul deck can play red and green cards 183 + const gruul = search("id<=rg"); 184 + expect(gruul.ok).toBe(true); 185 + if (gruul.ok) { 186 + expect(gruul.value.match(bolt)).toBe(true); // R fits in RG 187 + expect(gruul.value.match(elves)).toBe(true); // G fits in RG 188 + expect(gruul.value.match(forest)).toBe(true); // Colorless fits 189 + } 190 + 191 + // Simic deck can't play red 192 + const simic = search("id<=ug"); 193 + expect(simic.ok).toBe(true); 194 + if (simic.ok) { 195 + expect(simic.value.match(bolt)).toBe(false); // R doesn't fit 196 + expect(simic.value.match(elves)).toBe(true); // G fits 197 + } 198 + }); 199 + 200 + it("id!= excludes exact color identity", async () => { 201 + const bolt = await cards.get("Lightning Bolt"); 202 + const elves = await cards.get("Llanowar Elves"); 203 + 204 + // Exclude mono-red identity 205 + const notRed = search("id!=r"); 206 + expect(notRed.ok).toBe(true); 207 + if (notRed.ok) { 208 + expect(notRed.value.match(bolt)).toBe(false); // R = R, excluded 209 + expect(notRed.value.match(elves)).toBe(true); // G != R, included 210 + } 211 + 212 + // Exclude mono-green identity 213 + const notGreen = search("id!=g"); 214 + expect(notGreen.ok).toBe(true); 215 + if (notGreen.ok) { 216 + expect(notGreen.value.match(bolt)).toBe(true); // R != G, included 217 + expect(notGreen.value.match(elves)).toBe(false); // G = G, excluded 218 + } 219 + }); 220 + }); 221 + 222 + describe("color identity count matching", () => { 223 + const mockColorless = { color_identity: [] as string[] } as Card; 224 + const mockMono = { color_identity: ["R"] } as Card; 225 + const mockTwoColor = { color_identity: ["U", "R"] } as Card; 226 + const mockThreeColor = { color_identity: ["W", "U", "B"] } as Card; 227 + const mockFiveColor = { 228 + color_identity: ["W", "U", "B", "R", "G"], 229 + } as Card; 230 + 231 + it.each([ 232 + ["id=0", mockColorless, true], 233 + ["id=0", mockMono, false], 234 + ["id=1", mockMono, true], 235 + ["id=1", mockTwoColor, false], 236 + ["id=2", mockTwoColor, true], 237 + ["id=3", mockThreeColor, true], 238 + ["id=5", mockFiveColor, true], 239 + ])( 240 + "%s matches card with %d identity colors: %s", 241 + (query, card, expected) => { 242 + const result = search(query); 243 + expect(result.ok).toBe(true); 244 + if (result.ok) { 245 + expect(result.value.match(card)).toBe(expected); 246 + } 247 + }, 248 + ); 249 + 250 + it.each([ 251 + ["id>0", mockColorless, false], 252 + ["id>0", mockMono, true], 253 + ["id>1", mockMono, false], 254 + ["id>1", mockTwoColor, true], 255 + ["id>2", mockThreeColor, true], 256 + ])("%s (more than N colors) matches correctly", (query, card, expected) => { 257 + const result = search(query); 258 + expect(result.ok).toBe(true); 259 + if (result.ok) { 260 + expect(result.value.match(card)).toBe(expected); 261 + } 262 + }); 263 + 264 + it.each([ 265 + ["id<1", mockColorless, true], 266 + ["id<1", mockMono, false], 267 + ["id<2", mockMono, true], 268 + ["id<2", mockTwoColor, false], 269 + ["id<3", mockTwoColor, true], 270 + ])( 271 + "%s (fewer than N colors) matches correctly", 272 + (query, card, expected) => { 273 + const result = search(query); 274 + expect(result.ok).toBe(true); 275 + if (result.ok) { 276 + expect(result.value.match(card)).toBe(expected); 277 + } 278 + }, 279 + ); 280 + 281 + it.each([ 282 + ["id>=1", mockColorless, false], 283 + ["id>=1", mockMono, true], 284 + ["id>=2", mockMono, false], 285 + ["id>=2", mockTwoColor, true], 286 + ["id<=2", mockThreeColor, false], 287 + ["id<=3", mockThreeColor, true], 288 + ])( 289 + "%s (N or more/fewer colors) matches correctly", 290 + (query, card, expected) => { 291 + const result = search(query); 292 + expect(result.ok).toBe(true); 293 + if (result.ok) { 294 + expect(result.value.match(card)).toBe(expected); 295 + } 296 + }, 297 + ); 298 + 299 + it.each([ 300 + ["id!=1", mockMono, false], 301 + ["id!=1", mockTwoColor, true], 302 + ["id!=2", mockTwoColor, false], 303 + ])( 304 + "%s (not exactly N colors) matches correctly", 305 + (query, card, expected) => { 306 + const result = search(query); 307 + expect(result.ok).toBe(true); 308 + if (result.ok) { 309 + expect(result.value.match(card)).toBe(expected); 310 + } 311 + }, 312 + ); 313 + }); 314 + 315 + describe("mana value matching", () => { 316 + it.each([ 317 + ["cmc=1", "Lightning Bolt", true], 318 + ["cmc>0", "Lightning Bolt", true], 319 + ["cmc>=2", "Lightning Bolt", false], 320 + ["cmc<=3", "Llanowar Elves", true], 321 + ["mv=1", "Sol Ring", true], 322 + ])("%s matches %s: %s", async (query, cardName, expected) => { 323 + const card = await cards.get(cardName); 324 + const result = search(query); 325 + expect(result.ok).toBe(true); 326 + if (result.ok) { 327 + expect(result.value.match(card)).toBe(expected); 328 + } 329 + }); 330 + }); 331 + 332 + describe("format legality", () => { 333 + it("f: matches format legality", async () => { 334 + const bolt = await cards.get("Lightning Bolt"); 335 + const ring = await cards.get("Sol Ring"); 336 + 337 + const modern = search("f:modern"); 338 + expect(modern.ok).toBe(true); 339 + if (modern.ok) { 340 + expect(modern.value.match(bolt)).toBe(true); 341 + } 342 + 343 + const commander = search("f:commander"); 344 + expect(commander.ok).toBe(true); 345 + if (commander.ok) { 346 + expect(commander.value.match(ring)).toBe(true); 347 + } 348 + }); 349 + }); 350 + 351 + describe("is: predicates", () => { 352 + it("is:creature matches creatures", async () => { 353 + const elves = await cards.get("Llanowar Elves"); 354 + const bolt = await cards.get("Lightning Bolt"); 355 + 356 + const result = search("is:creature"); 357 + expect(result.ok).toBe(true); 358 + if (result.ok) { 359 + expect(result.value.match(elves)).toBe(true); 360 + expect(result.value.match(bolt)).toBe(false); 361 + } 362 + }); 363 + 364 + it("is:instant matches instants", async () => { 365 + const bolt = await cards.get("Lightning Bolt"); 366 + const result = search("is:instant"); 367 + expect(result.ok).toBe(true); 368 + if (result.ok) { 369 + expect(result.value.match(bolt)).toBe(true); 370 + } 371 + }); 372 + 373 + it("is:legendary matches legendary", async () => { 374 + const elves = await cards.get("Llanowar Elves"); 375 + const result = search("is:legendary"); 376 + expect(result.ok).toBe(true); 377 + if (result.ok) { 378 + expect(result.value.match(elves)).toBe(false); 379 + } 380 + }); 381 + }); 382 + 383 + describe("boolean operators", () => { 384 + it("implicit AND", async () => { 385 + const elves = await cards.get("Llanowar Elves"); 386 + const result = search("t:creature c:g"); 387 + expect(result.ok).toBe(true); 388 + if (result.ok) { 389 + expect(result.value.match(elves)).toBe(true); 390 + } 391 + }); 392 + 393 + it("explicit OR", async () => { 394 + const bolt = await cards.get("Lightning Bolt"); 395 + const elves = await cards.get("Llanowar Elves"); 396 + 397 + const result = search("t:instant or t:creature"); 398 + expect(result.ok).toBe(true); 399 + if (result.ok) { 400 + expect(result.value.match(bolt)).toBe(true); 401 + expect(result.value.match(elves)).toBe(true); 402 + } 403 + }); 404 + 405 + it("NOT with -", async () => { 406 + const bolt = await cards.get("Lightning Bolt"); 407 + const elves = await cards.get("Llanowar Elves"); 408 + 409 + const result = search("-t:creature"); 410 + expect(result.ok).toBe(true); 411 + if (result.ok) { 412 + expect(result.value.match(bolt)).toBe(true); 413 + expect(result.value.match(elves)).toBe(false); 414 + } 415 + }); 416 + 417 + it("parentheses for grouping", async () => { 418 + const bolt = await cards.get("Lightning Bolt"); 419 + 420 + const result = search("(t:instant or t:sorcery) c:r"); 421 + expect(result.ok).toBe(true); 422 + if (result.ok) { 423 + expect(result.value.match(bolt)).toBe(true); 424 + } 425 + }); 426 + }); 427 + 428 + describe("rarity matching", () => { 429 + // Use mock cards with explicit rarities to avoid canonical printing variance 430 + const mockCommon = { rarity: "common" } as Card; 431 + const mockUncommon = { rarity: "uncommon" } as Card; 432 + const mockRare = { rarity: "rare" } as Card; 433 + const mockMythic = { rarity: "mythic" } as Card; 434 + 435 + it.each([ 436 + ["r:c", mockCommon, true], 437 + ["r:c", mockUncommon, false], 438 + ["r:common", mockCommon, true], 439 + ["r:u", mockUncommon, true], 440 + ["r:uncommon", mockUncommon, true], 441 + ["r:r", mockRare, true], 442 + ["r:rare", mockRare, true], 443 + ["r:m", mockMythic, true], 444 + ["r:mythic", mockMythic, true], 445 + ])("%s matches %s rarity: %s", (query, card, expected) => { 446 + const result = search(query); 447 + expect(result.ok).toBe(true); 448 + if (result.ok) { 449 + expect(result.value.match(card)).toBe(expected); 450 + } 451 + }); 452 + 453 + it.each([ 454 + ["r>=c", mockCommon, true], 455 + ["r>=c", mockUncommon, true], 456 + ["r>=c", mockRare, true], 457 + ["r>=u", mockCommon, false], 458 + ["r>=u", mockUncommon, true], 459 + ["r>=u", mockRare, true], 460 + ["r>=r", mockUncommon, false], 461 + ["r>=r", mockRare, true], 462 + ["r>=r", mockMythic, true], 463 + ])("%s matches %s rarity: %s", (query, card, expected) => { 464 + const result = search(query); 465 + expect(result.ok).toBe(true); 466 + if (result.ok) { 467 + expect(result.value.match(card)).toBe(expected); 468 + } 469 + }); 470 + 471 + it.each([ 472 + ["r<=m", mockMythic, true], 473 + ["r<=m", mockRare, true], 474 + ["r<=r", mockRare, true], 475 + ["r<=r", mockMythic, false], 476 + ["r<=u", mockUncommon, true], 477 + ["r<=u", mockRare, false], 478 + ["r<=c", mockCommon, true], 479 + ["r<=c", mockUncommon, false], 480 + ])("%s matches %s rarity: %s", (query, card, expected) => { 481 + const result = search(query); 482 + expect(result.ok).toBe(true); 483 + if (result.ok) { 484 + expect(result.value.match(card)).toBe(expected); 485 + } 486 + }); 487 + 488 + it.each([ 489 + ["r>c", mockCommon, false], 490 + ["r>c", mockUncommon, true], 491 + ["r<u", mockCommon, true], 492 + ["r<u", mockUncommon, false], 493 + ])("%s matches %s rarity: %s", (query, card, expected) => { 494 + const result = search(query); 495 + expect(result.ok).toBe(true); 496 + if (result.ok) { 497 + expect(result.value.match(card)).toBe(expected); 498 + } 499 + }); 500 + 501 + it("r!=c excludes common", () => { 502 + const result = search("r!=c"); 503 + if (!result.ok) { 504 + console.log("Parse error:", result.error); 505 + } 506 + expect(result.ok).toBe(true); 507 + if (result.ok) { 508 + expect(result.value.match(mockCommon)).toBe(false); 509 + expect(result.value.match(mockUncommon)).toBe(true); 510 + } 511 + }); 512 + }); 513 + 514 + describe("in: matching (game, set type, set, language)", () => { 515 + const mockPaperCard = { 516 + games: ["paper", "mtgo"], 517 + set: "lea", 518 + set_type: "expansion", 519 + lang: "en", 520 + } as Card; 521 + const mockArenaCard = { 522 + games: ["arena"], 523 + set: "afr", 524 + set_type: "expansion", 525 + lang: "en", 526 + } as Card; 527 + const mockCommanderCard = { 528 + games: ["paper"], 529 + set: "cmr", 530 + set_type: "commander", 531 + lang: "en", 532 + } as Card; 533 + const mockJapaneseCard = { 534 + games: ["paper"], 535 + set: "sta", 536 + set_type: "expansion", 537 + lang: "ja", 538 + } as Card; 539 + 540 + it("in:paper matches paper games", () => { 541 + const result = search("in:paper"); 542 + expect(result.ok).toBe(true); 543 + if (result.ok) { 544 + expect(result.value.match(mockPaperCard)).toBe(true); 545 + expect(result.value.match(mockArenaCard)).toBe(false); 546 + } 547 + }); 548 + 549 + it("in:arena matches arena games", () => { 550 + const result = search("in:arena"); 551 + expect(result.ok).toBe(true); 552 + if (result.ok) { 553 + expect(result.value.match(mockArenaCard)).toBe(true); 554 + expect(result.value.match(mockPaperCard)).toBe(false); 555 + } 556 + }); 557 + 558 + it("in:mtgo matches mtgo games", () => { 559 + const result = search("in:mtgo"); 560 + expect(result.ok).toBe(true); 561 + if (result.ok) { 562 + expect(result.value.match(mockPaperCard)).toBe(true); 563 + expect(result.value.match(mockArenaCard)).toBe(false); 564 + } 565 + }); 566 + 567 + it("in:commander matches commander set type", () => { 568 + const result = search("in:commander"); 569 + expect(result.ok).toBe(true); 570 + if (result.ok) { 571 + expect(result.value.match(mockCommanderCard)).toBe(true); 572 + expect(result.value.match(mockPaperCard)).toBe(false); 573 + } 574 + }); 575 + 576 + it("in:expansion matches expansion set type", () => { 577 + const result = search("in:expansion"); 578 + expect(result.ok).toBe(true); 579 + if (result.ok) { 580 + expect(result.value.match(mockPaperCard)).toBe(true); 581 + expect(result.value.match(mockCommanderCard)).toBe(false); 582 + } 583 + }); 584 + 585 + it("in:<set> matches set code", () => { 586 + const result = search("in:lea"); 587 + expect(result.ok).toBe(true); 588 + if (result.ok) { 589 + expect(result.value.match(mockPaperCard)).toBe(true); 590 + expect(result.value.match(mockArenaCard)).toBe(false); 591 + } 592 + }); 593 + 594 + it("in:<lang> matches language", () => { 595 + const result = search("in:ja"); 596 + expect(result.ok).toBe(true); 597 + if (result.ok) { 598 + expect(result.value.match(mockJapaneseCard)).toBe(true); 599 + expect(result.value.match(mockPaperCard)).toBe(false); 600 + } 601 + }); 602 + 603 + it("-in:paper excludes paper cards", () => { 604 + const result = search("-in:paper"); 605 + expect(result.ok).toBe(true); 606 + if (result.ok) { 607 + expect(result.value.match(mockPaperCard)).toBe(false); 608 + expect(result.value.match(mockArenaCard)).toBe(true); 609 + } 610 + }); 611 + }); 612 + 613 + describe("complex queries", () => { 614 + it("commander deckbuilding query", async () => { 615 + const elves = await cards.get("Llanowar Elves"); 616 + 617 + // Find green creatures with cmc <= 2 for a Golgari commander deck 618 + const result = search("t:creature id<=bg cmc<=2"); 619 + expect(result.ok).toBe(true); 620 + if (result.ok) { 621 + expect(result.value.match(elves)).toBe(true); 622 + } 623 + }); 624 + 625 + it("negated color with type", async () => { 626 + const bolt = await cards.get("Lightning Bolt"); 627 + const elves = await cards.get("Llanowar Elves"); 628 + 629 + // Red non-creatures 630 + const result = search("c:r -t:creature"); 631 + expect(result.ok).toBe(true); 632 + if (result.ok) { 633 + expect(result.value.match(bolt)).toBe(true); 634 + expect(result.value.match(elves)).toBe(false); 635 + } 636 + }); 637 + }); 638 + });
+731
src/lib/search/__tests__/is-predicates.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "../../__tests__/test-card-lookup"; 6 + import type { Card } from "../../scryfall-types"; 7 + import { search } from "../index"; 8 + 9 + describe("is: predicate tests", () => { 10 + let cards: TestCardLookup; 11 + 12 + beforeAll(async () => { 13 + cards = await setupTestCards(); 14 + }, 30_000); 15 + 16 + describe("fetchland", () => { 17 + it("matches Scalding Tarn (pay 1 life, search for mountain or island)", async () => { 18 + const card = await cards.get("Scalding Tarn"); 19 + const result = search("is:fetchland"); 20 + expect(result.ok).toBe(true); 21 + if (result.ok) { 22 + expect(result.value.match(card)).toBe(true); 23 + } 24 + }); 25 + 26 + it("matches Misty Rainforest", async () => { 27 + const card = await cards.get("Misty Rainforest"); 28 + const result = search("is:fetchland"); 29 + expect(result.ok).toBe(true); 30 + if (result.ok) { 31 + expect(result.value.match(card)).toBe(true); 32 + } 33 + }); 34 + 35 + it("matches Polluted Delta", async () => { 36 + const card = await cards.get("Polluted Delta"); 37 + const result = search("is:fetchland"); 38 + expect(result.ok).toBe(true); 39 + if (result.ok) { 40 + expect(result.value.match(card)).toBe(true); 41 + } 42 + }); 43 + 44 + it("does NOT match Prismatic Vista (searches for 'basic land', not in the 10-card cycle)", async () => { 45 + const card = await cards.get("Prismatic Vista"); 46 + const result = search("is:fetchland"); 47 + expect(result.ok).toBe(true); 48 + if (result.ok) { 49 + // Prismatic Vista searches for "a basic land card" - different oracle pattern 50 + expect(result.value.match(card)).toBe(false); 51 + } 52 + }); 53 + 54 + // These are "fetch-like" but not true fetchlands (no life payment) 55 + it("does NOT match Fabled Passage (no life payment)", async () => { 56 + const card = await cards.get("Fabled Passage"); 57 + const result = search("is:fetchland"); 58 + expect(result.ok).toBe(true); 59 + if (result.ok) { 60 + // Fabled Passage doesn't pay life, so our strict definition excludes it 61 + expect(result.value.match(card)).toBe(false); 62 + } 63 + }); 64 + 65 + it("does NOT match Evolving Wilds (no life payment)", async () => { 66 + const card = await cards.get("Evolving Wilds"); 67 + const result = search("is:fetchland"); 68 + expect(result.ok).toBe(true); 69 + if (result.ok) { 70 + expect(result.value.match(card)).toBe(false); 71 + } 72 + }); 73 + 74 + it("does NOT match Farseek (sorcery, not a land)", async () => { 75 + const card = await cards.get("Farseek"); 76 + const result = search("is:fetchland"); 77 + expect(result.ok).toBe(true); 78 + if (result.ok) { 79 + expect(result.value.match(card)).toBe(false); 80 + } 81 + }); 82 + }); 83 + 84 + describe("shockland", () => { 85 + it("matches Breeding Pool", async () => { 86 + const card = await cards.get("Breeding Pool"); 87 + const result = search("is:shockland"); 88 + expect(result.ok).toBe(true); 89 + if (result.ok) { 90 + expect(result.value.match(card)).toBe(true); 91 + } 92 + }); 93 + 94 + it("matches Steam Vents", async () => { 95 + const card = await cards.get("Steam Vents"); 96 + const result = search("is:shockland"); 97 + expect(result.ok).toBe(true); 98 + if (result.ok) { 99 + expect(result.value.match(card)).toBe(true); 100 + } 101 + }); 102 + 103 + it("does NOT match basic Forest", async () => { 104 + const card = await cards.get("Forest"); 105 + const result = search("is:shockland"); 106 + expect(result.ok).toBe(true); 107 + if (result.ok) { 108 + expect(result.value.match(card)).toBe(false); 109 + } 110 + }); 111 + }); 112 + 113 + describe("dual", () => { 114 + it("matches Tropical Island (two basic types, no text)", async () => { 115 + const card = await cards.get("Tropical Island"); 116 + const result = search("is:dual"); 117 + expect(result.ok).toBe(true); 118 + if (result.ok) { 119 + expect(result.value.match(card)).toBe(true); 120 + } 121 + }); 122 + 123 + it("matches Underground Sea", async () => { 124 + const card = await cards.get("Underground Sea"); 125 + const result = search("is:dual"); 126 + expect(result.ok).toBe(true); 127 + if (result.ok) { 128 + expect(result.value.match(card)).toBe(true); 129 + } 130 + }); 131 + 132 + it("does NOT match Breeding Pool (has oracle text)", async () => { 133 + const card = await cards.get("Breeding Pool"); 134 + const result = search("is:dual"); 135 + expect(result.ok).toBe(true); 136 + if (result.ok) { 137 + // Shocklands have oracle text about paying life 138 + expect(result.value.match(card)).toBe(false); 139 + } 140 + }); 141 + 142 + it("does NOT match basic Forest", async () => { 143 + const card = await cards.get("Forest"); 144 + const result = search("is:dual"); 145 + expect(result.ok).toBe(true); 146 + if (result.ok) { 147 + expect(result.value.match(card)).toBe(false); 148 + } 149 + }); 150 + }); 151 + 152 + describe("triome", () => { 153 + it("matches Ketria Triome (three basic land types)", async () => { 154 + const card = await cards.get("Ketria Triome"); 155 + const result = search("is:triome"); 156 + expect(result.ok).toBe(true); 157 + if (result.ok) { 158 + expect(result.value.match(card)).toBe(true); 159 + } 160 + }); 161 + 162 + it("does NOT match Tropical Island (only two types)", async () => { 163 + const card = await cards.get("Tropical Island"); 164 + const result = search("is:triome"); 165 + expect(result.ok).toBe(true); 166 + if (result.ok) { 167 + expect(result.value.match(card)).toBe(false); 168 + } 169 + }); 170 + }); 171 + 172 + describe("checkland", () => { 173 + it("matches Hinterland Harbor", async () => { 174 + const card = await cards.get("Hinterland Harbor"); 175 + const result = search("is:checkland"); 176 + expect(result.ok).toBe(true); 177 + if (result.ok) { 178 + expect(result.value.match(card)).toBe(true); 179 + } 180 + }); 181 + 182 + it("matches Glacial Fortress", async () => { 183 + const card = await cards.get("Glacial Fortress"); 184 + const result = search("is:checkland"); 185 + expect(result.ok).toBe(true); 186 + if (result.ok) { 187 + expect(result.value.match(card)).toBe(true); 188 + } 189 + }); 190 + 191 + it("does NOT match Breeding Pool", async () => { 192 + const card = await cards.get("Breeding Pool"); 193 + const result = search("is:checkland"); 194 + expect(result.ok).toBe(true); 195 + if (result.ok) { 196 + expect(result.value.match(card)).toBe(false); 197 + } 198 + }); 199 + }); 200 + 201 + describe("fastland", () => { 202 + it("matches Botanical Sanctum", async () => { 203 + const card = await cards.get("Botanical Sanctum"); 204 + const result = search("is:fastland"); 205 + expect(result.ok).toBe(true); 206 + if (result.ok) { 207 + expect(result.value.match(card)).toBe(true); 208 + } 209 + }); 210 + 211 + it("matches Seachrome Coast", async () => { 212 + const card = await cards.get("Seachrome Coast"); 213 + const result = search("is:fastland"); 214 + expect(result.ok).toBe(true); 215 + if (result.ok) { 216 + expect(result.value.match(card)).toBe(true); 217 + } 218 + }); 219 + 220 + it("matches Inspiring Vantage", async () => { 221 + const card = await cards.get("Inspiring Vantage"); 222 + const result = search("is:fastland"); 223 + expect(result.ok).toBe(true); 224 + if (result.ok) { 225 + expect(result.value.match(card)).toBe(true); 226 + } 227 + }); 228 + 229 + it("matches Concealed Courtyard", async () => { 230 + const card = await cards.get("Concealed Courtyard"); 231 + const result = search("is:fastland"); 232 + expect(result.ok).toBe(true); 233 + if (result.ok) { 234 + expect(result.value.match(card)).toBe(true); 235 + } 236 + }); 237 + 238 + it("does NOT match slowlands", async () => { 239 + const card = await cards.get("Dreamroot Cascade"); 240 + const result = search("is:fastland"); 241 + expect(result.ok).toBe(true); 242 + if (result.ok) { 243 + expect(result.value.match(card)).toBe(false); 244 + } 245 + }); 246 + 247 + it("does NOT match Thran Portal (has extra abilities beyond the cycle)", async () => { 248 + const card = await cards.get("Thran Portal"); 249 + const result = search("is:fastland"); 250 + expect(result.ok).toBe(true); 251 + if (result.ok) { 252 + expect(result.value.match(card)).toBe(false); 253 + } 254 + }); 255 + }); 256 + 257 + describe("slowland", () => { 258 + it("matches Dreamroot Cascade", async () => { 259 + const card = await cards.get("Dreamroot Cascade"); 260 + const result = search("is:slowland"); 261 + expect(result.ok).toBe(true); 262 + if (result.ok) { 263 + expect(result.value.match(card)).toBe(true); 264 + } 265 + }); 266 + 267 + it("does NOT match fastlands", async () => { 268 + const card = await cards.get("Botanical Sanctum"); 269 + const result = search("is:slowland"); 270 + expect(result.ok).toBe(true); 271 + if (result.ok) { 272 + expect(result.value.match(card)).toBe(false); 273 + } 274 + }); 275 + }); 276 + 277 + describe("painland", () => { 278 + it("matches Yavimaya Coast", async () => { 279 + const card = await cards.get("Yavimaya Coast"); 280 + const result = search("is:painland"); 281 + expect(result.ok).toBe(true); 282 + if (result.ok) { 283 + expect(result.value.match(card)).toBe(true); 284 + } 285 + }); 286 + 287 + it("matches Adarkar Wastes", async () => { 288 + const card = await cards.get("Adarkar Wastes"); 289 + const result = search("is:painland"); 290 + expect(result.ok).toBe(true); 291 + if (result.ok) { 292 + expect(result.value.match(card)).toBe(true); 293 + } 294 + }); 295 + 296 + it("does NOT match shocklands (2 damage, not 1)", async () => { 297 + const card = await cards.get("Breeding Pool"); 298 + const result = search("is:painland"); 299 + expect(result.ok).toBe(true); 300 + if (result.ok) { 301 + expect(result.value.match(card)).toBe(false); 302 + } 303 + }); 304 + 305 + it("does NOT match Caldera Lake (enters tapped unconditionally)", async () => { 306 + const card = await cards.get("Caldera Lake"); 307 + const result = search("is:painland"); 308 + expect(result.ok).toBe(true); 309 + if (result.ok) { 310 + expect(result.value.match(card)).toBe(false); 311 + } 312 + }); 313 + }); 314 + 315 + describe("filterland", () => { 316 + it("matches Mystic Gate", async () => { 317 + const card = await cards.get("Mystic Gate"); 318 + const result = search("is:filterland"); 319 + expect(result.ok).toBe(true); 320 + if (result.ok) { 321 + expect(result.value.match(card)).toBe(true); 322 + } 323 + }); 324 + 325 + it("matches Graven Cairns", async () => { 326 + const card = await cards.get("Graven Cairns"); 327 + const result = search("is:filterland"); 328 + expect(result.ok).toBe(true); 329 + if (result.ok) { 330 + expect(result.value.match(card)).toBe(true); 331 + } 332 + }); 333 + }); 334 + 335 + describe("bounceland", () => { 336 + it("matches Azorius Chancery", async () => { 337 + const card = await cards.get("Azorius Chancery"); 338 + const result = search("is:bounceland"); 339 + expect(result.ok).toBe(true); 340 + if (result.ok) { 341 + expect(result.value.match(card)).toBe(true); 342 + } 343 + }); 344 + 345 + it("matches Simic Growth Chamber", async () => { 346 + const card = await cards.get("Simic Growth Chamber"); 347 + const result = search("is:bounceland"); 348 + expect(result.ok).toBe(true); 349 + if (result.ok) { 350 + expect(result.value.match(card)).toBe(true); 351 + } 352 + }); 353 + 354 + it("does NOT match Blossoming Tortoise (creature that returns lands from graveyard)", async () => { 355 + const card = await cards.get("Blossoming Tortoise"); 356 + const result = search("is:bounceland"); 357 + expect(result.ok).toBe(true); 358 + if (result.ok) { 359 + expect(result.value.match(card)).toBe(false); 360 + } 361 + }); 362 + }); 363 + 364 + describe("tangoland / battleland", () => { 365 + it("matches Canopy Vista", async () => { 366 + const card = await cards.get("Canopy Vista"); 367 + const result = search("is:tangoland"); 368 + expect(result.ok).toBe(true); 369 + if (result.ok) { 370 + expect(result.value.match(card)).toBe(true); 371 + } 372 + }); 373 + 374 + it("matches Prairie Stream", async () => { 375 + const card = await cards.get("Prairie Stream"); 376 + const result = search("is:battleland"); 377 + expect(result.ok).toBe(true); 378 + if (result.ok) { 379 + expect(result.value.match(card)).toBe(true); 380 + } 381 + }); 382 + 383 + it("battleland and tangoland are synonyms", async () => { 384 + const card = await cards.get("Canopy Vista"); 385 + const tango = search("is:tangoland"); 386 + const battle = search("is:battleland"); 387 + expect(tango.ok && battle.ok).toBe(true); 388 + if (tango.ok && battle.ok) { 389 + expect(tango.value.match(card)).toBe(battle.value.match(card)); 390 + } 391 + }); 392 + }); 393 + 394 + describe("scryland", () => { 395 + it("matches Temple of Mystery", async () => { 396 + const card = await cards.get("Temple of Mystery"); 397 + const result = search("is:scryland"); 398 + expect(result.ok).toBe(true); 399 + if (result.ok) { 400 + expect(result.value.match(card)).toBe(true); 401 + } 402 + }); 403 + 404 + it("does NOT match gainlands", async () => { 405 + const card = await cards.get("Tranquil Cove"); 406 + const result = search("is:scryland"); 407 + expect(result.ok).toBe(true); 408 + if (result.ok) { 409 + expect(result.value.match(card)).toBe(false); 410 + } 411 + }); 412 + }); 413 + 414 + describe("gainland", () => { 415 + it("matches Tranquil Cove", async () => { 416 + const card = await cards.get("Tranquil Cove"); 417 + const result = search("is:gainland"); 418 + expect(result.ok).toBe(true); 419 + if (result.ok) { 420 + expect(result.value.match(card)).toBe(true); 421 + } 422 + }); 423 + 424 + it("does NOT match scrylands", async () => { 425 + const card = await cards.get("Temple of Mystery"); 426 + const result = search("is:gainland"); 427 + expect(result.ok).toBe(true); 428 + if (result.ok) { 429 + expect(result.value.match(card)).toBe(false); 430 + } 431 + }); 432 + }); 433 + 434 + describe("manland / creatureland", () => { 435 + it("matches Celestial Colonnade", async () => { 436 + const card = await cards.get("Celestial Colonnade"); 437 + const result = search("is:manland"); 438 + expect(result.ok).toBe(true); 439 + if (result.ok) { 440 + expect(result.value.match(card)).toBe(true); 441 + } 442 + }); 443 + 444 + it("matches Raging Ravine", async () => { 445 + const card = await cards.get("Raging Ravine"); 446 + const result = search("is:creatureland"); 447 + expect(result.ok).toBe(true); 448 + if (result.ok) { 449 + expect(result.value.match(card)).toBe(true); 450 + } 451 + }); 452 + 453 + it("does NOT match Dryad Arbor (always a creature, doesn't 'become')", async () => { 454 + const card = await cards.get("Dryad Arbor"); 455 + const result = search("is:manland"); 456 + expect(result.ok).toBe(true); 457 + if (result.ok) { 458 + // Dryad Arbor IS a creature land but doesn't have "becomes a" text 459 + expect(result.value.match(card)).toBe(false); 460 + } 461 + }); 462 + }); 463 + 464 + describe("canopyland (horizon lands)", () => { 465 + it("matches Horizon Canopy", async () => { 466 + const card = await cards.get("Horizon Canopy"); 467 + const result = search("is:canopyland"); 468 + expect(result.ok).toBe(true); 469 + if (result.ok) { 470 + expect(result.value.match(card)).toBe(true); 471 + } 472 + }); 473 + }); 474 + 475 + describe("frame and border fields", () => { 476 + it("frame:2015 parses correctly", () => { 477 + const result = search("frame:2015"); 478 + expect(result.ok).toBe(true); 479 + }); 480 + 481 + it("border:black parses correctly", () => { 482 + const result = search("border:black"); 483 + expect(result.ok).toBe(true); 484 + }); 485 + }); 486 + 487 + describe("archetype predicates", () => { 488 + it("is:vanilla matches creatures with no oracle text", () => { 489 + const vanilla = { 490 + type_line: "Creature — Human", 491 + oracle_text: "", 492 + } as Card; 493 + const result = search("is:vanilla"); 494 + expect(result.ok).toBe(true); 495 + if (result.ok) { 496 + expect(result.value.match(vanilla)).toBe(true); 497 + } 498 + }); 499 + 500 + it("is:vanilla does NOT match creatures with text", async () => { 501 + const elves = await cards.get("Llanowar Elves"); 502 + const result = search("is:vanilla"); 503 + expect(result.ok).toBe(true); 504 + if (result.ok) { 505 + expect(result.value.match(elves)).toBe(false); 506 + } 507 + }); 508 + 509 + // Inline card tests for frenchvanilla 510 + it.each([ 511 + [ 512 + "keywords on one line", 513 + "Flying, first strike, lifelink", 514 + ["Flying", "First strike", "Lifelink"], 515 + true, 516 + ], 517 + [ 518 + "keywords with reminder text", 519 + "Flying\nVigilance (Attacking doesn't cause this creature to tap.)", 520 + ["Flying", "Vigilance"], 521 + true, 522 + ], 523 + ["vanilla creature (no keywords)", "", [], false], 524 + ])("is:frenchvanilla with %s", (_desc, oracle, keywords, expected) => { 525 + const card = { 526 + type_line: "Creature — Test", 527 + oracle_text: oracle, 528 + keywords, 529 + } as Card; 530 + const result = search("is:frenchvanilla"); 531 + expect(result.ok).toBe(true); 532 + if (result.ok) expect(result.value.match(card)).toBe(expected); 533 + }); 534 + 535 + // Real card lookups - cards that SHOULD match 536 + it.each([ 537 + ["Serra Angel", "basic keywords"], 538 + ["Gallowbraid", "Cumulative upkeep—Pay 1 life"], 539 + ["Aboroth", "Cumulative upkeep—Put counter"], 540 + ["Deepcavern Imp", "Echo with discard cost"], 541 + ["Bird Admirer", "transform with keyword-only faces"], 542 + ["Black Knight", "protection with reminder text"], 543 + ["Blood Knight", "protection without reminder"], 544 + ["Beloved Chaplain", "protection from creatures"], 545 + ["Tel-Jilad Chosen", "protection from artifacts"], 546 + ["Vault Skirge", "Phyrexian mana with keywords"], 547 + ["Arcbound Wanderer", "Modular—Sunburst (keyword as cost)"], 548 + ["Axebane Ferox", "Ward—Collect evidence (keyword as cost)"], 549 + ["Bloodbraid Challenger", "Escape with mana cost first"], 550 + ["Lunar Hatchling", "complex Escape costs"], 551 + ["Toy Boat", "Cumulative upkeep—Say (unusual verb)"], 552 + ["Wall of Shards", "Cumulative upkeep—An opponent"], 553 + ])("is:frenchvanilla matches %s (%s)", async (name) => { 554 + const card = await cards.get(name); 555 + const result = search("is:frenchvanilla"); 556 + expect(result.ok).toBe(true); 557 + if (result.ok) expect(result.value.match(card)).toBe(true); 558 + }); 559 + 560 + // Real card lookups - cards that should NOT match 561 + it.each([ 562 + ["Llanowar Elves", "has mana ability"], 563 + ["Aerathi Berserker", "Rampage N (direct numeric param)"], 564 + ["Akki Lavarunner", "flip card layout"], 565 + ["Akoum Warrior", "MDFC layout"], 566 + ["A-Lantern Bearer", "transform with non-keyword face"], 567 + ["A-Llanowar Greenwidow", "ability word with activated ability"], 568 + ["Barbara Wright", "ability word with static effect"], 569 + ["Karlach, Raging Tiefling", "keyword cost with extra rules text"], 570 + ])("is:frenchvanilla does NOT match %s (%s)", async (name) => { 571 + const card = await cards.get(name); 572 + const result = search("is:frenchvanilla"); 573 + expect(result.ok).toBe(true); 574 + if (result.ok) expect(result.value.match(card)).toBe(false); 575 + }); 576 + 577 + it("is:bear matches 2/2 for 2 creatures", () => { 578 + const bear = { 579 + type_line: "Creature — Bear", 580 + cmc: 2, 581 + power: "2", 582 + toughness: "2", 583 + } as Card; 584 + const result = search("is:bear"); 585 + expect(result.ok).toBe(true); 586 + if (result.ok) { 587 + expect(result.value.match(bear)).toBe(true); 588 + } 589 + }); 590 + 591 + it("is:bear does NOT match 2/3 for 2", () => { 592 + const notBear = { 593 + type_line: "Creature — Elf", 594 + cmc: 2, 595 + power: "2", 596 + toughness: "3", 597 + } as Card; 598 + const result = search("is:bear"); 599 + expect(result.ok).toBe(true); 600 + if (result.ok) { 601 + expect(result.value.match(notBear)).toBe(false); 602 + } 603 + }); 604 + 605 + it("is:modal matches cards with 'choose one'", () => { 606 + const modal = { 607 + oracle_text: "Choose one —\n• Effect A\n• Effect B", 608 + } as Card; 609 + const result = search("is:modal"); 610 + expect(result.ok).toBe(true); 611 + if (result.ok) { 612 + expect(result.value.match(modal)).toBe(true); 613 + } 614 + }); 615 + 616 + it("is:modal matches MDFCs", () => { 617 + const mdfc = { layout: "modal_dfc" } as Card; 618 + const result = search("is:modal"); 619 + expect(result.ok).toBe(true); 620 + if (result.ok) { 621 + expect(result.value.match(mdfc)).toBe(true); 622 + } 623 + }); 624 + 625 + it("is:party matches party creature types", () => { 626 + const cleric = { type_line: "Creature — Human Cleric" } as Card; 627 + const rogue = { type_line: "Creature — Vampire Rogue" } as Card; 628 + const warrior = { type_line: "Creature — Orc Warrior" } as Card; 629 + const wizard = { type_line: "Creature — Elf Wizard" } as Card; 630 + const notParty = { type_line: "Creature — Elf Druid" } as Card; 631 + 632 + const result = search("is:party"); 633 + expect(result.ok).toBe(true); 634 + if (result.ok) { 635 + expect(result.value.match(cleric)).toBe(true); 636 + expect(result.value.match(rogue)).toBe(true); 637 + expect(result.value.match(warrior)).toBe(true); 638 + expect(result.value.match(wizard)).toBe(true); 639 + expect(result.value.match(notParty)).toBe(false); 640 + } 641 + }); 642 + 643 + it("is:outlaw matches outlaw creature types", () => { 644 + const assassin = { type_line: "Creature — Human Assassin" } as Card; 645 + const pirate = { type_line: "Creature — Human Pirate" } as Card; 646 + const warlock = { type_line: "Creature — Human Warlock" } as Card; 647 + const notOutlaw = { type_line: "Creature — Human Knight" } as Card; 648 + 649 + const result = search("is:outlaw"); 650 + expect(result.ok).toBe(true); 651 + if (result.ok) { 652 + expect(result.value.match(assassin)).toBe(true); 653 + expect(result.value.match(pirate)).toBe(true); 654 + expect(result.value.match(warlock)).toBe(true); 655 + expect(result.value.match(notOutlaw)).toBe(false); 656 + } 657 + }); 658 + }); 659 + 660 + describe("frame effect predicates", () => { 661 + it("is:showcase matches cards with showcase frame effect", () => { 662 + const showcase = { frame_effects: ["showcase"] } as Card; 663 + const normal = { frame_effects: [] as string[] } as Card; 664 + 665 + const result = search("is:showcase"); 666 + expect(result.ok).toBe(true); 667 + if (result.ok) { 668 + expect(result.value.match(showcase)).toBe(true); 669 + expect(result.value.match(normal)).toBe(false); 670 + } 671 + }); 672 + 673 + it("is:extendedart matches extended art cards", () => { 674 + const extended = { frame_effects: ["extendedart"] } as Card; 675 + const result = search("is:extendedart"); 676 + expect(result.ok).toBe(true); 677 + if (result.ok) { 678 + expect(result.value.match(extended)).toBe(true); 679 + } 680 + }); 681 + 682 + it("is:borderless matches borderless cards", () => { 683 + const borderless = { border_color: "borderless" } as Card; 684 + const black = { border_color: "black" } as Card; 685 + 686 + const result = search("is:borderless"); 687 + expect(result.ok).toBe(true); 688 + if (result.ok) { 689 + expect(result.value.match(borderless)).toBe(true); 690 + expect(result.value.match(black)).toBe(false); 691 + } 692 + }); 693 + 694 + it("is:retro matches old frame cards", () => { 695 + const retro = { frame: "1997" } as Card; 696 + const oldOld = { frame: "1993" } as Card; 697 + const modern = { frame: "2015" } as Card; 698 + 699 + const result = search("is:retro"); 700 + expect(result.ok).toBe(true); 701 + if (result.ok) { 702 + expect(result.value.match(retro)).toBe(true); 703 + expect(result.value.match(oldOld)).toBe(true); 704 + expect(result.value.match(modern)).toBe(false); 705 + } 706 + }); 707 + }); 708 + 709 + describe("promo type predicates", () => { 710 + it("is:prerelease matches prerelease promos", () => { 711 + const prerelease = { promo_types: ["prerelease", "datestamped"] } as Card; 712 + const normal = { promo_types: [] as string[] } as Card; 713 + 714 + const result = search("is:prerelease"); 715 + expect(result.ok).toBe(true); 716 + if (result.ok) { 717 + expect(result.value.match(prerelease)).toBe(true); 718 + expect(result.value.match(normal)).toBe(false); 719 + } 720 + }); 721 + 722 + it("is:buyabox matches buy-a-box promos", () => { 723 + const bab = { promo_types: ["buyabox"] } as Card; 724 + const result = search("is:buyabox"); 725 + expect(result.ok).toBe(true); 726 + if (result.ok) { 727 + expect(result.value.match(bab)).toBe(true); 728 + } 729 + }); 730 + }); 731 + });
+213
src/lib/search/__tests__/lexer.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { getRegexPattern, tokenize } from "../lexer"; 3 + 4 + describe("tokenize", () => { 5 + function expectTokens( 6 + input: string, 7 + expected: Array<{ type: string; value: string }>, 8 + ) { 9 + const result = tokenize(input); 10 + expect(result.ok).toBe(true); 11 + if (!result.ok) return; 12 + 13 + const tokens = result.value.slice(0, -1); // exclude EOF 14 + expect(tokens).toHaveLength(expected.length); 15 + for (let i = 0; i < expected.length; i++) { 16 + expect(tokens[i].type).toBe(expected[i].type); 17 + expect(tokens[i].value).toBe(expected[i].value); 18 + } 19 + } 20 + 21 + describe("simple tokens", () => { 22 + it("tokenizes parentheses", () => { 23 + expectTokens("()", [ 24 + { type: "LPAREN", value: "(" }, 25 + { type: "RPAREN", value: ")" }, 26 + ]); 27 + }); 28 + 29 + it("tokenizes comparison operators", () => { 30 + expectTokens(": = != < > <= >=", [ 31 + { type: "COLON", value: ":" }, 32 + { type: "EQUALS", value: "=" }, 33 + { type: "NOT_EQUALS", value: "!=" }, 34 + { type: "LT", value: "<" }, 35 + { type: "GT", value: ">" }, 36 + { type: "LTE", value: "<=" }, 37 + { type: "GTE", value: ">=" }, 38 + ]); 39 + }); 40 + 41 + it("tokenizes NOT operator", () => { 42 + expectTokens("-foo", [ 43 + { type: "NOT", value: "-" }, 44 + { type: "WORD", value: "foo" }, 45 + ]); 46 + }); 47 + 48 + it("tokenizes OR keyword", () => { 49 + expectTokens("foo or bar OR baz", [ 50 + { type: "WORD", value: "foo" }, 51 + { type: "OR", value: "or" }, 52 + { type: "WORD", value: "bar" }, 53 + { type: "OR", value: "OR" }, 54 + { type: "WORD", value: "baz" }, 55 + ]); 56 + }); 57 + }); 58 + 59 + describe("words", () => { 60 + it("tokenizes simple words", () => { 61 + expectTokens("hello world", [ 62 + { type: "WORD", value: "hello" }, 63 + { type: "WORD", value: "world" }, 64 + ]); 65 + }); 66 + 67 + it("tokenizes field-like words", () => { 68 + expectTokens("t:creature", [ 69 + { type: "WORD", value: "t" }, 70 + { type: "COLON", value: ":" }, 71 + { type: "WORD", value: "creature" }, 72 + ]); 73 + }); 74 + 75 + it("tokenizes numeric comparisons", () => { 76 + expectTokens("cmc>=3", [ 77 + { type: "WORD", value: "cmc" }, 78 + { type: "GTE", value: ">=" }, 79 + { type: "WORD", value: "3" }, 80 + ]); 81 + }); 82 + }); 83 + 84 + describe("quoted strings", () => { 85 + it("tokenizes quoted strings", () => { 86 + expectTokens('"Serra Angel"', [{ type: "QUOTED", value: "Serra Angel" }]); 87 + }); 88 + 89 + it("handles escaped quotes", () => { 90 + expectTokens('"say \\"hello\\""', [ 91 + { type: "QUOTED", value: 'say "hello"' }, 92 + ]); 93 + }); 94 + 95 + it("returns error for unterminated quote", () => { 96 + const result = tokenize('"unterminated'); 97 + expect(result.ok).toBe(false); 98 + if (!result.ok) { 99 + expect(result.error.message).toContain("Unterminated"); 100 + } 101 + }); 102 + }); 103 + 104 + describe("regex", () => { 105 + it("tokenizes regex", () => { 106 + expectTokens("/goblin|elf/", [{ type: "REGEX", value: "goblin|elf" }]); 107 + }); 108 + 109 + it("stores compiled pattern", () => { 110 + const result = tokenize("/^bolt$/i"); 111 + expect(result.ok).toBe(true); 112 + if (!result.ok) return; 113 + 114 + const token = result.value[0]; 115 + expect(token.type).toBe("REGEX"); 116 + const pattern = getRegexPattern(token); 117 + expect(pattern).toBeInstanceOf(RegExp); 118 + expect(pattern?.test("bolt")).toBe(true); 119 + expect(pattern?.test("BOLT")).toBe(true); 120 + }); 121 + 122 + it("returns error for invalid regex", () => { 123 + const result = tokenize("/[invalid/"); 124 + expect(result.ok).toBe(false); 125 + if (!result.ok) { 126 + expect(result.error.message).toContain("Invalid regex"); 127 + } 128 + }); 129 + 130 + it("returns error for unterminated regex", () => { 131 + const result = tokenize("/unterminated"); 132 + expect(result.ok).toBe(false); 133 + if (!result.ok) { 134 + expect(result.error.message).toContain("Unterminated"); 135 + } 136 + }); 137 + }); 138 + 139 + describe("exact name", () => { 140 + it("tokenizes exact name with !", () => { 141 + expectTokens("!Lightning", [{ type: "EXACT_NAME", value: "Lightning" }]); 142 + }); 143 + 144 + it("tokenizes quoted exact name", () => { 145 + expectTokens('!"Lightning Bolt"', [ 146 + { type: "EXACT_NAME", value: "Lightning Bolt" }, 147 + ]); 148 + }); 149 + }); 150 + 151 + describe("complex queries", () => { 152 + it("tokenizes field expressions", () => { 153 + expectTokens("t:creature o:flying", [ 154 + { type: "WORD", value: "t" }, 155 + { type: "COLON", value: ":" }, 156 + { type: "WORD", value: "creature" }, 157 + { type: "WORD", value: "o" }, 158 + { type: "COLON", value: ":" }, 159 + { type: "WORD", value: "flying" }, 160 + ]); 161 + }); 162 + 163 + it("tokenizes nested expressions", () => { 164 + expectTokens("(t:creature or t:artifact) -c:r", [ 165 + { type: "LPAREN", value: "(" }, 166 + { type: "WORD", value: "t" }, 167 + { type: "COLON", value: ":" }, 168 + { type: "WORD", value: "creature" }, 169 + { type: "OR", value: "or" }, 170 + { type: "WORD", value: "t" }, 171 + { type: "COLON", value: ":" }, 172 + { type: "WORD", value: "artifact" }, 173 + { type: "RPAREN", value: ")" }, 174 + { type: "NOT", value: "-" }, 175 + { type: "WORD", value: "c" }, 176 + { type: "COLON", value: ":" }, 177 + { type: "WORD", value: "r" }, 178 + ]); 179 + }); 180 + 181 + it("tokenizes commander identity query", () => { 182 + expectTokens("id<=bg f:commander", [ 183 + { type: "WORD", value: "id" }, 184 + { type: "LTE", value: "<=" }, 185 + { type: "WORD", value: "bg" }, 186 + { type: "WORD", value: "f" }, 187 + { type: "COLON", value: ":" }, 188 + { type: "WORD", value: "commander" }, 189 + ]); 190 + }); 191 + }); 192 + 193 + describe("span tracking", () => { 194 + it("tracks token positions", () => { 195 + const result = tokenize("foo bar"); 196 + expect(result.ok).toBe(true); 197 + if (!result.ok) return; 198 + 199 + expect(result.value[0].span).toEqual({ start: 0, end: 3 }); 200 + expect(result.value[1].span).toEqual({ start: 4, end: 7 }); 201 + }); 202 + 203 + it("tracks multi-character operator positions", () => { 204 + const result = tokenize("cmc>=3"); 205 + expect(result.ok).toBe(true); 206 + if (!result.ok) return; 207 + 208 + expect(result.value[0].span).toEqual({ start: 0, end: 3 }); // cmc 209 + expect(result.value[1].span).toEqual({ start: 3, end: 5 }); // >= 210 + expect(result.value[2].span).toEqual({ start: 5, end: 6 }); // 3 211 + }); 212 + }); 213 + });
+51
src/lib/search/__tests__/operators.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { hasSearchOperators } from "../operators"; 3 + 4 + describe("hasSearchOperators", () => { 5 + it.each([ 6 + ["bolt", "single word"], 7 + ["lightning bolt", "multiple words"], 8 + ["or dragon", "lowercase 'or' in name"], 9 + ["fire and ice", "lowercase 'and' in name"], 10 + ["type specimen", "words that look like fields but no operator"], 11 + ["fire-breathing", "hyphenated names"], 12 + ["The Ur-Dragon", "proper card name with hyphens"], 13 + ["bolt And shock", "mixed case AND"], 14 + ])("`%s` → false (%s)", (query) => { 15 + expect(hasSearchOperators(query)).toBe(false); 16 + }); 17 + 18 + it.each([ 19 + // Field operators 20 + ["t:creature", "type field"], 21 + ["id:bg", "color identity field"], 22 + ["cmc>=3", "mana value comparison"], 23 + ["o:flying", "oracle text"], 24 + ["s:lea", "set field"], 25 + ["r:rare", "rarity field"], 26 + ["f:commander", "format field"], 27 + ["fire id>=g", "mixed name and field"], 28 + ["t!=land", "not equals operator"], 29 + ["pow<=2", "less than or equal"], 30 + // Explicit AND/OR 31 + ["bolt AND shock", "uppercase AND"], 32 + ["bolt OR shock", "uppercase OR"], 33 + // Exact match 34 + ["!Lightning Bolt", "! at start of query"], 35 + ["something !exact", "! after space"], 36 + ["(!Lightning Bolt)", "! after paren"], 37 + // Negation 38 + ["-blue", "-word at start"], 39 + ["creature -human", "-word after space"], 40 + ["(-blue)", "-word after paren"], 41 + // Quotes 42 + ['"Lightning Bolt"', "double quotes"], 43 + ['name:"bolt', "partial quotes"], 44 + // Parentheses 45 + ["(red", "opening paren"], 46 + ["red)", "closing paren"], 47 + ["(red OR blue)", "grouped expression"], 48 + ])("`%s` → true (%s)", (query) => { 49 + expect(hasSearchOperators(query)).toBe(true); 50 + }); 51 + });
+398
src/lib/search/__tests__/parser-proptest.test.ts
··· 1 + /** 2 + * Property-based tests for the search parser 3 + * 4 + * These tests use fast-check to generate random valid inputs and verify 5 + * parser invariants hold across the input space. 6 + */ 7 + 8 + import fc from "fast-check"; 9 + import { describe, expect, it } from "vitest"; 10 + import type { Card } from "../../scryfall-types"; 11 + import { describeQuery } from "../describe"; 12 + import { search } from "../index"; 13 + import { parse } from "../parser"; 14 + import { FIELD_ALIASES, type FieldName } from "../types"; 15 + 16 + const COLORS = ["w", "u", "b", "r", "g", "c"] as const; 17 + const ALL_OPERATORS = [":", "=", "!=", "<", ">", "<=", ">="] as const; 18 + const NUMERIC_OPERATORS = [":", "=", "!=", "<", ">", "<=", ">="] as const; 19 + 20 + const TEXT_FIELDS = ["t", "o", "name", "type", "oracle"] as const; 21 + const COLOR_FIELDS = ["c", "id", "color", "identity"] as const; 22 + const NUMERIC_FIELDS = [ 23 + "mv", 24 + "cmc", 25 + "pow", 26 + "power", 27 + "tou", 28 + "loy", 29 + "def", 30 + ] as const; 31 + const wordArb = fc 32 + .stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,8}$/) 33 + .filter((w) => w.toLowerCase() !== "or"); 34 + const numArb = fc.integer({ min: 0, max: 20 }); 35 + const colorArb = fc 36 + .subarray([...COLORS], { minLength: 1, maxLength: 5 }) 37 + .map((cs) => cs.join("")); 38 + 39 + const textFieldArb = fc.constantFrom(...TEXT_FIELDS); 40 + const colorFieldArb = fc.constantFrom(...COLOR_FIELDS); 41 + const numericFieldArb = fc.constantFrom(...NUMERIC_FIELDS); 42 + const anyFieldArb = fc.constantFrom(...Object.keys(FIELD_ALIASES)); 43 + 44 + const operatorArb = fc.constantFrom(...ALL_OPERATORS); 45 + const numericOpArb = fc.constantFrom(...NUMERIC_OPERATORS); 46 + 47 + describe("parser property tests", () => { 48 + describe("parsing invariants", () => { 49 + it("never crashes on alphanumeric input", () => { 50 + fc.assert( 51 + fc.property(fc.stringMatching(/^[a-zA-Z0-9 ]{1,50}$/), (input) => { 52 + const result = parse(input); 53 + expect(result).toBeDefined(); 54 + expect(typeof result.ok).toBe("boolean"); 55 + }), 56 + { numRuns: 200 }, 57 + ); 58 + }); 59 + 60 + it("never crashes on input with operators", () => { 61 + fc.assert( 62 + fc.property( 63 + fc.stringMatching(/^[a-zA-Z0-9:=<>! ()-]{1,50}$/), 64 + (input) => { 65 + const result = parse(input); 66 + expect(result).toBeDefined(); 67 + expect(typeof result.ok).toBe("boolean"); 68 + }, 69 + ), 70 + { numRuns: 200 }, 71 + ); 72 + }); 73 + 74 + it("parses all text field + word combinations", () => { 75 + fc.assert( 76 + fc.property(textFieldArb, wordArb, (field, value) => { 77 + const result = parse(`${field}:${value}`); 78 + expect(result.ok).toBe(true); 79 + if (result.ok) { 80 + expect(result.value.type).toBe("FIELD"); 81 + } 82 + }), 83 + { numRuns: 100 }, 84 + ); 85 + }); 86 + 87 + it("parses all color field + color combinations", () => { 88 + fc.assert( 89 + fc.property( 90 + colorFieldArb, 91 + operatorArb, 92 + colorArb, 93 + (field, op, colors) => { 94 + const result = parse(`${field}${op}${colors}`); 95 + expect(result.ok).toBe(true); 96 + if (result.ok && result.value.type === "FIELD") { 97 + expect(result.value.value.kind).toBe("colors"); 98 + } 99 + }, 100 + ), 101 + { numRuns: 100 }, 102 + ); 103 + }); 104 + 105 + it("parses all numeric field + operator + number combinations", () => { 106 + fc.assert( 107 + fc.property(numericFieldArb, numericOpArb, numArb, (field, op, num) => { 108 + const result = parse(`${field}${op}${num}`); 109 + expect(result.ok).toBe(true); 110 + if (result.ok && result.value.type === "FIELD") { 111 + expect(result.value.value.kind).toBe("number"); 112 + } 113 + }), 114 + { numRuns: 100 }, 115 + ); 116 + }); 117 + 118 + it("parses negative numbers in numeric fields", () => { 119 + fc.assert( 120 + fc.property( 121 + numericFieldArb, 122 + numericOpArb, 123 + fc.integer({ min: 1, max: 10 }), 124 + (field, op, num) => { 125 + const result = parse(`${field}${op}-${num}`); 126 + expect(result.ok).toBe(true); 127 + if (result.ok && result.value.type === "FIELD") { 128 + expect(result.value.value.kind).toBe("number"); 129 + if (result.value.value.kind === "number") { 130 + expect(result.value.value.value).toBe(-num); 131 + } 132 + } 133 + }, 134 + ), 135 + { numRuns: 50 }, 136 + ); 137 + }); 138 + }); 139 + 140 + describe("boolean operator combinations", () => { 141 + it("parses arbitrary AND chains", () => { 142 + fc.assert( 143 + fc.property( 144 + fc.array(wordArb, { minLength: 2, maxLength: 6 }), 145 + (words) => { 146 + const result = parse(words.join(" ")); 147 + expect(result.ok).toBe(true); 148 + if (result.ok && words.length > 1) { 149 + expect(result.value.type).toBe("AND"); 150 + } 151 + }, 152 + ), 153 + { numRuns: 50 }, 154 + ); 155 + }); 156 + 157 + it("parses arbitrary OR chains", () => { 158 + fc.assert( 159 + fc.property( 160 + fc.array(wordArb, { minLength: 2, maxLength: 4 }), 161 + (words) => { 162 + const result = parse(words.join(" or ")); 163 + expect(result.ok).toBe(true); 164 + if (result.ok && words.length > 1) { 165 + expect(result.value.type).toBe("OR"); 166 + } 167 + }, 168 + ), 169 + { numRuns: 50 }, 170 + ); 171 + }); 172 + 173 + it("parses nested NOT expressions", () => { 174 + fc.assert( 175 + fc.property(fc.integer({ min: 1, max: 3 }), wordArb, (depth, word) => { 176 + const prefix = "-".repeat(depth); 177 + const result = parse(`${prefix}${word}`); 178 + expect(result.ok).toBe(true); 179 + if (result.ok) { 180 + let node = result.value; 181 + for (let i = 0; i < depth; i++) { 182 + expect(node.type).toBe("NOT"); 183 + if (node.type === "NOT") { 184 + node = node.child; 185 + } 186 + } 187 + } 188 + }), 189 + { numRuns: 30 }, 190 + ); 191 + }); 192 + 193 + it("parses parenthesized expressions", () => { 194 + fc.assert( 195 + fc.property(wordArb, wordArb, wordArb, (a, b, c) => { 196 + const result = parse(`(${a} or ${b}) ${c}`); 197 + expect(result.ok).toBe(true); 198 + if (result.ok) { 199 + expect(result.value.type).toBe("AND"); 200 + } 201 + }), 202 + { numRuns: 50 }, 203 + ); 204 + }); 205 + 206 + it("parses nested parentheses", () => { 207 + fc.assert( 208 + fc.property(wordArb, wordArb, wordArb, wordArb, (a, b, c, d) => { 209 + const result = parse(`((${a} or ${b}) (${c} or ${d}))`); 210 + expect(result.ok).toBe(true); 211 + }), 212 + { numRuns: 30 }, 213 + ); 214 + }); 215 + }); 216 + 217 + describe("describe invariants", () => { 218 + it("describeQuery never crashes on valid parse", () => { 219 + fc.assert( 220 + fc.property(textFieldArb, wordArb, (field, value) => { 221 + const query = `${field}:${value}`; 222 + const parseResult = parse(query); 223 + if (parseResult.ok) { 224 + const description = describeQuery(parseResult.value); 225 + expect(typeof description).toBe("string"); 226 + } 227 + }), 228 + { numRuns: 100 }, 229 + ); 230 + }); 231 + 232 + it("describeQuery returns non-empty for valid queries", () => { 233 + fc.assert( 234 + fc.property( 235 + fc.oneof( 236 + fc.tuple(textFieldArb, wordArb).map(([f, v]) => `${f}:${v}`), 237 + fc.tuple(colorFieldArb, colorArb).map(([f, c]) => `${f}:${c}`), 238 + fc.tuple(numericFieldArb, numArb).map(([f, n]) => `${f}:${n}`), 239 + ), 240 + (query) => { 241 + const parseResult = parse(query); 242 + if (parseResult.ok) { 243 + const description = describeQuery(parseResult.value); 244 + expect(description.length).toBeGreaterThan(0); 245 + } 246 + }, 247 + ), 248 + { numRuns: 100 }, 249 + ); 250 + }); 251 + }); 252 + 253 + describe("search + match invariants", () => { 254 + const minimalCard = { 255 + id: "test-id", 256 + name: "Test Card", 257 + oracle_id: "test-oracle", 258 + set: "tst", 259 + set_name: "Test Set", 260 + collector_number: "1", 261 + released_at: "2024-01-01", 262 + rarity: "common", 263 + colors: [], 264 + color_identity: [], 265 + cmc: 0, 266 + type_line: "Creature", 267 + legalities: {}, 268 + } as unknown as Card; 269 + 270 + it("compiled matcher never crashes on minimal card", () => { 271 + fc.assert( 272 + fc.property( 273 + fc.oneof( 274 + wordArb, 275 + fc.tuple(textFieldArb, wordArb).map(([f, v]) => `${f}:${v}`), 276 + fc.tuple(colorFieldArb, colorArb).map(([f, c]) => `${f}:${c}`), 277 + fc.tuple(numericFieldArb, numArb).map(([f, n]) => `${f}=${n}`), 278 + ), 279 + (query) => { 280 + const result = search(query); 281 + if (result.ok) { 282 + const matchResult = result.value.match(minimalCard); 283 + expect(typeof matchResult).toBe("boolean"); 284 + } 285 + }, 286 + ), 287 + { numRuns: 200 }, 288 + ); 289 + }); 290 + 291 + it("compiled matcher never crashes on card with missing fields", () => { 292 + const sparseCard = { id: "x", name: "X" } as Card; 293 + 294 + fc.assert( 295 + fc.property(anyFieldArb, wordArb, (field, value) => { 296 + const result = search(`${field}:${value}`); 297 + if (result.ok) { 298 + const matchResult = result.value.match(sparseCard); 299 + expect(typeof matchResult).toBe("boolean"); 300 + } 301 + }), 302 + { numRuns: 100 }, 303 + ); 304 + }); 305 + 306 + it("NOT inverts match result", () => { 307 + fc.assert( 308 + fc.property(wordArb, (word) => { 309 + const pos = search(word); 310 + const neg = search(`-${word}`); 311 + 312 + if (pos.ok && neg.ok) { 313 + const card = { ...minimalCard, name: word }; 314 + const posMatch = pos.value.match(card); 315 + const negMatch = neg.value.match(card); 316 + expect(negMatch).toBe(!posMatch); 317 + } 318 + }), 319 + { numRuns: 50 }, 320 + ); 321 + }); 322 + 323 + it("OR matches if either side matches", () => { 324 + fc.assert( 325 + fc.property(wordArb, wordArb, (a, b) => { 326 + const result = search(`${a} or ${b}`); 327 + if (result.ok) { 328 + // Cards whose names contain the search terms 329 + const cardA = { ...minimalCard, name: `prefix${a}suffix` }; 330 + const cardB = { ...minimalCard, name: `prefix${b}suffix` }; 331 + 332 + expect(result.value.match(cardA)).toBe(true); 333 + expect(result.value.match(cardB)).toBe(true); 334 + } 335 + }), 336 + { numRuns: 50 }, 337 + ); 338 + }); 339 + }); 340 + 341 + describe("field alias consistency", () => { 342 + it("all field aliases parse to FIELD nodes", () => { 343 + for (const alias of Object.keys(FIELD_ALIASES)) { 344 + const result = parse(`${alias}:test`); 345 + expect(result.ok).toBe(true); 346 + if (result.ok) { 347 + expect(result.value.type).toBe("FIELD"); 348 + } 349 + } 350 + }); 351 + 352 + it("field aliases resolve to expected canonical names", () => { 353 + const aliasGroups: Record<FieldName, string[]> = { 354 + name: ["name", "n"], 355 + type: ["type", "t"], 356 + oracle: ["oracle", "o"], 357 + color: ["color", "c"], 358 + identity: ["identity", "id"], 359 + manavalue: ["manavalue", "mv", "cmc"], 360 + power: ["power", "pow"], 361 + toughness: ["toughness", "tou"], 362 + loyalty: ["loyalty", "loy"], 363 + defense: ["defense", "def"], 364 + set: ["set", "s", "e", "edition"], 365 + rarity: ["rarity", "r"], 366 + format: ["format", "f"], 367 + mana: ["mana", "m"], 368 + keyword: ["keyword", "kw"], 369 + settype: ["settype", "st"], 370 + layout: ["layout"], 371 + frame: ["frame"], 372 + border: ["border"], 373 + number: ["number", "cn"], 374 + artist: ["artist", "a"], 375 + banned: ["banned"], 376 + restricted: ["restricted"], 377 + game: ["game"], 378 + in: ["in"], 379 + produces: ["produces"], 380 + year: ["year"], 381 + date: ["date"], 382 + lang: ["lang", "language"], 383 + is: ["is"], 384 + not: ["not"], 385 + }; 386 + 387 + for (const [canonical, aliases] of Object.entries(aliasGroups)) { 388 + for (const alias of aliases) { 389 + const result = parse(`${alias}:test`); 390 + expect(result.ok).toBe(true); 391 + if (result.ok && result.value.type === "FIELD") { 392 + expect(result.value.field).toBe(canonical); 393 + } 394 + } 395 + } 396 + }); 397 + }); 398 + });
+311
src/lib/search/__tests__/parser.test.ts
··· 1 + import fc from "fast-check"; 2 + import { describe, expect, it } from "vitest"; 3 + import { parse } from "../parser"; 4 + import type { SearchNode } from "../types"; 5 + 6 + describe("parse", () => { 7 + function expectParse(input: string): SearchNode { 8 + const result = parse(input); 9 + expect(result.ok).toBe(true); 10 + if (!result.ok) throw new Error(result.error.message); 11 + return result.value; 12 + } 13 + 14 + function expectParseError(input: string): string { 15 + const result = parse(input); 16 + expect(result.ok).toBe(false); 17 + if (result.ok) throw new Error("Expected parse error"); 18 + return result.error.message; 19 + } 20 + 21 + describe("name expressions", () => { 22 + it("parses bare word as name", () => { 23 + const node = expectParse("bolt"); 24 + expect(node.type).toBe("NAME"); 25 + if (node.type === "NAME") { 26 + expect(node.value).toBe("bolt"); 27 + expect(node.pattern).toBeNull(); 28 + } 29 + }); 30 + 31 + it("parses quoted string as name", () => { 32 + const node = expectParse('"Lightning Bolt"'); 33 + expect(node.type).toBe("NAME"); 34 + if (node.type === "NAME") { 35 + expect(node.value).toBe("Lightning Bolt"); 36 + } 37 + }); 38 + 39 + it("parses exact name with !", () => { 40 + const node = expectParse("!Lightning"); 41 + expect(node.type).toBe("EXACT_NAME"); 42 + if (node.type === "EXACT_NAME") { 43 + expect(node.value).toBe("Lightning"); 44 + } 45 + }); 46 + 47 + it("parses regex as name", () => { 48 + const node = expectParse("/bolt$/i"); 49 + expect(node.type).toBe("NAME"); 50 + if (node.type === "NAME") { 51 + expect(node.pattern).toBeInstanceOf(RegExp); 52 + expect(node.pattern?.test("Lightning Bolt")).toBe(true); 53 + } 54 + }); 55 + }); 56 + 57 + describe("field expressions", () => { 58 + it("parses type field", () => { 59 + const node = expectParse("t:creature"); 60 + expect(node.type).toBe("FIELD"); 61 + if (node.type === "FIELD") { 62 + expect(node.field).toBe("type"); 63 + expect(node.operator).toBe(":"); 64 + expect(node.value).toEqual({ kind: "string", value: "creature" }); 65 + } 66 + }); 67 + 68 + it("parses oracle field", () => { 69 + const node = expectParse("o:flying"); 70 + expect(node.type).toBe("FIELD"); 71 + if (node.type === "FIELD") { 72 + expect(node.field).toBe("oracle"); 73 + } 74 + }); 75 + 76 + it("parses color field as colors", () => { 77 + const node = expectParse("c:urg"); 78 + expect(node.type).toBe("FIELD"); 79 + if (node.type === "FIELD") { 80 + expect(node.field).toBe("color"); 81 + expect(node.value.kind).toBe("colors"); 82 + if (node.value.kind === "colors") { 83 + expect(node.value.colors).toEqual(new Set(["U", "R", "G"])); 84 + } 85 + } 86 + }); 87 + 88 + it("parses identity field", () => { 89 + const node = expectParse("id<=bg"); 90 + expect(node.type).toBe("FIELD"); 91 + if (node.type === "FIELD") { 92 + expect(node.field).toBe("identity"); 93 + expect(node.operator).toBe("<="); 94 + if (node.value.kind === "colors") { 95 + expect(node.value.colors).toEqual(new Set(["B", "G"])); 96 + } 97 + } 98 + }); 99 + 100 + it("parses numeric fields", () => { 101 + const node = expectParse("cmc>=3"); 102 + expect(node.type).toBe("FIELD"); 103 + if (node.type === "FIELD") { 104 + expect(node.field).toBe("manavalue"); 105 + expect(node.operator).toBe(">="); 106 + expect(node.value).toEqual({ kind: "number", value: 3 }); 107 + } 108 + }); 109 + 110 + it("parses power with star", () => { 111 + const node = expectParse("pow=*"); 112 + expect(node.type).toBe("FIELD"); 113 + if (node.type === "FIELD") { 114 + expect(node.value).toEqual({ kind: "string", value: "*" }); 115 + } 116 + }); 117 + 118 + it("parses regex in field", () => { 119 + const node = expectParse("o:/draw.*card/"); 120 + expect(node.type).toBe("FIELD"); 121 + if (node.type === "FIELD") { 122 + expect(node.value.kind).toBe("regex"); 123 + } 124 + }); 125 + 126 + it("parses format field", () => { 127 + const node = expectParse("f:commander"); 128 + expect(node.type).toBe("FIELD"); 129 + if (node.type === "FIELD") { 130 + expect(node.field).toBe("format"); 131 + expect(node.value).toEqual({ kind: "string", value: "commander" }); 132 + } 133 + }); 134 + }); 135 + 136 + describe("boolean operators", () => { 137 + it("parses implicit AND", () => { 138 + const node = expectParse("t:creature c:g"); 139 + expect(node.type).toBe("AND"); 140 + if (node.type === "AND") { 141 + expect(node.children).toHaveLength(2); 142 + } 143 + }); 144 + 145 + it("parses explicit OR", () => { 146 + const node = expectParse("t:creature or t:artifact"); 147 + expect(node.type).toBe("OR"); 148 + if (node.type === "OR") { 149 + expect(node.children).toHaveLength(2); 150 + } 151 + }); 152 + 153 + it("parses NOT", () => { 154 + const node = expectParse("-t:creature"); 155 + expect(node.type).toBe("NOT"); 156 + if (node.type === "NOT") { 157 + expect(node.child.type).toBe("FIELD"); 158 + } 159 + }); 160 + 161 + it("parses parentheses", () => { 162 + const node = expectParse("(t:creature or t:artifact) c:r"); 163 + expect(node.type).toBe("AND"); 164 + if (node.type === "AND") { 165 + expect(node.children[0].type).toBe("OR"); 166 + } 167 + }); 168 + 169 + it("NOT binds tighter than AND", () => { 170 + const node = expectParse("-t:creature c:g"); 171 + expect(node.type).toBe("AND"); 172 + if (node.type === "AND") { 173 + expect(node.children[0].type).toBe("NOT"); 174 + } 175 + }); 176 + 177 + it("AND binds tighter than OR", () => { 178 + const node = expectParse("a b or c d"); 179 + expect(node.type).toBe("OR"); 180 + if (node.type === "OR") { 181 + expect(node.children).toHaveLength(2); 182 + expect(node.children[0].type).toBe("AND"); 183 + expect(node.children[1].type).toBe("AND"); 184 + } 185 + }); 186 + }); 187 + 188 + describe("complex queries", () => { 189 + it("parses commander deckbuilding query", () => { 190 + const node = expectParse("id<=bg t:creature cmc<=3"); 191 + expect(node.type).toBe("AND"); 192 + if (node.type === "AND") { 193 + expect(node.children).toHaveLength(3); 194 + } 195 + }); 196 + 197 + it("parses nested groups", () => { 198 + const node = expectParse("((a or b) (c or d))"); 199 + expect(node.type).toBe("AND"); 200 + }); 201 + 202 + it("parses word that looks like field but isnt", () => { 203 + // "is" without : should be treated as name 204 + const node = expectParse("is cool"); 205 + expect(node.type).toBe("AND"); 206 + if (node.type === "AND") { 207 + expect(node.children[0].type).toBe("NAME"); 208 + expect(node.children[1].type).toBe("NAME"); 209 + } 210 + }); 211 + }); 212 + 213 + describe("error handling", () => { 214 + it("errors on empty query", () => { 215 + const msg = expectParseError(""); 216 + expect(msg).toContain("Empty"); 217 + }); 218 + 219 + it("errors on unmatched paren", () => { 220 + const msg = expectParseError("(foo"); 221 + expect(msg).toContain("parenthesis"); 222 + }); 223 + 224 + it("errors on trailing garbage", () => { 225 + const msg = expectParseError("foo )"); 226 + expect(msg).toContain("Unexpected"); 227 + }); 228 + }); 229 + 230 + describe("span tracking", () => { 231 + it("tracks span for simple term", () => { 232 + const node = expectParse("bolt"); 233 + expect(node.span).toEqual({ start: 0, end: 4 }); 234 + }); 235 + 236 + it("tracks span for field expression", () => { 237 + const node = expectParse("t:creature"); 238 + expect(node.span).toEqual({ start: 0, end: 10 }); 239 + }); 240 + 241 + it("tracks span for AND expression", () => { 242 + const node = expectParse("foo bar"); 243 + expect(node.span).toEqual({ start: 0, end: 7 }); 244 + }); 245 + }); 246 + 247 + describe("property tests", () => { 248 + const wordArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,8}$/); 249 + const fieldArb = fc.constantFrom("t", "o", "c", "cmc", "pow"); 250 + const opArb = fc.constantFrom(":", "=", ">=", "<=", ">", "<"); 251 + 252 + it("parses any valid field expression", () => { 253 + fc.assert( 254 + fc.property(fieldArb, opArb, wordArb, (field, op, value) => { 255 + const result = parse(`${field}${op}${value}`); 256 + expect(result.ok).toBe(true); 257 + }), 258 + { numRuns: 100 }, 259 + ); 260 + }); 261 + 262 + it("parses any sequence of words", () => { 263 + fc.assert( 264 + fc.property( 265 + fc.array(wordArb, { minLength: 1, maxLength: 5 }), 266 + (words) => { 267 + const result = parse(words.join(" ")); 268 + expect(result.ok).toBe(true); 269 + }, 270 + ), 271 + { numRuns: 100 }, 272 + ); 273 + }); 274 + 275 + it("parses OR combinations", () => { 276 + fc.assert( 277 + fc.property(wordArb, wordArb, (a, b) => { 278 + const result = parse(`${a} or ${b}`); 279 + expect(result.ok).toBe(true); 280 + if (result.ok) { 281 + expect(result.value.type).toBe("OR"); 282 + } 283 + }), 284 + { numRuns: 50 }, 285 + ); 286 + }); 287 + 288 + it("parses NOT expressions", () => { 289 + fc.assert( 290 + fc.property(wordArb, (word) => { 291 + const result = parse(`-${word}`); 292 + expect(result.ok).toBe(true); 293 + if (result.ok) { 294 + expect(result.value.type).toBe("NOT"); 295 + } 296 + }), 297 + { numRuns: 50 }, 298 + ); 299 + }); 300 + 301 + it("parses grouped expressions", () => { 302 + fc.assert( 303 + fc.property(wordArb, wordArb, (a, b) => { 304 + const result = parse(`(${a} or ${b})`); 305 + expect(result.ok).toBe(true); 306 + }), 307 + { numRuns: 50 }, 308 + ); 309 + }); 310 + }); 311 + });
+165
src/lib/search/colors.ts
··· 1 + /** 2 + * Color set comparison utilities for Scryfall search 3 + * 4 + * Scryfall uses set theory for color comparisons: 5 + * - : or >= means "superset of" (card has at least these colors) 6 + * - = means "exactly these colors" 7 + * - <= means "subset of" (card has at most these colors) - key for commander deckbuilding 8 + * - < means "strict subset" 9 + * - > means "strict superset" 10 + */ 11 + 12 + import type { ComparisonOp } from "./types"; 13 + 14 + /** 15 + * Valid color characters 16 + */ 17 + export const COLOR_CHARS = new Set(["W", "U", "B", "R", "G", "C"]); 18 + 19 + /** 20 + * Check if set A is a subset of set B (A ⊆ B) 21 + * Every element in A is also in B 22 + */ 23 + export function isSubset<T>(a: Set<T>, b: Set<T>): boolean { 24 + for (const item of a) { 25 + if (!b.has(item)) return false; 26 + } 27 + return true; 28 + } 29 + 30 + /** 31 + * Check if set A is a superset of set B (A ⊇ B) 32 + * Every element in B is also in A 33 + */ 34 + export function isSuperset<T>(a: Set<T>, b: Set<T>): boolean { 35 + return isSubset(b, a); 36 + } 37 + 38 + /** 39 + * Check if two sets are equal 40 + */ 41 + export function setsEqual<T>(a: Set<T>, b: Set<T>): boolean { 42 + return a.size === b.size && isSubset(a, b); 43 + } 44 + 45 + /** 46 + * Check if set A is a strict subset of set B (A ⊂ B) 47 + * A is subset of B and A ≠ B 48 + */ 49 + export function isStrictSubset<T>(a: Set<T>, b: Set<T>): boolean { 50 + return a.size < b.size && isSubset(a, b); 51 + } 52 + 53 + /** 54 + * Check if set A is a strict superset of set B (A ⊃ B) 55 + * A is superset of B and A ≠ B 56 + */ 57 + export function isStrictSuperset<T>(a: Set<T>, b: Set<T>): boolean { 58 + return a.size > b.size && isSuperset(a, b); 59 + } 60 + 61 + /** 62 + * Compare card colors against search colors using the given operator 63 + * 64 + * Special handling for colorless (C) in colors/color_identity: 65 + * - "C" in search means "colorless" = empty color set 66 + * - Colorless cards have [] for colors/color_identity, not ["C"] 67 + * 68 + * For produced_mana, C is literal - set literalColorless=true 69 + */ 70 + export function compareColors( 71 + cardColors: string[] | undefined, 72 + searchColors: Set<string>, 73 + operator: ComparisonOp, 74 + literalColorless = false, 75 + ): boolean { 76 + const cardSet = new Set(cardColors ?? []); 77 + 78 + // For colors/color_identity, colorless means empty array 79 + // For produced_mana, C is literal in the array 80 + if (!literalColorless) { 81 + const isColorlessSearch = searchColors.size === 1 && searchColors.has("C"); 82 + const cardIsColorless = cardSet.size === 0; 83 + 84 + if (isColorlessSearch) { 85 + switch (operator) { 86 + case ":": 87 + case ">=": 88 + case "=": 89 + return cardIsColorless; 90 + case "!=": 91 + return !cardIsColorless; 92 + case "<=": 93 + return cardIsColorless; 94 + case "<": 95 + return false; 96 + case ">": 97 + return !cardIsColorless; 98 + } 99 + } 100 + 101 + // Remove C from search - it doesn't appear in colors/color_identity 102 + const normalizedSearch = new Set(searchColors); 103 + normalizedSearch.delete("C"); 104 + searchColors = normalizedSearch; 105 + } 106 + 107 + switch (operator) { 108 + case ":": 109 + case ">=": 110 + return isSuperset(cardSet, searchColors); 111 + 112 + case "=": 113 + return setsEqual(cardSet, searchColors); 114 + 115 + case "!=": 116 + return !setsEqual(cardSet, searchColors); 117 + 118 + case "<=": 119 + return isSubset(cardSet, searchColors); 120 + 121 + case "<": 122 + return isStrictSubset(cardSet, searchColors); 123 + 124 + case ">": 125 + return isStrictSuperset(cardSet, searchColors); 126 + } 127 + } 128 + 129 + /** 130 + * Parse a color string into a set of color characters 131 + * 132 + * Supports: 133 + * - Single letters: W, U, B, R, G, C 134 + * - Combined: wubrg, bg, ur 135 + * - Full names: white, blue, black, red, green, colorless 136 + * - Guild/shard names could be added here 137 + */ 138 + export function parseColors(input: string): Set<string> { 139 + const colors = new Set<string>(); 140 + const lower = input.toLowerCase(); 141 + 142 + // Check full names first (before single char parsing) 143 + if (lower.includes("white")) colors.add("W"); 144 + if (lower.includes("blue")) colors.add("U"); 145 + if (lower.includes("black")) colors.add("B"); 146 + if (lower.includes("red")) colors.add("R"); 147 + if (lower.includes("green")) colors.add("G"); 148 + if (lower.includes("colorless")) colors.add("C"); 149 + 150 + // If we found full names, don't do character parsing 151 + // (avoids "blue" matching B from the letter) 152 + if (colors.size > 0) { 153 + return colors; 154 + } 155 + 156 + // Single character parsing for short color codes like "wubrg" or "bg" 157 + const upper = input.toUpperCase(); 158 + for (const char of upper) { 159 + if (COLOR_CHARS.has(char)) { 160 + colors.add(char); 161 + } 162 + } 163 + 164 + return colors; 165 + }
+291
src/lib/search/describe.ts
··· 1 + /** 2 + * Converts a parsed search AST to a human-readable description. 3 + */ 4 + 5 + import { RARITY_ALIASES } from "./fields"; 6 + import { 7 + type ComparisonOp, 8 + DISCRETE_FIELDS, 9 + type FieldName, 10 + type FieldNode, 11 + type FieldValue, 12 + type SearchNode, 13 + } from "./types"; 14 + 15 + const FIELD_LABELS: Record<FieldName, string> = { 16 + name: "name", 17 + type: "type", 18 + oracle: "oracle text", 19 + color: "color", 20 + identity: "color identity", 21 + mana: "mana cost", 22 + manavalue: "mana value", 23 + power: "power", 24 + toughness: "toughness", 25 + loyalty: "loyalty", 26 + defense: "defense", 27 + keyword: "keyword", 28 + set: "set", 29 + settype: "set type", 30 + layout: "layout", 31 + frame: "frame", 32 + border: "border", 33 + number: "collector number", 34 + rarity: "rarity", 35 + artist: "artist", 36 + format: "format", 37 + banned: "banned in", 38 + restricted: "restricted in", 39 + game: "game", 40 + in: "printed in", 41 + produces: "produces", 42 + year: "year", 43 + date: "release date", 44 + lang: "language", 45 + is: "is", 46 + not: "is not", 47 + }; 48 + 49 + const OPERATOR_LABELS: Record<ComparisonOp, string> = { 50 + ":": "includes", 51 + "=": "=", 52 + "!=": "≠", 53 + "<": "<", 54 + ">": ">", 55 + "<=": "≤", 56 + ">=": "≥", 57 + }; 58 + 59 + const COLOR_OPERATOR_LABELS: Record<ComparisonOp, string> = { 60 + ":": "includes", 61 + "=": "is exactly", 62 + "!=": "is not", 63 + "<": "is a strict subset of", 64 + ">": "is a strict superset of", 65 + "<=": "is within", 66 + ">=": "includes at least", 67 + }; 68 + 69 + const WUBRG_ORDER = ["W", "U", "B", "R", "G"]; 70 + 71 + /** 72 + * Human-readable descriptions for is: predicates 73 + */ 74 + const IS_DESCRIPTIONS: Record<string, string> = { 75 + // Land cycles 76 + fetchland: "fetch lands (sacrifice, pay 1 life, search)", 77 + shockland: "shock lands (pay 2 life or enter tapped)", 78 + dual: "original dual lands", 79 + triome: "triomes (three basic land types)", 80 + checkland: "check lands (enter tapped unless you control...)", 81 + fastland: "fast lands (enter tapped unless ≤2 other lands)", 82 + slowland: "slow lands (enter tapped unless ≥2 other lands)", 83 + painland: "pain lands (deal 1 damage for colored mana)", 84 + filterland: "filter lands (hybrid mana activation)", 85 + bounceland: "bounce lands (return a land when entering)", 86 + tangoland: "battle lands (enter tapped unless ≥2 basics)", 87 + battleland: "battle lands (enter tapped unless ≥2 basics)", 88 + scryland: "scry lands (enter tapped, scry 1)", 89 + gainland: "gain lands (enter tapped, gain 1 life)", 90 + manland: "creature lands (can become creatures)", 91 + creatureland: "creature lands (can become creatures)", 92 + canopyland: "horizon lands (pay 1 life for mana, sacrifice to draw)", 93 + 94 + // Card types 95 + creature: "creatures", 96 + artifact: "artifacts", 97 + enchantment: "enchantments", 98 + land: "lands", 99 + planeswalker: "planeswalkers", 100 + instant: "instants", 101 + sorcery: "sorceries", 102 + permanent: "permanents", 103 + spell: "spells (instants and sorceries)", 104 + legendary: "legendary cards", 105 + snow: "snow cards", 106 + historic: "historic cards (legendary, artifact, or saga)", 107 + 108 + // Archetypes 109 + vanilla: "vanilla creatures (no abilities)", 110 + frenchvanilla: "French vanilla creatures (only keyword abilities)", 111 + bear: "bears (2/2 creatures for 2 mana)", 112 + modal: "modal spells (choose one or more)", 113 + spree: "spree cards", 114 + party: "party creatures (cleric, rogue, warrior, wizard)", 115 + outlaw: "outlaws (assassin, mercenary, pirate, rogue, warlock)", 116 + commander: "cards that can be commanders", 117 + 118 + // Layouts 119 + split: "split cards", 120 + flip: "flip cards", 121 + transform: "transforming cards", 122 + mdfc: "modal double-faced cards", 123 + dfc: "double-faced cards", 124 + meld: "meld cards", 125 + leveler: "level up cards", 126 + saga: "sagas", 127 + adventure: "adventure cards", 128 + battle: "battles", 129 + prototype: "prototype cards", 130 + token: "tokens", 131 + art_series: "art series cards", 132 + 133 + // Printing characteristics 134 + reprint: "reprints", 135 + promo: "promos", 136 + digital: "digital-only cards", 137 + reserved: "reserved list cards", 138 + full: "full-art cards", 139 + fullart: "full-art cards", 140 + hires: "high-resolution images available", 141 + foil: "available in foil", 142 + nonfoil: "available in non-foil", 143 + etched: "available in etched foil", 144 + 145 + // Frame effects 146 + showcase: "showcase frame cards", 147 + extendedart: "extended art cards", 148 + borderless: "borderless cards", 149 + inverted: "inverted frame cards", 150 + colorshifted: "colorshifted cards", 151 + retro: "retro frame cards (1993/1997)", 152 + old: "old frame cards (1993/1997)", 153 + modern: "modern frame cards (2003/2015)", 154 + new: "new frame cards (2015)", 155 + future: "future frame cards", 156 + boosterfun: "booster fun treatments", 157 + 158 + // Promo types 159 + buyabox: "buy-a-box promos", 160 + prerelease: "prerelease promos", 161 + datestamped: "date-stamped promos", 162 + fnm: "FNM promos", 163 + gameday: "game day promos", 164 + release: "release promos", 165 + promopacks: "promo pack cards", 166 + }; 167 + 168 + function sortColors(colors: Set<string>): string[] { 169 + return WUBRG_ORDER.filter((c) => colors.has(c)); 170 + } 171 + 172 + function formatColors(colors: Set<string>): string { 173 + const sorted = sortColors(colors); 174 + if (sorted.length === 0) return "{C}"; 175 + return sorted.map((c) => `{${c}}`).join(""); 176 + } 177 + 178 + function describeValue(value: FieldValue, quoted = true): string { 179 + switch (value.kind) { 180 + case "string": 181 + return quoted 182 + ? `"${value.value.toLowerCase()}"` 183 + : value.value.toLowerCase(); 184 + case "number": 185 + return String(value.value); 186 + case "regex": { 187 + // Filter out 'i' since case-insensitive is the default 188 + const flags = value.pattern.flags.replace("i", ""); 189 + return `/${value.source}/${flags}`; 190 + } 191 + case "colors": 192 + return formatColors(value.colors); 193 + } 194 + } 195 + 196 + function describeField(node: FieldNode): string { 197 + const fieldLabel = FIELD_LABELS[node.field]; 198 + const isColorField = node.field === "color" || node.field === "identity"; 199 + 200 + // Special handling for identity count queries (id>1, id=2, etc.) 201 + if (node.field === "identity" && node.value.kind === "number") { 202 + const n = node.value.value; 203 + // Grammatically: "1 color" but "0/2/3 colors", "fewer than 3 colors", "2 or more colors" 204 + const colorWordExact = n === 1 ? "color" : "colors"; 205 + switch (node.operator) { 206 + case ":": 207 + case "=": 208 + return `cards with exactly ${n} identity ${colorWordExact}`; 209 + case "!=": 210 + return `cards without exactly ${n} identity ${colorWordExact}`; 211 + case "<": 212 + return `cards with fewer than ${n} identity colors`; 213 + case ">": 214 + return `cards with more than ${n} identity ${colorWordExact}`; 215 + case "<=": 216 + return `cards with ${n} or fewer identity colors`; 217 + case ">=": 218 + return `cards with ${n} or more identity colors`; 219 + } 220 + } 221 + 222 + // Special handling for is:/not: predicates 223 + if ( 224 + (node.field === "is" || node.field === "not") && 225 + node.value.kind === "string" 226 + ) { 227 + const predicate = node.value.value.toLowerCase(); 228 + const description = IS_DESCRIPTIONS[predicate]; 229 + if (description) { 230 + return node.field === "not" ? `not ${description}` : description; 231 + } 232 + // Fallback for unknown predicates 233 + return node.field === "not" ? `not "${predicate}"` : `"${predicate}" cards`; 234 + } 235 + 236 + // Pick operator label: discrete fields use "is" instead of "includes" for ":" 237 + // But regex always uses "includes" since it's a pattern match, not exact 238 + // "in" field is special - the label already implies the relationship 239 + let opLabel: string; 240 + if (isColorField) { 241 + opLabel = COLOR_OPERATOR_LABELS[node.operator]; 242 + } else if (node.field === "in" && node.operator === ":") { 243 + opLabel = ""; 244 + } else if ( 245 + node.operator === ":" && 246 + DISCRETE_FIELDS.has(node.field) && 247 + node.value.kind !== "regex" 248 + ) { 249 + opLabel = "is"; 250 + } else { 251 + opLabel = OPERATOR_LABELS[node.operator]; 252 + } 253 + 254 + // Expand rarity shorthand 255 + if (node.field === "rarity" && node.value.kind === "string") { 256 + const expanded = RARITY_ALIASES[node.value.value.toLowerCase()]; 257 + if (expanded) { 258 + return `${fieldLabel} ${opLabel} ${expanded}`; 259 + } 260 + } 261 + 262 + const valueStr = describeValue(node.value); 263 + return opLabel 264 + ? `${fieldLabel} ${opLabel} ${valueStr}` 265 + : `${fieldLabel} ${valueStr}`; 266 + } 267 + 268 + export function describeQuery(node: SearchNode): string { 269 + switch (node.type) { 270 + case "NAME": 271 + return `name includes "${node.value.toLowerCase()}"`; 272 + 273 + case "EXACT_NAME": 274 + return `name is exactly "${node.value.toLowerCase()}"`; 275 + 276 + case "FIELD": 277 + return describeField(node); 278 + 279 + case "AND": 280 + return node.children.map(describeQuery).join(" AND "); 281 + 282 + case "OR": 283 + if (node.children.length === 1) { 284 + return describeQuery(node.children[0]); 285 + } 286 + return `(${node.children.map(describeQuery).join(" OR ")})`; 287 + 288 + case "NOT": 289 + return `NOT ${describeQuery(node.child)}`; 290 + } 291 + }
+1071
src/lib/search/fields.ts
··· 1 + /** 2 + * Field-specific matching logic for Scryfall search 3 + */ 4 + 5 + import type { Card } from "../scryfall-types"; 6 + import { compareColors } from "./colors"; 7 + import type { ComparisonOp, FieldName, FieldValue } from "./types"; 8 + 9 + /** 10 + * Card predicate function type 11 + */ 12 + export type CardPredicate = (card: Card) => boolean; 13 + 14 + /** 15 + * Create a predicate for ordered comparisons (numbers, dates, ranks). 16 + * Handles all comparison operators with a single pattern. 17 + * 18 + * @param getValue - Extract comparable value from card (null = no match for inequalities) 19 + * @param searchValue - Value to compare against 20 + * @param operator - Comparison operator 21 + */ 22 + function createOrderedMatcher<T>( 23 + getValue: (card: Card) => T | null | undefined, 24 + searchValue: T, 25 + operator: ComparisonOp, 26 + ): CardPredicate { 27 + switch (operator) { 28 + case ":": 29 + case "=": 30 + return (card) => getValue(card) === searchValue; 31 + case "!=": 32 + return (card) => getValue(card) !== searchValue; 33 + case "<": 34 + return (card) => { 35 + const v = getValue(card); 36 + return v != null && v < searchValue; 37 + }; 38 + case ">": 39 + return (card) => { 40 + const v = getValue(card); 41 + return v != null && v > searchValue; 42 + }; 43 + case "<=": 44 + return (card) => { 45 + const v = getValue(card); 46 + return v != null && v <= searchValue; 47 + }; 48 + case ">=": 49 + return (card) => { 50 + const v = getValue(card); 51 + return v != null && v >= searchValue; 52 + }; 53 + } 54 + } 55 + 56 + /** 57 + * Compile a field expression into a card predicate 58 + */ 59 + export function compileField( 60 + field: FieldName, 61 + operator: ComparisonOp, 62 + value: FieldValue, 63 + ): CardPredicate { 64 + switch (field) { 65 + // Text fields 66 + case "name": 67 + return compileTextField((c) => c.name, operator, value); 68 + 69 + case "type": 70 + return compileTextField((c) => c.type_line, operator, value); 71 + 72 + case "oracle": 73 + return compileOracleText(operator, value); 74 + 75 + // Color fields 76 + case "color": 77 + return compileColorField((c) => c.colors, operator, value); 78 + 79 + case "identity": 80 + // Numeric comparison: id>1 means "more than 1 color in identity" 81 + if (value.kind === "number") { 82 + return createOrderedMatcher( 83 + (card) => card.color_identity?.length ?? 0, 84 + value.value, 85 + operator, 86 + ); 87 + } 88 + return compileColorField((c) => c.color_identity, operator, value); 89 + 90 + // Mana fields 91 + case "mana": 92 + return compileTextField((c) => c.mana_cost, operator, value); 93 + 94 + case "manavalue": 95 + return compileNumericField((c) => c.cmc, operator, value); 96 + 97 + // Stats 98 + case "power": 99 + return compileStatField((c) => c.power, operator, value); 100 + 101 + case "toughness": 102 + return compileStatField((c) => c.toughness, operator, value); 103 + 104 + case "loyalty": 105 + return compileStatField((c) => c.loyalty, operator, value); 106 + 107 + case "defense": 108 + return compileStatField((c) => c.defense, operator, value); 109 + 110 + // Keywords 111 + case "keyword": 112 + return compileKeyword(operator, value); 113 + 114 + // Set/printing (discrete fields use exact match for ':') 115 + case "set": 116 + return compileTextField((c) => c.set, operator, value, true); 117 + 118 + case "settype": 119 + return compileTextField((c) => c.set_type, operator, value, true); 120 + 121 + case "layout": 122 + return compileTextField((c) => c.layout, operator, value, true); 123 + 124 + case "frame": 125 + return compileTextField((c) => c.frame, operator, value, true); 126 + 127 + case "border": 128 + return compileTextField((c) => c.border_color, operator, value, true); 129 + 130 + case "number": 131 + return compileTextField((c) => c.collector_number, operator, value); 132 + 133 + case "rarity": 134 + return compileRarity(operator, value); 135 + 136 + case "artist": 137 + return compileTextField((c) => c.artist, operator, value); 138 + 139 + // Legality 140 + case "format": 141 + return compileFormat(operator, value); 142 + 143 + case "banned": 144 + return compileLegality("banned", value); 145 + 146 + case "restricted": 147 + return compileLegality("restricted", value); 148 + 149 + // Misc 150 + case "game": 151 + return compileGame(operator, value); 152 + 153 + case "in": 154 + return compileIn(operator, value); 155 + 156 + case "produces": 157 + return compileProduces(operator, value); 158 + 159 + case "year": 160 + return compileYear(operator, value); 161 + 162 + case "date": 163 + return compileDate(operator, value); 164 + 165 + case "lang": 166 + return compileTextField((c) => c.lang, operator, value, true); 167 + 168 + // Boolean predicates 169 + case "is": 170 + return compileIs(value); 171 + 172 + case "not": 173 + return compileNot(value); 174 + 175 + default: 176 + return () => false; 177 + } 178 + } 179 + 180 + /** 181 + * Compile text field matcher 182 + * @param discrete - If true, ':' means exact match instead of substring 183 + */ 184 + function compileTextField( 185 + getter: (card: Card) => string | undefined, 186 + operator: ComparisonOp, 187 + value: FieldValue, 188 + discrete = false, 189 + ): CardPredicate { 190 + if (value.kind === "regex") { 191 + const pattern = value.pattern; 192 + return (card) => { 193 + const cardValue = getter(card); 194 + return cardValue ? pattern.test(cardValue) : false; 195 + }; 196 + } 197 + 198 + if (value.kind !== "string") { 199 + return () => false; 200 + } 201 + 202 + const searchValue = value.value.toLowerCase(); 203 + 204 + switch (operator) { 205 + case ":": 206 + // Discrete fields use exact match, text fields use substring 207 + if (discrete) { 208 + return (card) => { 209 + const cardValue = getter(card); 210 + return cardValue ? cardValue.toLowerCase() === searchValue : false; 211 + }; 212 + } 213 + return (card) => { 214 + const cardValue = getter(card); 215 + return cardValue 216 + ? cardValue.toLowerCase().includes(searchValue) 217 + : false; 218 + }; 219 + case "=": 220 + return (card) => { 221 + const cardValue = getter(card); 222 + return cardValue ? cardValue.toLowerCase() === searchValue : false; 223 + }; 224 + case "!=": 225 + return (card) => { 226 + const cardValue = getter(card); 227 + return cardValue ? cardValue.toLowerCase() !== searchValue : true; 228 + }; 229 + default: 230 + return (card) => { 231 + const cardValue = getter(card); 232 + return cardValue 233 + ? cardValue.toLowerCase().includes(searchValue) 234 + : false; 235 + }; 236 + } 237 + } 238 + 239 + /** 240 + * Compile oracle text matcher (checks card faces too) 241 + */ 242 + function compileOracleText( 243 + operator: ComparisonOp, 244 + value: FieldValue, 245 + ): CardPredicate { 246 + const textMatcher = compileTextField((c) => c.oracle_text, operator, value); 247 + 248 + return (card) => { 249 + if (textMatcher(card)) return true; 250 + 251 + if (card.card_faces) { 252 + for (const face of card.card_faces) { 253 + const faceCard = { oracle_text: face.oracle_text } as Card; 254 + if (textMatcher(faceCard)) return true; 255 + } 256 + } 257 + 258 + return false; 259 + }; 260 + } 261 + 262 + /** 263 + * Compile color field matcher 264 + */ 265 + function compileColorField( 266 + getter: (card: Card) => string[] | undefined, 267 + operator: ComparisonOp, 268 + value: FieldValue, 269 + ): CardPredicate { 270 + let searchColors: Set<string>; 271 + 272 + if (value.kind === "colors") { 273 + searchColors = value.colors; 274 + } else if (value.kind === "string") { 275 + searchColors = new Set<string>(); 276 + for (const char of value.value.toUpperCase()) { 277 + if ("WUBRGC".includes(char)) searchColors.add(char); 278 + } 279 + } else { 280 + return () => false; 281 + } 282 + 283 + return (card) => compareColors(getter(card), searchColors, operator); 284 + } 285 + 286 + /** 287 + * Compile numeric field matcher 288 + */ 289 + function compileNumericField( 290 + getter: (card: Card) => number | undefined, 291 + operator: ComparisonOp, 292 + value: FieldValue, 293 + ): CardPredicate { 294 + if (value.kind !== "number") { 295 + return () => false; 296 + } 297 + return createOrderedMatcher(getter, value.value, operator); 298 + } 299 + 300 + /** 301 + * Compile stat field matcher (power/toughness with * handling) 302 + */ 303 + function compileStatField( 304 + getter: (card: Card) => string | undefined, 305 + operator: ComparisonOp, 306 + value: FieldValue, 307 + ): CardPredicate { 308 + // Special case: matching * exactly 309 + if (value.kind === "string" && value.value === "*") { 310 + switch (operator) { 311 + case ":": 312 + case "=": 313 + return (card) => { 314 + const v = getter(card); 315 + return v === "*" || (v?.includes("*") ?? false); 316 + }; 317 + case "!=": 318 + return (card) => { 319 + const v = getter(card); 320 + return v !== "*" && !(v?.includes("*") ?? false); 321 + }; 322 + default: 323 + return () => false; 324 + } 325 + } 326 + 327 + if (value.kind !== "number") { 328 + return () => false; 329 + } 330 + 331 + const parseStatValue = (cardValue: string | undefined): number | null => { 332 + if (!cardValue) return null; 333 + if (cardValue === "*" || cardValue.includes("*")) return 0; 334 + const num = parseFloat(cardValue); 335 + return Number.isNaN(num) ? null : num; 336 + }; 337 + 338 + return createOrderedMatcher( 339 + (card) => parseStatValue(getter(card)), 340 + value.value, 341 + operator, 342 + ); 343 + } 344 + 345 + /** 346 + * Compile keyword matcher 347 + */ 348 + function compileKeyword( 349 + operator: ComparisonOp, 350 + value: FieldValue, 351 + ): CardPredicate { 352 + if (value.kind === "regex") { 353 + const pattern = value.pattern; 354 + return (card) => card.keywords?.some((kw) => pattern.test(kw)) ?? false; 355 + } 356 + 357 + if (value.kind !== "string") { 358 + return () => false; 359 + } 360 + 361 + const searchValue = value.value.toLowerCase(); 362 + 363 + switch (operator) { 364 + case ":": 365 + return (card) => 366 + card.keywords?.some((kw) => kw.toLowerCase().includes(searchValue)) ?? 367 + false; 368 + case "=": 369 + return (card) => 370 + card.keywords?.some((kw) => kw.toLowerCase() === searchValue) ?? false; 371 + case "!=": 372 + return (card) => 373 + !( 374 + card.keywords?.some((kw) => kw.toLowerCase() === searchValue) ?? false 375 + ); 376 + default: 377 + return (card) => 378 + card.keywords?.some((kw) => kw.toLowerCase().includes(searchValue)) ?? 379 + false; 380 + } 381 + } 382 + 383 + /** 384 + * Rarity shorthand expansion 385 + */ 386 + export const RARITY_ALIASES: Record<string, string> = { 387 + c: "common", 388 + common: "common", 389 + u: "uncommon", 390 + uncommon: "uncommon", 391 + r: "rare", 392 + rare: "rare", 393 + m: "mythic", 394 + mythic: "mythic", 395 + s: "special", 396 + special: "special", 397 + b: "bonus", 398 + bonus: "bonus", 399 + }; 400 + 401 + /** 402 + * Rarity ordering for comparisons (lower = less rare) 403 + */ 404 + const RARITY_ORDER: Record<string, number> = { 405 + common: 0, 406 + uncommon: 1, 407 + rare: 2, 408 + mythic: 3, 409 + special: 4, 410 + bonus: 5, 411 + }; 412 + 413 + /** 414 + * Compile rarity matcher with shorthand expansion and comparisons 415 + */ 416 + function compileRarity( 417 + operator: ComparisonOp, 418 + value: FieldValue, 419 + ): CardPredicate { 420 + if (value.kind !== "string") { 421 + return () => false; 422 + } 423 + 424 + const expanded = RARITY_ALIASES[value.value.toLowerCase()]; 425 + if (!expanded) { 426 + return () => false; 427 + } 428 + 429 + const targetRank = RARITY_ORDER[expanded]; 430 + 431 + return createOrderedMatcher( 432 + (card) => RARITY_ORDER[card.rarity ?? ""], 433 + targetRank, 434 + operator, 435 + ); 436 + } 437 + 438 + /** 439 + * Compile format legality matcher 440 + */ 441 + function compileFormat( 442 + operator: ComparisonOp, 443 + value: FieldValue, 444 + ): CardPredicate { 445 + if (value.kind !== "string") { 446 + return () => false; 447 + } 448 + 449 + const format = value.value.toLowerCase(); 450 + 451 + switch (operator) { 452 + case ":": 453 + case "=": 454 + return (card) => { 455 + const legality = card.legalities?.[format]; 456 + return legality === "legal" || legality === "restricted"; 457 + }; 458 + case "!=": 459 + return (card) => { 460 + const legality = card.legalities?.[format]; 461 + return legality !== "legal" && legality !== "restricted"; 462 + }; 463 + default: 464 + return (card) => { 465 + const legality = card.legalities?.[format]; 466 + return legality === "legal" || legality === "restricted"; 467 + }; 468 + } 469 + } 470 + 471 + /** 472 + * Compile specific legality status matcher 473 + */ 474 + function compileLegality( 475 + status: "banned" | "restricted", 476 + value: FieldValue, 477 + ): CardPredicate { 478 + if (value.kind !== "string") { 479 + return () => false; 480 + } 481 + 482 + const format = value.value.toLowerCase(); 483 + return (card) => card.legalities?.[format] === status; 484 + } 485 + 486 + /** 487 + * Compile game availability matcher 488 + */ 489 + function compileGame(operator: ComparisonOp, value: FieldValue): CardPredicate { 490 + if (value.kind !== "string") { 491 + return () => false; 492 + } 493 + 494 + const game = value.value.toLowerCase() as "paper" | "arena" | "mtgo"; 495 + 496 + switch (operator) { 497 + case ":": 498 + case "=": 499 + return (card) => card.games?.includes(game) ?? false; 500 + case "!=": 501 + return (card) => !(card.games?.includes(game) ?? false); 502 + default: 503 + return (card) => card.games?.includes(game) ?? false; 504 + } 505 + } 506 + 507 + const GAMES = new Set(["paper", "mtgo", "arena"]); 508 + const SET_TYPES = new Set([ 509 + "alchemy", 510 + "archenemy", 511 + "arsenal", 512 + "box", 513 + "commander", 514 + "core", 515 + "draft_innovation", 516 + "duel_deck", 517 + "eternal", 518 + "expansion", 519 + "from_the_vault", 520 + "funny", 521 + "masterpiece", 522 + "masters", 523 + "memorabilia", 524 + "minigame", 525 + "planechase", 526 + "premium_deck", 527 + "promo", 528 + "spellbook", 529 + "starter", 530 + "token", 531 + "treasure_chest", 532 + "vanguard", 533 + ]); 534 + 535 + /** 536 + * Compile "in:" matcher - unified field for game, set, set type, and language 537 + * Scryfall's "in:" checks if a card has been printed in a given context 538 + */ 539 + function compileIn(operator: ComparisonOp, value: FieldValue): CardPredicate { 540 + if (value.kind !== "string") { 541 + return () => false; 542 + } 543 + 544 + const searchValue = value.value.toLowerCase(); 545 + const isNegated = operator === "!="; 546 + 547 + // Check game availability (paper, mtgo, arena) 548 + if (GAMES.has(searchValue)) { 549 + const game = searchValue as "paper" | "arena" | "mtgo"; 550 + return isNegated 551 + ? (card) => !(card.games?.includes(game) ?? false) 552 + : (card) => card.games?.includes(game) ?? false; 553 + } 554 + 555 + // Check set type (core, expansion, commander, etc.) 556 + if (SET_TYPES.has(searchValue)) { 557 + return isNegated 558 + ? (card) => card.set_type?.toLowerCase() !== searchValue 559 + : (card) => card.set_type?.toLowerCase() === searchValue; 560 + } 561 + 562 + // Fall back to set code or language 563 + // Set codes are typically 3-4 chars, languages are 2-3 chars 564 + // Check both - if either matches, include the card 565 + return isNegated 566 + ? (card) => 567 + card.set?.toLowerCase() !== searchValue && 568 + card.lang?.toLowerCase() !== searchValue 569 + : (card) => 570 + card.set?.toLowerCase() === searchValue || 571 + card.lang?.toLowerCase() === searchValue; 572 + } 573 + 574 + /** 575 + * Compile mana production matcher 576 + */ 577 + function compileProduces( 578 + operator: ComparisonOp, 579 + value: FieldValue, 580 + ): CardPredicate { 581 + let searchColors: Set<string>; 582 + 583 + if (value.kind === "colors") { 584 + searchColors = value.colors; 585 + } else if (value.kind === "string") { 586 + searchColors = new Set<string>(); 587 + for (const char of value.value.toUpperCase()) { 588 + if ("WUBRGC".includes(char)) searchColors.add(char); 589 + } 590 + } else { 591 + return () => false; 592 + } 593 + 594 + return (card) => { 595 + if (!card.produced_mana) return false; 596 + // produced_mana has literal "C" for colorless, unlike colors/color_identity 597 + return compareColors(card.produced_mana, searchColors, operator, true); 598 + }; 599 + } 600 + 601 + /** 602 + * Compile release year matcher 603 + */ 604 + function compileYear(operator: ComparisonOp, value: FieldValue): CardPredicate { 605 + if (value.kind !== "number") { 606 + return () => false; 607 + } 608 + 609 + const getYear = (card: Card): number | null => { 610 + if (!card.released_at) return null; 611 + const year = parseInt(card.released_at.slice(0, 4), 10); 612 + return Number.isNaN(year) ? null : year; 613 + }; 614 + 615 + return createOrderedMatcher(getYear, value.value, operator); 616 + } 617 + 618 + /** 619 + * Compile release date matcher 620 + */ 621 + function compileDate(operator: ComparisonOp, value: FieldValue): CardPredicate { 622 + if (value.kind !== "string") { 623 + return () => false; 624 + } 625 + return createOrderedMatcher( 626 + (card) => card.released_at, 627 + value.value, 628 + operator, 629 + ); 630 + } 631 + 632 + /** 633 + * Check if a card can be used as a commander 634 + */ 635 + function canBeCommander(card: Card): boolean { 636 + const typeLine = card.type_line?.toLowerCase() ?? ""; 637 + 638 + // Legendary creatures, vehicles, and spacecraft can be commanders 639 + if (typeLine.includes("legendary")) { 640 + if ( 641 + typeLine.includes("creature") || 642 + typeLine.includes("vehicle") || 643 + typeLine.includes("spacecraft") 644 + ) { 645 + return true; 646 + } 647 + } 648 + 649 + // Cards with "can be your commander" text 650 + const oracleText = card.oracle_text?.toLowerCase() ?? ""; 651 + if (oracleText.includes("can be your commander")) { 652 + return true; 653 + } 654 + 655 + // Check card faces for MDFCs 656 + if (card.card_faces) { 657 + for (const face of card.card_faces) { 658 + const faceType = face.type_line?.toLowerCase() ?? ""; 659 + if (faceType.includes("legendary")) { 660 + if ( 661 + faceType.includes("creature") || 662 + faceType.includes("vehicle") || 663 + faceType.includes("spacecraft") 664 + ) { 665 + return true; 666 + } 667 + } 668 + const faceText = face.oracle_text?.toLowerCase() ?? ""; 669 + if (faceText.includes("can be your commander")) { 670 + return true; 671 + } 672 + } 673 + } 674 + 675 + return false; 676 + } 677 + 678 + /** 679 + * Boolean is: predicates 680 + */ 681 + const IS_PREDICATES: Record<string, CardPredicate> = { 682 + // Reserved list 683 + reserved: (card) => card.reserved === true, 684 + 685 + // Printing characteristics 686 + reprint: (card) => card.reprint === true, 687 + promo: (card) => card.promo === true, 688 + full: (card) => card.full_art === true, 689 + digital: (card) => card.digital === true, 690 + 691 + // Finishes 692 + foil: (card) => card.finishes?.includes("foil") ?? false, 693 + nonfoil: (card) => card.finishes?.includes("nonfoil") ?? false, 694 + etched: (card) => card.finishes?.includes("etched") ?? false, 695 + 696 + // Layout types 697 + split: (card) => card.layout === "split", 698 + flip: (card) => card.layout === "flip", 699 + transform: (card) => card.layout === "transform", 700 + mdfc: (card) => card.layout === "modal_dfc", 701 + dfc: (card) => card.layout === "transform" || card.layout === "modal_dfc", 702 + meld: (card) => card.layout === "meld", 703 + leveler: (card) => card.layout === "leveler", 704 + saga: (card) => card.layout === "saga", 705 + adventure: (card) => card.layout === "adventure", 706 + battle: (card) => card.layout === "battle", 707 + prototype: (card) => card.layout === "prototype", 708 + token: (card) => card.layout === "token", 709 + art_series: (card) => card.layout === "art_series", 710 + 711 + // Commander - can this card BE a commander 712 + commander: canBeCommander, 713 + 714 + // Type-based predicates 715 + permanent: (card) => { 716 + const types = card.type_line?.toLowerCase() ?? ""; 717 + return [ 718 + "creature", 719 + "artifact", 720 + "enchantment", 721 + "land", 722 + "planeswalker", 723 + "battle", 724 + ].some((t) => types.includes(t)); 725 + }, 726 + spell: (card) => { 727 + const types = card.type_line?.toLowerCase() ?? ""; 728 + return ["instant", "sorcery"].some((t) => types.includes(t)); 729 + }, 730 + creature: (card) => 731 + card.type_line?.toLowerCase().includes("creature") ?? false, 732 + artifact: (card) => 733 + card.type_line?.toLowerCase().includes("artifact") ?? false, 734 + enchantment: (card) => 735 + card.type_line?.toLowerCase().includes("enchantment") ?? false, 736 + land: (card) => card.type_line?.toLowerCase().includes("land") ?? false, 737 + planeswalker: (card) => 738 + card.type_line?.toLowerCase().includes("planeswalker") ?? false, 739 + instant: (card) => card.type_line?.toLowerCase().includes("instant") ?? false, 740 + sorcery: (card) => card.type_line?.toLowerCase().includes("sorcery") ?? false, 741 + legendary: (card) => 742 + card.type_line?.toLowerCase().includes("legendary") ?? false, 743 + snow: (card) => card.type_line?.toLowerCase().includes("snow") ?? false, 744 + historic: (card) => { 745 + const types = card.type_line?.toLowerCase() ?? ""; 746 + return ( 747 + types.includes("legendary") || 748 + types.includes("artifact") || 749 + types.includes("saga") 750 + ); 751 + }, 752 + 753 + // Frame effects (from frame_effects array) 754 + showcase: (card) => card.frame_effects?.includes("showcase") ?? false, 755 + extendedart: (card) => card.frame_effects?.includes("extendedart") ?? false, 756 + borderless: (card) => card.border_color === "borderless", 757 + fullart: (card) => card.full_art === true, 758 + inverted: (card) => card.frame_effects?.includes("inverted") ?? false, 759 + colorshifted: (card) => card.frame_effects?.includes("colorshifted") ?? false, 760 + retro: (card) => card.frame === "1997" || card.frame === "1993", 761 + old: (card) => card.frame === "1993" || card.frame === "1997", 762 + modern: (card) => card.frame === "2003" || card.frame === "2015", 763 + new: (card) => card.frame === "2015", 764 + future: (card) => card.frame === "future", 765 + 766 + // Promo types (from promo_types array) 767 + buyabox: (card) => card.promo_types?.includes("buyabox") ?? false, 768 + prerelease: (card) => card.promo_types?.includes("prerelease") ?? false, 769 + datestamped: (card) => card.promo_types?.includes("datestamped") ?? false, 770 + fnm: (card) => card.promo_types?.includes("fnm") ?? false, 771 + gameday: (card) => card.promo_types?.includes("gameday") ?? false, 772 + release: (card) => card.promo_types?.includes("release") ?? false, 773 + promopacks: (card) => card.promo_types?.includes("promopack") ?? false, 774 + boosterfun: (card) => card.frame_effects?.includes("boosterfun") ?? false, 775 + 776 + // Land cycle predicates - use exact oracle text patterns to match only the 10-card cycles 777 + // Each pattern should match exactly 10 cards 778 + 779 + fetchland: (card) => { 780 + const oracle = card.oracle_text ?? ""; 781 + // Pattern: "{T}, Pay 1 life, Sacrifice this land: Search your library for a[n] X or Y card..." 782 + const pattern = 783 + /^\{T\}, Pay 1 life, Sacrifice this land: Search your library for an? \w+ or \w+ card, put it onto the battlefield, then shuffle\.$/i; 784 + return pattern.test(oracle); 785 + }, 786 + shockland: (card) => { 787 + const oracle = card.oracle_text ?? ""; 788 + // Pattern: "({T}: Add {X} or {Y}.)\nAs this land enters, you may pay 2 life..." 789 + const pattern = 790 + /^\(\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.\)\nAs this land enters, you may pay 2 life\. If you don't, it enters tapped\.$/i; 791 + return pattern.test(oracle); 792 + }, 793 + dual: (card) => { 794 + const typeLine = card.type_line ?? ""; 795 + const oracle = card.oracle_text ?? ""; 796 + // Original duals: type line exactly "Land — X Y" (no supertypes like Snow) 797 + // and oracle text is exactly the mana reminder 798 + const typePattern = 799 + /^Land — (Plains|Island|Swamp|Mountain|Forest) (Plains|Island|Swamp|Mountain|Forest)$/; 800 + const oraclePattern = /^\(\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.\)$/; 801 + return typePattern.test(typeLine) && oraclePattern.test(oracle); 802 + }, 803 + triome: (card) => { 804 + const types = card.type_line?.toLowerCase() ?? ""; 805 + const landTypes = ["plains", "island", "swamp", "mountain", "forest"]; 806 + const matchCount = landTypes.filter((t) => types.includes(t)).length; 807 + return types.includes("land") && matchCount >= 3; 808 + }, 809 + checkland: (card) => { 810 + const oracle = card.oracle_text ?? ""; 811 + // Pattern: "This land enters tapped unless you control a X or a Y.\n{T}: Add {X} or {Y}." 812 + const pattern = 813 + /^This land enters tapped unless you control an? \w+ or an? \w+\.\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.$/i; 814 + return pattern.test(oracle); 815 + }, 816 + fastland: (card) => { 817 + const oracle = card.oracle_text ?? ""; 818 + // Pattern: "This land enters tapped unless you control two or fewer other lands.\n{T}: Add {X} or {Y}." 819 + const pattern = 820 + /^This land enters tapped unless you control two or fewer other lands\.\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.$/i; 821 + return pattern.test(oracle); 822 + }, 823 + slowland: (card) => { 824 + const oracle = card.oracle_text ?? ""; 825 + // Pattern: "This land enters tapped unless you control two or more other lands.\n{T}: Add {X} or {Y}." 826 + const pattern = 827 + /^This land enters tapped unless you control two or more other lands\.\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.$/i; 828 + return pattern.test(oracle); 829 + }, 830 + painland: (card) => { 831 + const oracle = card.oracle_text ?? ""; 832 + // Pattern: "{T}: Add {C}.\n{T}: Add {X} or {Y}. This land deals 1 damage to you." 833 + const pattern = 834 + /^\{T\}: Add \{C\}\.\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\. This land deals 1 damage to you\.$/i; 835 + return pattern.test(oracle); 836 + }, 837 + filterland: (card) => { 838 + const oracle = card.oracle_text ?? ""; 839 + // Pattern: "{T}: Add {C}.\n{W/U}, {T}: Add {W}{W}, {W}{U}, or {U}{U}." 840 + const pattern = 841 + /^\{T\}: Add \{C\}\.\n\{[WUBRG]\/[WUBRG]\}, \{T\}: Add \{[WUBRG]\}\{[WUBRG]\}, \{[WUBRG]\}\{[WUBRG]\}, or \{[WUBRG]\}\{[WUBRG]\}\.$/i; 842 + return pattern.test(oracle); 843 + }, 844 + bounceland: (card) => { 845 + const oracle = card.oracle_text ?? ""; 846 + // Pattern: "This land enters tapped.\nWhen this land enters, return a land you control to its owner's hand.\n{T}: Add {W}{U}." 847 + const pattern = 848 + /^This land enters tapped\.\nWhen this land enters, return a land you control to its owner's hand\.\n\{T\}: Add \{[WUBRG]\}\{[WUBRG]\}\.$/i; 849 + return pattern.test(oracle); 850 + }, 851 + tangoland: (card) => { 852 + const oracle = card.oracle_text?.toLowerCase() ?? ""; 853 + const types = card.type_line?.toLowerCase() ?? ""; 854 + const landTypes = ["plains", "island", "swamp", "mountain", "forest"]; 855 + const matchCount = landTypes.filter((t) => types.includes(t)).length; 856 + return ( 857 + matchCount >= 2 && 858 + oracle.includes("enters tapped unless you control two or more basic") 859 + ); 860 + }, 861 + battleland: (card) => { 862 + const oracle = card.oracle_text?.toLowerCase() ?? ""; 863 + const types = card.type_line?.toLowerCase() ?? ""; 864 + const landTypes = ["plains", "island", "swamp", "mountain", "forest"]; 865 + const matchCount = landTypes.filter((t) => types.includes(t)).length; 866 + return ( 867 + matchCount >= 2 && 868 + oracle.includes("enters tapped unless you control two or more basic") 869 + ); 870 + }, 871 + scryland: (card) => { 872 + const oracle = card.oracle_text ?? ""; 873 + // Pattern: "This land enters tapped.\nWhen this land enters, scry 1. (reminder text)\n{T}: Add {W} or {U}." 874 + const pattern = 875 + /^This land enters tapped\.\nWhen this land enters, scry 1\. \([^)]+\)\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.$/i; 876 + return pattern.test(oracle); 877 + }, 878 + gainland: (card) => { 879 + const oracle = card.oracle_text ?? ""; 880 + // Pattern: "This land enters tapped.\nWhen this land enters, you gain 1 life.\n{T}: Add {W} or {U}." 881 + const pattern = 882 + /^This land enters tapped\.\nWhen this land enters, you gain 1 life\.\n\{T\}: Add \{[WUBRG]\} or \{[WUBRG]\}\.$/i; 883 + return pattern.test(oracle); 884 + }, 885 + manland: (card) => { 886 + const oracle = card.oracle_text?.toLowerCase() ?? ""; 887 + const types = card.type_line?.toLowerCase() ?? ""; 888 + return types.includes("land") && oracle.includes("becomes a"); 889 + }, 890 + canopyland: (card) => { 891 + const oracle = card.oracle_text ?? ""; 892 + // Pattern: "{T}, Pay 1 life: Add {G} or {W}.\n{1}, {T}, Sacrifice this land: Draw a card." 893 + const pattern = 894 + /^\{T\}, Pay 1 life: Add \{[WUBRG]\} or \{[WUBRG]\}\.\n\{1\}, \{T\}, Sacrifice this land: Draw a card\.$/i; 895 + return pattern.test(oracle); 896 + }, 897 + creatureland: (card) => { 898 + const oracle = card.oracle_text?.toLowerCase() ?? ""; 899 + const types = card.type_line?.toLowerCase() ?? ""; 900 + return types.includes("land") && oracle.includes("becomes a"); 901 + }, 902 + 903 + // Card archetypes 904 + vanilla: (card) => { 905 + const types = card.type_line?.toLowerCase() ?? ""; 906 + return ( 907 + types.includes("creature") && 908 + (!card.oracle_text || card.oracle_text === "") 909 + ); 910 + }, 911 + frenchvanilla: (card) => { 912 + const types = card.type_line?.toLowerCase() ?? ""; 913 + const keywords = card.keywords ?? []; 914 + if (!types.includes("creature") || keywords.length === 0) return false; 915 + 916 + // Adventure, flip, and MDFC are never french vanilla 917 + const neverVanillaLayouts = new Set(["adventure", "flip", "modal_dfc"]); 918 + if (neverVanillaLayouts.has(card.layout ?? "")) return false; 919 + 920 + // Helper to check if oracle text is keyword-only 921 + const isKeywordOnly = (oracle: string, kws: string[]): boolean => { 922 + oracle = oracle.replace(/\([^)]*\)/g, "").trim(); 923 + if (!oracle) return true; 924 + 925 + const kwLower = kws.map((k) => k.toLowerCase()); 926 + const lines = oracle.split(/\n/).map((s) => s.trim().toLowerCase()); 927 + 928 + // Helper to validate a single keyword segment 929 + const isValidKeywordSegment = (seg: string): boolean => { 930 + if (!seg) return true; 931 + if (seg.includes(":")) return false; // Activated ability 932 + 933 + const kw = kwLower.find((k) => seg.startsWith(k)); 934 + if (!kw) return false; 935 + 936 + const afterKw = seg.slice(kw.length).trim(); 937 + if (!afterKw) return true; // Just the keyword 938 + 939 + // "keyword N" = direct numeric parameter (Rampage 3, Bushido 2) - NOT french vanilla 940 + if (/^\d+($|—)/.test(afterKw)) return false; 941 + 942 + // "from X" - protection/hexproof targets 943 + if (afterKw.startsWith("from ")) return true; 944 + 945 + // "with X" (partner with) 946 + if (afterKw.startsWith("with ")) return true; 947 + 948 + // Mana cost 949 + if (afterKw.startsWith("{")) return true; 950 + 951 + // Em dash for keyword costs - the whole rest of the line is the cost 952 + if (afterKw.startsWith("—") || afterKw.startsWith("— ")) { 953 + const afterDash = afterKw.replace(/^—\s*/, ""); 954 + // Reject if there's an additional sentence (period followed by more text) 955 + // e.g. "Specialize {6}. You may also activate..." has extra rules 956 + if (/\.\s+\S/.test(afterDash)) return false; 957 + // Mana cost first (Escape—{3}{R}{G}, ...) 958 + if (afterDash.startsWith("{")) return true; 959 + // Another keyword as cost (Modular—Sunburst, Ward—Collect evidence) 960 + if (kwLower.some((k) => afterDash.startsWith(k))) return true; 961 + // Cost verbs or patterns - everything after dash is the cost clause 962 + const costPatterns = 963 + /^(put|discard|pay|sacrifice|remove|exile|tap|untap|return|reveal|say|an opponent)/i; 964 + if (costPatterns.test(afterDash)) return true; 965 + return false; 966 + } 967 + 968 + return false; 969 + }; 970 + 971 + return lines.every((line) => { 972 + if (!line) return true; 973 + // If line has em dash, treat whole line as one keyword clause 974 + if (line.includes("—")) { 975 + return isValidKeywordSegment(line); 976 + } 977 + // Otherwise comma-separated keywords (Flying, vigilance) 978 + const parts = line.split(/,/).map((s) => s.trim()); 979 + return parts.every(isValidKeywordSegment); 980 + }); 981 + }; 982 + 983 + // For transform cards, check if BOTH faces are keyword-only 984 + if (card.layout === "transform" && card.card_faces?.length === 2) { 985 + const face0 = card.card_faces[0]; 986 + const face1 = card.card_faces[1]; 987 + // Both faces must be creatures with keywords 988 + if ( 989 + !face0.type_line?.toLowerCase().includes("creature") || 990 + !face1.type_line?.toLowerCase().includes("creature") 991 + ) { 992 + return false; 993 + } 994 + return ( 995 + isKeywordOnly(face0.oracle_text ?? "", keywords) && 996 + isKeywordOnly(face1.oracle_text ?? "", keywords) 997 + ); 998 + } 999 + 1000 + // For normal cards 1001 + let oracle = card.oracle_text ?? ""; 1002 + if (!oracle && card.card_faces?.[0]?.oracle_text) { 1003 + oracle = card.card_faces[0].oracle_text; 1004 + } 1005 + return isKeywordOnly(oracle, keywords); 1006 + }, 1007 + bear: (card) => { 1008 + const types = card.type_line?.toLowerCase() ?? ""; 1009 + return ( 1010 + types.includes("creature") && 1011 + card.cmc === 2 && 1012 + card.power === "2" && 1013 + card.toughness === "2" 1014 + ); 1015 + }, 1016 + modal: (card) => { 1017 + const oracle = card.oracle_text?.toLowerCase() ?? ""; 1018 + return ( 1019 + oracle.includes("choose one") || 1020 + oracle.includes("choose two") || 1021 + oracle.includes("choose three") || 1022 + oracle.includes("choose four") || 1023 + oracle.includes("choose any number") || 1024 + card.layout === "modal_dfc" || 1025 + card.frame_effects?.includes("spree") === true 1026 + ); 1027 + }, 1028 + spree: (card) => card.frame_effects?.includes("spree") ?? false, 1029 + party: (card) => { 1030 + const types = card.type_line?.toLowerCase() ?? ""; 1031 + return ( 1032 + types.includes("cleric") || 1033 + types.includes("rogue") || 1034 + types.includes("warrior") || 1035 + types.includes("wizard") 1036 + ); 1037 + }, 1038 + outlaw: (card) => { 1039 + const types = card.type_line?.toLowerCase() ?? ""; 1040 + return ( 1041 + types.includes("assassin") || 1042 + types.includes("mercenary") || 1043 + types.includes("pirate") || 1044 + types.includes("rogue") || 1045 + types.includes("warlock") 1046 + ); 1047 + }, 1048 + 1049 + // Hires/quality 1050 + hires: (card) => card.highres_image === true, 1051 + }; 1052 + 1053 + /** 1054 + * Compile is: predicate 1055 + */ 1056 + function compileIs(value: FieldValue): CardPredicate { 1057 + if (value.kind !== "string") { 1058 + return () => false; 1059 + } 1060 + 1061 + const predicate = IS_PREDICATES[value.value.toLowerCase()]; 1062 + return predicate ?? (() => false); 1063 + } 1064 + 1065 + /** 1066 + * Compile not: predicate (negated is:) 1067 + */ 1068 + function compileNot(value: FieldValue): CardPredicate { 1069 + const isPredicate = compileIs(value); 1070 + return (card) => !isPredicate(card); 1071 + }
+109
src/lib/search/index.ts
··· 1 + /** 2 + * Scryfall search syntax parser and matcher 3 + * 4 + * Usage: 5 + * import { search } from "@/lib/search"; 6 + * 7 + * const result = search("t:creature cmc<=3 id<=bg"); 8 + * if (result.ok) { 9 + * const matches = cards.filter(result.value.match); 10 + * } 11 + */ 12 + 13 + import type { Card } from "../scryfall-types"; 14 + import { type CardPredicate, compile } from "./matcher"; 15 + import { parse } from "./parser"; 16 + import type { ParseError, Result, SearchNode } from "./types"; 17 + 18 + /** 19 + * Compiled search query 20 + */ 21 + export interface CompiledSearch { 22 + /** Test if a card matches the query */ 23 + match: CardPredicate; 24 + /** The parsed AST */ 25 + ast: SearchNode; 26 + } 27 + 28 + /** 29 + * Parse and compile a Scryfall search query 30 + * 31 + * Returns a Result with either a compiled search or a parse error. 32 + */ 33 + export function search(query: string): Result<CompiledSearch> { 34 + const parseResult = parse(query); 35 + 36 + if (!parseResult.ok) { 37 + return parseResult; 38 + } 39 + 40 + const ast = parseResult.value; 41 + const match = compile(ast); 42 + 43 + return { 44 + ok: true, 45 + value: { match, ast }, 46 + }; 47 + } 48 + 49 + /** 50 + * Filter an array of cards using a Scryfall search query 51 + * 52 + * Returns matching cards, or empty array if query is invalid. 53 + */ 54 + export function filterCards( 55 + cards: Card[], 56 + query: string, 57 + maxResults?: number, 58 + ): Card[] { 59 + const result = search(query); 60 + 61 + if (!result.ok) { 62 + return []; 63 + } 64 + 65 + const matches: Card[] = []; 66 + for (const card of cards) { 67 + if (result.value.match(card)) { 68 + matches.push(card); 69 + if (maxResults && matches.length >= maxResults) { 70 + break; 71 + } 72 + } 73 + } 74 + 75 + return matches; 76 + } 77 + 78 + /** 79 + * Check if any node in the AST matches the predicate (like Array.some for AST) 80 + */ 81 + export function someNode( 82 + node: SearchNode, 83 + predicate: (node: SearchNode) => boolean, 84 + ): boolean { 85 + if (predicate(node)) return true; 86 + 87 + switch (node.type) { 88 + case "AND": 89 + case "OR": 90 + return node.children.some((child) => someNode(child, predicate)); 91 + case "NOT": 92 + return someNode(node.child, predicate); 93 + default: 94 + return false; 95 + } 96 + } 97 + 98 + // Re-export types 99 + export type { SearchNode, Result, ParseError, CardPredicate }; 100 + export type { CompiledSearch as SearchResult }; 101 + 102 + export { describeQuery } from "./describe"; 103 + export { tokenize } from "./lexer"; 104 + export { compile } from "./matcher"; 105 + 106 + // Operator detection and query description 107 + export { hasSearchOperators } from "./operators"; 108 + // Re-export parsing functions for advanced use 109 + export { parse } from "./parser";
+327
src/lib/search/lexer.ts
··· 1 + /** 2 + * Lexer for Scryfall search syntax 3 + * 4 + * Converts input string to token stream. 5 + */ 6 + 7 + import { 8 + err, 9 + FIELD_ALIASES, 10 + ok, 11 + type ParseError, 12 + type Result, 13 + type Token, 14 + type TokenType, 15 + } from "./types"; 16 + 17 + /** 18 + * Characters that end a word token 19 + */ 20 + const WORD_TERMINATORS = new Set([ 21 + " ", 22 + "\t", 23 + "\n", 24 + "\r", 25 + "(", 26 + ")", 27 + ":", 28 + "=", 29 + "!", 30 + "<", 31 + ">", 32 + '"', 33 + ]); 34 + 35 + // Track what token types can precede a regex 36 + const REGEX_STARTERS = new Set<TokenType>([ 37 + "COLON", 38 + "EQUALS", 39 + "NOT_EQUALS", 40 + "LT", 41 + "GT", 42 + "LTE", 43 + "GTE", 44 + "LPAREN", 45 + "NOT", 46 + "OR", 47 + ]); 48 + 49 + /** 50 + * Check if a word looks like a field name 51 + */ 52 + function isFieldName(word: string): boolean { 53 + return word.toLowerCase() in FIELD_ALIASES; 54 + } 55 + 56 + /** 57 + * Tokenize input string into token array 58 + */ 59 + export function tokenize(input: string): Result<Token[]> { 60 + const tokens: Token[] = []; 61 + let pos = 0; 62 + 63 + function peek(offset = 0): string { 64 + return input[pos + offset] ?? ""; 65 + } 66 + 67 + function advance(): string { 68 + return input[pos++] ?? ""; 69 + } 70 + 71 + function skipWhitespace(): void { 72 + while (pos < input.length && /\s/.test(peek())) { 73 + pos++; 74 + } 75 + } 76 + 77 + function makeToken(type: TokenType, value: string, start: number): Token { 78 + return { type, value, span: { start, end: pos } }; 79 + } 80 + 81 + function makeError(message: string, start: number): ParseError { 82 + return { message, span: { start, end: pos }, input }; 83 + } 84 + 85 + function readWord(): string { 86 + const start = pos; 87 + while (pos < input.length && !WORD_TERMINATORS.has(peek())) { 88 + // Handle ! at start of word specially (EXACT_NAME) 89 + if (peek() === "!" && pos === start) { 90 + break; 91 + } 92 + // Handle - at start of word specially (NOT) 93 + if (peek() === "-" && pos === start) { 94 + break; 95 + } 96 + pos++; 97 + } 98 + return input.slice(start, pos); 99 + } 100 + 101 + function readQuoted(): Result<string> { 102 + const start = pos; 103 + advance(); // consume opening " 104 + let value = ""; 105 + 106 + while (pos < input.length && peek() !== '"') { 107 + if (peek() === "\\") { 108 + advance(); 109 + if (pos < input.length) { 110 + value += advance(); 111 + } 112 + } else { 113 + value += advance(); 114 + } 115 + } 116 + 117 + if (peek() !== '"') { 118 + return err(makeError("Unterminated quoted string", start)); 119 + } 120 + advance(); // consume closing " 121 + return ok(value); 122 + } 123 + 124 + function readRegex(): Result<{ source: string; pattern: RegExp }> { 125 + const start = pos; 126 + advance(); // consume opening / 127 + let source = ""; 128 + 129 + while (pos < input.length && peek() !== "/") { 130 + if (peek() === "\\") { 131 + source += advance(); 132 + if (pos < input.length) { 133 + source += advance(); 134 + } 135 + } else { 136 + source += advance(); 137 + } 138 + } 139 + 140 + if (peek() !== "/") { 141 + return err(makeError("Unterminated regex", start)); 142 + } 143 + advance(); // consume closing / 144 + 145 + // Read optional flags 146 + let flags = "i"; // default case-insensitive 147 + while (pos < input.length && /[gimsuy]/.test(peek())) { 148 + const flag = advance(); 149 + if (!flags.includes(flag)) { 150 + flags += flag; 151 + } 152 + } 153 + 154 + // Try to construct the regex 155 + try { 156 + const pattern = new RegExp(source, flags); 157 + return ok({ source, pattern }); 158 + } catch (e) { 159 + const message = 160 + e instanceof Error ? e.message : "Invalid regular expression"; 161 + return err(makeError(`Invalid regex: ${message}`, start)); 162 + } 163 + } 164 + 165 + while (pos < input.length) { 166 + skipWhitespace(); 167 + if (pos >= input.length) break; 168 + 169 + const start = pos; 170 + const char = peek(); 171 + 172 + // Single-character tokens 173 + if (char === "(") { 174 + advance(); 175 + tokens.push(makeToken("LPAREN", "(", start)); 176 + continue; 177 + } 178 + 179 + if (char === ")") { 180 + advance(); 181 + tokens.push(makeToken("RPAREN", ")", start)); 182 + continue; 183 + } 184 + 185 + // Operators 186 + if (char === ":") { 187 + advance(); 188 + tokens.push(makeToken("COLON", ":", start)); 189 + continue; 190 + } 191 + 192 + if (char === "=") { 193 + advance(); 194 + tokens.push(makeToken("EQUALS", "=", start)); 195 + continue; 196 + } 197 + 198 + if (char === "!" && peek(1) === "=") { 199 + advance(); 200 + advance(); 201 + tokens.push(makeToken("NOT_EQUALS", "!=", start)); 202 + continue; 203 + } 204 + 205 + if (char === "<") { 206 + advance(); 207 + if (peek() === "=") { 208 + advance(); 209 + tokens.push(makeToken("LTE", "<=", start)); 210 + } else { 211 + tokens.push(makeToken("LT", "<", start)); 212 + } 213 + continue; 214 + } 215 + 216 + if (char === ">") { 217 + advance(); 218 + if (peek() === "=") { 219 + advance(); 220 + tokens.push(makeToken("GTE", ">=", start)); 221 + } else { 222 + tokens.push(makeToken("GT", ">", start)); 223 + } 224 + continue; 225 + } 226 + 227 + // Quoted string 228 + if (char === '"') { 229 + const result = readQuoted(); 230 + if (!result.ok) return result; 231 + tokens.push(makeToken("QUOTED", result.value, start)); 232 + continue; 233 + } 234 + 235 + // Regex - only after operators/parens, not mid-word 236 + if (char === "/") { 237 + const lastToken = tokens[tokens.length - 1]; 238 + const canStartRegex = 239 + tokens.length === 0 || REGEX_STARTERS.has(lastToken.type); 240 + 241 + if (canStartRegex) { 242 + const result = readRegex(); 243 + if (!result.ok) return result; 244 + tokens.push(makeToken("REGEX", result.value.source, start)); 245 + // Store the compiled pattern for later use 246 + const token = tokens[tokens.length - 1]; 247 + (token as Token & { pattern?: RegExp }).pattern = result.value.pattern; 248 + continue; 249 + } 250 + // Otherwise fall through to word parsing - / will be part of word 251 + } 252 + 253 + // NOT operator (- at word boundary) 254 + if (char === "-") { 255 + advance(); 256 + tokens.push(makeToken("NOT", "-", start)); 257 + continue; 258 + } 259 + 260 + // Exact name match (! followed by name) 261 + if (char === "!") { 262 + advance(); 263 + skipWhitespace(); 264 + // Read until end or quote 265 + if (peek() === '"') { 266 + const result = readQuoted(); 267 + if (!result.ok) return result; 268 + tokens.push(makeToken("EXACT_NAME", result.value, start)); 269 + } else { 270 + // Read to end of line/input or next structural token 271 + let value = ""; 272 + while ( 273 + pos < input.length && 274 + peek() !== "(" && 275 + peek() !== ")" && 276 + !/\s/.test(peek()) 277 + ) { 278 + value += advance(); 279 + } 280 + // For exact match, spaces can be part of name until end 281 + if (value) { 282 + tokens.push(makeToken("EXACT_NAME", value, start)); 283 + } 284 + } 285 + continue; 286 + } 287 + 288 + // Words (including field names, OR keyword) 289 + const word = readWord(); 290 + if (word) { 291 + // Check for OR keyword 292 + if (word.toLowerCase() === "or") { 293 + tokens.push(makeToken("OR", word, start)); 294 + continue; 295 + } 296 + 297 + // Check if this might be a field name followed by operator 298 + const nextChar = peek(); 299 + if ( 300 + isFieldName(word) && 301 + (nextChar === ":" || 302 + nextChar === "=" || 303 + nextChar === "<" || 304 + nextChar === ">") 305 + ) { 306 + // It's a field name - emit as WORD, the parser will handle it 307 + tokens.push(makeToken("WORD", word, start)); 308 + } else { 309 + tokens.push(makeToken("WORD", word, start)); 310 + } 311 + continue; 312 + } 313 + 314 + // Unknown character - skip it 315 + advance(); 316 + } 317 + 318 + tokens.push(makeToken("EOF", "", pos)); 319 + return ok(tokens); 320 + } 321 + 322 + /** 323 + * Get token with compiled regex pattern if it's a REGEX token 324 + */ 325 + export function getRegexPattern(token: Token): RegExp | undefined { 326 + return (token as Token & { pattern?: RegExp }).pattern; 327 + }
+117
src/lib/search/matcher.ts
··· 1 + /** 2 + * AST to predicate compiler for Scryfall search 3 + * 4 + * Compiles a SearchNode AST into a function that tests cards. 5 + */ 6 + 7 + import { type CardPredicate, compileField } from "./fields"; 8 + import type { SearchNode } from "./types"; 9 + 10 + // Re-export CardPredicate for convenience 11 + export type { CardPredicate }; 12 + 13 + /** 14 + * Compile an AST node into a card predicate function 15 + */ 16 + export function compile(node: SearchNode): CardPredicate { 17 + switch (node.type) { 18 + case "AND": 19 + return compileAnd(node.children); 20 + 21 + case "OR": 22 + return compileOr(node.children); 23 + 24 + case "NOT": 25 + return compileNot(node.child); 26 + 27 + case "FIELD": 28 + return compileField(node.field, node.operator, node.value); 29 + 30 + case "NAME": 31 + return compileName(node.value, node.pattern); 32 + 33 + case "EXACT_NAME": 34 + return compileExactName(node.value); 35 + } 36 + } 37 + 38 + /** 39 + * Compile AND node - all children must match 40 + */ 41 + function compileAnd(children: SearchNode[]): CardPredicate { 42 + const predicates = children.map(compile); 43 + return (card) => predicates.every((p) => p(card)); 44 + } 45 + 46 + /** 47 + * Compile OR node - any child must match 48 + */ 49 + function compileOr(children: SearchNode[]): CardPredicate { 50 + const predicates = children.map(compile); 51 + return (card) => predicates.some((p) => p(card)); 52 + } 53 + 54 + /** 55 + * Compile NOT node - child must not match 56 + */ 57 + function compileNot(child: SearchNode): CardPredicate { 58 + const predicate = compile(child); 59 + return (card) => !predicate(card); 60 + } 61 + 62 + /** 63 + * Compile name search - substring or regex match 64 + */ 65 + function compileName(value: string, pattern: RegExp | null): CardPredicate { 66 + if (pattern) { 67 + return (card) => { 68 + // Match against main name 69 + if (pattern.test(card.name)) return true; 70 + 71 + // Match against card face names for multi-face cards 72 + if (card.card_faces) { 73 + for (const face of card.card_faces) { 74 + if (pattern.test(face.name)) return true; 75 + } 76 + } 77 + 78 + return false; 79 + }; 80 + } 81 + 82 + // Substring match (case-insensitive) 83 + const lower = value.toLowerCase(); 84 + return (card) => { 85 + // Match against main name 86 + if (card.name.toLowerCase().includes(lower)) return true; 87 + 88 + // Match against card face names for multi-face cards 89 + if (card.card_faces) { 90 + for (const face of card.card_faces) { 91 + if (face.name.toLowerCase().includes(lower)) return true; 92 + } 93 + } 94 + 95 + return false; 96 + }; 97 + } 98 + 99 + /** 100 + * Compile exact name match 101 + */ 102 + function compileExactName(value: string): CardPredicate { 103 + const lower = value.toLowerCase(); 104 + return (card) => { 105 + // Match against main name exactly 106 + if (card.name.toLowerCase() === lower) return true; 107 + 108 + // Match against card face names for multi-face cards 109 + if (card.card_faces) { 110 + for (const face of card.card_faces) { 111 + if (face.name.toLowerCase() === lower) return true; 112 + } 113 + } 114 + 115 + return false; 116 + }; 117 + }
+55
src/lib/search/operators.ts
··· 1 + /** 2 + * Regex-based detection for search operator syntax. 3 + * 4 + * This is used BEFORE parsing to determine if a query should use 5 + * fuzzy name search or the full syntax parser. The goal is to be 6 + * conservative: only return true when there's clear intent to use 7 + * syntax operators. 8 + * 9 + * This avoids the "or dragon" problem where a typo for "ur dragon" 10 + * would be misinterpreted as an OR expression if we parsed first. 11 + */ 12 + 13 + import { FIELD_ALIASES } from "./types"; 14 + 15 + const FIELD_NAMES = Object.keys(FIELD_ALIASES); 16 + 17 + const FIELD_PATTERN = new RegExp( 18 + `\\b(${FIELD_NAMES.join("|")})(:|=|!=|<=|>=|<|>)`, 19 + "i", 20 + ); 21 + 22 + const EXPLICIT_AND = /\bAND\b/; 23 + const EXPLICIT_OR = /\bOR\b/; 24 + const EXACT_MATCH = /(^|[\s(])!/; 25 + const NEGATION = /(^|[\s(])-\w/; 26 + const QUOTES = /"/; 27 + const PARENS = /[()]/; 28 + 29 + /** 30 + * Check if a query string contains search operators that indicate 31 + * it should be parsed with the syntax parser rather than fuzzy matched. 32 + * 33 + * Returns true for queries like: 34 + * - "t:creature" (field operator) 35 + * - "bolt AND shock" (explicit AND - case sensitive) 36 + * - "!Lightning Bolt" (exact match) 37 + * - "-blue" (negation) 38 + * - '"Lightning Bolt"' (quoted) 39 + * - "(red OR blue)" (grouping) 40 + * 41 + * Returns false for simple name searches like: 42 + * - "lightning bolt" 43 + * - "or dragon" (lowercase "or" is NOT treated as operator) 44 + */ 45 + export function hasSearchOperators(query: string): boolean { 46 + return ( 47 + FIELD_PATTERN.test(query) || 48 + EXPLICIT_AND.test(query) || 49 + EXPLICIT_OR.test(query) || 50 + EXACT_MATCH.test(query) || 51 + NEGATION.test(query) || 52 + QUOTES.test(query) || 53 + PARENS.test(query) 54 + ); 55 + }
+493
src/lib/search/parser.ts
··· 1 + /** 2 + * Recursive descent parser for Scryfall search syntax 3 + * 4 + * Grammar: 5 + * query = or_expr 6 + * or_expr = and_expr ("OR" and_expr)* 7 + * and_expr = unary_expr+ 8 + * unary_expr = "-" unary_expr | primary 9 + * primary = "(" or_expr ")" | field_expr | name_expr 10 + * field_expr = WORD operator value 11 + * name_expr = EXACT_NAME | WORD | QUOTED | REGEX 12 + */ 13 + 14 + import { getRegexPattern, tokenize } from "./lexer"; 15 + import { 16 + type AndNode, 17 + type ComparisonOp, 18 + type ExactNameNode, 19 + err, 20 + FIELD_ALIASES, 21 + type FieldName, 22 + type FieldNode, 23 + type FieldValue, 24 + type NameNode, 25 + type NotNode, 26 + type OrNode, 27 + ok, 28 + type ParseError, 29 + type Result, 30 + type SearchNode, 31 + type Span, 32 + type Token, 33 + type TokenType, 34 + } from "./types"; 35 + 36 + /** 37 + * Parser class encapsulates parsing state 38 + */ 39 + class Parser { 40 + private tokens: Token[]; 41 + private pos: number = 0; 42 + private input: string; 43 + 44 + constructor(tokens: Token[], input: string) { 45 + this.tokens = tokens; 46 + this.input = input; 47 + } 48 + 49 + private peek(): Token { 50 + return this.tokens[this.pos] ?? this.tokens[this.tokens.length - 1]; 51 + } 52 + 53 + private previous(): Token { 54 + return this.tokens[this.pos - 1]; 55 + } 56 + 57 + private isAtEnd(): boolean { 58 + return this.peek().type === "EOF"; 59 + } 60 + 61 + private check(type: TokenType): boolean { 62 + return this.peek().type === type; 63 + } 64 + 65 + private advance(): Token { 66 + if (!this.isAtEnd()) { 67 + this.pos++; 68 + } 69 + return this.previous(); 70 + } 71 + 72 + /** 73 + * Match token type and advance if found. 74 + * WARNING: This consumes the token. For speculative parsing, 75 + * save pos before and restore on failure. 76 + */ 77 + private match(...types: TokenType[]): boolean { 78 + for (const type of types) { 79 + if (this.check(type)) { 80 + this.advance(); 81 + return true; 82 + } 83 + } 84 + return false; 85 + } 86 + 87 + private makeError(message: string, span?: Span): ParseError { 88 + return { 89 + message, 90 + span: span ?? this.peek().span, 91 + input: this.input, 92 + }; 93 + } 94 + 95 + /** 96 + * Parse the token stream into an AST 97 + */ 98 + parse(): Result<SearchNode> { 99 + if (this.isAtEnd()) { 100 + return err(this.makeError("Empty query")); 101 + } 102 + 103 + const result = this.parseOrExpr(); 104 + if (!result.ok) return result; 105 + 106 + if (!this.isAtEnd()) { 107 + return err(this.makeError(`Unexpected token: ${this.peek().value}`)); 108 + } 109 + 110 + return result; 111 + } 112 + 113 + /** 114 + * or_expr = and_expr ("OR" and_expr)* 115 + */ 116 + private parseOrExpr(): Result<SearchNode> { 117 + const firstResult = this.parseAndExpr(); 118 + if (!firstResult.ok) return firstResult; 119 + 120 + const children: SearchNode[] = [firstResult.value]; 121 + const startSpan = firstResult.value.span; 122 + 123 + while (this.match("OR")) { 124 + const nextResult = this.parseAndExpr(); 125 + if (!nextResult.ok) return nextResult; 126 + children.push(nextResult.value); 127 + } 128 + 129 + if (children.length === 1) { 130 + return ok(children[0]); 131 + } 132 + 133 + const endSpan = children[children.length - 1].span; 134 + const node: OrNode = { 135 + type: "OR", 136 + children, 137 + span: { start: startSpan.start, end: endSpan.end }, 138 + }; 139 + return ok(node); 140 + } 141 + 142 + /** 143 + * and_expr = unary_expr+ 144 + */ 145 + private parseAndExpr(): Result<SearchNode> { 146 + const children: SearchNode[] = []; 147 + 148 + while (!this.isAtEnd() && !this.check("OR") && !this.check("RPAREN")) { 149 + const result = this.parseUnaryExpr(); 150 + if (!result.ok) return result; 151 + children.push(result.value); 152 + } 153 + 154 + if (children.length === 0) { 155 + return err(this.makeError("Expected expression")); 156 + } 157 + 158 + if (children.length === 1) { 159 + return ok(children[0]); 160 + } 161 + 162 + const node: AndNode = { 163 + type: "AND", 164 + children, 165 + span: { 166 + start: children[0].span.start, 167 + end: children[children.length - 1].span.end, 168 + }, 169 + }; 170 + return ok(node); 171 + } 172 + 173 + /** 174 + * unary_expr = "-" unary_expr | primary 175 + */ 176 + private parseUnaryExpr(): Result<SearchNode> { 177 + if (this.match("NOT")) { 178 + const notToken = this.previous(); 179 + const result = this.parseUnaryExpr(); 180 + if (!result.ok) return result; 181 + 182 + const node: NotNode = { 183 + type: "NOT", 184 + child: result.value, 185 + span: { start: notToken.span.start, end: result.value.span.end }, 186 + }; 187 + return ok(node); 188 + } 189 + 190 + return this.parsePrimary(); 191 + } 192 + 193 + /** 194 + * primary = "(" or_expr ")" | field_expr | name_expr 195 + */ 196 + private parsePrimary(): Result<SearchNode> { 197 + // Grouped expression 198 + if (this.match("LPAREN")) { 199 + const openParen = this.previous(); 200 + const result = this.parseOrExpr(); 201 + if (!result.ok) return result; 202 + 203 + if (!this.match("RPAREN")) { 204 + return err( 205 + this.makeError("Expected closing parenthesis", openParen.span), 206 + ); 207 + } 208 + 209 + // Update span to include parens 210 + result.value.span = { 211 + start: openParen.span.start, 212 + end: this.previous().span.end, 213 + }; 214 + return ok(result.value); 215 + } 216 + 217 + // Try field expression first (with backtracking) 218 + const savepoint = this.pos; 219 + const fieldResult = this.tryParseFieldExpr(); 220 + if (fieldResult) { 221 + return fieldResult; 222 + } 223 + this.pos = savepoint; // Restore on failure 224 + 225 + // Name expression 226 + return this.parseNameExpr(); 227 + } 228 + 229 + /** 230 + * Check if token type is a comparison operator 231 + */ 232 + private isOperator(type: TokenType): boolean { 233 + return ( 234 + type === "COLON" || 235 + type === "EQUALS" || 236 + type === "NOT_EQUALS" || 237 + type === "LT" || 238 + type === "GT" || 239 + type === "LTE" || 240 + type === "GTE" 241 + ); 242 + } 243 + 244 + /** 245 + * Try to parse a field expression, returning null if not a field expr. 246 + * Caller must save/restore pos on null return. 247 + */ 248 + private tryParseFieldExpr(): Result<FieldNode> | null { 249 + if (!this.check("WORD")) { 250 + return null; 251 + } 252 + 253 + const fieldToken = this.peek(); 254 + const fieldName = FIELD_ALIASES[fieldToken.value.toLowerCase()]; 255 + 256 + if (!fieldName) { 257 + return null; 258 + } 259 + 260 + // Check if followed by operator (lookahead) 261 + const nextToken = this.tokens[this.pos + 1]; 262 + if (!nextToken || !this.isOperator(nextToken.type)) { 263 + return null; 264 + } 265 + 266 + // Commit to parsing field expression 267 + this.advance(); // consume field name 268 + const opToken = this.advance(); // consume operator 269 + const operator = this.tokenToOperator(opToken.type); 270 + 271 + const valueResult = this.parseFieldValue(fieldName); 272 + if (!valueResult.ok) { 273 + // Return the error - caller will restore pos 274 + return valueResult as Result<FieldNode>; 275 + } 276 + 277 + const node: FieldNode = { 278 + type: "FIELD", 279 + field: fieldName, 280 + operator, 281 + value: valueResult.value, 282 + span: { 283 + start: fieldToken.span.start, 284 + end: this.previous().span.end, 285 + }, 286 + }; 287 + return ok(node); 288 + } 289 + 290 + /** 291 + * Convert token type to comparison operator 292 + */ 293 + private tokenToOperator(type: TokenType): ComparisonOp { 294 + switch (type) { 295 + case "COLON": 296 + return ":"; 297 + case "EQUALS": 298 + return "="; 299 + case "NOT_EQUALS": 300 + return "!="; 301 + case "LT": 302 + return "<"; 303 + case "GT": 304 + return ">"; 305 + case "LTE": 306 + return "<="; 307 + case "GTE": 308 + return ">="; 309 + default: 310 + return ":"; 311 + } 312 + } 313 + 314 + /** 315 + * Parse field value based on field type 316 + */ 317 + private parseFieldValue(field: FieldName): Result<FieldValue> { 318 + // Regex value 319 + if (this.match("REGEX")) { 320 + const pattern = getRegexPattern(this.previous()); 321 + if (!pattern) { 322 + return err( 323 + this.makeError("Invalid regex pattern", this.previous().span), 324 + ); 325 + } 326 + return ok({ 327 + kind: "regex", 328 + pattern, 329 + source: this.previous().value, 330 + }); 331 + } 332 + 333 + // Quoted string 334 + if (this.match("QUOTED")) { 335 + return ok({ kind: "string", value: this.previous().value }); 336 + } 337 + 338 + // Negative number: NOT followed by WORD that looks numeric 339 + if ( 340 + this.check("NOT") && 341 + this.isNumericField(field) && 342 + this.pos + 1 < this.tokens.length && 343 + this.tokens[this.pos + 1].type === "WORD" 344 + ) { 345 + const nextValue = this.tokens[this.pos + 1].value; 346 + const num = parseFloat(nextValue); 347 + if (!Number.isNaN(num)) { 348 + this.advance(); // consume NOT 349 + this.advance(); // consume WORD 350 + return ok({ kind: "number", value: -num }); 351 + } 352 + } 353 + 354 + // Word value 355 + if (this.match("WORD")) { 356 + const value = this.previous().value; 357 + 358 + // For color fields, parse as colors - but identity can also take numeric values 359 + // for counting colors (id>1 = "more than 1 color in identity") 360 + if (field === "color" || field === "identity") { 361 + // Check if value is purely numeric (for identity count queries like id>1) 362 + if (field === "identity" && /^\d+$/.test(value)) { 363 + const num = parseInt(value, 10); 364 + return ok({ kind: "number", value: num }); 365 + } 366 + return ok({ 367 + kind: "colors", 368 + colors: this.parseColors(value), 369 + }); 370 + } 371 + 372 + // For numeric fields, try to parse as number 373 + if (this.isNumericField(field)) { 374 + const num = parseFloat(value); 375 + if (!Number.isNaN(num)) { 376 + return ok({ kind: "number", value: num }); 377 + } 378 + // Could be * for power/toughness 379 + if (value === "*") { 380 + return ok({ kind: "string", value: "*" }); 381 + } 382 + } 383 + 384 + return ok({ kind: "string", value }); 385 + } 386 + 387 + return err(this.makeError("Expected field value")); 388 + } 389 + 390 + /** 391 + * Check if field expects numeric values 392 + */ 393 + private isNumericField(field: FieldName): boolean { 394 + return ( 395 + field === "manavalue" || 396 + field === "power" || 397 + field === "toughness" || 398 + field === "loyalty" || 399 + field === "defense" || 400 + field === "year" 401 + ); 402 + } 403 + 404 + /** 405 + * Parse color string into color set 406 + */ 407 + private parseColors(input: string): Set<string> { 408 + const colors = new Set<string>(); 409 + const upper = input.toUpperCase(); 410 + 411 + for (const char of upper) { 412 + if ("WUBRGC".includes(char)) { 413 + colors.add(char); 414 + } 415 + } 416 + 417 + // Handle full names and aliases 418 + const lower = input.toLowerCase(); 419 + if (lower.includes("white")) colors.add("W"); 420 + if (lower.includes("blue")) colors.add("U"); 421 + if (lower.includes("black")) colors.add("B"); 422 + if (lower.includes("red")) colors.add("R"); 423 + if (lower.includes("green")) colors.add("G"); 424 + if (lower.includes("colorless")) colors.add("C"); 425 + 426 + return colors; 427 + } 428 + 429 + /** 430 + * name_expr = EXACT_NAME | WORD | QUOTED | REGEX 431 + */ 432 + private parseNameExpr(): Result<SearchNode> { 433 + const token = this.peek(); 434 + 435 + // Exact name match 436 + if (this.match("EXACT_NAME")) { 437 + const node: ExactNameNode = { 438 + type: "EXACT_NAME", 439 + value: this.previous().value, 440 + span: this.previous().span, 441 + }; 442 + return ok(node); 443 + } 444 + 445 + // Regex match 446 + if (this.match("REGEX")) { 447 + const prevToken = this.previous(); 448 + const pattern = getRegexPattern(prevToken); 449 + const node: NameNode = { 450 + type: "NAME", 451 + value: prevToken.value, 452 + pattern: pattern ?? null, 453 + span: prevToken.span, 454 + }; 455 + return ok(node); 456 + } 457 + 458 + // Quoted string match 459 + if (this.match("QUOTED")) { 460 + const node: NameNode = { 461 + type: "NAME", 462 + value: this.previous().value, 463 + pattern: null, 464 + span: this.previous().span, 465 + }; 466 + return ok(node); 467 + } 468 + 469 + // Word match 470 + if (this.match("WORD")) { 471 + const node: NameNode = { 472 + type: "NAME", 473 + value: this.previous().value, 474 + pattern: null, 475 + span: this.previous().span, 476 + }; 477 + return ok(node); 478 + } 479 + 480 + return err(this.makeError(`Unexpected token: ${token.value}`)); 481 + } 482 + } 483 + 484 + /** 485 + * Parse a search query string into an AST 486 + */ 487 + export function parse(input: string): Result<SearchNode> { 488 + const tokenResult = tokenize(input); 489 + if (!tokenResult.ok) return tokenResult; 490 + 491 + const parser = new Parser(tokenResult.value, input); 492 + return parser.parse(); 493 + }
+290
src/lib/search/types.ts
··· 1 + /** 2 + * Type definitions for Scryfall search syntax parser 3 + */ 4 + 5 + /** 6 + * Span tracks position in input for error reporting 7 + */ 8 + export interface Span { 9 + start: number; 10 + end: number; 11 + } 12 + 13 + /** 14 + * Token types produced by the lexer 15 + */ 16 + export type TokenType = 17 + // Structural 18 + | "LPAREN" 19 + | "RPAREN" 20 + | "OR" 21 + | "NOT" 22 + // Operators 23 + | "COLON" 24 + | "EQUALS" 25 + | "NOT_EQUALS" 26 + | "LT" 27 + | "GT" 28 + | "LTE" 29 + | "GTE" 30 + // Values 31 + | "WORD" 32 + | "QUOTED" 33 + | "REGEX" 34 + | "EXACT_NAME" 35 + // End 36 + | "EOF"; 37 + 38 + /** 39 + * Token with position tracking 40 + */ 41 + export interface Token { 42 + type: TokenType; 43 + value: string; 44 + span: Span; 45 + } 46 + 47 + /** 48 + * Comparison operators 49 + */ 50 + export type ComparisonOp = ":" | "=" | "!=" | "<" | ">" | "<=" | ">="; 51 + 52 + /** 53 + * Field value variants 54 + */ 55 + export type FieldValue = 56 + | { kind: "string"; value: string } 57 + | { kind: "number"; value: number } 58 + | { kind: "regex"; pattern: RegExp; source: string } 59 + | { kind: "colors"; colors: Set<string> }; 60 + 61 + /** 62 + * Known field names (canonical forms) 63 + */ 64 + export type FieldName = 65 + // Text fields 66 + | "name" 67 + | "type" 68 + | "oracle" 69 + // Color fields 70 + | "color" 71 + | "identity" 72 + // Mana 73 + | "mana" 74 + | "manavalue" 75 + // Stats 76 + | "power" 77 + | "toughness" 78 + | "loyalty" 79 + | "defense" 80 + // Keywords 81 + | "keyword" 82 + // Set/printing 83 + | "set" 84 + | "settype" 85 + | "layout" 86 + | "frame" 87 + | "border" 88 + | "number" 89 + | "rarity" 90 + | "artist" 91 + // Legality 92 + | "format" 93 + | "banned" 94 + | "restricted" 95 + // Misc 96 + | "game" 97 + | "in" 98 + | "produces" 99 + | "year" 100 + | "date" 101 + | "lang" 102 + // Boolean 103 + | "is" 104 + | "not"; 105 + 106 + /** 107 + * Fields where ':' means exact match (is) rather than substring (includes). 108 + * These are discrete/enumerated values, not free-form text. 109 + */ 110 + export const DISCRETE_FIELDS: ReadonlySet<FieldName> = new Set([ 111 + "set", 112 + "settype", 113 + "layout", 114 + "frame", 115 + "border", 116 + "rarity", 117 + "game", 118 + "in", 119 + "lang", 120 + "format", 121 + "banned", 122 + "restricted", 123 + ]); 124 + 125 + /** 126 + * Map of field aliases to canonical names 127 + */ 128 + export const FIELD_ALIASES: Record<string, FieldName> = { 129 + // Text 130 + name: "name", 131 + n: "name", 132 + t: "type", 133 + type: "type", 134 + o: "oracle", 135 + oracle: "oracle", 136 + // Colors 137 + c: "color", 138 + color: "color", 139 + id: "identity", 140 + identity: "identity", 141 + // Mana 142 + m: "mana", 143 + mana: "mana", 144 + mv: "manavalue", 145 + manavalue: "manavalue", 146 + cmc: "manavalue", 147 + // Stats 148 + pow: "power", 149 + power: "power", 150 + tou: "toughness", 151 + toughness: "toughness", 152 + loy: "loyalty", 153 + loyalty: "loyalty", 154 + def: "defense", 155 + defense: "defense", 156 + // Keywords 157 + kw: "keyword", 158 + keyword: "keyword", 159 + // Set/printing 160 + s: "set", 161 + e: "set", 162 + set: "set", 163 + edition: "set", 164 + st: "settype", 165 + settype: "settype", 166 + layout: "layout", 167 + frame: "frame", 168 + border: "border", 169 + cn: "number", 170 + number: "number", 171 + r: "rarity", 172 + rarity: "rarity", 173 + a: "artist", 174 + artist: "artist", 175 + // Legality 176 + f: "format", 177 + format: "format", 178 + banned: "banned", 179 + restricted: "restricted", 180 + // Misc 181 + game: "game", 182 + in: "in", 183 + produces: "produces", 184 + year: "year", 185 + date: "date", 186 + lang: "lang", 187 + language: "lang", 188 + // Boolean 189 + is: "is", 190 + not: "not", 191 + }; 192 + 193 + /** 194 + * Base interface for AST nodes with span 195 + */ 196 + interface BaseNode { 197 + span: Span; 198 + } 199 + 200 + /** 201 + * AND node - all children must match (implicit between terms) 202 + */ 203 + export interface AndNode extends BaseNode { 204 + type: "AND"; 205 + children: SearchNode[]; 206 + } 207 + 208 + /** 209 + * OR node - any child must match 210 + */ 211 + export interface OrNode extends BaseNode { 212 + type: "OR"; 213 + children: SearchNode[]; 214 + } 215 + 216 + /** 217 + * NOT node - child must not match 218 + */ 219 + export interface NotNode extends BaseNode { 220 + type: "NOT"; 221 + child: SearchNode; 222 + } 223 + 224 + /** 225 + * Field comparison node (t:creature, cmc>3, etc.) 226 + */ 227 + export interface FieldNode extends BaseNode { 228 + type: "FIELD"; 229 + field: FieldName; 230 + operator: ComparisonOp; 231 + value: FieldValue; 232 + } 233 + 234 + /** 235 + * Bare name search - string or regex match against card name 236 + */ 237 + export interface NameNode extends BaseNode { 238 + type: "NAME"; 239 + value: string; 240 + pattern: RegExp | null; 241 + } 242 + 243 + /** 244 + * Exact name match (!Lightning Bolt) 245 + */ 246 + export interface ExactNameNode extends BaseNode { 247 + type: "EXACT_NAME"; 248 + value: string; 249 + } 250 + 251 + /** 252 + * Union of all AST node types 253 + */ 254 + export type SearchNode = 255 + | AndNode 256 + | OrNode 257 + | NotNode 258 + | FieldNode 259 + | NameNode 260 + | ExactNameNode; 261 + 262 + /** 263 + * Parse error with location info 264 + */ 265 + export interface ParseError { 266 + message: string; 267 + span: Span; 268 + input: string; 269 + } 270 + 271 + /** 272 + * Result type for fallible operations 273 + */ 274 + export type Result<T, E = ParseError> = 275 + | { ok: true; value: T } 276 + | { ok: false; error: E }; 277 + 278 + /** 279 + * Helper to create success result 280 + */ 281 + export function ok<T>(value: T): Result<T, never> { 282 + return { ok: true, value }; 283 + } 284 + 285 + /** 286 + * Helper to create error result 287 + */ 288 + export function err<E>(error: E): Result<never, E> { 289 + return { ok: false, error }; 290 + }
+48
src/lib/search-types.ts
··· 1 + /** 2 + * Shared types for search functionality 3 + * Centralizes types used by worker, provider, and UI 4 + */ 5 + 6 + import type { Card, SearchRestrictions } from "./scryfall-types"; 7 + 8 + export type SortField = "name" | "mv" | "released" | "rarity" | "color"; 9 + export type SortDirection = "asc" | "desc" | "auto"; 10 + 11 + export interface SortOption { 12 + field: SortField; 13 + direction: SortDirection; 14 + } 15 + 16 + export interface SearchError { 17 + message: string; 18 + start: number; 19 + end: number; 20 + } 21 + 22 + export interface UnifiedSearchResult { 23 + mode: "fuzzy" | "syntax"; 24 + cards: Card[]; 25 + description: string | null; 26 + error: SearchError | null; 27 + } 28 + 29 + export interface PaginatedSearchResult { 30 + mode: "fuzzy" | "syntax"; 31 + cards: Card[]; 32 + totalCount: number; 33 + description: string | null; 34 + error: SearchError | null; 35 + } 36 + 37 + /** 38 + * Cached search result stored in worker LRU cache 39 + * Stores full result set without pagination 40 + */ 41 + export interface CachedSearchResult { 42 + mode: "fuzzy" | "syntax"; 43 + cards: Card[]; 44 + description: string | null; 45 + error: SearchError | null; 46 + } 47 + 48 + export type { Card, SearchRestrictions };
+176
src/lib/stats-selection.ts
··· 1 + import type { 2 + FacedCard, 3 + ManaCurveData, 4 + ManaSymbolsData, 5 + SourceTempo, 6 + SpeedCategory, 7 + SpeedData, 8 + TypeData, 9 + } from "./deck-stats"; 10 + import type { ManaColorWithColorless } from "./scryfall-types"; 11 + 12 + export type ManaSelectionType = "symbol" | "land" | SourceTempo; 13 + 14 + export type StatsSelection = 15 + | { chart: "curve"; bucket: string; type: "permanent" | "spell" } 16 + | { chart: "type"; type: string } 17 + | { chart: "subtype"; subtype: string } 18 + | { chart: "speed"; category: SpeedCategory } 19 + | { 20 + chart: "mana"; 21 + color: ManaColorWithColorless; 22 + type: ManaSelectionType; 23 + } 24 + | null; 25 + 26 + export interface AllStats { 27 + manaCurve: ManaCurveData[]; 28 + typeDistribution: TypeData[]; 29 + subtypeDistribution: TypeData[]; 30 + speedDistribution: SpeedData[]; 31 + manaBreakdown: ManaSymbolsData[]; 32 + } 33 + 34 + export interface SelectedCardsResult { 35 + cards: FacedCard[]; 36 + title: string; 37 + } 38 + 39 + const COLOR_NAMES: Record<ManaColorWithColorless, string> = { 40 + W: "White", 41 + U: "Blue", 42 + B: "Black", 43 + R: "Red", 44 + G: "Green", 45 + C: "Colorless", 46 + }; 47 + 48 + const SPEED_LABELS: Record<SpeedCategory, string> = { 49 + instant: "Instant Speed", 50 + sorcery: "Sorcery Speed", 51 + }; 52 + 53 + export function getSelectedCards( 54 + selection: StatsSelection, 55 + stats: AllStats, 56 + ): SelectedCardsResult { 57 + if (!selection) { 58 + return { cards: [], title: "" }; 59 + } 60 + 61 + switch (selection.chart) { 62 + case "curve": { 63 + const bucket = stats.manaCurve.find((b) => b.bucket === selection.bucket); 64 + if (!bucket) return { cards: [], title: "" }; 65 + const cards = 66 + selection.type === "permanent" 67 + ? bucket.permanentCards 68 + : bucket.spellCards; 69 + const title = `${selection.type === "permanent" ? "Permanents" : "Spells"} (MV ${selection.bucket})`; 70 + return { cards, title }; 71 + } 72 + 73 + case "type": { 74 + const typeData = stats.typeDistribution.find( 75 + (t) => t.type === selection.type, 76 + ); 77 + if (!typeData) return { cards: [], title: "" }; 78 + return { 79 + cards: typeData.cards, 80 + title: `${typeData.type} (${typeData.count})`, 81 + }; 82 + } 83 + 84 + case "subtype": { 85 + const subtypeData = stats.subtypeDistribution.find( 86 + (s) => s.type === selection.subtype, 87 + ); 88 + if (!subtypeData) return { cards: [], title: "" }; 89 + return { 90 + cards: subtypeData.cards, 91 + title: `${subtypeData.type} (${subtypeData.count})`, 92 + }; 93 + } 94 + 95 + case "speed": { 96 + const speedData = stats.speedDistribution.find( 97 + (s) => s.category === selection.category, 98 + ); 99 + if (!speedData) return { cards: [], title: "" }; 100 + return { 101 + cards: speedData.cards, 102 + title: `${SPEED_LABELS[selection.category]} (${speedData.count})`, 103 + }; 104 + } 105 + 106 + case "mana": { 107 + const manaData = stats.manaBreakdown.find( 108 + (m) => m.color === selection.color, 109 + ); 110 + if (!manaData) return { cards: [], title: "" }; 111 + 112 + const colorName = COLOR_NAMES[selection.color]; 113 + 114 + if (selection.type === "symbol") { 115 + return { 116 + cards: manaData.symbolCards, 117 + title: `${colorName} Symbols (${manaData.symbolCount})`, 118 + }; 119 + } 120 + 121 + if (selection.type === "land") { 122 + return { 123 + cards: manaData.landSourceCards, 124 + title: `${colorName} Lands (${manaData.landSourceCount})`, 125 + }; 126 + } 127 + 128 + const tempoLabels: Record<SourceTempo, string> = { 129 + immediate: "Immediate", 130 + conditional: "Conditional", 131 + delayed: "Delayed", 132 + bounce: "Bounce", 133 + }; 134 + 135 + const cardsByTempo: Record<SourceTempo, FacedCard[]> = { 136 + immediate: manaData.immediateSourceCards, 137 + conditional: manaData.conditionalSourceCards, 138 + delayed: manaData.delayedSourceCards, 139 + bounce: manaData.bounceSourceCards, 140 + }; 141 + 142 + const countByTempo: Record<SourceTempo, number> = { 143 + immediate: manaData.immediateSourceCount, 144 + conditional: manaData.conditionalSourceCount, 145 + delayed: manaData.delayedSourceCount, 146 + bounce: manaData.bounceSourceCount, 147 + }; 148 + 149 + return { 150 + cards: cardsByTempo[selection.type], 151 + title: `${colorName} ${tempoLabels[selection.type]} Sources (${countByTempo[selection.type]})`, 152 + }; 153 + } 154 + } 155 + } 156 + 157 + export function isSelectionEqual( 158 + a: StatsSelection, 159 + b: StatsSelection, 160 + ): boolean { 161 + if (a === null || b === null) return a === b; 162 + if (a.chart !== b.chart) return false; 163 + 164 + switch (a.chart) { 165 + case "curve": 166 + return b.chart === "curve" && a.bucket === b.bucket && a.type === b.type; 167 + case "type": 168 + return b.chart === "type" && a.type === b.type; 169 + case "subtype": 170 + return b.chart === "subtype" && a.subtype === b.subtype; 171 + case "speed": 172 + return b.chart === "speed" && a.category === b.category; 173 + case "mana": 174 + return b.chart === "mana" && a.color === b.color && a.type === b.type; 175 + } 176 + }
+85
src/lib/ufos-queries.ts
··· 1 + /** 2 + * TanStack Query definitions for UFOs API 3 + * Fetches recent records from the ATProto firehose 4 + */ 5 + 6 + import { queryOptions } from "@tanstack/react-query"; 7 + import type { Result } from "./atproto-client"; 8 + import type { 9 + ComDeckbelcherCollectionList, 10 + ComDeckbelcherDeckList, 11 + } from "./lexicons/index"; 12 + import type { 13 + ActivityCollection, 14 + UfosDeckRecord, 15 + UfosListRecord, 16 + UfosRecord, 17 + } from "./ufos-types"; 18 + 19 + const UFOS_BASE = "https://ufos-api.microcosm.blue"; 20 + 21 + /** 22 + * Fetch recent records from UFOs API 23 + */ 24 + async function fetchRecentRecords<T>( 25 + collections: ActivityCollection | ActivityCollection[], 26 + limit: number, 27 + ): Promise<Result<UfosRecord<T>[]>> { 28 + try { 29 + const url = new URL(`${UFOS_BASE}/records`); 30 + const collectionParam = Array.isArray(collections) 31 + ? collections.join(",") 32 + : collections; 33 + url.searchParams.set("collection", collectionParam); 34 + url.searchParams.set("limit", String(limit)); 35 + 36 + const response = await fetch(url.toString()); 37 + 38 + if (!response.ok) { 39 + return { 40 + success: false, 41 + error: new Error(`UFOs API error: ${response.statusText}`), 42 + }; 43 + } 44 + 45 + const data = (await response.json()) as UfosRecord<T>[]; 46 + return { success: true, data }; 47 + } catch (error) { 48 + return { 49 + success: false, 50 + error: error instanceof Error ? error : new Error(String(error)), 51 + }; 52 + } 53 + } 54 + 55 + export type ActivityRecord = UfosDeckRecord | UfosListRecord; 56 + 57 + export function isDeckRecord(record: ActivityRecord): record is UfosDeckRecord { 58 + return record.collection === "com.deckbelcher.deck.list"; 59 + } 60 + 61 + export function isListRecord(record: ActivityRecord): record is UfosListRecord { 62 + return record.collection === "com.deckbelcher.collection.list"; 63 + } 64 + 65 + /** 66 + * Query options for recent activity (decks + lists) 67 + */ 68 + export const recentActivityQueryOptions = (limit = 10) => 69 + queryOptions({ 70 + queryKey: ["ufos", "recentActivity", limit] as const, 71 + queryFn: async () => { 72 + const result = await fetchRecentRecords< 73 + ComDeckbelcherDeckList.Main | ComDeckbelcherCollectionList.Main 74 + >( 75 + ["com.deckbelcher.deck.list", "com.deckbelcher.collection.list"], 76 + limit, 77 + ); 78 + if (!result.success) { 79 + throw result.error; 80 + } 81 + return result.data as ActivityRecord[]; 82 + }, 83 + staleTime: 60 * 1000, 84 + refetchOnWindowFocus: true, 85 + });
+39
src/lib/ufos-types.ts
··· 1 + /** 2 + * Types for UFOs API (ufos-api.microcosm.blue) 3 + * Provides recent records from the ATProto firehose 4 + */ 5 + 6 + import type { Did } from "@atcute/lexicons"; 7 + import type { 8 + ComDeckbelcherCollectionList, 9 + ComDeckbelcherDeckList, 10 + } from "./lexicons/index"; 11 + 12 + /** 13 + * A record from the UFOs API firehose 14 + */ 15 + export interface UfosRecord<T = unknown> { 16 + did: Did; 17 + collection: string; 18 + rkey: string; 19 + record: T; 20 + /** Timestamp in microseconds since epoch */ 21 + time_us: number; 22 + } 23 + 24 + /** 25 + * Deck record from UFOs API 26 + */ 27 + export type UfosDeckRecord = UfosRecord<ComDeckbelcherDeckList.Main>; 28 + 29 + /** 30 + * Collection list record from UFOs API 31 + */ 32 + export type UfosListRecord = UfosRecord<ComDeckbelcherCollectionList.Main>; 33 + 34 + /** 35 + * Supported collection NSIDs for the activity feed 36 + */ 37 + export type ActivityCollection = 38 + | "com.deckbelcher.deck.list" 39 + | "com.deckbelcher.collection.list";
+1
src/lib/useAuth.tsx
··· 16 16 import { toast } from "sonner"; 17 17 18 18 const STORAGE_KEY = "deckbelcher:last-did"; 19 + export const RETURN_TO_KEY = "deckbelcher:return-to"; 19 20 20 21 interface AuthContextValue { 21 22 session: Session | null;
+213 -5
src/lib/useDebounce.ts
··· 1 - import { useEffect, useState } from "react"; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 2 3 - export function useDebounce<T>(value: T, delay: number): T { 3 + export interface UseDebounceResult<T> { 4 + value: T; 5 + flush: () => T; 6 + isPending: boolean; 7 + } 8 + 9 + export function useDebounce<T>(value: T, delay: number): UseDebounceResult<T> { 4 10 const [debouncedValue, setDebouncedValue] = useState<T>(value); 11 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 12 + const latestValueRef = useRef(value); 13 + 14 + latestValueRef.current = value; 15 + 16 + const isPending = value !== debouncedValue; 17 + 18 + const flush = useCallback(() => { 19 + if (timeoutRef.current) { 20 + clearTimeout(timeoutRef.current); 21 + timeoutRef.current = null; 22 + } 23 + setDebouncedValue(latestValueRef.current); 24 + return latestValueRef.current; 25 + }, []); 5 26 6 27 useEffect(() => { 7 - const timer = setTimeout(() => { 28 + timeoutRef.current = setTimeout(() => { 8 29 setDebouncedValue(value); 30 + timeoutRef.current = null; 9 31 }, delay); 10 32 11 33 return () => { 12 - clearTimeout(timer); 34 + if (timeoutRef.current) { 35 + clearTimeout(timeoutRef.current); 36 + } 13 37 }; 14 38 }, [value, delay]); 15 39 16 - return debouncedValue; 40 + return { value: debouncedValue, flush, isPending }; 41 + } 42 + 43 + export interface UseThrottleResult<T> { 44 + value: T; 45 + flush: () => T; 46 + isPending: boolean; 47 + } 48 + 49 + /** 50 + * Throttle: emits value at most once per `interval` ms. 51 + * Unlike debounce, this guarantees periodic updates during continuous input. 52 + */ 53 + export function useThrottle<T>( 54 + value: T, 55 + interval: number, 56 + ): UseThrottleResult<T> { 57 + const [throttledValue, setThrottledValue] = useState<T>(value); 58 + const lastEmitRef = useRef<number>(Date.now()); 59 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 60 + const latestValueRef = useRef(value); 61 + 62 + latestValueRef.current = value; 63 + 64 + const isPending = value !== throttledValue; 65 + 66 + const flush = useCallback(() => { 67 + if (timeoutRef.current) { 68 + clearTimeout(timeoutRef.current); 69 + timeoutRef.current = null; 70 + } 71 + setThrottledValue(latestValueRef.current); 72 + lastEmitRef.current = Date.now(); 73 + return latestValueRef.current; 74 + }, []); 75 + 76 + useEffect(() => { 77 + const now = Date.now(); 78 + const elapsed = now - lastEmitRef.current; 79 + 80 + if (elapsed >= interval) { 81 + setThrottledValue(value); 82 + lastEmitRef.current = now; 83 + } else { 84 + // Schedule emit for remaining time 85 + timeoutRef.current = setTimeout(() => { 86 + setThrottledValue(latestValueRef.current); 87 + lastEmitRef.current = Date.now(); 88 + timeoutRef.current = null; 89 + }, interval - elapsed); 90 + } 91 + 92 + return () => { 93 + if (timeoutRef.current) { 94 + clearTimeout(timeoutRef.current); 95 + } 96 + }; 97 + }, [value, interval]); 98 + 99 + return { value: throttledValue, flush, isPending }; 100 + } 101 + 102 + export interface ImperativeThrottle<T> { 103 + /** Call with new value - will emit on throttle boundary */ 104 + update: (value: T) => void; 105 + /** Force emit current value immediately */ 106 + flush: () => void; 107 + /** Get current throttled value */ 108 + getValue: () => T; 109 + } 110 + 111 + /** 112 + * Imperative throttle: call update() on every input, emits via callback at most once per interval. 113 + * Unlike the hook version, this doesn't require state as input - you control when to call update(). 114 + */ 115 + export function useImperativeThrottle<T>( 116 + initialValue: T, 117 + interval: number, 118 + onEmit: (value: T) => void, 119 + ): ImperativeThrottle<T> { 120 + const latestValueRef = useRef(initialValue); 121 + const emittedValueRef = useRef(initialValue); 122 + const lastEmitRef = useRef<number>(0); 123 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 124 + const onEmitRef = useRef(onEmit); 125 + 126 + onEmitRef.current = onEmit; 127 + 128 + return useMemo(() => { 129 + const emit = () => { 130 + const value = latestValueRef.current; 131 + if (value !== emittedValueRef.current) { 132 + emittedValueRef.current = value; 133 + onEmitRef.current(value); 134 + } 135 + lastEmitRef.current = Date.now(); 136 + }; 137 + 138 + return { 139 + update: (value: T) => { 140 + latestValueRef.current = value; 141 + 142 + const now = Date.now(); 143 + const elapsed = now - lastEmitRef.current; 144 + 145 + if (elapsed >= interval) { 146 + if (timeoutRef.current) { 147 + clearTimeout(timeoutRef.current); 148 + timeoutRef.current = null; 149 + } 150 + emit(); 151 + } else if (!timeoutRef.current) { 152 + timeoutRef.current = setTimeout(() => { 153 + timeoutRef.current = null; 154 + emit(); 155 + }, interval - elapsed); 156 + } 157 + }, 158 + flush: () => { 159 + if (timeoutRef.current) { 160 + clearTimeout(timeoutRef.current); 161 + timeoutRef.current = null; 162 + } 163 + emit(); 164 + }, 165 + getValue: () => latestValueRef.current, 166 + }; 167 + }, [interval]); 168 + } 169 + 170 + export interface ImperativeDebounce<T> { 171 + /** Call with new value - will emit after delay of inactivity */ 172 + update: (value: T) => void; 173 + /** Force emit current value immediately */ 174 + flush: () => void; 175 + /** Get current value */ 176 + getValue: () => T; 177 + } 178 + 179 + /** 180 + * Imperative debounce: call update() on every input, emits via callback after delay of inactivity. 181 + */ 182 + export function useImperativeDebounce<T>( 183 + initialValue: T, 184 + delay: number, 185 + onEmit: (value: T) => void, 186 + ): ImperativeDebounce<T> { 187 + const latestValueRef = useRef(initialValue); 188 + const emittedValueRef = useRef(initialValue); 189 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 190 + const onEmitRef = useRef(onEmit); 191 + 192 + onEmitRef.current = onEmit; 193 + 194 + return useMemo(() => { 195 + const emit = () => { 196 + const value = latestValueRef.current; 197 + if (value !== emittedValueRef.current) { 198 + emittedValueRef.current = value; 199 + onEmitRef.current(value); 200 + } 201 + }; 202 + 203 + return { 204 + update: (value: T) => { 205 + latestValueRef.current = value; 206 + 207 + if (timeoutRef.current) { 208 + clearTimeout(timeoutRef.current); 209 + } 210 + timeoutRef.current = setTimeout(() => { 211 + timeoutRef.current = null; 212 + emit(); 213 + }, delay); 214 + }, 215 + flush: () => { 216 + if (timeoutRef.current) { 217 + clearTimeout(timeoutRef.current); 218 + timeoutRef.current = null; 219 + } 220 + emit(); 221 + }, 222 + getValue: () => latestValueRef.current, 223 + }; 224 + }, [delay]); 17 225 }
+80
src/lib/useDeckStats.ts
··· 1 + import { useQueries } from "@tanstack/react-query"; 2 + import { useMemo } from "react"; 3 + import { 4 + computeManaCurve, 5 + computeManaSymbolsVsSources, 6 + computeSpeedDistribution, 7 + computeSubtypeDistribution, 8 + computeTypeDistribution, 9 + type ManaCurveData, 10 + type ManaSymbolsData, 11 + type SpeedData, 12 + type TypeData, 13 + } from "@/lib/deck-stats"; 14 + import type { DeckCard } from "@/lib/deck-types"; 15 + import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; 16 + 17 + export interface DeckStatsData { 18 + manaCurve: ManaCurveData[]; 19 + typeDistribution: TypeData[]; 20 + subtypeDistribution: TypeData[]; 21 + speedDistribution: SpeedData[]; 22 + manaBreakdown: ManaSymbolsData[]; 23 + isLoading: boolean; 24 + } 25 + 26 + export function useDeckStats(cards: DeckCard[]): DeckStatsData { 27 + const cardMap = useQueries({ 28 + queries: cards.map((card) => getCardByIdQueryOptions(card.scryfallId)), 29 + combine: combineCardQueries, 30 + }); 31 + 32 + const manaCurve = useMemo( 33 + () => 34 + cardMap 35 + ? computeManaCurve(cards, (dc) => cardMap.get(dc.scryfallId)) 36 + : [], 37 + [cards, cardMap], 38 + ); 39 + 40 + const typeDistribution = useMemo( 41 + () => 42 + cardMap 43 + ? computeTypeDistribution(cards, (dc) => cardMap.get(dc.scryfallId)) 44 + : [], 45 + [cards, cardMap], 46 + ); 47 + 48 + const subtypeDistribution = useMemo( 49 + () => 50 + cardMap 51 + ? computeSubtypeDistribution(cards, (dc) => cardMap.get(dc.scryfallId)) 52 + : [], 53 + [cards, cardMap], 54 + ); 55 + 56 + const speedDistribution = useMemo( 57 + () => 58 + cardMap 59 + ? computeSpeedDistribution(cards, (dc) => cardMap.get(dc.scryfallId)) 60 + : [], 61 + [cards, cardMap], 62 + ); 63 + 64 + const manaBreakdown = useMemo( 65 + () => 66 + cardMap 67 + ? computeManaSymbolsVsSources(cards, (dc) => cardMap.get(dc.scryfallId)) 68 + : [], 69 + [cards, cardMap], 70 + ); 71 + 72 + return { 73 + manaCurve, 74 + typeDistribution, 75 + subtypeDistribution, 76 + speedDistribution, 77 + manaBreakdown, 78 + isLoading: !cardMap, 79 + }; 80 + }
+111
src/lib/useRichText.ts
··· 1 + import { 2 + type RefObject, 3 + startTransition, 4 + useCallback, 5 + useEffect, 6 + useMemo, 7 + useRef, 8 + useState, 9 + } from "react"; 10 + import { type ParseResult, parseMarkdown } from "./richtext"; 11 + import { useImperativeDebounce, useImperativeThrottle } from "./useDebounce"; 12 + 13 + export interface UseRichTextOptions { 14 + initialValue?: string; 15 + onSave?: (parsed: ParseResult) => void | Promise<void>; 16 + /** How often to update preview during typing (ms) */ 17 + previewThrottleMs?: number; 18 + /** How long to wait after typing stops before saving (ms) */ 19 + saveDebounceMs?: number; 20 + } 21 + 22 + export interface UseRichTextResult { 23 + inputRef: RefObject<HTMLTextAreaElement | null>; 24 + onInput: () => void; 25 + defaultValue: string; 26 + parsed: ParseResult; 27 + isDirty: boolean; 28 + save: () => void; 29 + } 30 + 31 + export function useRichText({ 32 + initialValue = "", 33 + onSave, 34 + previewThrottleMs = 100, 35 + saveDebounceMs = 1500, 36 + }: UseRichTextOptions = {}): UseRichTextResult { 37 + const inputRef = useRef<HTMLTextAreaElement | null>(null); 38 + const savedValueRef = useRef(initialValue); 39 + const onSaveRef = useRef(onSave); 40 + 41 + // State only updates on throttle/debounce boundaries, not every keystroke 42 + const [previewMarkdown, setPreviewMarkdown] = useState(initialValue); 43 + const [saveState, setSaveState] = useState<"saved" | "dirty">("saved"); 44 + 45 + onSaveRef.current = onSave; 46 + 47 + // Throttle for preview (update every N ms during typing) 48 + const throttle = useImperativeThrottle( 49 + initialValue, 50 + previewThrottleMs, 51 + (value) => { 52 + // Low-priority update - React can interrupt for user input 53 + startTransition(() => { 54 + setPreviewMarkdown(value); 55 + // Mark dirty when preview updates (throttle boundary) 56 + if (value !== savedValueRef.current) { 57 + setSaveState("dirty"); 58 + } 59 + }); 60 + }, 61 + ); 62 + 63 + // Debounce for save (wait N ms after typing stops) 64 + const debounce = useImperativeDebounce( 65 + initialValue, 66 + saveDebounceMs, 67 + (value) => { 68 + if (value !== savedValueRef.current && onSaveRef.current) { 69 + onSaveRef.current(parseMarkdown(value)); 70 + savedValueRef.current = value; 71 + } 72 + setSaveState("saved"); 73 + }, 74 + ); 75 + 76 + const parsed = useMemo( 77 + () => parseMarkdown(previewMarkdown), 78 + [previewMarkdown], 79 + ); 80 + 81 + const onInput = useCallback(() => { 82 + const value = inputRef.current?.value ?? ""; 83 + throttle.update(value); 84 + debounce.update(value); 85 + // No setState here - dirty state updates on throttle boundary 86 + }, [throttle, debounce]); 87 + 88 + const save = useCallback(() => { 89 + throttle.flush(); 90 + debounce.flush(); 91 + }, [throttle, debounce]); 92 + 93 + // Sync with external initialValue changes (e.g., deck reload) 94 + useEffect(() => { 95 + if (inputRef.current) { 96 + inputRef.current.value = initialValue; 97 + } 98 + setPreviewMarkdown(initialValue); 99 + savedValueRef.current = initialValue; 100 + setSaveState("saved"); 101 + }, [initialValue]); 102 + 103 + return { 104 + inputRef, 105 + onInput, 106 + defaultValue: initialValue, 107 + parsed, 108 + isDirty: saveState === "dirty", 109 + save, 110 + }; 111 + }
+58
src/lib/useSeededRandom.tsx
··· 1 + import { useId, useRef, useState } from "react"; 2 + 3 + /** 4 + * Seeded PRNG (mulberry32) 5 + */ 6 + export function createSeededRng(stateRef: { current: number }): () => number { 7 + return () => { 8 + let s = stateRef.current | 0; 9 + s = (s + 0x6d2b79f5) | 0; 10 + let t = Math.imul(s ^ (s >>> 15), 1 | s); 11 + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 12 + stateRef.current = s; 13 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 14 + }; 15 + } 16 + 17 + /** 18 + * Shuffle array using seeded RNG 19 + */ 20 + export function seededShuffle<T>(array: T[], rng: () => number): T[] { 21 + const result = [...array]; 22 + for (let i = result.length - 1; i > 0; i--) { 23 + const j = Math.floor(rng() * (i + 1)); 24 + [result[i], result[j]] = [result[j], result[i]]; 25 + } 26 + return result; 27 + } 28 + 29 + /** 30 + * Hook that provides a stable seed across SSR and hydration. 31 + * 32 + * On SSR: generates a random seed, embeds it in a hidden span 33 + * On hydration: reads the seed from the DOM 34 + * On client-only: generates a fresh seed 35 + */ 36 + export function useSeededRandom(): { 37 + seed: number; 38 + rng: () => number; 39 + SeedEmbed: () => React.ReactElement; 40 + } { 41 + const id = useId(); 42 + 43 + const [seed] = useState(() => { 44 + if (typeof document !== "undefined") { 45 + const el = document.getElementById(id); 46 + if (el?.dataset.seed) { 47 + return parseInt(el.dataset.seed, 10); 48 + } 49 + } 50 + return Math.floor(Math.random() * 2147483647); 51 + }); 52 + 53 + const stateRef = useRef(seed); 54 + const rng = createSeededRng(stateRef); 55 + const SeedEmbed = () => <span id={id} data-seed={seed} hidden />; 56 + 57 + return { seed, rng, SeedEmbed }; 58 + }
+70
src/routeTree.gen.ts
··· 17 17 import { Route as DeckNewRouteImport } from './routes/deck/new' 18 18 import { Route as CardIdRouteImport } from './routes/card/$id' 19 19 import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' 20 + import { Route as ProfileDidListRkeyRouteImport } from './routes/profile/$did/list/$rkey' 20 21 import { Route as ProfileDidDeckRkeyRouteImport } from './routes/profile/$did/deck/$rkey' 22 + import { Route as ProfileDidListRkeyIndexRouteImport } from './routes/profile/$did/list/$rkey/index' 21 23 import { Route as ProfileDidDeckRkeyIndexRouteImport } from './routes/profile/$did/deck/$rkey/index' 24 + import { Route as ProfileDidDeckRkeyPlayRouteImport } from './routes/profile/$did/deck/$rkey/play' 22 25 import { Route as ProfileDidDeckRkeyBulkEditRouteImport } from './routes/profile/$did/deck/$rkey/bulk-edit' 23 26 24 27 const SigninRoute = SigninRouteImport.update({ ··· 61 64 path: '/profile/$did/', 62 65 getParentRoute: () => rootRouteImport, 63 66 } as any) 67 + const ProfileDidListRkeyRoute = ProfileDidListRkeyRouteImport.update({ 68 + id: '/profile/$did/list/$rkey', 69 + path: '/profile/$did/list/$rkey', 70 + getParentRoute: () => rootRouteImport, 71 + } as any) 64 72 const ProfileDidDeckRkeyRoute = ProfileDidDeckRkeyRouteImport.update({ 65 73 id: '/profile/$did/deck/$rkey', 66 74 path: '/profile/$did/deck/$rkey', 67 75 getParentRoute: () => rootRouteImport, 68 76 } as any) 77 + const ProfileDidListRkeyIndexRoute = ProfileDidListRkeyIndexRouteImport.update({ 78 + id: '/', 79 + path: '/', 80 + getParentRoute: () => ProfileDidListRkeyRoute, 81 + } as any) 69 82 const ProfileDidDeckRkeyIndexRoute = ProfileDidDeckRkeyIndexRouteImport.update({ 70 83 id: '/', 71 84 path: '/', 85 + getParentRoute: () => ProfileDidDeckRkeyRoute, 86 + } as any) 87 + const ProfileDidDeckRkeyPlayRoute = ProfileDidDeckRkeyPlayRouteImport.update({ 88 + id: '/play', 89 + path: '/play', 72 90 getParentRoute: () => ProfileDidDeckRkeyRoute, 73 91 } as any) 74 92 const ProfileDidDeckRkeyBulkEditRoute = ··· 88 106 '/cards': typeof CardsIndexRoute 89 107 '/profile/$did': typeof ProfileDidIndexRoute 90 108 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 109 + '/profile/$did/list/$rkey': typeof ProfileDidListRkeyRouteWithChildren 91 110 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 111 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 92 112 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 113 + '/profile/$did/list/$rkey/': typeof ProfileDidListRkeyIndexRoute 93 114 } 94 115 export interface FileRoutesByTo { 95 116 '/': typeof IndexRoute ··· 101 122 '/cards': typeof CardsIndexRoute 102 123 '/profile/$did': typeof ProfileDidIndexRoute 103 124 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 125 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 104 126 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyIndexRoute 127 + '/profile/$did/list/$rkey': typeof ProfileDidListRkeyIndexRoute 105 128 } 106 129 export interface FileRoutesById { 107 130 __root__: typeof rootRouteImport ··· 114 137 '/cards/': typeof CardsIndexRoute 115 138 '/profile/$did/': typeof ProfileDidIndexRoute 116 139 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 140 + '/profile/$did/list/$rkey': typeof ProfileDidListRkeyRouteWithChildren 117 141 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 142 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 118 143 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 144 + '/profile/$did/list/$rkey/': typeof ProfileDidListRkeyIndexRoute 119 145 } 120 146 export interface FileRouteTypes { 121 147 fileRoutesByFullPath: FileRoutesByFullPath ··· 129 155 | '/cards' 130 156 | '/profile/$did' 131 157 | '/profile/$did/deck/$rkey' 158 + | '/profile/$did/list/$rkey' 132 159 | '/profile/$did/deck/$rkey/bulk-edit' 160 + | '/profile/$did/deck/$rkey/play' 133 161 | '/profile/$did/deck/$rkey/' 162 + | '/profile/$did/list/$rkey/' 134 163 fileRoutesByTo: FileRoutesByTo 135 164 to: 136 165 | '/' ··· 142 171 | '/cards' 143 172 | '/profile/$did' 144 173 | '/profile/$did/deck/$rkey/bulk-edit' 174 + | '/profile/$did/deck/$rkey/play' 145 175 | '/profile/$did/deck/$rkey' 176 + | '/profile/$did/list/$rkey' 146 177 id: 147 178 | '__root__' 148 179 | '/' ··· 154 185 | '/cards/' 155 186 | '/profile/$did/' 156 187 | '/profile/$did/deck/$rkey' 188 + | '/profile/$did/list/$rkey' 157 189 | '/profile/$did/deck/$rkey/bulk-edit' 190 + | '/profile/$did/deck/$rkey/play' 158 191 | '/profile/$did/deck/$rkey/' 192 + | '/profile/$did/list/$rkey/' 159 193 fileRoutesById: FileRoutesById 160 194 } 161 195 export interface RootRouteChildren { ··· 168 202 CardsIndexRoute: typeof CardsIndexRoute 169 203 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 170 204 ProfileDidDeckRkeyRoute: typeof ProfileDidDeckRkeyRouteWithChildren 205 + ProfileDidListRkeyRoute: typeof ProfileDidListRkeyRouteWithChildren 171 206 } 172 207 173 208 declare module '@tanstack/react-router' { ··· 228 263 preLoaderRoute: typeof ProfileDidIndexRouteImport 229 264 parentRoute: typeof rootRouteImport 230 265 } 266 + '/profile/$did/list/$rkey': { 267 + id: '/profile/$did/list/$rkey' 268 + path: '/profile/$did/list/$rkey' 269 + fullPath: '/profile/$did/list/$rkey' 270 + preLoaderRoute: typeof ProfileDidListRkeyRouteImport 271 + parentRoute: typeof rootRouteImport 272 + } 231 273 '/profile/$did/deck/$rkey': { 232 274 id: '/profile/$did/deck/$rkey' 233 275 path: '/profile/$did/deck/$rkey' ··· 235 277 preLoaderRoute: typeof ProfileDidDeckRkeyRouteImport 236 278 parentRoute: typeof rootRouteImport 237 279 } 280 + '/profile/$did/list/$rkey/': { 281 + id: '/profile/$did/list/$rkey/' 282 + path: '/' 283 + fullPath: '/profile/$did/list/$rkey/' 284 + preLoaderRoute: typeof ProfileDidListRkeyIndexRouteImport 285 + parentRoute: typeof ProfileDidListRkeyRoute 286 + } 238 287 '/profile/$did/deck/$rkey/': { 239 288 id: '/profile/$did/deck/$rkey/' 240 289 path: '/' ··· 242 291 preLoaderRoute: typeof ProfileDidDeckRkeyIndexRouteImport 243 292 parentRoute: typeof ProfileDidDeckRkeyRoute 244 293 } 294 + '/profile/$did/deck/$rkey/play': { 295 + id: '/profile/$did/deck/$rkey/play' 296 + path: '/play' 297 + fullPath: '/profile/$did/deck/$rkey/play' 298 + preLoaderRoute: typeof ProfileDidDeckRkeyPlayRouteImport 299 + parentRoute: typeof ProfileDidDeckRkeyRoute 300 + } 245 301 '/profile/$did/deck/$rkey/bulk-edit': { 246 302 id: '/profile/$did/deck/$rkey/bulk-edit' 247 303 path: '/bulk-edit' ··· 254 310 255 311 interface ProfileDidDeckRkeyRouteChildren { 256 312 ProfileDidDeckRkeyBulkEditRoute: typeof ProfileDidDeckRkeyBulkEditRoute 313 + ProfileDidDeckRkeyPlayRoute: typeof ProfileDidDeckRkeyPlayRoute 257 314 ProfileDidDeckRkeyIndexRoute: typeof ProfileDidDeckRkeyIndexRoute 258 315 } 259 316 260 317 const ProfileDidDeckRkeyRouteChildren: ProfileDidDeckRkeyRouteChildren = { 261 318 ProfileDidDeckRkeyBulkEditRoute: ProfileDidDeckRkeyBulkEditRoute, 319 + ProfileDidDeckRkeyPlayRoute: ProfileDidDeckRkeyPlayRoute, 262 320 ProfileDidDeckRkeyIndexRoute: ProfileDidDeckRkeyIndexRoute, 263 321 } 264 322 265 323 const ProfileDidDeckRkeyRouteWithChildren = 266 324 ProfileDidDeckRkeyRoute._addFileChildren(ProfileDidDeckRkeyRouteChildren) 267 325 326 + interface ProfileDidListRkeyRouteChildren { 327 + ProfileDidListRkeyIndexRoute: typeof ProfileDidListRkeyIndexRoute 328 + } 329 + 330 + const ProfileDidListRkeyRouteChildren: ProfileDidListRkeyRouteChildren = { 331 + ProfileDidListRkeyIndexRoute: ProfileDidListRkeyIndexRoute, 332 + } 333 + 334 + const ProfileDidListRkeyRouteWithChildren = 335 + ProfileDidListRkeyRoute._addFileChildren(ProfileDidListRkeyRouteChildren) 336 + 268 337 const rootRouteChildren: RootRouteChildren = { 269 338 IndexRoute: IndexRoute, 270 339 SigninRoute: SigninRoute, ··· 275 344 CardsIndexRoute: CardsIndexRoute, 276 345 ProfileDidIndexRoute: ProfileDidIndexRoute, 277 346 ProfileDidDeckRkeyRoute: ProfileDidDeckRkeyRouteWithChildren, 347 + ProfileDidListRkeyRoute: ProfileDidListRkeyRouteWithChildren, 278 348 } 279 349 export const routeTree = rootRouteImport 280 350 ._addFileChildren(rootRouteChildren)
+28 -15
src/routes/__root.tsx
··· 1 - import { TanStackDevtools } from "@tanstack/react-devtools"; 2 1 import type { QueryClient } from "@tanstack/react-query"; 3 2 import { 4 3 createRootRouteWithContext, 5 4 HeadContent, 6 5 Scripts, 7 6 } from "@tanstack/react-router"; 8 - import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 7 + import { lazy, Suspense } from "react"; 9 8 import { Toaster } from "sonner"; 10 9 import Header from "../components/Header"; 11 10 import { WorkerStatusIndicator } from "../components/WorkerStatusIndicator"; 12 - import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; 13 11 import { initializeApp } from "../lib/app-init"; 14 12 import { AuthProvider } from "../lib/useAuth"; 15 13 import { ThemeProvider, useTheme } from "../lib/useTheme"; 16 14 import appCss from "../styles.css?url"; 15 + 16 + const DevTools = import.meta.env.DEV 17 + ? lazy(() => 18 + import("@tanstack/react-devtools").then(({ TanStackDevtools }) => 19 + Promise.all([ 20 + import("@tanstack/react-router-devtools"), 21 + import("../integrations/tanstack-query/devtools"), 22 + ]).then(([{ TanStackRouterDevtoolsPanel }, TanStackQueryDevtools]) => ({ 23 + default: () => ( 24 + <TanStackDevtools 25 + config={{ position: "bottom-right" }} 26 + plugins={[ 27 + { 28 + name: "Tanstack Router", 29 + render: <TanStackRouterDevtoolsPanel />, 30 + }, 31 + TanStackQueryDevtools.default, 32 + ]} 33 + /> 34 + ), 35 + })), 36 + ), 37 + ) 38 + : () => null; 17 39 18 40 initializeApp(); 19 41 ··· 90 112 <WorkerStatusIndicator /> 91 113 <Header /> 92 114 {children} 93 - <TanStackDevtools 94 - config={{ 95 - position: "bottom-right", 96 - }} 97 - plugins={[ 98 - { 99 - name: "Tanstack Router", 100 - render: <TanStackRouterDevtoolsPanel />, 101 - }, 102 - TanStackQueryDevtools, 103 - ]} 104 - /> 115 + <Suspense> 116 + <DevTools /> 117 + </Suspense> 105 118 <ThemedToaster /> 106 119 </AuthProvider> 107 120 </ThemeProvider>
+311 -101
src/routes/card/$id.tsx
··· 2 2 import { createFileRoute, Link } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import { CardImage } from "@/components/CardImage"; 5 + import { SaveToListButton } from "@/components/list/SaveToListButton"; 5 6 import { ManaCost } from "@/components/ManaCost"; 6 7 import { OracleText } from "@/components/OracleText"; 8 + import { getAllFaces } from "@/lib/card-faces"; 9 + import { FORMAT_GROUPS } from "@/lib/format-utils"; 7 10 import { 8 11 getCardByIdQueryOptions, 9 12 getCardPrintingsQueryOptions, 13 + getVolatileDataQueryOptions, 10 14 } from "@/lib/queries"; 11 - import type { Card, ScryfallId } from "@/lib/scryfall-types"; 15 + import type { Card, CardFace, ScryfallId } from "@/lib/scryfall-types"; 12 16 import { asOracleId, isScryfallId } from "@/lib/scryfall-types"; 13 17 import { getImageUri } from "@/lib/scryfall-utils"; 14 18 ··· 27 31 return null; 28 32 } 29 33 30 - // Prefetch only the main card during SSR 34 + // Prefetch card and volatile data in parallel 31 35 // Printing IDs and printing cards are loaded client-side to avoid memory bloat 32 36 // (some cards like Lightning Bolt have 100+ printings) 33 - const card = await context.queryClient.ensureQueryData( 34 - getCardByIdQueryOptions(params.id), 35 - ); 37 + const [card] = await Promise.all([ 38 + context.queryClient.ensureQueryData(getCardByIdQueryOptions(params.id)), 39 + context.queryClient.ensureQueryData( 40 + getVolatileDataQueryOptions(params.id), 41 + ), 42 + ]); 36 43 return card ?? null; 37 44 }, 38 45 head: ({ loaderData }) => { ··· 130 137 null, 131 138 ); 132 139 const currentPrintingRef = useRef<HTMLAnchorElement>(null); 140 + const printingsContainerRef = useRef<HTMLDivElement>(null); 133 141 134 142 const isValidId = isScryfallId(id); 135 143 const { data: card, isLoading: cardLoading } = useQuery( ··· 148 156 combine: combinePrintingQueries, 149 157 }); 150 158 159 + // Use hovered printing's ID for volatile data, fall back to current card 160 + const displayedId = hoveredPrintingId ?? (isValidId ? id : null); 161 + const { data: volatileData, isLoading: volatileLoading } = useQuery({ 162 + ...getVolatileDataQueryOptions(displayedId ?? ("" as ScryfallId)), 163 + enabled: !!displayedId, 164 + }); 165 + 151 166 useEffect(() => { 152 - if (currentPrintingRef.current) { 153 - currentPrintingRef.current.scrollIntoView({ 154 - behavior: "smooth", 155 - block: "nearest", 156 - }); 157 - } 158 - }, []); 167 + const container = printingsContainerRef.current; 168 + const element = currentPrintingRef.current; 169 + 170 + if (!printingsMap || !container || !element) return; 171 + 172 + requestAnimationFrame(() => { 173 + const elementTop = element.offsetTop - container.offsetTop; 174 + const elementBottom = elementTop + element.offsetHeight; 175 + const containerScroll = container.scrollTop; 176 + const containerHeight = container.clientHeight; 177 + 178 + if (elementTop < containerScroll) { 179 + container.scrollTop = elementTop; 180 + } else if (elementBottom > containerScroll + containerHeight) { 181 + container.scrollTop = elementBottom - containerHeight; 182 + } 183 + }); 184 + }, [printingsMap]); 159 185 160 186 if (!isValidId) { 161 187 return ( ··· 193 219 ? (printingsMap?.get(hoveredPrintingId) ?? card) 194 220 : card; 195 221 222 + // Sort printings by release date (newest first) for display 223 + // oracleIdToPrintings is canonical order, but users expect chronological when browsing 196 224 const allPrintings = (printingIds ?? []) 197 225 .map((pid) => printingsMap?.get(pid)) 198 - .filter((c): c is Card => c !== undefined); 226 + .filter((c): c is Card => c !== undefined) 227 + .sort((a, b) => (b.released_at ?? "").localeCompare(a.released_at ?? "")); 199 228 200 229 return ( 201 230 <div className="min-h-screen bg-white dark:bg-slate-900"> ··· 210 239 </div> 211 240 212 241 <div className="space-y-6 min-w-0"> 213 - <div> 214 - <div className="flex items-baseline gap-3 mb-2 flex-wrap"> 215 - <h1 className="text-4xl font-bold text-gray-900 dark:text-white"> 216 - {card.name} 217 - </h1> 218 - {card.mana_cost && ( 219 - <div className="flex-shrink-0"> 220 - <ManaCost cost={card.mana_cost} size="large" /> 221 - </div> 222 - )} 223 - </div> 224 - {card.type_line && ( 225 - <p className="text-lg text-gray-600 dark:text-gray-400"> 226 - {card.type_line} 227 - </p> 228 - )} 229 - </div> 230 - 231 - {card.oracle_text && ( 232 - <div className="bg-gray-100 dark:bg-slate-800 rounded-lg p-4 border border-gray-300 dark:border-slate-700"> 233 - <p className="text-gray-900 dark:text-gray-200"> 234 - <OracleText text={card.oracle_text} /> 235 - </p> 236 - </div> 237 - )} 238 - 239 - {(card.power || card.toughness || card.loyalty) && ( 240 - <div className="flex gap-4 text-gray-700 dark:text-gray-300"> 241 - {card.power && card.toughness && ( 242 - <div> 243 - <span className="text-gray-600 dark:text-gray-400"> 244 - P/T: 245 - </span>{" "} 246 - <span className="font-semibold"> 247 - {card.power}/{card.toughness} 248 - </span> 249 - </div> 250 - )} 251 - {card.loyalty && ( 252 - <div> 253 - <span className="text-gray-600 dark:text-gray-400"> 254 - Loyalty: 255 - </span>{" "} 256 - <span className="font-semibold">{card.loyalty}</span> 257 - </div> 242 + {getAllFaces(card).map((face, idx) => ( 243 + <div key={face.name}> 244 + {idx > 0 && ( 245 + <div className="border-t border-gray-300 dark:border-slate-600 mb-4" /> 258 246 )} 247 + <FaceInfo 248 + face={face} 249 + primary={idx === 0} 250 + cardId={idx === 0 ? id : undefined} 251 + /> 259 252 </div> 260 - )} 253 + ))} 261 254 262 255 <div 263 256 className="grid gap-x-8 gap-y-4 text-sm" ··· 265 258 gridTemplateColumns: "minmax(50%, auto) minmax(0, 1fr)", 266 259 }} 267 260 > 268 - {displayCard.set_name && ( 269 - <> 270 - <div className="min-w-0"> 271 - <p className="text-gray-600 dark:text-gray-400">Set</p> 272 - <p 273 - className="text-gray-900 dark:text-white truncate" 274 - title={`${displayCard.set_name} (${displayCard.set?.toUpperCase()})`} 275 - > 261 + <div className="min-w-0"> 262 + <p className="text-gray-600 dark:text-gray-400">Set</p> 263 + <p 264 + className="text-gray-900 dark:text-white truncate" 265 + title={ 266 + displayCard.set_name 267 + ? `${displayCard.set_name} (${displayCard.set?.toUpperCase()})` 268 + : undefined 269 + } 270 + > 271 + {displayCard.set_name ? ( 272 + <> 276 273 {displayCard.set_name} ({displayCard.set?.toUpperCase()}) 277 - </p> 278 - </div> 279 - {displayCard.rarity && ( 280 - <div className="min-w-0"> 281 - <p className="text-gray-600 dark:text-gray-400">Rarity</p> 282 - <p className="text-gray-900 dark:text-white capitalize truncate"> 283 - {displayCard.rarity} 284 - </p> 285 - </div> 274 + </> 275 + ) : ( 276 + <span className="text-gray-400 dark:text-gray-600">—</span> 277 + )} 278 + </p> 279 + </div> 280 + <div className="min-w-0"> 281 + <p className="text-gray-600 dark:text-gray-400">Rarity</p> 282 + <p className="text-gray-900 dark:text-white capitalize truncate"> 283 + {displayCard.rarity ?? ( 284 + <span className="text-gray-400 dark:text-gray-600">—</span> 285 + )} 286 + </p> 287 + </div> 288 + <div className="min-w-0"> 289 + <p className="text-gray-600 dark:text-gray-400">Artist</p> 290 + <p 291 + className="text-gray-900 dark:text-white truncate" 292 + title={displayCard.artist} 293 + > 294 + {displayCard.artist ?? ( 295 + <span className="text-gray-400 dark:text-gray-600">—</span> 286 296 )} 287 - </> 288 - )} 289 - {displayCard.artist && ( 290 - <> 291 - <div className="min-w-0"> 292 - <p className="text-gray-600 dark:text-gray-400">Artist</p> 293 - <p 294 - className="text-gray-900 dark:text-white truncate" 295 - title={displayCard.artist} 296 - > 297 - {displayCard.artist} 298 - </p> 299 - </div> 300 - {displayCard.collector_number && ( 301 - <div className="min-w-0"> 302 - <p className="text-gray-600 dark:text-gray-400"> 303 - Collector Number 304 - </p> 305 - <p className="text-gray-900 dark:text-white truncate"> 306 - {displayCard.collector_number} 307 - </p> 308 - </div> 297 + </p> 298 + </div> 299 + <div className="min-w-0"> 300 + <p className="text-gray-600 dark:text-gray-400"> 301 + Collector Number 302 + </p> 303 + <p className="text-gray-900 dark:text-white truncate"> 304 + {displayCard.collector_number ?? ( 305 + <span className="text-gray-400 dark:text-gray-600">—</span> 309 306 )} 310 - </> 311 - )} 307 + </p> 308 + </div> 309 + </div> 310 + 311 + <div className="h-10 overflow-x-auto overflow-y-hidden"> 312 + {volatileLoading ? ( 313 + <div className="flex gap-3 items-center"> 314 + <span className="px-2.5 py-1 w-16 bg-gray-200 dark:bg-slate-700 rounded text-sm animate-pulse"> 315 + &nbsp; 316 + </span> 317 + <span className="px-2.5 py-1 w-20 bg-gray-200 dark:bg-slate-700 rounded text-sm animate-pulse"> 318 + &nbsp; 319 + </span> 320 + <span className="px-2.5 py-1 w-24 bg-gray-200 dark:bg-slate-700 rounded text-sm animate-pulse"> 321 + &nbsp; 322 + </span> 323 + </div> 324 + ) : volatileData && 325 + (volatileData.usd || 326 + volatileData.usdFoil || 327 + volatileData.eur || 328 + volatileData.tix || 329 + volatileData.edhrecRank) ? ( 330 + <div className="flex gap-3 items-center"> 331 + {volatileData.edhrecRank && ( 332 + <span className="px-2.5 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 rounded text-sm whitespace-nowrap"> 333 + #{volatileData.edhrecRank.toLocaleString()} EDHREC 334 + </span> 335 + )} 336 + {volatileData.usd && ( 337 + <span className="px-2.5 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded text-sm whitespace-nowrap"> 338 + ${volatileData.usd.toFixed(2)} 339 + </span> 340 + )} 341 + {volatileData.usdFoil && ( 342 + <span className="px-2.5 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 rounded text-sm whitespace-nowrap"> 343 + ${volatileData.usdFoil.toFixed(2)}{" "} 344 + <span className="opacity-70">foil</span> 345 + </span> 346 + )} 347 + {volatileData.usdEtched && ( 348 + <span className="px-2.5 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 rounded text-sm whitespace-nowrap"> 349 + ${volatileData.usdEtched.toFixed(2)}{" "} 350 + <span className="opacity-70">etched</span> 351 + </span> 352 + )} 353 + {volatileData.eur && ( 354 + <span className="px-2.5 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded text-sm whitespace-nowrap"> 355 + €{volatileData.eur.toFixed(2)} 356 + </span> 357 + )} 358 + {volatileData.eurFoil && ( 359 + <span className="px-2.5 py-1 bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300 rounded text-sm whitespace-nowrap"> 360 + €{volatileData.eurFoil.toFixed(2)}{" "} 361 + <span className="opacity-70">foil</span> 362 + </span> 363 + )} 364 + {volatileData.tix && ( 365 + <span className="px-2.5 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 rounded text-sm whitespace-nowrap"> 366 + {volatileData.tix.toFixed(2)} tix 367 + </span> 368 + )} 369 + </div> 370 + ) : null} 312 371 </div> 313 372 314 373 {printingIdsLoading ? ( ··· 333 392 <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3"> 334 393 Printings ({allPrintings.length}) 335 394 </h2> 336 - <div className="max-h-96 overflow-y-auto border border-gray-300 dark:border-slate-700 rounded-lg p-3 bg-gray-50 dark:bg-slate-800/50"> 395 + <div 396 + ref={printingsContainerRef} 397 + className="max-h-96 overflow-y-auto border border-gray-300 dark:border-slate-700 rounded-lg p-3 bg-gray-50 dark:bg-slate-800/50" 398 + > 337 399 <div className="flex flex-wrap gap-2"> 338 400 {allPrintings.map((printing) => ( 339 401 <Link ··· 363 425 </div> 364 426 </div> 365 427 ) : null} 428 + 429 + {card.legalities && <LegalityTable legalities={card.legalities} />} 366 430 </div> 367 431 </div> 368 432 </div> 369 433 </div> 370 434 ); 371 435 } 436 + 437 + interface FaceInfoProps { 438 + face: CardFace; 439 + primary?: boolean; 440 + cardId?: ScryfallId; 441 + } 442 + 443 + function FaceInfo({ face, primary = false, cardId }: FaceInfoProps) { 444 + const hasStats = face.power || face.toughness || face.loyalty || face.defense; 445 + 446 + return ( 447 + <div className="space-y-3"> 448 + <div> 449 + <div className="flex items-center justify-between gap-3 mb-2"> 450 + <div className="flex items-center gap-3 flex-wrap"> 451 + {primary ? ( 452 + <h1 className="text-4xl font-bold text-gray-900 dark:text-white"> 453 + {face.name} 454 + </h1> 455 + ) : ( 456 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 457 + {face.name} 458 + </h2> 459 + )} 460 + {face.mana_cost && ( 461 + <ManaCost 462 + cost={face.mana_cost} 463 + size={primary ? "large" : "medium"} 464 + /> 465 + )} 466 + </div> 467 + {cardId && ( 468 + <SaveToListButton 469 + item={{ type: "card", scryfallId: cardId }} 470 + itemName={face.name} 471 + /> 472 + )} 473 + </div> 474 + {face.type_line && ( 475 + <p 476 + className={`text-gray-600 dark:text-gray-400 ${primary ? "text-lg" : ""}`} 477 + > 478 + {face.type_line} 479 + </p> 480 + )} 481 + </div> 482 + 483 + {face.oracle_text && ( 484 + <div className="bg-gray-100 dark:bg-slate-800 rounded-lg p-4 border border-gray-300 dark:border-slate-700 text-gray-900 dark:text-gray-100"> 485 + <OracleText text={face.oracle_text} /> 486 + </div> 487 + )} 488 + 489 + {hasStats && ( 490 + <div className="flex gap-4 text-gray-700 dark:text-gray-300"> 491 + {face.power && face.toughness && ( 492 + <span> 493 + <span className="text-gray-600 dark:text-gray-400">P/T:</span>{" "} 494 + <span className="font-semibold"> 495 + {face.power}/{face.toughness} 496 + </span> 497 + </span> 498 + )} 499 + {face.loyalty && ( 500 + <span> 501 + <span className="text-gray-600 dark:text-gray-400">Loyalty:</span>{" "} 502 + <span className="font-semibold">{face.loyalty}</span> 503 + </span> 504 + )} 505 + {face.defense && ( 506 + <span> 507 + <span className="text-gray-600 dark:text-gray-400">Defense:</span>{" "} 508 + <span className="font-semibold">{face.defense}</span> 509 + </span> 510 + )} 511 + </div> 512 + )} 513 + </div> 514 + ); 515 + } 516 + 517 + interface LegalityTableProps { 518 + legalities: Record<string, string>; 519 + } 520 + 521 + function LegalityTable({ legalities }: LegalityTableProps) { 522 + return ( 523 + <div> 524 + <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3"> 525 + Format Legality 526 + </h2> 527 + <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 528 + {FORMAT_GROUPS.map((group) => ( 529 + <div key={group.label}> 530 + <h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1.5"> 531 + {group.label} 532 + </h3> 533 + <div className="space-y-1"> 534 + {group.formats.map((format) => { 535 + const legality = legalities[format.value] ?? "not_legal"; 536 + return ( 537 + <div 538 + key={format.value} 539 + className="flex items-center justify-between text-sm" 540 + > 541 + <span className="text-gray-700 dark:text-gray-300"> 542 + {format.label} 543 + </span> 544 + <LegalityBadge legality={legality} /> 545 + </div> 546 + ); 547 + })} 548 + </div> 549 + </div> 550 + ))} 551 + </div> 552 + </div> 553 + ); 554 + } 555 + 556 + function LegalityBadge({ legality }: { legality: string }) { 557 + const styles: Record<string, string> = { 558 + legal: 559 + "bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300", 560 + restricted: 561 + "bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300", 562 + banned: "bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300", 563 + not_legal: 564 + "bg-gray-100 dark:bg-slate-700/50 text-gray-500 dark:text-gray-500", 565 + }; 566 + 567 + const labels: Record<string, string> = { 568 + legal: "Legal", 569 + restricted: "Restricted", 570 + banned: "Banned", 571 + not_legal: "—", 572 + }; 573 + 574 + return ( 575 + <span 576 + className={`px-1.5 py-0.5 rounded text-xs font-medium ${styles[legality] ?? styles.not_legal}`} 577 + > 578 + {labels[legality] ?? legality} 579 + </span> 580 + ); 581 + }
+327 -50
src/routes/cards/index.tsx
··· 1 - import { useQuery } from "@tanstack/react-query"; 1 + import { useQueries, useQuery } from "@tanstack/react-query"; 2 2 import { createFileRoute } from "@tanstack/react-router"; 3 - import { Loader2, Search } from "lucide-react"; 4 - import { useEffect, useState } from "react"; 5 - import { CardThumbnail } from "@/components/CardImage"; 3 + import { useWindowVirtualizer } from "@tanstack/react-virtual"; 4 + import { AlertCircle, ChevronDown, Loader2, Search } from "lucide-react"; 5 + import { useEffect, useMemo, useRef, useState } from "react"; 6 + import { CardSkeleton, CardThumbnail } from "@/components/CardImage"; 7 + import { OracleText } from "@/components/OracleText"; 6 8 import { 7 9 getCardsMetadataQueryOptions, 8 - searchCardsQueryOptions, 10 + PAGE_SIZE, 11 + searchPageQueryOptions, 9 12 } from "@/lib/queries"; 13 + import type { Card, SortOption } from "@/lib/search-types"; 10 14 import { useDebounce } from "@/lib/useDebounce"; 11 15 12 16 export const Route = createFileRoute("/cards/")({ ··· 15 19 validateSearch: (search: Record<string, unknown>) => { 16 20 return { 17 21 q: (search.q as string) || "", 22 + sort: (search.sort as string) || undefined, 18 23 }; 19 24 }, 20 25 }); ··· 32 37 33 38 return ( 34 39 <p className="text-gray-400"> 35 - {metadata.cardCount.toLocaleString()} cards • Version: {metadata.version} 40 + {metadata.cardCount.toLocaleString()} cards • Version: {metadata.version}{" "} 41 + • Data from{" "} 42 + <a 43 + href="https://scryfall.com" 44 + target="_blank" 45 + rel="noopener noreferrer" 46 + className="text-cyan-600 dark:text-cyan-400 hover:underline" 47 + > 48 + Scryfall 49 + </a> 36 50 </p> 37 51 ); 38 52 } 39 53 54 + // Breakpoints match Tailwind: grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 55 + function getColumns() { 56 + if (typeof window === "undefined") return 5; 57 + const width = window.innerWidth; 58 + if (width >= 1280) return 5; 59 + if (width >= 1024) return 4; 60 + if (width >= 768) return 3; 61 + return 2; 62 + } 63 + 64 + function useColumns() { 65 + const [columns, setColumns] = useState(getColumns); 66 + 67 + useEffect(() => { 68 + let timeout: ReturnType<typeof setTimeout>; 69 + const update = () => { 70 + clearTimeout(timeout); 71 + timeout = setTimeout(() => setColumns(getColumns()), 150); 72 + }; 73 + 74 + window.addEventListener("resize", update); 75 + return () => { 76 + clearTimeout(timeout); 77 + window.removeEventListener("resize", update); 78 + }; 79 + }, []); 80 + 81 + return columns; 82 + } 83 + 84 + const SCROLL_STORAGE_KEY = "cards-scroll-position"; 85 + 86 + const SORT_OPTIONS: { value: string; label: string; sort: SortOption }[] = [ 87 + { 88 + value: "name-asc", 89 + label: "Name (A to Z)", 90 + sort: { field: "name", direction: "asc" }, 91 + }, 92 + { 93 + value: "name-desc", 94 + label: "Name (Z to A)", 95 + sort: { field: "name", direction: "desc" }, 96 + }, 97 + { 98 + value: "mv-asc", 99 + label: "Mana Value (Low to High)", 100 + sort: { field: "mv", direction: "asc" }, 101 + }, 102 + { 103 + value: "mv-desc", 104 + label: "Mana Value (High to Low)", 105 + sort: { field: "mv", direction: "desc" }, 106 + }, 107 + { 108 + value: "released-desc", 109 + label: "Release (Newest)", 110 + sort: { field: "released", direction: "desc" }, 111 + }, 112 + { 113 + value: "released-asc", 114 + label: "Release (Oldest)", 115 + sort: { field: "released", direction: "asc" }, 116 + }, 117 + { 118 + value: "rarity-desc", 119 + label: "Rarity (Mythic to Common)", 120 + sort: { field: "rarity", direction: "desc" }, 121 + }, 122 + { 123 + value: "rarity-asc", 124 + label: "Rarity (Common to Mythic)", 125 + sort: { field: "rarity", direction: "asc" }, 126 + }, 127 + ]; 128 + 129 + const DEFAULT_SORT = SORT_OPTIONS[0]; 130 + 40 131 function CardsPage() { 41 132 const navigate = Route.useNavigate(); 42 133 const search = Route.useSearch(); 43 - const [searchQuery, setSearchQuery] = useState(search.q || ""); 44 - const debouncedSearchQuery = useDebounce(searchQuery, 250); 45 - const { data: searchResults, isFetching } = useQuery( 46 - searchCardsQueryOptions(debouncedSearchQuery), 134 + const { value: debouncedSearchQuery } = useDebounce(search.q || "", 400); 135 + const searchInputRef = useRef<HTMLInputElement>(null); 136 + const listRef = useRef<HTMLDivElement>(null); 137 + const columns = useColumns(); 138 + const hasRestoredScroll = useRef(false); 139 + const sortOption = 140 + SORT_OPTIONS.find((o) => o.value === search.sort) ?? DEFAULT_SORT; 141 + 142 + // First page query to get totalCount and metadata 143 + const firstPageQuery = useQuery( 144 + searchPageQueryOptions(debouncedSearchQuery, 0, undefined, sortOption.sort), 47 145 ); 146 + const totalCount = firstPageQuery.data?.totalCount ?? 0; 147 + const hasError = firstPageQuery.data?.error != null; 48 148 49 - // Sync state with URL when navigating back/forward 149 + const rowCount = Math.ceil(totalCount / columns); 150 + 151 + const virtualizer = useWindowVirtualizer({ 152 + count: rowCount, 153 + estimateSize: () => 300, // Initial estimate, measureElement will correct it 154 + overscan: 2, 155 + scrollMargin: listRef.current?.offsetTop ?? 0, 156 + }); 157 + 158 + // Save clicked card's row before navigating to detail 159 + const saveScrollPosition = (cardIndex: number) => { 160 + if (!debouncedSearchQuery) return; 161 + const rowIndex = Math.floor(cardIndex / columns); 162 + sessionStorage.setItem( 163 + SCROLL_STORAGE_KEY, 164 + JSON.stringify({ 165 + query: debouncedSearchQuery, 166 + rowIndex, 167 + }), 168 + ); 169 + // Reset window scroll before navigation. useWindowVirtualizer reads 170 + // window.scrollY during React reconciliation and calls scrollTo() with 171 + // that value—causing the old scroll position to bleed into the new page. 172 + // This must happen before navigation starts, not in a cleanup effect. 173 + window.scrollTo(0, 0); 174 + }; 175 + 176 + // Restore scroll position after data loads 50 177 useEffect(() => { 51 - setSearchQuery(search.q || ""); 52 - }, [search.q]); 178 + if (hasRestoredScroll.current || !firstPageQuery.data || rowCount === 0) 179 + return; 180 + 181 + try { 182 + const saved = sessionStorage.getItem(SCROLL_STORAGE_KEY); 183 + if (!saved) { 184 + hasRestoredScroll.current = true; 185 + return; 186 + } 187 + 188 + const { query, rowIndex } = JSON.parse(saved); 189 + if (query === debouncedSearchQuery && rowIndex > 0) { 190 + sessionStorage.removeItem(SCROLL_STORAGE_KEY); 191 + virtualizer.scrollToIndex(rowIndex, { align: "center" }); 192 + } 193 + } catch { 194 + // Ignore parse errors 195 + } 196 + 197 + hasRestoredScroll.current = true; 198 + }, [firstPageQuery.data, debouncedSearchQuery, rowCount, virtualizer]); 199 + 200 + // Calculate which pages are needed based on visible rows (excluding page 0, handled by firstPageQuery) 201 + const visibleItems = virtualizer.getVirtualItems(); 202 + const extraOffsets = (() => { 203 + if (!debouncedSearchQuery.trim() || visibleItems.length === 0) return []; 204 + 205 + const firstRowIndex = visibleItems[0]?.index ?? 0; 206 + const lastRowIndex = visibleItems[visibleItems.length - 1]?.index ?? 0; 207 + 208 + const firstCardIndex = firstRowIndex * columns; 209 + const lastCardIndex = (lastRowIndex + 1) * columns - 1; 210 + 211 + const firstPage = Math.floor(firstCardIndex / PAGE_SIZE); 212 + const lastPage = Math.floor(lastCardIndex / PAGE_SIZE); 213 + 214 + const offsets: number[] = []; 215 + for (let p = firstPage; p <= lastPage; p++) { 216 + const offset = p * PAGE_SIZE; 217 + if (offset > 0) offsets.push(offset); // Skip page 0, already fetched 218 + } 219 + return offsets; 220 + })(); 221 + 222 + // Fetch additional pages beyond page 0 223 + const extraPageQueries = useQueries({ 224 + queries: extraOffsets.map((offset) => 225 + searchPageQueryOptions( 226 + debouncedSearchQuery, 227 + offset, 228 + undefined, 229 + sortOption.sort, 230 + ), 231 + ), 232 + }); 233 + 234 + // Build card map from all loaded pages 235 + const cardMap = useMemo(() => { 236 + const map = new Map<number, Card>(); 237 + 238 + // Add cards from first page 239 + const firstPageCards = firstPageQuery.data?.cards; 240 + if (firstPageCards) { 241 + for (let j = 0; j < firstPageCards.length; j++) { 242 + map.set(j, firstPageCards[j]); 243 + } 244 + } 53 245 54 - // Update URL immediately on search query change 55 - useEffect(() => { 56 - if (searchQuery !== search.q) { 57 - navigate({ 58 - search: { q: searchQuery }, 59 - replace: true, 60 - }); 246 + // Add cards from extra pages 247 + for (let i = 0; i < extraPageQueries.length; i++) { 248 + const offset = extraOffsets[i]; 249 + const query = extraPageQueries[i]; 250 + if (query?.data?.cards) { 251 + for (let j = 0; j < query.data.cards.length; j++) { 252 + map.set(offset + j, query.data.cards[j]); 253 + } 254 + } 61 255 } 62 - }, [searchQuery, search.q, navigate]); 256 + 257 + return map; 258 + }, [firstPageQuery.data?.cards, extraOffsets, extraPageQueries]); 259 + 260 + useEffect(() => { 261 + searchInputRef.current?.focus(); 262 + }, []); 263 + 264 + const firstPage = firstPageQuery.data; 63 265 64 266 return ( 65 267 <div className="min-h-screen bg-white dark:bg-slate-900"> 66 - <div className="max-w-7xl mx-auto px-6 py-8"> 268 + <div className="max-w-7xl w-full mx-auto px-6 pt-8 pb-4"> 67 269 <div className="mb-8"> 68 270 <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2"> 69 271 Card Browser ··· 71 273 <MetadataDisplay /> 72 274 </div> 73 275 74 - <div className="mb-6"> 75 - <div className="relative"> 76 - <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 77 - <input 78 - type="text" 79 - placeholder="Search cards..." 80 - value={searchQuery} 81 - onChange={(e) => setSearchQuery(e.target.value)} 82 - className="w-full pl-12 pr-4 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 transition-colors" 83 - /> 276 + <div className="mb-4"> 277 + <div className="flex gap-2"> 278 + <div className="relative flex-1"> 279 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 280 + <input 281 + ref={searchInputRef} 282 + type="text" 283 + placeholder="Search by name or try t:creature cmc<=3" 284 + value={search.q} 285 + onChange={(e) => 286 + navigate({ 287 + search: (prev) => ({ ...prev, q: e.target.value }), 288 + replace: true, 289 + }) 290 + } 291 + className={`w-full pl-12 pr-4 py-3 bg-gray-100 dark:bg-slate-800 border rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none transition-colors ${ 292 + hasError 293 + ? "border-red-500 focus:border-red-500" 294 + : "border-gray-300 dark:border-slate-700 focus:border-cyan-500" 295 + }`} 296 + /> 297 + </div> 298 + <div className="relative"> 299 + <select 300 + value={sortOption.value} 301 + onChange={(e) => 302 + navigate({ 303 + search: (prev) => ({ ...prev, sort: e.target.value }), 304 + replace: true, 305 + }) 306 + } 307 + className="appearance-none h-full px-4 pr-10 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500 transition-colors cursor-pointer" 308 + > 309 + {SORT_OPTIONS.map((opt) => ( 310 + <option key={opt.value} value={opt.value}> 311 + {opt.label} 312 + </option> 313 + ))} 314 + </select> 315 + <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" /> 316 + </div> 84 317 </div> 85 - {searchQuery && searchResults && searchResults.cards.length > 0 && ( 86 - <p className="text-sm text-gray-400 mt-2"> 87 - Found {searchResults.cards.length} results for "{searchQuery}" 88 - {isFetching && " • Searching..."} 318 + 319 + {hasError && firstPage?.error && ( 320 + <div className="mt-2 flex items-start gap-2 text-sm text-red-500 dark:text-red-400"> 321 + <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 322 + <span>{firstPage.error.message}</span> 323 + </div> 324 + )} 325 + 326 + {firstPage?.description && !hasError && ( 327 + <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> 328 + <OracleText text={firstPage.description} /> 89 329 </p> 90 330 )} 91 - {searchQuery && searchResults && searchResults.cards.length === 0 && ( 331 + 332 + {search.q && !hasError && ( 92 333 <p className="text-sm text-gray-400 mt-2"> 93 - No results found for "{searchQuery}" 334 + {totalCount > 0 && ( 335 + <> 336 + Found {totalCount.toLocaleString()} results 337 + {firstPage?.mode === "syntax" && " (syntax)"} 338 + </> 339 + )} 340 + {totalCount === 0 && 341 + firstPage && 342 + !firstPageQuery.isFetching && 343 + "No results found"} 344 + {!firstPage && firstPageQuery.isFetching && "Searching..."} 94 345 </p> 95 346 )} 96 - {!searchQuery && ( 347 + 348 + {!search.q && ( 97 349 <p className="text-sm text-gray-400 mt-2"> 98 350 Enter a search query to find cards 99 351 </p> 100 352 )} 101 - {searchQuery && !searchResults && ( 102 - <p className="text-sm text-gray-400 mt-2">Searching...</p> 103 - )} 104 353 </div> 354 + </div> 105 355 356 + <div ref={listRef} className="px-6 pb-6"> 106 357 <div 107 - className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 transition-opacity ${isFetching || searchQuery !== debouncedSearchQuery ? "opacity-50" : "opacity-100"}`} 358 + className="max-w-7xl mx-auto relative" 359 + style={{ height: virtualizer.getTotalSize() }} 108 360 > 109 - {searchResults?.cards.map((card) => ( 110 - <CardThumbnail 111 - key={card.id} 112 - card={card} 113 - href={`/card/${card.id}`} 114 - /> 115 - ))} 361 + {visibleItems.map((virtualRow) => { 362 + const startIndex = virtualRow.index * columns; 363 + const itemsInRow = Math.min(columns, totalCount - startIndex); 364 + 365 + return ( 366 + <div 367 + key={virtualRow.key} 368 + data-index={virtualRow.index} 369 + ref={virtualizer.measureElement} 370 + className="absolute top-0 left-0 w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 pb-4" 371 + style={{ 372 + transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`, 373 + }} 374 + > 375 + {Array.from({ length: itemsInRow }, (_, i) => { 376 + const cardIndex = startIndex + i; 377 + const card = cardMap.get(cardIndex); 378 + if (card) { 379 + return ( 380 + <CardThumbnail 381 + key={card.id} 382 + card={card} 383 + href={`/card/${card.id}`} 384 + onClick={() => saveScrollPosition(cardIndex)} 385 + /> 386 + ); 387 + } 388 + return <CardSkeleton key={`skeleton-${cardIndex}`} />; 389 + })} 390 + </div> 391 + ); 392 + })} 116 393 </div> 117 394 </div> 118 395 </div>
+10 -8
src/routes/deck/new.tsx
··· 1 1 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { useId, useState } from "react"; 3 3 import { useCreateDeckMutation } from "@/lib/deck-queries"; 4 + import { FORMAT_GROUPS } from "@/lib/format-utils"; 4 5 5 6 export const Route = createFileRoute("/deck/new")({ 6 7 component: NewDeckPage, ··· 65 66 onChange={(e) => setFormat(e.target.value)} 66 67 className="w-full px-4 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500 transition-colors" 67 68 > 68 - <option value="commander">Commander</option> 69 - <option value="cube">Cube</option> 70 - <option value="pauper">Pauper</option> 71 - <option value="paupercommander">Pauper Commander (PDH)</option> 72 - <option value="standard">Standard</option> 73 - <option value="modern">Modern</option> 74 - <option value="legacy">Legacy</option> 75 - <option value="vintage">Vintage</option> 69 + {FORMAT_GROUPS.map((group) => ( 70 + <optgroup key={group.label} label={group.label}> 71 + {group.formats.map((fmt) => ( 72 + <option key={fmt.value} value={fmt.value}> 73 + {fmt.label} 74 + </option> 75 + ))} 76 + </optgroup> 77 + ))} 76 78 </select> 77 79 </div> 78 80
+112 -28
src/routes/index.tsx
··· 1 1 import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { Plus, Search, User } from "lucide-react"; 3 + import { ActivityFeed } from "@/components/ActivityFeed"; 2 4 import { useAuth } from "@/lib/useAuth"; 3 5 4 6 export const Route = createFileRoute("/")({ component: App }); ··· 7 9 const { session } = useAuth(); 8 10 9 11 return ( 10 - <div className="min-h-screen bg-white dark:bg-slate-900 flex items-center justify-center px-6"> 11 - <div className="text-center max-w-2xl"> 12 - <h1 className="text-6xl font-bold text-gray-900 dark:text-white mb-6"> 13 - DeckBelcher 14 - </h1> 15 - <p className="text-xl text-gray-600 dark:text-gray-400 mb-8"> 16 - MTG deck building and sharing powered by AT Protocol 17 - </p> 18 - <div className="flex gap-4 justify-center"> 12 + <div className="min-h-[calc(100vh-64px)] bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950"> 13 + <div className="max-w-5xl mx-auto px-6 py-16 md:py-24"> 14 + <div className="text-center mb-16"> 15 + <h1 className="text-5xl md:text-6xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight"> 16 + DeckBelcher 17 + </h1> 18 + <p className="text-xl text-gray-600 dark:text-gray-400 max-w-xl mx-auto"> 19 + MTG deck building and sharing powered by AT Protocol 20 + </p> 21 + </div> 22 + 23 + <div className="grid md:grid-cols-3 gap-6 max-w-3xl mx-auto"> 19 24 {session ? ( 20 - <> 21 - <Link 22 - to="/deck/new" 23 - className="inline-block px-8 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-semibold rounded-lg transition-colors" 24 - > 25 + <Link 26 + to="/deck/new" 27 + className="group flex flex-col items-center p-6 bg-white dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl hover:border-cyan-500 dark:hover:border-cyan-500 hover:shadow-lg transition-all" 28 + > 29 + <div className="w-12 h-12 bg-cyan-100 dark:bg-cyan-900/30 rounded-full flex items-center justify-center mb-4 group-hover:bg-cyan-200 dark:group-hover:bg-cyan-800/40 transition-colors"> 30 + <Plus size={24} className="text-cyan-600 dark:text-cyan-400" /> 31 + </div> 32 + <span className="font-semibold text-gray-900 dark:text-white"> 25 33 Create Deck 26 - </Link> 27 - <Link 28 - to="/profile/$did" 29 - params={{ did: session.info.sub }} 30 - className="inline-block px-8 py-3 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white font-semibold rounded-lg transition-colors" 31 - > 32 - My Decks 33 - </Link> 34 - </> 34 + </span> 35 + <span className="text-sm text-gray-500 dark:text-gray-400 mt-1"> 36 + Start building 37 + </span> 38 + </Link> 35 39 ) : ( 36 40 <Link 37 41 to="/signin" 38 - className="inline-block px-8 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-semibold rounded-lg transition-colors" 42 + className="group flex flex-col items-center p-6 bg-white dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl hover:border-cyan-500 dark:hover:border-cyan-500 hover:shadow-lg transition-all" 39 43 > 40 - Sign In 44 + <div className="w-12 h-12 bg-cyan-100 dark:bg-cyan-900/30 rounded-full flex items-center justify-center mb-4 group-hover:bg-cyan-200 dark:group-hover:bg-cyan-800/40 transition-colors"> 45 + <User size={24} className="text-cyan-600 dark:text-cyan-400" /> 46 + </div> 47 + <span className="font-semibold text-gray-900 dark:text-white"> 48 + Sign In 49 + </span> 50 + <span className="text-sm text-gray-500 dark:text-gray-400 mt-1"> 51 + Get started 52 + </span> 41 53 </Link> 42 54 )} 55 + 56 + {session ? ( 57 + <Link 58 + to="/profile/$did" 59 + params={{ did: session.info.sub }} 60 + className="group flex flex-col items-center p-6 bg-white dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl hover:border-cyan-500 dark:hover:border-cyan-500 hover:shadow-lg transition-all" 61 + > 62 + <div className="w-12 h-12 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4 group-hover:bg-gray-200 dark:group-hover:bg-slate-600 transition-colors"> 63 + <User size={24} className="text-gray-600 dark:text-gray-300" /> 64 + </div> 65 + <span className="font-semibold text-gray-900 dark:text-white"> 66 + My Decks 67 + </span> 68 + <span className="text-sm text-gray-500 dark:text-gray-400 mt-1"> 69 + View your collection 70 + </span> 71 + </Link> 72 + ) : ( 73 + <div className="flex flex-col items-center p-6 bg-gray-50 dark:bg-slate-800/30 border border-gray-200 dark:border-slate-700/50 rounded-xl opacity-50 cursor-not-allowed"> 74 + <div className="w-12 h-12 bg-gray-100 dark:bg-slate-700/50 rounded-full flex items-center justify-center mb-4"> 75 + <User size={24} className="text-gray-400 dark:text-gray-500" /> 76 + </div> 77 + <span className="font-semibold text-gray-400 dark:text-gray-500"> 78 + My Decks 79 + </span> 80 + <span className="text-sm text-gray-400 dark:text-gray-500 mt-1"> 81 + Sign in to view 82 + </span> 83 + </div> 84 + )} 85 + 43 86 <Link 44 87 to="/cards" 45 - search={{ q: "" }} 46 - className="inline-block px-8 py-3 bg-gray-200 dark:bg-slate-800 hover:bg-gray-300 dark:hover:bg-slate-700 text-gray-900 dark:text-white font-semibold rounded-lg transition-colors" 88 + search={{ q: "", sort: undefined }} 89 + className="group flex flex-col items-center p-6 bg-white dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl hover:border-cyan-500 dark:hover:border-cyan-500 hover:shadow-lg transition-all" 47 90 > 48 - Browse Cards 91 + <div className="w-12 h-12 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4 group-hover:bg-gray-200 dark:group-hover:bg-slate-600 transition-colors"> 92 + <Search size={24} className="text-gray-600 dark:text-gray-300" /> 93 + </div> 94 + <span className="font-semibold text-gray-900 dark:text-white"> 95 + Browse Cards 96 + </span> 97 + <span className="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Search the database 99 + </span> 49 100 </Link> 101 + </div> 102 + 103 + <div className="mt-16"> 104 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-1 text-center"> 105 + Recent Activity 106 + </h2> 107 + <p className="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center"> 108 + powered by{" "} 109 + <a 110 + href="https://ufos.microcosm.blue" 111 + target="_blank" 112 + rel="noopener noreferrer" 113 + className="text-cyan-600 dark:text-cyan-400 hover:underline" 114 + > 115 + UFOs 116 + </a> 117 + </p> 118 + <ActivityFeed limit={9} /> 119 + </div> 120 + 121 + <div className="mt-16 text-center"> 122 + <p className="text-sm text-gray-500 dark:text-gray-500"> 123 + Built on the{" "} 124 + <a 125 + href="https://atproto.com" 126 + target="_blank" 127 + rel="noopener noreferrer" 128 + className="text-cyan-600 dark:text-cyan-400 hover:underline" 129 + > 130 + AT Protocol 131 + </a>{" "} 132 + — your decks, your data 133 + </p> 50 134 </div> 51 135 </div> 52 136 </div>
+4 -2
src/routes/oauth/callback.tsx
··· 1 1 import { finalizeAuthorization } from "@atcute/oauth-browser-client"; 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 - import { useAuth } from "@/lib/useAuth"; 4 + import { RETURN_TO_KEY, useAuth } from "@/lib/useAuth"; 5 5 6 6 export const Route = createFileRoute("/oauth/callback")({ 7 7 component: OAuthCallback, ··· 63 63 const { session } = await finalizeAuthorization(params); 64 64 setAuthSession(session); 65 65 66 - navigate({ to: "/" }); 66 + const returnTo = sessionStorage.getItem(RETURN_TO_KEY); 67 + sessionStorage.removeItem(RETURN_TO_KEY); 68 + navigate({ to: returnTo || "/" }); 67 69 } catch (err) { 68 70 console.error("OAuth callback error:", err); 69 71 const message =
+230 -40
src/routes/profile/$did/deck/$rkey/index.tsx
··· 2 2 import { type DragEndEvent, useDndMonitor } from "@dnd-kit/core"; 3 3 import { useSuspenseQuery } from "@tanstack/react-query"; 4 4 import { createFileRoute, Link } from "@tanstack/react-router"; 5 - import { useState } from "react"; 5 + import { useMemo, useState } from "react"; 6 6 import { toast } from "sonner"; 7 7 import { CardDragOverlay } from "@/components/deck/CardDragOverlay"; 8 8 import { CardModal } from "@/components/deck/CardModal"; 9 9 import { CardPreviewPane } from "@/components/deck/CardPreviewPane"; 10 10 import { CardSearchAutocomplete } from "@/components/deck/CardSearchAutocomplete"; 11 11 import { CommonTagsOverlay } from "@/components/deck/CommonTagsOverlay"; 12 + import { DeckActionsMenu } from "@/components/deck/DeckActionsMenu"; 12 13 import { DeckHeader } from "@/components/deck/DeckHeader"; 13 14 import { DeckSection } from "@/components/deck/DeckSection"; 15 + import { DeckStats } from "@/components/deck/DeckStats"; 14 16 import { DragDropProvider } from "@/components/deck/DragDropProvider"; 15 17 import type { DragData } from "@/components/deck/DraggableCard"; 18 + import { GoldfishView } from "@/components/deck/GoldfishView"; 19 + import { PrimerSection } from "@/components/deck/PrimerSection"; 20 + import { StatsCardList } from "@/components/deck/stats/StatsCardList"; 16 21 import { TrashDropZone } from "@/components/deck/TrashDropZone"; 17 22 import { ViewControls } from "@/components/deck/ViewControls"; 23 + import { SaveToListButton } from "@/components/list/SaveToListButton"; 18 24 import { asRkey } from "@/lib/atproto-client"; 25 + import { prefetchCards } from "@/lib/card-prefetch"; 19 26 import { getDeckQueryOptions, useUpdateDeckMutation } from "@/lib/deck-queries"; 20 27 import type { Deck, GroupBy, Section, SortBy } from "@/lib/deck-types"; 21 28 import { ··· 27 34 updateCardQuantity, 28 35 updateCardTags, 29 36 } from "@/lib/deck-types"; 37 + import { formatDisplayName } from "@/lib/format-utils"; 30 38 import { getCardByIdQueryOptions } from "@/lib/queries"; 39 + import { serializeToMarkdown } from "@/lib/richtext"; 31 40 import type { ScryfallId } from "@/lib/scryfall-types"; 41 + import { getImageUri } from "@/lib/scryfall-utils"; 42 + import { getSelectedCards, type StatsSelection } from "@/lib/stats-selection"; 32 43 import { useAuth } from "@/lib/useAuth"; 44 + import { useDeckStats } from "@/lib/useDeckStats"; 33 45 import { usePersistedState } from "@/lib/usePersistedState"; 46 + import { useRichText } from "@/lib/useRichText"; 34 47 35 48 export const Route = createFileRoute("/profile/$did/deck/$rkey/")({ 36 49 component: DeckEditorPage, ··· 39 52 const deck = await context.queryClient.ensureQueryData( 40 53 getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 41 54 ); 42 - await Promise.all( 43 - deck.cards.map((card) => 44 - context.queryClient.ensureQueryData( 45 - getCardByIdQueryOptions(card.scryfallId), 46 - ), 47 - ), 48 - ); 55 + 56 + const cardIds = deck.cards.map((card) => card.scryfallId); 57 + await prefetchCards(context.queryClient, cardIds); 58 + 59 + return deck; 60 + }, 61 + head: ({ loaderData: deck }) => { 62 + if (!deck) { 63 + return { meta: [{ title: "Deck Not Found | DeckBelcher" }] }; 64 + } 65 + 66 + const format = formatDisplayName(deck.format); 67 + const title = format 68 + ? `${deck.name} (${format}) | DeckBelcher` 69 + : `${deck.name} | DeckBelcher`; 70 + 71 + const ogTitle = format ? `${deck.name} (${format})` : deck.name; 72 + 73 + const cardCount = deck.cards.reduce((sum, c) => sum + c.quantity, 0); 74 + const description = `${cardCount} card${cardCount === 1 ? "" : "s"}`; 75 + 76 + // Use first commander's image, or first card if no commanders 77 + const commanders = deck.cards.filter((c) => c.section === "commander"); 78 + const featuredCard = commanders[0] ?? deck.cards[0]; 79 + const cardImageUrl = featuredCard 80 + ? getImageUri(featuredCard.scryfallId, "large") 81 + : undefined; 82 + 83 + return { 84 + meta: [ 85 + { title }, 86 + { name: "description", content: description }, 87 + { property: "og:title", content: ogTitle }, 88 + { property: "og:description", content: description }, 89 + ...(cardImageUrl 90 + ? [ 91 + { property: "og:image", content: cardImageUrl }, 92 + { property: "og:image:width", content: "672" }, 93 + { property: "og:image:height", content: "936" }, 94 + ] 95 + : []), 96 + { property: "og:type", content: "website" }, 97 + { name: "twitter:card", content: "summary_large_image" }, 98 + { name: "twitter:title", content: ogTitle }, 99 + { name: "twitter:description", content: description }, 100 + ...(cardImageUrl 101 + ? [{ name: "twitter:image", content: cardImageUrl }] 102 + : []), 103 + ], 104 + }; 49 105 }, 50 106 }); 51 107 ··· 71 127 const [modalCard, setModalCard] = useState<DeckCard | null>(null); 72 128 const [draggedCardId, setDraggedCardId] = useState<ScryfallId | null>(null); 73 129 const [isDragging, setIsDragging] = useState(false); 130 + const [statsSelection, setStatsSelection] = useState<StatsSelection>(null); 131 + const [highlightedCards, setHighlightedCards] = useState<Set<ScryfallId>>( 132 + new Set(), 133 + ); 134 + 135 + const statsCards = useMemo( 136 + () => [ 137 + ...getCardsInSection(deck, "commander"), 138 + ...getCardsInSection(deck, "mainboard"), 139 + ], 140 + [deck], 141 + ); 142 + const stats = useDeckStats(statsCards); 143 + const selectedCards = useMemo( 144 + () => getSelectedCards(statsSelection, stats), 145 + [statsSelection, stats], 146 + ); 147 + 148 + // All unique tags in the deck (for autocomplete) 149 + const allTags = useMemo( 150 + () => Array.from(new Set(deck.cards.flatMap((c) => c.tags ?? []))), 151 + [deck], 152 + ); 74 153 75 154 const mutation = useUpdateDeckMutation(did as Did, asRkey(rkey)); 76 155 const queryClient = Route.useRouteContext().queryClient; ··· 78 157 // Check if current user is the owner 79 158 const isOwner = session?.info.sub === did; 80 159 160 + // Primer editor state 161 + const primerInitialValue = useMemo( 162 + () => 163 + serializeToMarkdown(deck.primer?.text ?? "", deck.primer?.facets ?? []), 164 + [deck.primer], 165 + ); 166 + const primer = useRichText({ 167 + initialValue: primerInitialValue, 168 + onSave: (parsed) => { 169 + if (!isOwner) return; 170 + mutation.mutate({ ...deck, primer: parsed }); 171 + }, 172 + saveDebounceMs: 1500, 173 + }); 174 + 81 175 // Helper to update deck via mutation 82 176 const updateDeck = async (updater: (prev: Deck) => Deck) => { 83 177 if (!isOwner) return; ··· 85 179 await mutation.mutateAsync(updated); 86 180 }; 87 181 182 + // Highlight cards that were changed - clear after render so it can trigger again 183 + const handleCardsChanged = (changedIds: Set<ScryfallId>) => { 184 + setHighlightedCards(changedIds); 185 + setTimeout(() => setHighlightedCards(new Set()), 0); 186 + }; 187 + 88 188 const handleCardHover = (cardId: ScryfallId | null) => { 89 189 // Only update preview if we have a card (persistence - don't clear on null) 90 190 if (cardId !== null) { ··· 201 301 error: (err) => `Failed to add card: ${err.message}`, 202 302 }, 203 303 ); 304 + handleCardsChanged(new Set([cardId])); 204 305 }; 205 306 206 307 const handleDragEnd = (event: DragEndEvent) => { ··· 315 416 draggedCardId={draggedCardId} 316 417 isDragging={isDragging} 317 418 isOwner={isOwner} 419 + stats={stats} 420 + statsSelection={statsSelection} 421 + selectedCards={selectedCards} 422 + setStatsSelection={setStatsSelection} 318 423 setIsDragging={setIsDragging} 319 424 setDraggedCardId={setDraggedCardId} 320 425 handleCardHover={handleCardHover} ··· 329 434 handleCardSelect={handleCardSelect} 330 435 setGroupBy={setGroupBy} 331 436 setSortBy={setSortBy} 437 + allTags={allTags} 438 + updateDeck={updateDeck} 439 + highlightedCards={highlightedCards} 440 + handleCardsChanged={handleCardsChanged} 441 + primer={primer} 442 + isSaving={mutation.isPending} 332 443 /> 333 444 </DragDropProvider> 334 445 ); ··· 345 456 draggedCardId: ScryfallId | null; 346 457 isDragging: boolean; 347 458 isOwner: boolean; 459 + stats: ReturnType<typeof useDeckStats>; 460 + statsSelection: StatsSelection; 461 + selectedCards: ReturnType<typeof getSelectedCards>; 462 + setStatsSelection: (selection: StatsSelection) => void; 348 463 setIsDragging: (dragging: boolean) => void; 349 464 setDraggedCardId: (id: ScryfallId | null) => void; 350 465 handleCardHover: (cardId: ScryfallId | null) => void; ··· 359 474 handleCardSelect: (cardId: ScryfallId) => Promise<void>; 360 475 setGroupBy: (groupBy: GroupBy) => void; 361 476 setSortBy: (sortBy: SortBy) => void; 477 + allTags: string[]; 478 + updateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 479 + highlightedCards: Set<ScryfallId>; 480 + handleCardsChanged: (changedIds: Set<ScryfallId>) => void; 481 + primer: ReturnType<typeof useRichText>; 482 + isSaving: boolean; 362 483 } 363 484 364 485 function DeckEditorInner({ ··· 372 493 draggedCardId, 373 494 isDragging, 374 495 isOwner, 496 + stats, 497 + statsSelection, 498 + selectedCards, 499 + setStatsSelection, 375 500 setIsDragging, 376 501 setDraggedCardId, 377 502 handleCardHover, ··· 386 511 handleCardSelect, 387 512 setGroupBy, 388 513 setSortBy, 514 + allTags, 515 + updateDeck, 516 + highlightedCards, 517 + handleCardsChanged, 518 + primer, 519 + isSaving, 389 520 }: DeckEditorInnerProps) { 390 521 // Track drag state globally (must be inside DndContext) 391 522 useDndMonitor({ ··· 409 540 return ( 410 541 <div className="min-h-screen bg-white dark:bg-slate-900"> 411 542 {/* Deck name and format */} 412 - <div className="max-w-7xl mx-auto px-6 pt-8 pb-4"> 543 + <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 pt-8 pb-4 space-y-4"> 413 544 <DeckHeader 414 545 name={deck.name} 415 546 format={deck.format} ··· 417 548 onFormatChange={handleFormatChange} 418 549 readOnly={!isOwner} 419 550 /> 551 + <PrimerSection {...primer} isSaving={isSaving} readOnly={!isOwner} /> 420 552 </div> 421 553 422 554 {/* Sticky header with search */} 423 - {isOwner && ( 424 - <div className="sticky top-0 z-10 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 shadow-sm"> 425 - <div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-4"> 426 - <Link 427 - to="/profile/$did/deck/$rkey/bulk-edit" 428 - params={{ did, rkey }} 429 - className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" 430 - > 431 - Bulk Edit 432 - </Link> 555 + <div className="sticky top-0 z-10 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 shadow-sm"> 556 + <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 py-3 flex items-center justify-between gap-4"> 557 + <div className="flex items-center gap-2"> 558 + <DeckActionsMenu 559 + deck={deck} 560 + did={did} 561 + rkey={asRkey(rkey)} 562 + onUpdateDeck={isOwner ? updateDeck : undefined} 563 + onCardsChanged={handleCardsChanged} 564 + readOnly={!isOwner} 565 + /> 566 + <SaveToListButton 567 + item={{ 568 + type: "deck", 569 + deckUri: `at://${did}/com.deckbelcher.deck.list/${rkey}`, 570 + }} 571 + itemName={deck.name} 572 + /> 573 + {isOwner && ( 574 + <Link 575 + to="/profile/$did/deck/$rkey/bulk-edit" 576 + params={{ did, rkey }} 577 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" 578 + > 579 + Bulk Edit 580 + </Link> 581 + )} 582 + </div> 583 + {isOwner && ( 433 584 <div className="w-full max-w-md"> 434 585 <CardSearchAutocomplete 435 586 deck={deck} ··· 438 589 onCardHover={handleCardHover} 439 590 /> 440 591 </div> 441 - </div> 592 + )} 442 593 </div> 443 - )} 594 + </div> 444 595 445 - {/* Trash drop zone - only show while dragging */} 446 - {isOwner && <TrashDropZone isDragging={isDragging} />} 596 + {/* Trash drop zone - only show while dragging, hide on mobile */} 597 + <div className="hidden md:block"> 598 + {isOwner && <TrashDropZone isDragging={isDragging} />} 599 + </div> 447 600 448 - {/* Common tags overlay - only show while dragging */} 449 - {isOwner && <CommonTagsOverlay deck={deck} isDragging={isDragging} />} 601 + {/* Common tags overlay - only show while dragging, hide on mobile */} 602 + <div className="hidden md:block"> 603 + {isOwner && <CommonTagsOverlay deck={deck} isDragging={isDragging} />} 604 + </div> 450 605 451 606 {/* Main content */} 452 - <div className="max-w-7xl mx-auto px-6 py-8"> 453 - <div className="flex flex-col lg:flex-row gap-6"> 454 - {/* Left pane: Card preview (fixed width) */} 455 - <div className="lg:w-80 lg:flex-shrink-0"> 456 - <CardPreviewPane cardId={previewCard} /> 607 + <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 py-8"> 608 + <div className="flex flex-col md:flex-row gap-6"> 609 + {/* Left pane: Card preview + stats card list (fixed width) */} 610 + <div className="hidden md:block md:w-48 lg:w-60 xl:w-80 md:flex-shrink-0"> 611 + <div className="sticky top-20 max-h-[calc(100vh-6rem)] flex flex-col"> 612 + <CardPreviewPane cardId={previewCard} /> 613 + {selectedCards.cards.length > 0 && ( 614 + <div className="min-h-0 flex-1 overflow-y-auto mt-8"> 615 + <StatsCardList 616 + title={selectedCards.title} 617 + cards={selectedCards.cards} 618 + onCardHover={handleCardHover} 619 + onCardClick={handleCardClick} 620 + /> 621 + </div> 622 + )} 623 + </div> 457 624 </div> 458 625 459 626 {/* Right pane: Deck sections (fills remaining space) */} ··· 465 632 onSortByChange={setSortBy} 466 633 /> 467 634 468 - <DeckSection 469 - section="commander" 470 - cards={getCardsInSection(deck, "commander")} 471 - groupBy={groupBy} 472 - sortBy={sortBy} 473 - onCardHover={handleCardHover} 474 - onCardClick={handleCardClick} 475 - isDragging={isDragging} 476 - readOnly={!isOwner} 477 - /> 635 + {(deck.format === "commander" || 636 + deck.format === "paupercommander" || 637 + deck.cards.some((card) => card.section === "commander")) && ( 638 + <DeckSection 639 + section="commander" 640 + cards={getCardsInSection(deck, "commander")} 641 + groupBy={groupBy} 642 + sortBy={sortBy} 643 + onCardHover={handleCardHover} 644 + onCardClick={handleCardClick} 645 + isDragging={isDragging} 646 + readOnly={!isOwner} 647 + highlightedCards={highlightedCards} 648 + /> 649 + )} 478 650 <DeckSection 479 651 section="mainboard" 480 652 cards={getCardsInSection(deck, "mainboard")} ··· 484 656 onCardClick={handleCardClick} 485 657 isDragging={isDragging} 486 658 readOnly={!isOwner} 659 + highlightedCards={highlightedCards} 487 660 /> 488 661 <DeckSection 489 662 section="sideboard" ··· 494 667 onCardClick={handleCardClick} 495 668 isDragging={isDragging} 496 669 readOnly={!isOwner} 670 + highlightedCards={highlightedCards} 497 671 /> 498 672 <DeckSection 499 673 section="maybeboard" ··· 504 678 onCardClick={handleCardClick} 505 679 isDragging={isDragging} 506 680 readOnly={!isOwner} 681 + highlightedCards={highlightedCards} 682 + /> 683 + 684 + <DeckStats 685 + stats={stats} 686 + selection={statsSelection} 687 + onSelect={setStatsSelection} 688 + /> 689 + 690 + <GoldfishView 691 + cards={[ 692 + ...getCardsInSection(deck, "commander"), 693 + ...getCardsInSection(deck, "mainboard"), 694 + ]} 695 + onCardHover={handleCardHover} 507 696 /> 508 697 </div> 509 698 </div> ··· 520 709 onMoveToSection={handleMoveToSection} 521 710 onDelete={handleDeleteCard} 522 711 readOnly={!isOwner} 712 + allTags={allTags} 523 713 /> 524 714 )} 525 715
+93
src/routes/profile/$did/deck/$rkey/play.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQueries, useSuspenseQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, Link } from "@tanstack/react-router"; 4 + import { ArrowLeft } from "lucide-react"; 5 + import { useCallback } from "react"; 6 + import { GoldfishBoard } from "@/components/deck/goldfish"; 7 + import { asRkey } from "@/lib/atproto-client"; 8 + import { prefetchCards } from "@/lib/card-prefetch"; 9 + import { getDeckQueryOptions } from "@/lib/deck-queries"; 10 + import { getCardsInSection } from "@/lib/deck-types"; 11 + import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; 12 + import type { ScryfallId } from "@/lib/scryfall-types"; 13 + 14 + export const Route = createFileRoute("/profile/$did/deck/$rkey/play")({ 15 + component: PlaytestPage, 16 + loader: async ({ context, params }) => { 17 + const deck = await context.queryClient.ensureQueryData( 18 + getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 19 + ); 20 + 21 + const cardIds = deck.cards.map((card) => card.scryfallId); 22 + await prefetchCards(context.queryClient, cardIds); 23 + 24 + return deck; 25 + }, 26 + head: ({ loaderData: deck }) => ({ 27 + meta: [ 28 + { 29 + title: deck 30 + ? `Playtest: ${deck.name} | DeckBelcher` 31 + : "Playtest | DeckBelcher", 32 + }, 33 + ], 34 + }), 35 + }); 36 + 37 + function PlaytestPage() { 38 + const { did, rkey } = Route.useParams(); 39 + const { data: deck } = useSuspenseQuery( 40 + getDeckQueryOptions(did as Did, asRkey(rkey)), 41 + ); 42 + 43 + const playtestCards = [ 44 + ...getCardsInSection(deck, "commander"), 45 + ...getCardsInSection(deck, "mainboard"), 46 + ]; 47 + 48 + const cardMap = useQueries({ 49 + queries: playtestCards.map((card) => 50 + getCardByIdQueryOptions(card.scryfallId), 51 + ), 52 + combine: combineCardQueries, 53 + }); 54 + 55 + const getCard = useCallback((id: ScryfallId) => cardMap?.get(id), [cardMap]); 56 + 57 + const startingLife = deck.format === "commander" ? 40 : 20; 58 + 59 + if (!cardMap) { 60 + return ( 61 + <div className="h-screen flex items-center justify-center bg-white dark:bg-slate-950"> 62 + <span className="text-gray-500 dark:text-gray-400"> 63 + Loading cards... 64 + </span> 65 + </div> 66 + ); 67 + } 68 + 69 + return ( 70 + <div className="h-screen flex flex-col bg-white dark:bg-slate-950"> 71 + <header className="flex items-center gap-4 px-4 py-2 border-b border-gray-200 dark:border-slate-800"> 72 + <Link 73 + to="/profile/$did/deck/$rkey" 74 + params={{ did, rkey }} 75 + className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" 76 + > 77 + <ArrowLeft className="w-4 h-4" /> 78 + Back to Editor 79 + </Link> 80 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white"> 81 + {deck.name} 82 + </h1> 83 + </header> 84 + <div className="flex-1 overflow-hidden"> 85 + <GoldfishBoard 86 + deck={playtestCards} 87 + cardLookup={getCard} 88 + startingLife={startingLife} 89 + /> 90 + </div> 91 + </div> 92 + ); 93 + }
+258 -52
src/routes/profile/$did/index.tsx
··· 1 1 import type { Did } from "@atcute/lexicons"; 2 - import { useSuspenseQuery } from "@tanstack/react-query"; 2 + import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; 3 3 import { createFileRoute, Link } from "@tanstack/react-router"; 4 - import { asRkey } from "@/lib/atproto-client"; 4 + import { Plus } from "lucide-react"; 5 + import { useMemo } from "react"; 6 + import { DeckPreview } from "@/components/DeckPreview"; 7 + import { ListPreview } from "@/components/ListPreview"; 8 + import type { DeckRecordResponse } from "@/lib/atproto-client"; 9 + import { 10 + listUserCollectionListsQueryOptions, 11 + useCreateCollectionListMutation, 12 + } from "@/lib/collection-list-queries"; 5 13 import { listUserDecksQueryOptions } from "@/lib/deck-queries"; 14 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 15 + import { formatDisplayName } from "@/lib/format-utils"; 6 16 import { useAuth } from "@/lib/useAuth"; 7 17 18 + type SortOption = "updated-desc" | "updated-asc" | "name-asc" | "name-desc"; 19 + 20 + interface ProfileSearch { 21 + sort?: SortOption; 22 + format?: string; 23 + } 24 + 8 25 export const Route = createFileRoute("/profile/$did/")({ 9 26 component: ProfilePage, 27 + validateSearch: (search: Record<string, unknown>): ProfileSearch => ({ 28 + sort: (search.sort as SortOption) || undefined, 29 + format: (search.format as string) || undefined, 30 + }), 10 31 loader: async ({ context, params }) => { 11 - // Prefetch deck list during SSR 12 - await context.queryClient.ensureQueryData( 13 - listUserDecksQueryOptions(params.did as Did), 14 - ); 32 + // Prefetch deck list, collection lists, and DID document during SSR 33 + await Promise.all([ 34 + context.queryClient.ensureQueryData( 35 + listUserDecksQueryOptions(params.did as Did), 36 + ), 37 + context.queryClient.ensureQueryData( 38 + listUserCollectionListsQueryOptions(params.did as Did), 39 + ), 40 + context.queryClient.ensureQueryData( 41 + didDocumentQueryOptions(params.did as Did), 42 + ), 43 + ]); 15 44 }, 16 45 }); 17 46 47 + function sortDecks( 48 + records: DeckRecordResponse[], 49 + sort: SortOption | undefined, 50 + ): DeckRecordResponse[] { 51 + const sorted = [...records]; 52 + 53 + switch (sort) { 54 + case "name-asc": 55 + sorted.sort((a, b) => a.value.name.localeCompare(b.value.name)); 56 + break; 57 + case "name-desc": 58 + sorted.sort((a, b) => b.value.name.localeCompare(a.value.name)); 59 + break; 60 + case "updated-asc": 61 + sorted.sort((a, b) => { 62 + const dateA = a.value.updatedAt ?? a.value.createdAt; 63 + const dateB = b.value.updatedAt ?? b.value.createdAt; 64 + return dateA.localeCompare(dateB); 65 + }); 66 + break; 67 + default: 68 + sorted.sort((a, b) => { 69 + const dateA = a.value.updatedAt ?? a.value.createdAt; 70 + const dateB = b.value.updatedAt ?? b.value.createdAt; 71 + return dateB.localeCompare(dateA); 72 + }); 73 + break; 74 + } 75 + 76 + return sorted; 77 + } 78 + 79 + const SORT_OPTIONS: { value: SortOption; label: string }[] = [ 80 + { value: "updated-desc", label: "Recently Updated" }, 81 + { value: "updated-asc", label: "Oldest First" }, 82 + { value: "name-asc", label: "Name A-Z" }, 83 + { value: "name-desc", label: "Name Z-A" }, 84 + ]; 85 + 18 86 function ProfilePage() { 19 87 const { did } = Route.useParams(); 88 + const search = Route.useSearch(); 89 + const navigate = Route.useNavigate(); 20 90 const { session } = useAuth(); 21 - const { data } = useSuspenseQuery(listUserDecksQueryOptions(did as Did)); 91 + const { data: decksData } = useSuspenseQuery( 92 + listUserDecksQueryOptions(did as Did), 93 + ); 94 + const { data: listsData } = useQuery( 95 + listUserCollectionListsQueryOptions(did as Did), 96 + ); 97 + const { data: didDocument } = useQuery(didDocumentQueryOptions(did as Did)); 98 + const createListMutation = useCreateCollectionListMutation(); 22 99 100 + const handle = extractHandle(didDocument ?? null); 23 101 const isOwner = session?.info.sub === did; 102 + const lists = listsData?.records ?? []; 103 + 104 + // Get unique formats for filter dropdown 105 + const availableFormats = useMemo(() => { 106 + const formats = new Set<string>(); 107 + for (const record of decksData.records) { 108 + if (record.value.format) { 109 + formats.add(record.value.format); 110 + } 111 + } 112 + return Array.from(formats).sort(); 113 + }, [decksData.records]); 114 + 115 + // Filter and sort 116 + const filteredAndSorted = useMemo(() => { 117 + let records = decksData.records; 118 + 119 + // Filter by format 120 + if (search.format) { 121 + records = records.filter((r) => r.value.format === search.format); 122 + } 123 + 124 + // Sort 125 + return sortDecks(records, search.sort); 126 + }, [decksData.records, search.format, search.sort]); 127 + 128 + const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => { 129 + const value = e.target.value as SortOption | ""; 130 + navigate({ 131 + search: (prev) => ({ 132 + ...prev, 133 + sort: value || undefined, 134 + }), 135 + replace: true, 136 + }); 137 + }; 138 + 139 + const handleFormatChange = (e: React.ChangeEvent<HTMLSelectElement>) => { 140 + const value = e.target.value; 141 + navigate({ 142 + search: (prev) => ({ 143 + ...prev, 144 + format: value || undefined, 145 + }), 146 + replace: true, 147 + }); 148 + }; 149 + 150 + const hasActiveFilters = search.format != null; 24 151 25 152 return ( 26 153 <div className="min-h-screen bg-white dark:bg-slate-900"> ··· 29 156 <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2"> 30 157 Decklists 31 158 </h1> 32 - <p className="text-gray-600 dark:text-gray-400">{did}</p> 159 + <p className="text-gray-600 dark:text-gray-400"> 160 + {handle ? `@${handle}` : did} 161 + </p> 33 162 </div> 34 163 35 - {data.records.length === 0 ? ( 36 - <div className="text-center py-12"> 37 - <p className="text-gray-600 dark:text-gray-400 mb-4"> 38 - {isOwner ? "No decklists yet" : "No decklists"} 39 - </p> 40 - {isOwner && ( 41 - <Link 42 - to="/deck/new" 43 - className="inline-block px-6 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 164 + {/* Sort and filter controls - only show if there are decks */} 165 + {decksData.records.length > 0 && ( 166 + <div className="flex flex-wrap gap-4 mb-6"> 167 + <select 168 + value={search.sort ?? "updated-desc"} 169 + onChange={handleSortChange} 170 + className="px-4 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500" 171 + > 172 + {SORT_OPTIONS.map((opt) => ( 173 + <option key={opt.value} value={opt.value}> 174 + {opt.label} 175 + </option> 176 + ))} 177 + </select> 178 + 179 + {availableFormats.length > 0 && ( 180 + <select 181 + value={search.format ?? ""} 182 + onChange={handleFormatChange} 183 + className="px-4 py-2 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500" 44 184 > 45 - Create Your First Deck 46 - </Link> 185 + <option value="">All Formats</option> 186 + {availableFormats.map((format) => ( 187 + <option key={format} value={format}> 188 + {formatDisplayName(format)} 189 + </option> 190 + ))} 191 + </select> 47 192 )} 48 193 </div> 49 - ) : ( 50 - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 51 - {data.records.map((record) => { 52 - const rkey = record.uri.split("/").pop(); 53 - if (!rkey) return null; 54 - 55 - const cardCount = record.value.cards.reduce( 56 - (sum, card) => sum + card.quantity, 57 - 0, 58 - ); 59 - const updatedDate = record.value.updatedAt 60 - ? new Date(record.value.updatedAt).toLocaleDateString() 61 - : new Date(record.value.createdAt).toLocaleDateString(); 194 + )} 62 195 63 - return ( 196 + {/* Decks Section */} 197 + <section className="mb-12"> 198 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> 199 + Decks 200 + </h2> 201 + {decksData.records.length === 0 ? ( 202 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 203 + <p className="text-gray-600 dark:text-gray-400 mb-4"> 204 + {isOwner ? "No decklists yet" : "No decklists"} 205 + </p> 206 + {isOwner && ( 64 207 <Link 65 - key={record.uri} 66 - to="/profile/$did/deck/$rkey" 67 - params={{ did, rkey: asRkey(rkey) }} 68 - className="block p-6 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-cyan-500 dark:hover:border-cyan-500 transition-colors" 208 + to="/deck/new" 209 + className="inline-block px-6 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 69 210 > 70 - <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2"> 71 - {record.value.name} 72 - </h2> 73 - {record.value.format && ( 74 - <p className="text-sm text-gray-600 dark:text-gray-400 mb-2 capitalize"> 75 - {record.value.format} 76 - </p> 77 - )} 78 - <div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-500"> 79 - <span>{cardCount} cards</span> 80 - <span>Updated {updatedDate}</span> 81 - </div> 211 + Create Your First Deck 82 212 </Link> 83 - ); 84 - })} 213 + )} 214 + </div> 215 + ) : filteredAndSorted.length === 0 ? ( 216 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 217 + <p className="text-gray-600 dark:text-gray-400 mb-4"> 218 + No decks match your filters 219 + </p> 220 + {hasActiveFilters && ( 221 + <button 222 + type="button" 223 + onClick={() => 224 + navigate({ search: { sort: search.sort }, replace: true }) 225 + } 226 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300 font-medium" 227 + > 228 + Clear filters 229 + </button> 230 + )} 231 + </div> 232 + ) : ( 233 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 234 + {filteredAndSorted.map((record) => { 235 + const rkey = record.uri.split("/").pop(); 236 + if (!rkey) return null; 237 + 238 + return ( 239 + <DeckPreview 240 + key={record.uri} 241 + did={did as Did} 242 + rkey={rkey} 243 + deck={record.value} 244 + /> 245 + ); 246 + })} 247 + </div> 248 + )} 249 + </section> 250 + 251 + {/* Lists Section */} 252 + <section> 253 + <div className="flex items-center justify-between mb-4"> 254 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 255 + Lists 256 + </h2> 257 + {isOwner && ( 258 + <button 259 + type="button" 260 + onClick={() => createListMutation.mutate({ name: "New List" })} 261 + disabled={createListMutation.isPending} 262 + className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white font-medium rounded-lg transition-colors" 263 + > 264 + <Plus className="w-4 h-4" /> 265 + New List 266 + </button> 267 + )} 85 268 </div> 86 - )} 269 + {lists.length === 0 ? ( 270 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 271 + <p className="text-gray-600 dark:text-gray-400"> 272 + {isOwner ? "No lists yet" : "No lists"} 273 + </p> 274 + </div> 275 + ) : ( 276 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 277 + {lists.map((record) => { 278 + const rkey = record.uri.split("/").pop(); 279 + if (!rkey) return null; 280 + 281 + return ( 282 + <ListPreview 283 + key={record.uri} 284 + did={did as Did} 285 + rkey={rkey} 286 + list={record.value} 287 + /> 288 + ); 289 + })} 290 + </div> 291 + )} 292 + </section> 87 293 </div> 88 294 </div> 89 295 );
+349
src/routes/profile/$did/list/$rkey/index.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, Link } from "@tanstack/react-router"; 4 + import { Trash2 } from "lucide-react"; 5 + import { useState } from "react"; 6 + import { CardImage } from "@/components/CardImage"; 7 + import { ClientDate } from "@/components/ClientDate"; 8 + import { type DeckData, DeckPreview } from "@/components/DeckPreview"; 9 + import { ListActionsMenu } from "@/components/list/ListActionsMenu"; 10 + import { asRkey, type Rkey } from "@/lib/atproto-client"; 11 + import { 12 + getCollectionListQueryOptions, 13 + useUpdateCollectionListMutation, 14 + } from "@/lib/collection-list-queries"; 15 + import { 16 + isCardItem, 17 + isDeckItem, 18 + type ListCardItem, 19 + type ListDeckItem, 20 + removeCardFromList, 21 + removeDeckFromList, 22 + } from "@/lib/collection-list-types"; 23 + import { getDeckQueryOptions } from "@/lib/deck-queries"; 24 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 25 + import { getCardByIdQueryOptions } from "@/lib/queries"; 26 + import { getImageUri } from "@/lib/scryfall-utils"; 27 + import { useAuth } from "@/lib/useAuth"; 28 + 29 + export const Route = createFileRoute("/profile/$did/list/$rkey/")({ 30 + component: ListDetailPage, 31 + loader: async ({ context, params }) => { 32 + const list = await context.queryClient.ensureQueryData( 33 + getCollectionListQueryOptions(params.did as Did, asRkey(params.rkey)), 34 + ); 35 + return list; 36 + }, 37 + head: ({ loaderData: list }) => { 38 + if (!list) { 39 + return { meta: [{ title: "List Not Found | DeckBelcher" }] }; 40 + } 41 + 42 + const title = `${list.name} | DeckBelcher`; 43 + const cardCount = list.items.filter(isCardItem).length; 44 + const deckCount = list.items.filter(isDeckItem).length; 45 + 46 + const parts: string[] = []; 47 + if (cardCount > 0) 48 + parts.push(`${cardCount} card${cardCount === 1 ? "" : "s"}`); 49 + if (deckCount > 0) 50 + parts.push(`${deckCount} deck${deckCount === 1 ? "" : "s"}`); 51 + const description = parts.length > 0 ? parts.join(", ") : "Empty list"; 52 + 53 + const firstCard = list.items.find(isCardItem); 54 + const cardImageUrl = firstCard 55 + ? getImageUri(firstCard.scryfallId, "large") 56 + : undefined; 57 + 58 + return { 59 + meta: [ 60 + { title }, 61 + { name: "description", content: description }, 62 + { property: "og:title", content: list.name }, 63 + { property: "og:description", content: description }, 64 + ...(cardImageUrl 65 + ? [ 66 + { property: "og:image", content: cardImageUrl }, 67 + { property: "og:image:width", content: "672" }, 68 + { property: "og:image:height", content: "936" }, 69 + ] 70 + : []), 71 + { property: "og:type", content: "website" }, 72 + { name: "twitter:card", content: "summary_large_image" }, 73 + { name: "twitter:title", content: list.name }, 74 + { name: "twitter:description", content: description }, 75 + ...(cardImageUrl 76 + ? [{ name: "twitter:image", content: cardImageUrl }] 77 + : []), 78 + ], 79 + }; 80 + }, 81 + }); 82 + 83 + function ListDetailPage() { 84 + const { did, rkey } = Route.useParams(); 85 + const { session } = useAuth(); 86 + const { data: list, isLoading } = useQuery( 87 + getCollectionListQueryOptions(did as Did, asRkey(rkey)), 88 + ); 89 + const { data: didDocument } = useQuery(didDocumentQueryOptions(did as Did)); 90 + const handle = extractHandle(didDocument ?? null); 91 + 92 + const mutation = useUpdateCollectionListMutation(did as Did, asRkey(rkey)); 93 + const isOwner = session?.info.sub === did; 94 + 95 + const [isEditingName, setIsEditingName] = useState(false); 96 + const [editedName, setEditedName] = useState(""); 97 + 98 + if (isLoading || !list) { 99 + return ( 100 + <div className="min-h-screen bg-white dark:bg-slate-900 flex items-center justify-center"> 101 + <p className="text-gray-600 dark:text-gray-400">Loading list...</p> 102 + </div> 103 + ); 104 + } 105 + 106 + const handleRemoveCard = (item: ListCardItem) => { 107 + const updated = removeCardFromList(list, item.scryfallId); 108 + mutation.mutate(updated); 109 + }; 110 + 111 + const handleRemoveDeck = (item: ListDeckItem) => { 112 + const updated = removeDeckFromList(list, item.deckUri); 113 + mutation.mutate(updated); 114 + }; 115 + 116 + const handleNameClick = () => { 117 + if (!isOwner) return; 118 + setEditedName(list.name); 119 + setIsEditingName(true); 120 + }; 121 + 122 + const handleNameSubmit = () => { 123 + const newName = editedName.trim() || "Untitled List"; 124 + if (newName !== list.name) { 125 + mutation.mutate({ ...list, name: newName }); 126 + } 127 + setIsEditingName(false); 128 + }; 129 + 130 + const handleNameKeyDown = (e: React.KeyboardEvent) => { 131 + if (e.key === "Enter") { 132 + handleNameSubmit(); 133 + } else if (e.key === "Escape") { 134 + setEditedName(list.name); 135 + setIsEditingName(false); 136 + } 137 + }; 138 + 139 + const dateString = list.updatedAt ?? list.createdAt; 140 + 141 + return ( 142 + <div className="min-h-screen bg-white dark:bg-slate-900"> 143 + <div className="max-w-4xl mx-auto px-6 py-8"> 144 + <div className="flex items-start justify-between gap-4 mb-2"> 145 + <div className="flex-1 min-w-0"> 146 + {isEditingName ? ( 147 + <input 148 + type="text" 149 + value={editedName} 150 + onChange={(e) => setEditedName(e.target.value)} 151 + onBlur={handleNameSubmit} 152 + onKeyDown={handleNameKeyDown} 153 + className="text-3xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-cyan-500 focus:outline-none w-full" 154 + /> 155 + ) : ( 156 + <h1 157 + className={`text-3xl font-bold text-gray-900 dark:text-white truncate ${isOwner ? "cursor-pointer hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors" : ""}`} 158 + onClick={handleNameClick} 159 + onKeyDown={(e) => { 160 + if (e.key === "Enter" || e.key === " ") { 161 + handleNameClick(); 162 + } 163 + }} 164 + tabIndex={isOwner ? 0 : undefined} 165 + role={isOwner ? "button" : undefined} 166 + > 167 + {list.name} 168 + </h1> 169 + )} 170 + </div> 171 + {isOwner && ( 172 + <ListActionsMenu listName={list.name} rkey={asRkey(rkey)} /> 173 + )} 174 + </div> 175 + 176 + <p className="text-sm text-gray-500 dark:text-gray-500 mb-1"> 177 + {handle ? ( 178 + <> 179 + by{" "} 180 + <Link 181 + to="/profile/$did" 182 + params={{ did }} 183 + className="hover:text-cyan-600 dark:hover:text-cyan-400" 184 + > 185 + @{handle} 186 + </Link> 187 + </> 188 + ) : ( 189 + <span className="inline-block h-4 w-20 bg-gray-200 dark:bg-slate-700 rounded animate-pulse align-middle" /> 190 + )} 191 + </p> 192 + <p className="text-sm text-gray-500 dark:text-gray-500 mb-8"> 193 + Updated <ClientDate dateString={dateString} /> 194 + </p> 195 + 196 + {list.items.length === 0 ? ( 197 + <p className="text-gray-600 dark:text-gray-400 text-center py-12"> 198 + This list is empty. 199 + </p> 200 + ) : ( 201 + <div className="space-y-4"> 202 + {list.items.map((item) => 203 + isCardItem(item) ? ( 204 + <CardListItem 205 + key={item.scryfallId} 206 + item={item} 207 + onRemove={isOwner ? handleRemoveCard : undefined} 208 + /> 209 + ) : isDeckItem(item) ? ( 210 + <DeckListItem 211 + key={item.deckUri} 212 + item={item} 213 + onRemove={isOwner ? handleRemoveDeck : undefined} 214 + /> 215 + ) : null, 216 + )} 217 + </div> 218 + )} 219 + </div> 220 + </div> 221 + ); 222 + } 223 + 224 + interface CardListItemProps { 225 + item: ListCardItem; 226 + onRemove?: (item: ListCardItem) => void; 227 + } 228 + 229 + function CardListItem({ item, onRemove }: CardListItemProps) { 230 + const { data: card, isLoading } = useQuery( 231 + getCardByIdQueryOptions(item.scryfallId), 232 + ); 233 + 234 + if (isLoading || !card) { 235 + return ( 236 + <div className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg animate-pulse"> 237 + <div className="w-16 h-22 bg-gray-300 dark:bg-slate-700 rounded" /> 238 + <div className="flex-1"> 239 + <div className="h-5 w-32 bg-gray-300 dark:bg-slate-700 rounded mb-2" /> 240 + <div className="h-4 w-24 bg-gray-200 dark:bg-slate-600 rounded" /> 241 + </div> 242 + </div> 243 + ); 244 + } 245 + 246 + return ( 247 + <div className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg"> 248 + <Link to="/card/$id" params={{ id: item.scryfallId }}> 249 + <CardImage 250 + card={{ id: item.scryfallId, name: card.name }} 251 + size="small" 252 + className="w-16 h-auto rounded" 253 + /> 254 + </Link> 255 + 256 + <div className="flex-1 min-w-0"> 257 + <Link 258 + to="/card/$id" 259 + params={{ id: item.scryfallId }} 260 + className="font-bold text-gray-900 dark:text-white hover:text-cyan-600 dark:hover:text-cyan-400 truncate block" 261 + > 262 + {card.name} 263 + </Link> 264 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 265 + {card.type_line} 266 + </p> 267 + </div> 268 + 269 + {onRemove && ( 270 + <button 271 + type="button" 272 + onClick={() => onRemove(item)} 273 + className="p-2 text-gray-400 hover:text-red-500 transition-colors" 274 + aria-label="Remove from list" 275 + > 276 + <Trash2 className="w-5 h-5" /> 277 + </button> 278 + )} 279 + </div> 280 + ); 281 + } 282 + 283 + interface DeckListItemProps { 284 + item: ListDeckItem; 285 + onRemove?: (item: ListDeckItem) => void; 286 + } 287 + 288 + function DeckListItem({ item, onRemove }: DeckListItemProps) { 289 + const parts = item.deckUri.split("/"); 290 + const deckDid = parts[2] as Did; 291 + const deckRkey = parts[4] as Rkey; 292 + 293 + const { data: deck, isError } = useQuery({ 294 + ...getDeckQueryOptions(deckDid, deckRkey), 295 + retry: false, 296 + }); 297 + 298 + if (isError || !deck) { 299 + return ( 300 + <div className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg opacity-50"> 301 + <div className="flex-1"> 302 + <p className="text-gray-600 dark:text-gray-400"> 303 + Deck no longer exists 304 + </p> 305 + </div> 306 + {onRemove && ( 307 + <button 308 + type="button" 309 + onClick={() => onRemove(item)} 310 + className="p-2 text-gray-400 hover:text-red-500 transition-colors" 311 + aria-label="Remove from list" 312 + > 313 + <Trash2 className="w-5 h-5" /> 314 + </button> 315 + )} 316 + </div> 317 + ); 318 + } 319 + 320 + const deckData: DeckData = { 321 + name: deck.name, 322 + format: deck.format, 323 + cards: deck.cards.map((c) => ({ 324 + scryfallId: c.scryfallId as string, 325 + quantity: c.quantity, 326 + section: c.section, 327 + })), 328 + createdAt: deck.createdAt, 329 + updatedAt: deck.updatedAt, 330 + }; 331 + 332 + return ( 333 + <div className="flex items-center gap-4"> 334 + <div className="flex-1"> 335 + <DeckPreview did={deckDid} rkey={deckRkey} deck={deckData} /> 336 + </div> 337 + {onRemove && ( 338 + <button 339 + type="button" 340 + onClick={() => onRemove(item)} 341 + className="p-2 text-gray-400 hover:text-red-500 transition-colors" 342 + aria-label="Remove from list" 343 + > 344 + <Trash2 className="w-5 h-5" /> 345 + </button> 346 + )} 347 + </div> 348 + ); 349 + }
+5
src/routes/profile/$did/list/$rkey.tsx
··· 1 + import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 + 3 + export const Route = createFileRoute("/profile/$did/list/$rkey")({ 4 + component: () => <Outlet />, 5 + });
+4 -2
src/routes/signin.tsx
··· 3 3 import { LogIn } from "lucide-react"; 4 4 import { useEffect, useId, useRef, useState } from "react"; 5 5 import { searchActorsQueryOptions } from "@/lib/actor-search"; 6 - import { useAuth } from "@/lib/useAuth"; 6 + import { RETURN_TO_KEY, useAuth } from "@/lib/useAuth"; 7 7 8 8 export const Route = createFileRoute("/signin")({ 9 9 component: SignIn, ··· 124 124 }; 125 125 126 126 if (session) { 127 - navigate({ to: "/" }); 127 + const returnTo = sessionStorage.getItem(RETURN_TO_KEY); 128 + sessionStorage.removeItem(RETURN_TO_KEY); 129 + navigate({ to: returnTo || "/" }); 128 130 return null; 129 131 } 130 132
+22
src/styles.css
··· 1 1 @import "tailwindcss"; 2 2 3 + /** 4 + * Keyrune set symbol font 5 + * https://github.com/andrewgioia/keyrune 6 + * Font licensed under SIL OFL 1.1 7 + */ 8 + @font-face { 9 + font-family: "Keyrune"; 10 + src: url("/fonts/keyrune/keyrune.woff2") format("woff2"); 11 + font-weight: normal; 12 + font-style: normal; 13 + font-display: swap; 14 + } 15 + 16 + @theme { 17 + /* MTG rarity colors (from Keyrune) */ 18 + --color-rarity-common: #1a1718; 19 + --color-rarity-uncommon: #707883; 20 + --color-rarity-rare: #a58e4a; 21 + --color-rarity-mythic: #bf4427; 22 + --color-rarity-timeshifted: #652978; 23 + } 24 + 3 25 @custom-variant dark (&:where(.dark, .dark *)); 4 26 5 27 body {
+670
src/workers/__tests__/syntax-search.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { mockFetchFromPublicDir } from "../../lib/__tests__/test-helpers"; 3 + import { __CardsWorkerForTestingOnly as CardsWorker } from "../cards.worker"; 4 + 5 + describe("CardsWorker syntaxSearch", () => { 6 + let worker: CardsWorker; 7 + 8 + beforeAll(async () => { 9 + mockFetchFromPublicDir(); 10 + 11 + worker = new CardsWorker(); 12 + await worker.initialize(); 13 + }, 30_000); 14 + 15 + describe("set queries (stable across time)", () => { 16 + it("finds Lightning Bolt in Limited Edition Alpha", () => { 17 + const result = worker.syntaxSearch('s:lea !"Lightning Bolt"'); 18 + expect(result.ok).toBe(true); 19 + if (result.ok) { 20 + expect(result.cards.length).toBe(1); 21 + expect(result.cards[0].name).toBe("Lightning Bolt"); 22 + expect(result.cards[0].set).toBe("lea"); 23 + } 24 + }); 25 + 26 + it("finds all creatures in Alpha", () => { 27 + const result = worker.syntaxSearch("s:lea t:creature", 300); 28 + expect(result.ok).toBe(true); 29 + if (result.ok) { 30 + expect(result.cards.length).toBeGreaterThan(50); 31 + expect( 32 + result.cards.every((c) => c.type_line?.includes("Creature")), 33 + ).toBe(true); 34 + expect(result.cards.every((c) => c.set === "lea")).toBe(true); 35 + } 36 + }); 37 + 38 + it("finds Llanowar Elves in Alpha", () => { 39 + const result = worker.syntaxSearch('s:lea !"Llanowar Elves"'); 40 + expect(result.ok).toBe(true); 41 + if (result.ok) { 42 + expect(result.cards.length).toBe(1); 43 + expect(result.cards[0].name).toBe("Llanowar Elves"); 44 + } 45 + }); 46 + 47 + it("finds Sol Ring in Unlimited", () => { 48 + const result = worker.syntaxSearch('s:2ed !"Sol Ring"'); 49 + expect(result.ok).toBe(true); 50 + if (result.ok) { 51 + expect(result.cards.length).toBe(1); 52 + expect(result.cards[0].name).toBe("Sol Ring"); 53 + } 54 + }); 55 + 56 + it("finds all Power 9 in Alpha by name", () => { 57 + const power9 = [ 58 + "Black Lotus", 59 + "Ancestral Recall", 60 + "Time Walk", 61 + "Mox Pearl", 62 + "Mox Sapphire", 63 + "Mox Jet", 64 + "Mox Ruby", 65 + "Mox Emerald", 66 + "Timetwister", 67 + ]; 68 + 69 + for (const cardName of power9) { 70 + const result = worker.syntaxSearch(`s:lea !"${cardName}"`); 71 + expect(result.ok).toBe(true); 72 + if (result.ok) { 73 + expect(result.cards.length).toBe(1); 74 + expect(result.cards[0].name).toBe(cardName); 75 + } 76 + } 77 + }); 78 + }); 79 + 80 + describe("combined queries", () => { 81 + it("finds cheap red instants in Alpha", () => { 82 + const result = worker.syntaxSearch("s:lea t:instant c:r cmc<=2"); 83 + expect(result.ok).toBe(true); 84 + if (result.ok) { 85 + expect(result.cards.length).toBeGreaterThan(0); 86 + for (const card of result.cards) { 87 + expect(card.type_line).toContain("Instant"); 88 + expect(card.colors).toContain("R"); 89 + expect(card.cmc).toBeLessThanOrEqual(2); 90 + } 91 + } 92 + }); 93 + 94 + it("finds green creatures with power >= 4 in Alpha", () => { 95 + const result = worker.syntaxSearch("s:lea t:creature c:g pow>=4"); 96 + expect(result.ok).toBe(true); 97 + if (result.ok) { 98 + expect(result.cards.length).toBeGreaterThan(0); 99 + for (const card of result.cards) { 100 + expect(card.type_line).toContain("Creature"); 101 + expect(card.colors).toContain("G"); 102 + const power = Number.parseInt(card.power ?? "0", 10); 103 + expect(power).toBeGreaterThanOrEqual(4); 104 + } 105 + } 106 + }); 107 + 108 + it("finds colorless artifacts in Alpha", () => { 109 + const result = worker.syntaxSearch("s:lea t:artifact c=c"); 110 + expect(result.ok).toBe(true); 111 + if (result.ok) { 112 + expect(result.cards.length).toBeGreaterThan(10); 113 + for (const card of result.cards) { 114 + expect(card.type_line).toContain("Artifact"); 115 + expect(card.colors?.length ?? 0).toBe(0); 116 + } 117 + } 118 + }); 119 + }); 120 + 121 + describe("negation queries", () => { 122 + it("finds non-creature spells in Alpha", () => { 123 + const result = worker.syntaxSearch("s:lea -t:creature -t:land", 50); 124 + expect(result.ok).toBe(true); 125 + if (result.ok) { 126 + expect(result.cards.length).toBeGreaterThan(0); 127 + for (const card of result.cards) { 128 + expect(card.type_line).not.toContain("Creature"); 129 + expect(card.type_line).not.toContain("Land"); 130 + } 131 + } 132 + }); 133 + }); 134 + 135 + describe("or queries", () => { 136 + it("finds instants or sorceries in Alpha", () => { 137 + const result = worker.syntaxSearch("s:lea (t:instant or t:sorcery)", 100); 138 + expect(result.ok).toBe(true); 139 + if (result.ok) { 140 + expect(result.cards.length).toBeGreaterThan(20); 141 + for (const card of result.cards) { 142 + const isInstantOrSorcery = 143 + card.type_line?.includes("Instant") || 144 + card.type_line?.includes("Sorcery"); 145 + expect(isInstantOrSorcery).toBe(true); 146 + } 147 + } 148 + }); 149 + }); 150 + 151 + describe("error handling", () => { 152 + it("returns error for invalid syntax", () => { 153 + const result = worker.syntaxSearch("t:"); 154 + expect(result.ok).toBe(false); 155 + if (!result.ok) { 156 + expect(result.error.message).toBeDefined(); 157 + expect(typeof result.error.start).toBe("number"); 158 + expect(typeof result.error.end).toBe("number"); 159 + } 160 + }); 161 + 162 + it("returns empty array for empty query", () => { 163 + const result = worker.syntaxSearch(""); 164 + expect(result.ok).toBe(true); 165 + if (result.ok) { 166 + expect(result.cards).toEqual([]); 167 + } 168 + }); 169 + 170 + it("returns empty array for whitespace query", () => { 171 + const result = worker.syntaxSearch(" "); 172 + expect(result.ok).toBe(true); 173 + if (result.ok) { 174 + expect(result.cards).toEqual([]); 175 + } 176 + }); 177 + }); 178 + 179 + describe("result limiting", () => { 180 + it("respects maxResults parameter", () => { 181 + const result = worker.syntaxSearch("s:lea", 5); 182 + expect(result.ok).toBe(true); 183 + if (result.ok) { 184 + expect(result.cards.length).toBe(5); 185 + } 186 + }); 187 + 188 + it("returns all matches if fewer than maxResults", () => { 189 + const result = worker.syntaxSearch('s:lea !"Black Lotus"', 100); 190 + expect(result.ok).toBe(true); 191 + if (result.ok) { 192 + expect(result.cards.length).toBe(1); 193 + } 194 + }); 195 + }); 196 + 197 + describe("deduplication", () => { 198 + it("returns one result per oracle_id", () => { 199 + // Lightning Bolt has many printings across sets 200 + const result = worker.syntaxSearch('!"Lightning Bolt"', 100); 201 + expect(result.ok).toBe(true); 202 + if (result.ok) { 203 + // Should only have one result despite many printings 204 + expect(result.cards.length).toBe(1); 205 + expect(result.cards[0].name).toBe("Lightning Bolt"); 206 + } 207 + }); 208 + 209 + it("returns most canonical printing when multiple match", () => { 210 + // Search for rarity:common which matches many printings of Lightning Bolt 211 + // Should return the most canonical (English, black border, modern frame, etc.) 212 + const result = worker.syntaxSearch( 213 + '!"Lightning Bolt" rarity:common', 214 + 100, 215 + ); 216 + expect(result.ok).toBe(true); 217 + if (result.ok) { 218 + expect(result.cards.length).toBe(1); 219 + expect(result.cards[0].name).toBe("Lightning Bolt"); 220 + // The returned printing should be English 221 + expect(result.cards[0].lang).toBe("en"); 222 + } 223 + }); 224 + 225 + it("dedups across different set queries", () => { 226 + // Llanowar Elves appears in many sets - should only return one per oracle_id 227 + const result = worker.syntaxSearch('!"Llanowar Elves"', 100); 228 + expect(result.ok).toBe(true); 229 + if (result.ok) { 230 + // Only one unique card despite many printings 231 + expect(result.cards.length).toBe(1); 232 + expect(result.cards[0].name).toBe("Llanowar Elves"); 233 + } 234 + }); 235 + }); 236 + 237 + describe("sorting", () => { 238 + it("sorts alphabetically by name by default", () => { 239 + const result = worker.syntaxSearch("s:lea t:creature", 10); 240 + expect(result.ok).toBe(true); 241 + if (result.ok) { 242 + const names = result.cards.map((c) => c.name); 243 + const sorted = [...names].sort((a, b) => a.localeCompare(b)); 244 + expect(names).toEqual(sorted); 245 + } 246 + }); 247 + 248 + it("sorts by mana value ascending", () => { 249 + const result = worker.syntaxSearch("s:lea t:creature", 20, { 250 + field: "mv", 251 + direction: "asc", 252 + }); 253 + expect(result.ok).toBe(true); 254 + if (result.ok) { 255 + const cmcs = result.cards.map((c) => c.cmc ?? 0); 256 + expect(cmcs).toEqual([...cmcs].sort((a, b) => a - b)); 257 + } 258 + }); 259 + 260 + it("sorts by mana value descending", () => { 261 + const result = worker.syntaxSearch("s:lea t:creature", 20, { 262 + field: "mv", 263 + direction: "desc", 264 + }); 265 + expect(result.ok).toBe(true); 266 + if (result.ok) { 267 + const cmcs = result.cards.map((c) => c.cmc ?? 0); 268 + expect(cmcs).toEqual([...cmcs].sort((a, b) => b - a)); 269 + } 270 + }); 271 + 272 + it("uses name as tiebreaker when sorting by mv", () => { 273 + const result = worker.syntaxSearch("s:lea cmc=1", 100, { 274 + field: "mv", 275 + direction: "asc", 276 + }); 277 + expect(result.ok).toBe(true); 278 + if (result.ok) { 279 + // All cards have mv=1, so should be sorted by name 280 + const names = result.cards.map((c) => c.name); 281 + const sorted = [...names].sort((a, b) => a.localeCompare(b)); 282 + expect(names).toEqual(sorted); 283 + } 284 + }); 285 + 286 + it("sorts by rarity descending (mythic first)", () => { 287 + const result = worker.syntaxSearch("s:dom rarity>=rare", 50, { 288 + field: "rarity", 289 + direction: "desc", 290 + }); 291 + expect(result.ok).toBe(true); 292 + if (result.ok && result.cards.length > 0) { 293 + // Mythics should come before rares 294 + const mythicIndex = result.cards.findIndex( 295 + (c) => c.rarity === "mythic", 296 + ); 297 + const lastRareIndex = result.cards.findIndex( 298 + (c) => c.rarity === "rare", 299 + ); 300 + if (mythicIndex >= 0 && lastRareIndex >= 0) { 301 + expect(mythicIndex).toBeLessThan(lastRareIndex); 302 + } 303 + } 304 + }); 305 + }); 306 + 307 + describe("discrete fields", () => { 308 + it("layout: uses exact match, not substring", () => { 309 + // "token" should not match "double_faced_token" 310 + const result = worker.syntaxSearch("layout:token", 100); 311 + expect(result.ok).toBe(true); 312 + if (result.ok) { 313 + for (const card of result.cards) { 314 + expect(card.layout).toBe("token"); 315 + } 316 + } 317 + }); 318 + 319 + it("layout: with regex still works", () => { 320 + // Regex should match partial values 321 + const result = worker.syntaxSearch("layout:/dfc/", 100); 322 + expect(result.ok).toBe(true); 323 + if (result.ok) { 324 + expect(result.cards.length).toBeGreaterThan(0); 325 + for (const card of result.cards) { 326 + expect(card.layout).toMatch(/dfc/); 327 + } 328 + } 329 + }); 330 + 331 + it("set: uses exact match", () => { 332 + // "lea" should not match "pleaf" or similar 333 + const result = worker.syntaxSearch("s:lea", 100); 334 + expect(result.ok).toBe(true); 335 + if (result.ok) { 336 + for (const card of result.cards) { 337 + expect(card.set).toBe("lea"); 338 + } 339 + } 340 + }); 341 + 342 + it("settype: uses exact match", () => { 343 + const result = worker.syntaxSearch("st:core", 50); 344 + expect(result.ok).toBe(true); 345 + if (result.ok) { 346 + for (const card of result.cards) { 347 + expect(card.set_type).toBe("core"); 348 + } 349 + } 350 + }); 351 + 352 + it("lang: uses exact match", () => { 353 + const result = worker.syntaxSearch("lang:en s:lea", 50); 354 + expect(result.ok).toBe(true); 355 + if (result.ok) { 356 + for (const card of result.cards) { 357 + expect(card.lang).toBe("en"); 358 + } 359 + } 360 + }); 361 + }); 362 + 363 + describe("paginatedUnifiedSearch", () => { 364 + const sort = { field: "name", direction: "auto" } as const; 365 + 366 + it("returns totalCount and requested page", async () => { 367 + const result = await worker.paginatedUnifiedSearch( 368 + "s:lea t:creature", 369 + undefined, 370 + sort, 371 + 0, 372 + 10, 373 + ); 374 + expect(result.totalCount).toBeGreaterThan(50); 375 + expect(result.cards.length).toBe(10); 376 + expect(result.error).toBeNull(); 377 + }); 378 + 379 + it("returns correct slice for offset", async () => { 380 + const page1 = await worker.paginatedUnifiedSearch( 381 + "s:lea", 382 + undefined, 383 + sort, 384 + 0, 385 + 10, 386 + ); 387 + const page2 = await worker.paginatedUnifiedSearch( 388 + "s:lea", 389 + undefined, 390 + sort, 391 + 10, 392 + 10, 393 + ); 394 + expect(page1.cards[0].id).not.toBe(page2.cards[0].id); 395 + expect(page1.totalCount).toBe(page2.totalCount); 396 + }); 397 + 398 + it("caches results across page fetches", async () => { 399 + const p1 = await worker.paginatedUnifiedSearch( 400 + "s:lea", 401 + undefined, 402 + sort, 403 + 0, 404 + 10, 405 + ); 406 + const p2 = await worker.paginatedUnifiedSearch( 407 + "s:lea", 408 + undefined, 409 + sort, 410 + 50, 411 + 10, 412 + ); 413 + expect(p1.totalCount).toBe(p2.totalCount); 414 + }); 415 + 416 + it("returns empty last page correctly", async () => { 417 + const result = await worker.paginatedUnifiedSearch( 418 + 's:lea !"Lightning Bolt"', 419 + undefined, 420 + sort, 421 + 0, 422 + 10, 423 + ); 424 + expect(result.totalCount).toBe(1); 425 + expect(result.cards.length).toBe(1); 426 + 427 + const page2 = await worker.paginatedUnifiedSearch( 428 + 's:lea !"Lightning Bolt"', 429 + undefined, 430 + sort, 431 + 10, 432 + 10, 433 + ); 434 + expect(page2.cards.length).toBe(0); 435 + }); 436 + 437 + it("recomputes when query changes", async () => { 438 + await worker.paginatedUnifiedSearch("s:lea", undefined, sort, 0, 10); 439 + const different = await worker.paginatedUnifiedSearch( 440 + "s:2ed", 441 + undefined, 442 + sort, 443 + 0, 444 + 10, 445 + ); 446 + expect(different.cards.every((c) => c.set === "2ed")).toBe(true); 447 + }); 448 + 449 + it("returns mode indicator for syntax search", async () => { 450 + const result = await worker.paginatedUnifiedSearch( 451 + "s:lea t:creature", 452 + undefined, 453 + sort, 454 + 0, 455 + 10, 456 + ); 457 + expect(result.mode).toBe("syntax"); 458 + }); 459 + 460 + it("returns mode indicator for fuzzy search", async () => { 461 + const result = await worker.paginatedUnifiedSearch( 462 + "lightning bolt", 463 + undefined, 464 + sort, 465 + 0, 466 + 10, 467 + ); 468 + expect(result.mode).toBe("fuzzy"); 469 + }); 470 + 471 + it("returns error for invalid syntax", async () => { 472 + const result = await worker.paginatedUnifiedSearch( 473 + "t:", 474 + undefined, 475 + sort, 476 + 0, 477 + 10, 478 + ); 479 + expect(result.error).not.toBeNull(); 480 + expect(result.cards).toEqual([]); 481 + expect(result.totalCount).toBe(0); 482 + }); 483 + }); 484 + 485 + describe("land cycle counts (is: predicates)", () => { 486 + // These tests verify that each land cycle predicate matches exactly 487 + // the expected number of unique cards. The predicates use precise 488 + // regex patterns to match only cards in the specific cycle. 489 + 490 + it("is:fetchland returns exactly 10 cards", () => { 491 + const result = worker.syntaxSearch("is:fetchland", 100); 492 + expect(result.ok).toBe(true); 493 + if (result.ok) { 494 + expect(result.cards.length).toBe(10); 495 + // Verify they're all lands with the fetch pattern 496 + for (const card of result.cards) { 497 + expect(card.type_line).toContain("Land"); 498 + expect(card.oracle_text).toMatch(/Pay 1 life, Sacrifice/i); 499 + } 500 + } 501 + }); 502 + 503 + it("is:shockland returns exactly 10 cards", () => { 504 + const result = worker.syntaxSearch("is:shockland", 100); 505 + expect(result.ok).toBe(true); 506 + if (result.ok) { 507 + expect(result.cards.length).toBe(10); 508 + for (const card of result.cards) { 509 + expect(card.type_line).toContain("Land"); 510 + expect(card.oracle_text).toMatch(/pay 2 life/i); 511 + } 512 + } 513 + }); 514 + 515 + it("is:dual returns exactly 10 cards", () => { 516 + const result = worker.syntaxSearch("is:dual", 100); 517 + expect(result.ok).toBe(true); 518 + if (result.ok) { 519 + expect(result.cards.length).toBe(10); 520 + for (const card of result.cards) { 521 + expect(card.type_line).toContain("Land"); 522 + // Duals have two basic land types 523 + const landTypes = ["Plains", "Island", "Swamp", "Mountain", "Forest"]; 524 + const typeCount = landTypes.filter((t) => 525 + card.type_line?.includes(t), 526 + ).length; 527 + expect(typeCount).toBeGreaterThanOrEqual(2); 528 + } 529 + } 530 + }); 531 + 532 + it("is:checkland returns exactly 10 cards", () => { 533 + const result = worker.syntaxSearch("is:checkland", 100); 534 + expect(result.ok).toBe(true); 535 + if (result.ok) { 536 + expect(result.cards.length).toBe(10); 537 + for (const card of result.cards) { 538 + expect(card.type_line).toContain("Land"); 539 + expect(card.oracle_text).toMatch(/unless you control/i); 540 + } 541 + } 542 + }); 543 + 544 + it("is:fastland returns exactly 10 cards", () => { 545 + const result = worker.syntaxSearch("is:fastland", 100); 546 + expect(result.ok).toBe(true); 547 + if (result.ok) { 548 + expect(result.cards.length).toBe(10); 549 + for (const card of result.cards) { 550 + expect(card.type_line).toContain("Land"); 551 + expect(card.oracle_text).toMatch(/two or fewer other lands/i); 552 + } 553 + } 554 + }); 555 + 556 + it("is:slowland returns exactly 10 cards", () => { 557 + const result = worker.syntaxSearch("is:slowland", 100); 558 + expect(result.ok).toBe(true); 559 + if (result.ok) { 560 + expect(result.cards.length).toBe(10); 561 + for (const card of result.cards) { 562 + expect(card.type_line).toContain("Land"); 563 + expect(card.oracle_text).toMatch(/two or more other lands/i); 564 + } 565 + } 566 + }); 567 + 568 + it("is:painland returns exactly 10 cards", () => { 569 + const result = worker.syntaxSearch("is:painland", 100); 570 + expect(result.ok).toBe(true); 571 + if (result.ok) { 572 + expect(result.cards.length).toBe(10); 573 + for (const card of result.cards) { 574 + expect(card.type_line).toContain("Land"); 575 + expect(card.oracle_text).toMatch(/deals 1 damage to you/i); 576 + } 577 + } 578 + }); 579 + 580 + it("is:filterland returns exactly 10 cards", () => { 581 + const result = worker.syntaxSearch("is:filterland", 100); 582 + expect(result.ok).toBe(true); 583 + if (result.ok) { 584 + expect(result.cards.length).toBe(10); 585 + for (const card of result.cards) { 586 + expect(card.type_line).toContain("Land"); 587 + // Filter lands use hybrid mana activation 588 + expect(card.oracle_text).toMatch(/\{[WUBRG]\/[WUBRG]\}/i); 589 + } 590 + } 591 + }); 592 + 593 + it("is:bounceland returns exactly 10 cards", () => { 594 + const result = worker.syntaxSearch("is:bounceland", 100); 595 + expect(result.ok).toBe(true); 596 + if (result.ok) { 597 + expect(result.cards.length).toBe(10); 598 + for (const card of result.cards) { 599 + expect(card.type_line).toContain("Land"); 600 + expect(card.oracle_text).toMatch(/return a land/i); 601 + } 602 + } 603 + }); 604 + 605 + it("is:scryland returns exactly 10 cards", () => { 606 + const result = worker.syntaxSearch("is:scryland", 100); 607 + expect(result.ok).toBe(true); 608 + if (result.ok) { 609 + expect(result.cards.length).toBe(10); 610 + for (const card of result.cards) { 611 + expect(card.type_line).toContain("Land"); 612 + expect(card.oracle_text).toMatch(/scry 1/i); 613 + } 614 + } 615 + }); 616 + 617 + it("is:gainland returns exactly 15 cards (two cycles)", () => { 618 + const result = worker.syntaxSearch("is:gainland", 100); 619 + expect(result.ok).toBe(true); 620 + if (result.ok) { 621 + expect(result.cards.length).toBe(15); 622 + for (const card of result.cards) { 623 + expect(card.type_line).toContain("Land"); 624 + expect(card.oracle_text).toMatch(/gain 1 life/i); 625 + } 626 + } 627 + }); 628 + 629 + it("is:tangoland returns exactly 8 cards", () => { 630 + const result = worker.syntaxSearch("is:tangoland", 100); 631 + expect(result.ok).toBe(true); 632 + if (result.ok) { 633 + expect(result.cards.length).toBe(8); 634 + for (const card of result.cards) { 635 + expect(card.type_line).toContain("Land"); 636 + expect(card.oracle_text).toMatch(/two or more basic/i); 637 + } 638 + } 639 + }); 640 + 641 + it("is:canopyland returns exactly 6 cards", () => { 642 + const result = worker.syntaxSearch("is:canopyland", 100); 643 + expect(result.ok).toBe(true); 644 + if (result.ok) { 645 + expect(result.cards.length).toBe(6); 646 + for (const card of result.cards) { 647 + expect(card.type_line).toContain("Land"); 648 + expect(card.oracle_text).toMatch(/Sacrifice this land: Draw a card/i); 649 + } 650 + } 651 + }); 652 + 653 + it("is:triome returns exactly 10 cards", () => { 654 + const result = worker.syntaxSearch("is:triome", 100); 655 + expect(result.ok).toBe(true); 656 + if (result.ok) { 657 + expect(result.cards.length).toBe(10); 658 + for (const card of result.cards) { 659 + expect(card.type_line).toContain("Land"); 660 + // Triomes have three basic land types 661 + const landTypes = ["Plains", "Island", "Swamp", "Mountain", "Forest"]; 662 + const typeCount = landTypes.filter((t) => 663 + card.type_line?.includes(t), 664 + ).length; 665 + expect(typeCount).toBeGreaterThanOrEqual(3); 666 + } 667 + } 668 + }); 669 + }); 670 + });
+543 -43
src/workers/cards.worker.ts
··· 7 7 8 8 import * as Comlink from "comlink"; 9 9 import MiniSearch from "minisearch"; 10 - import { CARD_CHUNKS } from "../lib/card-chunks"; 10 + import { CARD_CHUNKS, CARD_INDEXES, CARD_VOLATILE } from "../lib/card-manifest"; 11 + import { LRUCache } from "../lib/lru-cache"; 11 12 import type { 12 - Card, 13 13 CardDataOutput, 14 14 ManaColor, 15 15 OracleId, 16 16 ScryfallId, 17 + VolatileData, 18 + } from "../lib/scryfall-types"; 19 + import { 20 + type CardPredicate, 21 + describeQuery, 22 + hasSearchOperators, 23 + search as parseSearch, 24 + type SearchNode, 25 + someNode, 26 + } from "../lib/search"; 27 + import type { 28 + CachedSearchResult, 29 + Card, 30 + PaginatedSearchResult, 17 31 SearchRestrictions, 18 - } from "../lib/scryfall-types"; 32 + SortDirection, 33 + SortField, 34 + SortOption, 35 + UnifiedSearchResult, 36 + } from "../lib/search-types"; 37 + 38 + export type { SortField, SortDirection, SortOption }; 39 + 40 + // Rarity ordering (higher = more rare, matches fields.ts RARITY_ORDER) 41 + const RARITY_ORDER: Record<string, number> = { 42 + common: 0, 43 + uncommon: 1, 44 + rare: 2, 45 + mythic: 3, 46 + special: 4, 47 + bonus: 5, 48 + }; 49 + 50 + /** 51 + * Check if a card is a "non-game" card (token, art series, memorabilia). 52 + * These are excluded from search results unless explicitly queried. 53 + * For cards with both game and non-game printings (e.g., Ancestral Recall), 54 + * the canonical printing is sorted to prefer game printings in download-scryfall.ts. 55 + */ 56 + function isNonGameCard(card: Card): boolean { 57 + return ( 58 + card.layout === "art_series" || 59 + card.layout === "token" || 60 + card.layout === "double_faced_token" || 61 + card.set_type === "token" || 62 + card.set_type === "memorabilia" 63 + ); 64 + } 65 + 66 + // WUBRG ordering 67 + const WUBRG_ORDER = ["W", "U", "B", "R", "G"]; 68 + 69 + function resolveDirection( 70 + field: SortField, 71 + dir: SortDirection, 72 + ): "asc" | "desc" { 73 + if (dir !== "auto") return dir; 74 + switch (field) { 75 + case "name": 76 + return "asc"; 77 + case "mv": 78 + return "asc"; 79 + case "released": 80 + return "desc"; 81 + case "rarity": 82 + return "desc"; 83 + case "color": 84 + return "asc"; 85 + } 86 + } 87 + 88 + function colorIdentityRank(colors: string[] | undefined): number { 89 + if (!colors || colors.length === 0) return 100; // colorless last 90 + // Primary sort by number of colors, secondary by first color in WUBRG 91 + return ( 92 + colors.length * 10 + 93 + Math.min(...colors.map((c) => WUBRG_ORDER.indexOf(c)).filter((i) => i >= 0)) 94 + ); 95 + } 96 + 97 + function getSortableName(name: string): string { 98 + return name.startsWith("A-") ? name.slice(2) : name; 99 + } 100 + 101 + function sortCards(cards: Card[], sort: SortOption): void { 102 + const dir = resolveDirection(sort.field, sort.direction); 103 + const mult = dir === "desc" ? -1 : 1; 104 + 105 + cards.sort((a, b) => { 106 + let cmp = 0; 107 + const nameA = getSortableName(a.name); 108 + const nameB = getSortableName(b.name); 109 + switch (sort.field) { 110 + case "name": 111 + cmp = nameA.localeCompare(nameB); 112 + break; 113 + case "mv": 114 + cmp = (a.cmc ?? 0) - (b.cmc ?? 0); 115 + break; 116 + case "released": 117 + cmp = (a.released_at ?? "").localeCompare(b.released_at ?? ""); 118 + break; 119 + case "rarity": 120 + cmp = 121 + (RARITY_ORDER[a.rarity ?? ""] ?? 99) - 122 + (RARITY_ORDER[b.rarity ?? ""] ?? 99); 123 + break; 124 + case "color": 125 + cmp = 126 + colorIdentityRank(a.color_identity) - 127 + colorIdentityRank(b.color_identity); 128 + break; 129 + } 130 + cmp *= mult; 131 + return cmp !== 0 ? cmp : nameA.localeCompare(nameB); 132 + }); 133 + } 134 + 135 + const VOLATILE_RECORD_SIZE = 44; // 16 (UUID) + 4 (rank) + 6*4 (prices) 136 + const NULL_VALUE = 0xffffffff; 137 + 138 + function bytesToUuid(bytes: Uint8Array): string { 139 + const hex = Array.from(bytes) 140 + .map((b) => b.toString(16).padStart(2, "0")) 141 + .join(""); 142 + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; 143 + } 144 + 145 + // NOTE: We always search all printings and dedup to canonical. 146 + // If this proves slow, we could reintroduce hasPrintingQuery() to optimize 147 + // queries without printing-specific fields (set, rarity, artist, etc.) 148 + // by searching only canonical cards. The dedup logic would remain unchanged. 19 149 20 150 interface CardsWorkerAPI { 21 151 /** ··· 51 181 * Get canonical printing ID for an oracle ID 52 182 */ 53 183 getCanonicalPrinting(oracleId: OracleId): ScryfallId | undefined; 184 + 185 + /** 186 + * Search cards using Scryfall-like syntax 187 + */ 188 + syntaxSearch( 189 + query: string, 190 + maxResults?: number, 191 + sort?: SortOption, 192 + ): 193 + | { ok: true; cards: Card[] } 194 + | { ok: false; error: { message: string; start: number; end: number } }; 195 + 196 + /** 197 + * Get volatile data (prices, EDHREC rank) for a card 198 + * Waits for volatile data to load if not ready yet 199 + * Returns null if card not found 200 + */ 201 + getVolatileData(id: ScryfallId): Promise<VolatileData | null>; 202 + 203 + /** 204 + * Unified search that routes to fuzzy or syntax search based on query complexity 205 + */ 206 + unifiedSearch( 207 + query: string, 208 + restrictions?: SearchRestrictions, 209 + maxResults?: number, 210 + sort?: SortOption, 211 + ): UnifiedSearchResult; 212 + 213 + /** 214 + * Paginated unified search with caching for virtual scroll 215 + * Caches full result set in LRU cache, returns requested slice 216 + */ 217 + paginatedUnifiedSearch( 218 + query: string, 219 + restrictions: SearchRestrictions | undefined, 220 + sort: SortOption, 221 + offset: number, 222 + limit: number, 223 + ): Promise<PaginatedSearchResult>; 54 224 } 55 225 56 226 class CardsWorker implements CardsWorkerAPI { 57 227 private data: CardDataOutput | null = null; 58 228 private canonicalCards: Card[] = []; 229 + private canonicalRank: Map<ScryfallId, number> = new Map(); 59 230 private searchIndex: MiniSearch<Card> | null = null; 231 + private volatileDataPromise: Promise<Map<string, VolatileData>> | null = null; 232 + private searchCache = new LRUCache<string, CachedSearchResult>(5); 60 233 61 234 async initialize(): Promise<void> { 62 235 // Prevent re-initialization in SharedWorker mode (shared across tabs) ··· 70 243 `[CardsWorker] Loading ${CARD_CHUNKS.length} chunks + indexes...`, 71 244 ); 72 245 73 - // Fetch everything in parallel 246 + // Fetch card data in parallel (from immutable cache subfolder) 74 247 const [indexes, ...chunks] = await Promise.all([ 75 - fetch("/data/cards-indexes.json").then((r) => { 248 + fetch(`/data/cards/${CARD_INDEXES}`).then((r) => { 76 249 if (!r.ok) throw new Error("Failed to load card indexes"); 77 250 return r.json() as Promise< 78 - Pick< 79 - CardDataOutput, 80 - | "version" 81 - | "cardCount" 82 - | "oracleIdToPrintings" 83 - | "canonicalPrintingByOracleId" 84 - > 251 + Pick<CardDataOutput, "version" | "cardCount" | "oracleIdToPrintings"> 85 252 >; 86 253 }), 87 254 ...CARD_CHUNKS.map((filename) => 88 - fetch(`/data/${filename}`).then((r) => { 255 + fetch(`/data/cards/${filename}`).then((r) => { 89 256 if (!r.ok) throw new Error(`Failed to load chunk: ${filename}`); 90 257 return r.json() as Promise<{ cards: Record<string, Card> }>; 91 258 }), ··· 104 271 cardCount: indexes.cardCount, 105 272 cards, 106 273 oracleIdToPrintings: indexes.oracleIdToPrintings, 107 - canonicalPrintingByOracleId: indexes.canonicalPrintingByOracleId, 108 274 }; 109 275 110 276 // Build canonical cards array (one per oracle ID, excluding art cards) 111 - this.canonicalCards = Object.values(this.data.canonicalPrintingByOracleId) 112 - .map((scryfallId) => this.data?.cards[scryfallId]) 277 + // First element of each oracleIdToPrintings array is the canonical printing 278 + this.canonicalCards = Object.values(this.data.oracleIdToPrintings) 279 + .map((printingIds) => this.data?.cards[printingIds[0]]) 113 280 .filter((card): card is Card => card !== undefined) 114 281 .filter((card) => card.layout !== "art_series"); 115 282 283 + // Build canonical rank map for O(1) lookup during search dedup 284 + // Lower rank = more canonical (first in oracleIdToPrintings = rank 0) 285 + this.canonicalRank.clear(); 286 + for (const printingIds of Object.values(this.data.oracleIdToPrintings)) { 287 + for (let rank = 0; rank < printingIds.length; rank++) { 288 + this.canonicalRank.set(printingIds[rank], rank); 289 + } 290 + } 291 + 116 292 // Build fuzzy search index 117 293 console.log("[CardsWorker] Building search index..."); 118 294 this.searchIndex = new MiniSearch<Card>({ ··· 134 310 console.log( 135 311 `[CardsWorker] Initialized: ${this.data.cardCount.toLocaleString()} cards, ${this.canonicalCards.length.toLocaleString()} unique`, 136 312 ); 313 + 314 + // Load volatile data in background (non-blocking) 315 + this.volatileDataPromise = this.loadVolatileData(); 316 + } 317 + 318 + private async loadVolatileData(): Promise<Map<string, VolatileData>> { 319 + console.log("[CardsWorker] Loading volatile data..."); 320 + 321 + try { 322 + const response = await fetch(`/data/cards/${CARD_VOLATILE}`); 323 + if (!response.ok) { 324 + console.warn("[CardsWorker] Failed to load volatile data"); 325 + return new Map(); 326 + } 327 + 328 + const buffer = await response.arrayBuffer(); 329 + const view = new DataView(buffer); 330 + const recordCount = buffer.byteLength / VOLATILE_RECORD_SIZE; 331 + 332 + const volatileMap = new Map<string, VolatileData>(); 333 + 334 + for (let i = 0; i < recordCount; i++) { 335 + const offset = i * VOLATILE_RECORD_SIZE; 336 + 337 + // Read UUID (16 bytes) 338 + const uuidBytes = new Uint8Array(buffer, offset, 16); 339 + const id = bytesToUuid(uuidBytes); 340 + 341 + // Read values (little-endian uint32) 342 + const readValue = (fieldOffset: number): number | null => { 343 + const val = view.getUint32(offset + fieldOffset, true); 344 + return val === NULL_VALUE ? null : val; 345 + }; 346 + 347 + // Convert cents back to dollars for prices 348 + const centsToPrice = (cents: number | null): number | null => 349 + cents === null ? null : cents / 100; 350 + 351 + volatileMap.set(id, { 352 + edhrecRank: readValue(16), 353 + usd: centsToPrice(readValue(20)), 354 + usdFoil: centsToPrice(readValue(24)), 355 + usdEtched: centsToPrice(readValue(28)), 356 + eur: centsToPrice(readValue(32)), 357 + eurFoil: centsToPrice(readValue(36)), 358 + tix: centsToPrice(readValue(40)), 359 + }); 360 + } 361 + 362 + console.log( 363 + `[CardsWorker] Loaded volatile data for ${volatileMap.size.toLocaleString()} cards`, 364 + ); 365 + return volatileMap; 366 + } catch (error) { 367 + console.warn("[CardsWorker] Error loading volatile data:", error); 368 + return new Map(); 369 + } 137 370 } 138 371 139 372 searchCards( ··· 145 378 throw new Error("Worker not initialized - call initialize() first"); 146 379 } 147 380 148 - // Empty query returns no results 149 381 if (!query.trim()) { 150 382 return []; 151 383 } 152 384 153 - // Perform fuzzy search with exact-match priority 154 385 const searchResults = this.searchIndex.search(query); 386 + const restrictionCheck = this.buildRestrictionCheck(restrictions); 155 387 const results: Card[] = []; 156 388 157 - // Iterate incrementally, applying filters and stopping at maxResults 158 389 for (const result of searchResults) { 159 390 const card = this.data.cards[result.id as ScryfallId]; 160 391 if (!card) continue; 161 - 162 - // Apply restrictions 163 - if (restrictions) { 164 - // Format legality check 165 - if (restrictions.format) { 166 - const legality = card.legalities?.[restrictions.format]; 167 - if (legality !== "legal" && legality !== "restricted") { 168 - continue; 169 - } 170 - } 171 - 172 - // Color identity subset check (Scryfall order not guaranteed) 173 - if (restrictions.colorIdentity) { 174 - const cardIdentity = card.color_identity ?? []; 175 - const allowedSet = new Set(restrictions.colorIdentity); 176 - 177 - // Card must be subset of allowed colors 178 - if (!cardIdentity.every((c) => allowedSet.has(c as ManaColor))) { 179 - continue; 180 - } 181 - } 182 - } 392 + if (isNonGameCard(card)) continue; 393 + if (!restrictionCheck(card)) continue; 183 394 184 395 results.push(card); 185 - if (results.length >= maxResults) break; // Early exit 396 + if (results.length >= maxResults) break; 186 397 } 187 398 188 399 return results; ··· 216 427 if (!this.data) { 217 428 throw new Error("Worker not initialized - call initialize() first"); 218 429 } 219 - return this.data.canonicalPrintingByOracleId[oracleId]; 430 + // First element of oracleIdToPrintings is the canonical printing 431 + return this.data.oracleIdToPrintings[oracleId]?.[0]; 432 + } 433 + 434 + syntaxSearch( 435 + query: string, 436 + maxResults = 100, 437 + sort: SortOption = { field: "name", direction: "auto" }, 438 + ): 439 + | { ok: true; cards: Card[] } 440 + | { ok: false; error: { message: string; start: number; end: number } } { 441 + if (!this.data) { 442 + throw new Error("Worker not initialized - call initialize() first"); 443 + } 444 + 445 + if (!query.trim()) { 446 + return { ok: true, cards: [] }; 447 + } 448 + 449 + const parseResult = parseSearch(query); 450 + 451 + if (!parseResult.ok) { 452 + return { 453 + ok: false, 454 + error: { 455 + message: parseResult.error.message, 456 + start: parseResult.error.span.start, 457 + end: parseResult.error.span.end, 458 + }, 459 + }; 460 + } 461 + 462 + const { match, ast } = parseResult.value; 463 + const cards = this.runParsedQuery(ast, match, maxResults, sort); 464 + return { ok: true, cards }; 465 + } 466 + 467 + async getVolatileData(id: ScryfallId): Promise<VolatileData | null> { 468 + if (!this.volatileDataPromise) { 469 + return null; 470 + } 471 + const volatileData = await this.volatileDataPromise; 472 + return volatileData.get(id) ?? null; 473 + } 474 + 475 + unifiedSearch( 476 + query: string, 477 + restrictions?: SearchRestrictions, 478 + maxResults = 50, 479 + sort: SortOption = { field: "name", direction: "auto" }, 480 + ): UnifiedSearchResult { 481 + if (!this.data || !this.searchIndex) { 482 + throw new Error("Worker not initialized - call initialize() first"); 483 + } 484 + 485 + const trimmed = query.trim(); 486 + if (!trimmed) { 487 + return { mode: "fuzzy", cards: [], description: null, error: null }; 488 + } 489 + 490 + // Simple query - use fuzzy search (no parsing needed) 491 + if (!hasSearchOperators(trimmed)) { 492 + const cards = this.searchCards(trimmed, restrictions, maxResults); 493 + return { mode: "fuzzy", cards, description: null, error: null }; 494 + } 495 + 496 + // Complex query - parse and run syntax search 497 + const parseResult = parseSearch(trimmed); 498 + 499 + if (!parseResult.ok) { 500 + return { 501 + mode: "syntax", 502 + cards: [], 503 + description: null, 504 + error: { 505 + message: parseResult.error.message, 506 + start: parseResult.error.span.start, 507 + end: parseResult.error.span.end, 508 + }, 509 + }; 510 + } 511 + 512 + const { match, ast } = parseResult.value; 513 + const description = describeQuery(ast); 514 + const cards = this.runParsedQuery( 515 + ast, 516 + match, 517 + maxResults, 518 + sort, 519 + restrictions, 520 + ); 521 + return { mode: "syntax", cards, description, error: null }; 522 + } 523 + 524 + async paginatedUnifiedSearch( 525 + query: string, 526 + restrictions: SearchRestrictions | undefined, 527 + sort: SortOption, 528 + offset: number, 529 + limit: number, 530 + ): Promise<PaginatedSearchResult> { 531 + if (!this.data || !this.searchIndex) { 532 + throw new Error("Worker not initialized - call initialize() first"); 533 + } 534 + 535 + const trimmed = query.trim(); 536 + if (!trimmed) { 537 + return { 538 + mode: "fuzzy", 539 + cards: [], 540 + totalCount: 0, 541 + description: null, 542 + error: null, 543 + }; 544 + } 545 + 546 + const cacheKey = JSON.stringify({ query: trimmed, restrictions, sort }); 547 + const cached = await this.searchCache.getOrSet(cacheKey, async () => 548 + this.executeFullUnifiedSearch(trimmed, restrictions, sort), 549 + ); 550 + 551 + return { 552 + mode: cached.mode, 553 + cards: cached.cards.slice(offset, offset + limit), 554 + totalCount: cached.cards.length, 555 + description: cached.description, 556 + error: cached.error, 557 + }; 558 + } 559 + 560 + /** 561 + * Execute full unified search without pagination (for caching) 562 + */ 563 + private executeFullUnifiedSearch( 564 + query: string, 565 + restrictions: SearchRestrictions | undefined, 566 + sort: SortOption, 567 + ): CachedSearchResult { 568 + if (!this.data || !this.searchIndex) { 569 + return { mode: "fuzzy", cards: [], description: null, error: null }; 570 + } 571 + 572 + // Simple query - use fuzzy search 573 + if (!hasSearchOperators(query)) { 574 + const restrictionCheck = this.buildRestrictionCheck(restrictions); 575 + const searchResults = this.searchIndex.search(query); 576 + const cards: Card[] = []; 577 + 578 + for (const result of searchResults) { 579 + const card = this.data.cards[result.id as ScryfallId]; 580 + if (!card) continue; 581 + if (isNonGameCard(card)) continue; 582 + if (!restrictionCheck(card)) continue; 583 + cards.push(card); 584 + } 585 + 586 + return { mode: "fuzzy", cards, description: null, error: null }; 587 + } 588 + 589 + // Complex query - parse and run syntax search 590 + const parseResult = parseSearch(query); 591 + 592 + if (!parseResult.ok) { 593 + return { 594 + mode: "syntax", 595 + cards: [], 596 + description: null, 597 + error: { 598 + message: parseResult.error.message, 599 + start: parseResult.error.span.start, 600 + end: parseResult.error.span.end, 601 + }, 602 + }; 603 + } 604 + 605 + const { match, ast } = parseResult.value; 606 + const description = describeQuery(ast); 607 + const cards = this.runFullParsedQuery(ast, match, sort, restrictions); 608 + return { mode: "syntax", cards, description, error: null }; 609 + } 610 + 611 + /** 612 + * Run a parsed query without result limit (for caching) 613 + */ 614 + private runFullParsedQuery( 615 + ast: SearchNode, 616 + match: CardPredicate, 617 + sort: SortOption, 618 + restrictions?: SearchRestrictions, 619 + ): Card[] { 620 + if (!this.data) return []; 621 + 622 + const includesNonGameCards = someNode( 623 + ast, 624 + (n) => 625 + n.type === "FIELD" && (n.field === "settype" || n.field === "layout"), 626 + ); 627 + 628 + const restrictionCheck = this.buildRestrictionCheck(restrictions); 629 + 630 + const allMatches: Card[] = []; 631 + for (const card of Object.values(this.data.cards)) { 632 + if (!includesNonGameCards && isNonGameCard(card)) continue; 633 + if (!restrictionCheck(card)) continue; 634 + if (!match(card)) continue; 635 + allMatches.push(card); 636 + } 637 + 638 + const dedupedCards = this.collapseToCanonical(allMatches); 639 + sortCards(dedupedCards, sort); 640 + return dedupedCards; 641 + } 642 + 643 + /** 644 + * Run a parsed query: filter cards, collapse to canonical, sort. 645 + */ 646 + private runParsedQuery( 647 + ast: SearchNode, 648 + match: CardPredicate, 649 + maxResults: number, 650 + sort: SortOption, 651 + restrictions?: SearchRestrictions, 652 + ): Card[] { 653 + if (!this.data) return []; 654 + 655 + // Check if query explicitly references layout/set-type (don't filter non-game cards) 656 + const includesNonGameCards = someNode( 657 + ast, 658 + (n) => 659 + n.type === "FIELD" && (n.field === "settype" || n.field === "layout"), 660 + ); 661 + 662 + const restrictionCheck = this.buildRestrictionCheck(restrictions); 663 + 664 + // Filter cards, skipping non-game cards unless explicitly queried 665 + const allMatches: Card[] = []; 666 + for (const card of Object.values(this.data.cards)) { 667 + if (!includesNonGameCards && isNonGameCard(card)) continue; 668 + if (!restrictionCheck(card)) continue; 669 + if (!match(card)) continue; 670 + allMatches.push(card); 671 + } 672 + 673 + // Collapse to one per oracle_id, sort, and limit 674 + const dedupedCards = this.collapseToCanonical(allMatches); 675 + sortCards(dedupedCards, sort); 676 + return dedupedCards.slice(0, maxResults); 677 + } 678 + 679 + /** 680 + * Collapse multiple printings to one per oracle_id. 681 + * Picks the most canonical (lowest rank) match for each oracle. 682 + */ 683 + private collapseToCanonical(cards: Card[]): Card[] { 684 + const best = new Map<OracleId, { card: Card; rank: number }>(); 685 + 686 + for (const card of cards) { 687 + const rank = this.canonicalRank.get(card.id) ?? Number.MAX_SAFE_INTEGER; 688 + const existing = best.get(card.oracle_id); 689 + if (!existing || rank < existing.rank) { 690 + best.set(card.oracle_id, { card, rank }); 691 + } 692 + } 693 + 694 + return Array.from(best.values()).map((b) => b.card); 695 + } 696 + 697 + private buildRestrictionCheck( 698 + restrictions?: SearchRestrictions, 699 + ): (card: Card) => boolean { 700 + if (!restrictions) return () => true; 701 + 702 + const { format, colorIdentity } = restrictions; 703 + const allowedSet = colorIdentity ? new Set(colorIdentity) : null; 704 + 705 + return (card: Card) => { 706 + if (format) { 707 + const legality = card.legalities?.[format]; 708 + if (legality !== "legal" && legality !== "restricted") { 709 + return false; 710 + } 711 + } 712 + if (allowedSet) { 713 + const cardIdentity = card.color_identity ?? []; 714 + if (!cardIdentity.every((c) => allowedSet.has(c as ManaColor))) { 715 + return false; 716 + } 717 + } 718 + return true; 719 + }; 220 720 } 221 721 } 222 722
+144
todos.md
··· 1 + # Backlog 2 + 3 + This file tracks discovered issues, refactoring opportunities, and feature ideas that aren't being worked on immediately. Use it as a scratchpad during development - add things here when you notice them so they don't get lost. 4 + 5 + **Not a sprint board.** This is for parking things you don't want to forget, not active work tracking. 6 + 7 + --- 8 + 9 + ## Bugs 10 + 11 + ### Delete undo adds N+1 copies 12 + - **Location**: Deck editor undo logic 13 + - **Issue**: Undoing a card deletion adds N+1 of the card as independent copies instead of restoring the original single entry 14 + - **Repro**: Delete a card with qty 4, undo, observe 5 separate entries 15 + 16 + ### Bare regex for name search doesn't work 17 + - **Location**: `src/lib/search/parser.ts`, `parseNameExpr()` 18 + - **Issue**: `/goblin.*king/i` syntax is parsed but not matched correctly for bare name searches (works in field values like `o:/regex/`) 19 + - **Why it matters**: Documented in grammar but broken 20 + 21 + ### Flaky property test for OR parsing 22 + - **Location**: `src/lib/search/__tests__/parser.test.ts:276` ("parses OR combinations") 23 + - **Issue**: fast-check property test occasionally finds edge cases that fail parsing 24 + - **Repro**: Run full test suite repeatedly, fails intermittently 25 + - **Investigate**: What input caused the failure, is it a parser bug or test issue 26 + 27 + --- 28 + 29 + ## Features (Planned) 30 + 31 + ### Card modal improvements 32 + - Autocomplete for tags 33 + - Keyboard support for quantity changes (up/down arrows, number keys) 34 + - Focus trap for accessibility 35 + 36 + ### Commander selection 37 + - **Location**: `src/routes/deck/new.tsx` (has TODO comment) 38 + - When format is commander/paupercommander, prompt for commander selection before creating deck 39 + - Affects color identity filtering 40 + 41 + ### Multi-faced card handling 42 + - **Status**: Recently added (`src/lib/card-faces.ts`), needs integration 43 + - Deck stats should account for castable faces properly 44 + - Mana curve should use front-face CMC for transform cards 45 + 46 + --- 47 + 48 + ## UX / Navigation 49 + 50 + ### Static sidebar nav on desktop 51 + - **Feedback from**: nat 52 + - **Issue**: Collapsible sidebar is poor UX on desktop - constant open/close friction 53 + - **Fix**: Static sidebar that's always visible, with shadcn-style expandable sections (like Streamplace does) 54 + - **Effort**: Medium 55 + 56 + --- 57 + 58 + ## Search Improvements 59 + 60 + ### Autocomplete suggestions for known values 61 + - **Feedback from**: nat 62 + - **Issue**: When typing `t:`, should suggest 'creature', 'instant', 'sorcery', etc. Same for other fields with finite value sets 63 + - **Alternative**: At minimum, show link to search syntax docs 64 + - **Effort**: Medium-Large (needs dropdown UI, field-specific value lists) 65 + 66 + ### Add guild/shard/wedge color names 67 + - **Location**: `src/lib/search/colors.ts:137` (marked with comment) 68 + - Add support for `c:azorius`, `c:bant`, `c:jeskai`, etc. 69 + - Map names to color sets: azorius → WU, bant → WUG, etc. 70 + 71 + --- 72 + 73 + ## Refactoring (Technical Debt) 74 + 75 + ### High Priority 76 + 77 + #### Reduce computeManaSymbolsVsSources complexity 78 + - **Location**: `src/lib/deck-stats.ts:327-502` (176 lines) 79 + - **Issue**: Single function creates 13 separate color-keyed data structures, has deeply nested loops, mixes concerns (counting, classification, distribution) 80 + - **Fix**: Extract tempo classification to separate module, create `ColorMap` utility class, split into smaller focused functions 81 + - **Effort**: Medium (half day) 82 + 83 + #### Extract sorting strategies from sortGroupNames 84 + - **Location**: `src/lib/deck-grouping.ts:319-387` 85 + - **Issue**: 4 different sorting strategies in one big switch statement 86 + - **Fix**: Extract to strategy map: `const sorters: Record<GroupBy, SortFn>` 87 + - **Effort**: Small (1 hour) 88 + 89 + ### Medium Priority 90 + 91 + #### Memoize regex patterns in getSourceTempo 92 + - **Location**: `src/lib/deck-stats.ts:148-225` 93 + - **Issue**: Regex patterns compiled on every function call, no memoization 94 + - **Fix**: Move patterns to module-level constants or use lazy initialization 95 + - **Effort**: Small (30 min) 96 + 97 + #### Standardize error handling across ATProto operations 98 + - **Location**: `src/lib/atproto-client.ts`, `src/lib/cards-server-provider.ts`, etc. 99 + - **Issue**: Inconsistent patterns - some use try-catch with console.error, some return Result<T,E>, some silently return empty arrays 100 + - **Fix**: Adopt Result<T,E> consistently, remove unnecessary try-catch blocks 101 + - **Effort**: Medium (2-3 hours) 102 + 103 + #### Consolidate layout metadata for card-faces 104 + - **Location**: `src/lib/card-faces.ts:16-33` 105 + - **Issue**: `MODAL_LAYOUTS`, `TRANSFORM_IN_PLAY_LAYOUTS`, `HAS_BACK_IMAGE_LAYOUTS` are separate arrays checked in multiple places 106 + - **Fix**: Create single `LayoutMetadata` map with all properties per layout 107 + - **Effort**: Trivial (30 min) 108 + 109 + ### Lower Priority 110 + 111 + #### Extract meta tag builder in card route 112 + - **Location**: `src/routes/card/$id.tsx:62-113` 113 + - **Issue**: 51 lines of nested object literals for OG/Twitter meta tags 114 + - **Fix**: Extract to `buildCardMetaTags(card)` helper 115 + - **Effort**: Trivial (15 min) 116 + 117 + #### Parser error collection 118 + - **Location**: `src/lib/search/parser.ts` 119 + - **Issue**: Fails on first error instead of collecting all parse errors 120 + - **Fix**: Add error recovery, collect ParseError[], continue parsing 121 + - **Effort**: Large (needs design, affects error display) 122 + 123 + --- 124 + 125 + ## Documentation Gaps 126 + 127 + ### .claude/PROJECT.md stale lexicon status 128 + - Claims `com.deckbelcher.reply` is "planned" but doesn't exist 129 + - Claims `com.deckbelcher.like` is "planned" but it exists as `com.deckbelcher.social.like` 130 + - Update to reflect actual lexicon implementation status 131 + 132 + ### Drag & drop known limitation 133 + - **Location**: `src/components/deck/DragDropProvider.tsx` 134 + - Screen size checked once on mount, doesn't update on resize 135 + - Breaks on foldable phones when unfolding 136 + - Should document in DECK_EDITOR.md or fix 137 + 138 + --- 139 + 140 + ## Testing Gaps 141 + 142 + ### Integration tests for worker 143 + - Worker code tested via mocked Comlink 144 + - Would benefit from actual worker instantiation tests
+51
typelex/collection-list.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + import "./richtext.tsp"; 4 + 5 + namespace com.deckbelcher.collection.list { 6 + /** A curated list of cards and/or decks. */ 7 + @rec("tid") 8 + model Main { 9 + /** Name of the list. */ 10 + @required 11 + @maxGraphemes(128) 12 + @maxLength(1280) 13 + name: string; 14 + 15 + /** Description of the list. */ 16 + description?: com.deckbelcher.richtext.Main; 17 + 18 + /** Items in the list. */ 19 + @required 20 + items: (CardItem | DeckItem | unknown)[]; 21 + 22 + /** Timestamp when the list was created. */ 23 + @required 24 + createdAt: datetime; 25 + 26 + /** Timestamp when the list was last updated. */ 27 + updatedAt?: datetime; 28 + } 29 + 30 + /** A card saved to the list. */ 31 + model CardItem { 32 + /** Scryfall UUID for the card. */ 33 + @required 34 + scryfallId: string; 35 + 36 + /** Timestamp when this item was added to the list. */ 37 + @required 38 + addedAt: datetime; 39 + } 40 + 41 + /** A deck saved to the list. */ 42 + model DeckItem { 43 + /** AT-URI of the deck record. */ 44 + @required 45 + deckUri: atUri; 46 + 47 + /** Timestamp when this item was added to the list. */ 48 + @required 49 + addedAt: datetime; 50 + } 51 + }
+2 -7
typelex/deck-list.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 - import "./richtext-facet.tsp"; 3 + import "./richtext.tsp"; 4 4 5 5 namespace com.deckbelcher.deck.list { 6 6 /** A Magic: The Gathering decklist. */ ··· 22 22 cards: Card[]; 23 23 24 24 /** Deck primer with strategy, combos, and card choices. */ 25 - @maxGraphemes(10000) 26 - @maxLength(100000) 27 - primer?: string; 28 - 29 - /** Annotations of text in the primer (mentions, URLs, hashtags, card references, etc). */ 30 - primerFacets?: com.deckbelcher.richtext.facet.Main[]; 25 + primer?: com.deckbelcher.richtext.Main; 31 26 32 27 /** Timestamp when the decklist was created. */ 33 28 @required
+2
typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 import "./richtext-facet.tsp"; 4 + import "./richtext.tsp"; 4 5 import "./deck-list.tsp"; 6 + import "./collection-list.tsp"; 5 7 import "./social-like.tsp"; 6 8 7 9 namespace com.deckbelcher.actor.profile {
+25 -1
typelex/richtext-facet.tsp
··· 11 11 index: ByteSlice; 12 12 13 13 @required 14 - features: (Mention | Link | Tag | unknown)[]; 14 + features: (Mention | Link | Tag | Bold | Italic | Code | CodeBlock | unknown)[]; 15 15 } 16 16 17 17 /** ··· 57 57 @maxLength(640) 58 58 tag: string; 59 59 } 60 + 61 + /** 62 + * Facet feature for bold text formatting. 63 + * Typically rendered as `<strong>` in HTML. 64 + */ 65 + model Bold {} 66 + 67 + /** 68 + * Facet feature for italic text formatting. 69 + * Typically rendered as `<em>` in HTML. 70 + */ 71 + model Italic {} 72 + 73 + /** 74 + * Facet feature for inline code. 75 + * Typically rendered as `<code>` in HTML. 76 + */ 77 + model Code {} 78 + 79 + /** 80 + * Facet feature for code blocks. 81 + * Typically rendered as `<pre><code>` in HTML. 82 + */ 83 + model CodeBlock {} 60 84 }
+18
typelex/richtext.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./richtext-facet.tsp"; 3 + 4 + namespace com.deckbelcher.richtext { 5 + /** 6 + * Rich text content with optional facet annotations. 7 + * Used for primers, descriptions, and other formatted text. 8 + */ 9 + model Main { 10 + /** The text content. */ 11 + @maxGraphemes(50000) 12 + @maxLength(500000) 13 + text?: string; 14 + 15 + /** Annotations of text (mentions, URLs, hashtags, card references, etc). */ 16 + facets?: com.deckbelcher.richtext.facet.Main[]; 17 + } 18 + }
+60 -55
vite.config.ts
··· 1 - import { defineConfig } from 'vite' 2 - import { tanstackStart } from '@tanstack/react-start/plugin/vite' 3 - import viteReact from '@vitejs/plugin-react' 4 - import viteTsConfigPaths from 'vite-tsconfig-paths' 5 - import tailwindcss from '@tailwindcss/vite' 6 - import { cloudflare } from "@cloudflare/vite-plugin" 7 - import metadata from './public/client-metadata.json' with { type: 'json' } 1 + import { cloudflare } from "@cloudflare/vite-plugin"; 2 + import tailwindcss from "@tailwindcss/vite"; 3 + import { tanstackStart } from "@tanstack/react-start/plugin/vite"; 4 + import viteReact from "@vitejs/plugin-react"; 5 + import { defineConfig } from "vite"; 6 + import viteTsConfigPaths from "vite-tsconfig-paths"; 7 + import metadata from "./public/client-metadata.json" with { type: "json" }; 8 8 9 - const SERVER_HOST = '127.0.0.1' 10 - const SERVER_PORT = 3000 9 + const SERVER_HOST = "127.0.0.1"; 10 + const SERVER_PORT = 3000; 11 11 12 12 const config = defineConfig({ 13 - optimizeDeps: { 14 - exclude: ['wrangler'], 15 - }, 16 - plugins: [ 17 - cloudflare({ viteEnvironment: { name: 'ssr' } }), 18 - // this is the plugin that enables path aliases 19 - viteTsConfigPaths({ 20 - projects: ['./tsconfig.json'], 21 - }), 22 - tailwindcss(), 23 - tanstackStart(), 24 - viteReact(), 25 - { 26 - name: 'oauth-env-plugin', 27 - config(_conf, { command }) { 28 - if (command === 'build') { 29 - process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id 30 - process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0] 31 - } else { 32 - const redirectUri = (() => { 33 - const url = new URL(metadata.redirect_uris[0]) 34 - return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}` 35 - })() 13 + optimizeDeps: { 14 + exclude: ["wrangler"], 15 + }, 16 + plugins: [ 17 + cloudflare({ viteEnvironment: { name: "ssr" } }), 18 + // this is the plugin that enables path aliases 19 + viteTsConfigPaths({ 20 + projects: ["./tsconfig.json"], 21 + }), 22 + tailwindcss(), 23 + tanstackStart(), 24 + viteReact(), 25 + { 26 + name: "oauth-env-plugin", 27 + config(_conf, { command }) { 28 + if (command === "build") { 29 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 30 + process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 31 + } else { 32 + const redirectUri = (() => { 33 + const url = new URL(metadata.redirect_uris[0]); 34 + return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 35 + })(); 36 36 37 - const clientId = 38 - `http://localhost` + 39 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 40 - `&scope=${encodeURIComponent(metadata.scope)}` 37 + const clientId = 38 + `http://localhost` + 39 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 40 + `&scope=${encodeURIComponent(metadata.scope)}`; 41 41 42 - process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT 43 - process.env.VITE_OAUTH_CLIENT_ID = clientId 44 - process.env.VITE_OAUTH_REDIRECT_URI = redirectUri 45 - } 42 + process.env.VITE_DEV_SERVER_PORT = `${SERVER_PORT}`; 43 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 44 + process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 45 + } 46 46 47 - process.env.VITE_CLIENT_URI = metadata.client_uri 48 - process.env.VITE_OAUTH_SCOPE = metadata.scope 49 - }, 50 - }, 51 - ], 52 - server: { 53 - host: SERVER_HOST, 54 - port: SERVER_PORT, 55 - watch: { 56 - // Exclude large data files from watching 57 - ignored: ['**/public/data/**', '**/public/symbols/**', '**/.cache/**', '.direnv/**'], 58 - }, 59 - }, 60 - }) 47 + process.env.VITE_CLIENT_URI = metadata.client_uri; 48 + process.env.VITE_OAUTH_SCOPE = metadata.scope; 49 + }, 50 + }, 51 + ], 52 + server: { 53 + host: SERVER_HOST, 54 + port: SERVER_PORT, 55 + watch: { 56 + // Exclude large data files from watching 57 + ignored: [ 58 + "**/public/data/**", 59 + "**/public/symbols/**", 60 + "**/.cache/**", 61 + ".direnv/**", 62 + ], 63 + }, 64 + }, 65 + }); 61 66 62 - export default config 67 + export default config;