+201
.claude/ATPROTO.md
+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
+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
+1
-2
.claude/DECK_EDITOR.md
+155
.claude/HOOKS.md
+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
+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
+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
+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
+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
+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
+4
.gitignore
+53
-2
CLAUDE.md
+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
+1
-1
README.md
+2
-1
biome.json
+2
-1
biome.json
+6
flake.nix
+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
+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
+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
+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
+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
+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
+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
+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
public/fonts/keyrune/keyrune.woff2
This is a binary file and will not be displayed.
+94
-37
scripts/download-scryfall.test.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+3
src/lib/goldfish/index.ts
+67
src/lib/goldfish/types.ts
+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
+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
+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
+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
+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
+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
+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
+7
src/lib/lru-cache.ts
+156
src/lib/printing-selection.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
src/lib/useAuth.tsx
+213
-5
src/lib/useDebounce.ts
+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
+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
+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
+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
+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
+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
+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
+
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
+
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
+
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
+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
+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
+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
+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
+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
+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
+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
+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
+5
src/routes/profile/$did/list/$rkey.tsx
+4
-2
src/routes/signin.tsx
+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
+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
+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
+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
+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
+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
+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
+2
typelex/main.tsp
+25
-1
typelex/richtext-facet.tsp
+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
+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
+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;