👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

handle legality correctly

+632 -131
+141 -83
src/components/deck/CardSearchAutocomplete.tsx
··· 1 1 import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 - import { useEffect, useRef, useState } from "react"; 2 + import { useEffect, useId, useMemo, useRef, useState } from "react"; 3 3 import { toast } from "sonner"; 4 - import { searchCardsQueryOptions } from "@/lib/queries"; 5 - import type { Card, ScryfallId } from "@/lib/scryfall-types"; 4 + import { type Deck, getCommanderColorIdentity } from "@/lib/deck-types"; 5 + import { 6 + getCardByIdQueryOptions, 7 + searchCardsQueryOptions, 8 + } from "@/lib/queries"; 9 + import type { 10 + Card, 11 + ScryfallId, 12 + SearchRestrictions, 13 + } from "@/lib/scryfall-types"; 6 14 import { useDebounce } from "@/lib/useDebounce"; 15 + import { usePersistedState } from "@/lib/usePersistedState"; 7 16 import { ManaCost } from "../ManaCost"; 8 17 9 18 interface CardSearchAutocompleteProps { 19 + deck?: Deck; 10 20 format?: string; 11 21 onCardHover?: (cardId: ScryfallId | null) => void; 12 22 onCardSelect?: (cardId: ScryfallId) => void; 13 23 } 14 24 15 25 export function CardSearchAutocomplete({ 26 + deck, 16 27 format, 17 28 onCardHover, 18 29 onCardSelect, ··· 20 31 const [inputValue, setInputValue] = useState(""); 21 32 const [isDropdownOpen, setIsDropdownOpen] = useState(false); 22 33 const [selectedIndex, setSelectedIndex] = useState(0); 34 + const [legalityFilterEnabled, setLegalityFilterEnabled] = usePersistedState( 35 + "deckbelcher:searchLegalityFilter", 36 + true, 37 + ); 38 + 23 39 const inputRef = useRef<HTMLInputElement>(null); 24 40 const dropdownRef = useRef<HTMLDivElement>(null); 25 41 const prevSearchRef = useRef(""); ··· 27 43 28 44 const queryClient = useQueryClient(); 29 45 const debouncedSearch = useDebounce(inputValue, 300); 46 + const toggleId = useId(); 47 + 48 + // Calculate search restrictions 49 + const restrictions: SearchRestrictions | undefined = useMemo(() => { 50 + if (!legalityFilterEnabled || !format) return undefined; 51 + 52 + const colorIdentity = 53 + format === "commander" && deck 54 + ? getCommanderColorIdentity(deck, (id) => 55 + queryClient.getQueryData(getCardByIdQueryOptions(id).queryKey), 56 + ) 57 + : undefined; 58 + 59 + return { 60 + format, 61 + colorIdentity, 62 + }; 63 + }, [legalityFilterEnabled, format, deck, queryClient]); 30 64 31 65 const { data, isFetching } = useQuery( 32 - searchCardsQueryOptions(debouncedSearch), 66 + searchCardsQueryOptions(debouncedSearch, restrictions, 20), 33 67 ); 34 68 35 - const filteredCards = 36 - format && data 37 - ? data.cards.filter((card) => { 38 - const legality = card.legalities?.[format]; 39 - return legality === "legal" || legality === "restricted"; 40 - }) 41 - : (data?.cards ?? []); 42 - 43 - const displayCards = filteredCards.slice(0, 20); 69 + const displayCards = data?.cards ?? []; 44 70 const hasResults = displayCards.length > 0; 45 71 const showDropdown = 46 72 isDropdownOpen && debouncedSearch.trim().length > 0 && !isFetching && data; ··· 115 141 const toastId = toast.loading(`searching for "${searchTerm}"...`); 116 142 117 143 queryClient 118 - .fetchQuery(searchCardsQueryOptions(searchTerm)) 144 + .fetchQuery(searchCardsQueryOptions(searchTerm, restrictions, 1)) 119 145 .then((result) => { 120 146 const topCard = result.cards[0]; 121 147 if (topCard) { ··· 177 203 }; 178 204 179 205 return ( 180 - <div className="relative"> 181 - <input 182 - ref={inputRef} 183 - type="text" 184 - value={inputValue} 185 - onChange={handleInputChange} 186 - onKeyDown={handleKeyDown} 187 - onFocus={() => { 188 - if (inputValue.trim().length > 0) { 189 - setIsDropdownOpen(true); 190 - } 191 - }} 192 - placeholder="Search for a card..." 193 - className="w-full px-4 py-2 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 194 - /> 195 - 196 - {isFetching && debouncedSearch.trim().length > 0 && ( 197 - <div className="absolute right-3 top-1/2 -translate-y-1/2"> 198 - <div className="w-5 h-5 border-2 border-gray-300 dark:border-slate-600 border-t-blue-500 dark:border-t-blue-400 rounded-full animate-spin" /> 206 + <div className="flex gap-3 items-center"> 207 + {/* Legality filter toggle */} 208 + {format && ( 209 + <div className="flex items-center gap-2 flex-shrink-0"> 210 + <label 211 + htmlFor={toggleId} 212 + className="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap" 213 + > 214 + Legal only 215 + </label> 216 + <button 217 + id={toggleId} 218 + type="button" 219 + role="switch" 220 + aria-checked={legalityFilterEnabled} 221 + onClick={() => setLegalityFilterEnabled(!legalityFilterEnabled)} 222 + className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ 223 + legalityFilterEnabled 224 + ? "bg-blue-500 dark:bg-blue-600" 225 + : "bg-gray-300 dark:bg-gray-600" 226 + }`} 227 + > 228 + <span 229 + className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ 230 + legalityFilterEnabled ? "translate-x-6" : "translate-x-1" 231 + }`} 232 + /> 233 + </button> 199 234 </div> 200 235 )} 201 236 202 - {showDropdown && ( 203 - <div 204 - ref={dropdownRef} 205 - onMouseLeave={handleMouseLeaveDropdown} 206 - role="listbox" 207 - className="absolute z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-700 rounded-lg shadow-lg max-h-96 overflow-y-auto" 208 - > 209 - {hasResults ? ( 210 - <div className="py-1"> 211 - {displayCards.map((card, index) => ( 212 - <button 213 - type="button" 214 - key={card.id} 215 - ref={(el) => { 216 - if (el) { 217 - resultRefs.current.set(index, el); 218 - } else { 219 - resultRefs.current.delete(index); 220 - } 221 - }} 222 - onMouseEnter={() => { 223 - handleMouseEnterCard(card); 224 - setSelectedIndex(index); 225 - }} 226 - onClick={() => handleCardSelect(card)} 227 - className={`w-full px-3 py-1.5 text-left cursor-pointer transition-colors ${ 228 - index === selectedIndex 229 - ? "bg-blue-100 dark:bg-blue-900/30" 230 - : "hover:bg-gray-100 dark:hover:bg-slate-800" 231 - }`} 232 - > 233 - <div className="flex items-center justify-between gap-2"> 234 - <div className="font-medium text-sm text-gray-900 dark:text-white truncate"> 235 - {card.name} 236 - </div> 237 - {card.mana_cost && ( 238 - <div className="flex-shrink-0"> 239 - <ManaCost cost={card.mana_cost} size="small" /> 237 + <div className="relative flex-1"> 238 + <input 239 + ref={inputRef} 240 + type="text" 241 + value={inputValue} 242 + onChange={handleInputChange} 243 + onKeyDown={handleKeyDown} 244 + onFocus={() => { 245 + if (inputValue.trim().length > 0) { 246 + setIsDropdownOpen(true); 247 + } 248 + }} 249 + placeholder="Search for a card..." 250 + className="w-full px-4 py-2 pr-10 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 251 + /> 252 + 253 + {isFetching && debouncedSearch.trim().length > 0 && ( 254 + <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"> 255 + <div className="w-5 h-5 border-2 border-gray-300 dark:border-slate-600 border-t-blue-500 dark:border-t-blue-400 rounded-full animate-spin" /> 256 + </div> 257 + )} 258 + 259 + {showDropdown && ( 260 + <div 261 + ref={dropdownRef} 262 + onMouseLeave={handleMouseLeaveDropdown} 263 + role="listbox" 264 + className="absolute z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-700 rounded-lg shadow-lg max-h-96 overflow-y-auto top-full" 265 + > 266 + {hasResults ? ( 267 + <div className="py-1"> 268 + {displayCards.map((card, index) => ( 269 + <button 270 + type="button" 271 + key={card.id} 272 + ref={(el) => { 273 + if (el) { 274 + resultRefs.current.set(index, el); 275 + } else { 276 + resultRefs.current.delete(index); 277 + } 278 + }} 279 + onMouseEnter={() => { 280 + handleMouseEnterCard(card); 281 + setSelectedIndex(index); 282 + }} 283 + onClick={() => handleCardSelect(card)} 284 + className={`w-full px-3 py-1.5 text-left cursor-pointer transition-colors ${ 285 + index === selectedIndex 286 + ? "bg-blue-100 dark:bg-blue-900/30" 287 + : "hover:bg-gray-100 dark:hover:bg-slate-800" 288 + }`} 289 + > 290 + <div className="flex items-center justify-between gap-2"> 291 + <div className="font-medium text-sm text-gray-900 dark:text-white truncate"> 292 + {card.name} 240 293 </div> 241 - )} 242 - </div> 243 - </button> 244 - ))} 245 - </div> 246 - ) : ( 247 - <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> 248 - No results found 249 - </div> 250 - )} 251 - </div> 252 - )} 294 + {card.mana_cost && ( 295 + <div className="flex-shrink-0"> 296 + <ManaCost cost={card.mana_cost} size="small" /> 297 + </div> 298 + )} 299 + </div> 300 + </button> 301 + ))} 302 + </div> 303 + ) : ( 304 + <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> 305 + No results found 306 + </div> 307 + )} 308 + </div> 309 + )} 310 + </div> 253 311 </div> 254 312 ); 255 313 }
+4 -26
src/lib/__tests__/card-data-provider.test.ts
··· 4 4 * Ensures ClientCardProvider and ServerCardProvider return identical data 5 5 */ 6 6 7 - import { readFile } from "node:fs/promises"; 8 - import { join } from "node:path"; 9 7 import { beforeAll, describe, expect, it, vi } from "vitest"; 10 8 import type { CardDataProvider } from "../card-data-provider"; 11 9 import { ClientCardProvider } from "../cards-client-provider"; 12 10 import { ServerCardProvider } from "../cards-server-provider"; 13 11 import { asOracleId, asScryfallId } from "../scryfall-types"; 14 - 15 - const PUBLIC_DIR = join(process.cwd(), "public"); 12 + import { mockFetchFromPublicDir } from "./test-helpers"; 16 13 17 14 // Mock cards-worker-client to use real worker code without Comlink/Worker 18 15 vi.mock("../cards-worker-client", () => { ··· 110 107 let serverProvider: ServerCardProvider; 111 108 112 109 beforeAll(async () => { 113 - // Mock fetch to serve cards.json from filesystem 114 - vi.stubGlobal( 115 - "fetch", 116 - vi.fn(async (input: RequestInfo | URL) => { 117 - const url = typeof input === "string" ? input : input.toString(); 118 - 119 - if (url.startsWith("/data/")) { 120 - const filePath = join(PUBLIC_DIR, url); 121 - try { 122 - const content = await readFile(filePath, "utf-8"); 123 - return new Response(content, { 124 - status: 200, 125 - headers: { "Content-Type": "application/json" }, 126 - }); 127 - } catch {} 128 - } 129 - 130 - return new Response(null, { status: 404 }); 131 - }), 132 - ); 110 + mockFetchFromPublicDir(); 133 111 134 112 serverProvider = new ServerCardProvider(); 135 113 clientProvider = new ClientCardProvider(); 136 114 await clientProvider.initialize(); 137 - }); 115 + }, 20_000); 138 116 139 117 describe.each([ 140 118 ["ServerCardProvider", () => serverProvider], ··· 295 273 it("supports searchCards", async () => { 296 274 expect(clientProvider.searchCards).toBeDefined(); 297 275 298 - const results = await clientProvider.searchCards("forest", 10); 276 + const results = await clientProvider.searchCards("forest", undefined, 10); 299 277 expect(Array.isArray(results)).toBe(true); 300 278 expect(results.length).toBeGreaterThan(0); 301 279
+130
src/lib/__tests__/deck-types.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import type { Deck } from "../deck-types"; 3 + import { getCommanderColorIdentity } from "../deck-types"; 4 + import type { Card } from "../scryfall-types"; 5 + import { asOracleId, asScryfallId } from "../scryfall-types"; 6 + 7 + function mockCard(overrides: Partial<Card> = {}): Card { 8 + return { 9 + id: asScryfallId("00000000-0000-0000-0000-000000000000"), 10 + oracle_id: asOracleId("00000000-0000-0000-0000-000000000000"), 11 + name: "Test Card", 12 + ...overrides, 13 + } as Card; 14 + } 15 + 16 + function mockDeck(commanderIds: string[]): Deck { 17 + return { 18 + $type: "com.deckbelcher.deck.list", 19 + name: "Test Deck", 20 + format: "commander", 21 + cards: commanderIds.map((id) => ({ 22 + scryfallId: asScryfallId(id), 23 + quantity: 1, 24 + section: "commander" as const, 25 + tags: [], 26 + })), 27 + createdAt: new Date().toISOString(), 28 + }; 29 + } 30 + 31 + describe("getCommanderColorIdentity", () => { 32 + it("returns empty array for colorless commander", () => { 33 + const deck = mockDeck(["kozilek-id"]); 34 + const cardLookup = () => mockCard({ color_identity: [] }); 35 + 36 + const result = getCommanderColorIdentity(deck, cardLookup); 37 + 38 + expect(result).toEqual([]); 39 + }); 40 + 41 + it("returns single color for mono-color commander", () => { 42 + const deck = mockDeck(["blue-commander"]); 43 + const cardLookup = () => mockCard({ color_identity: ["U"] }); 44 + 45 + const result = getCommanderColorIdentity(deck, cardLookup); 46 + 47 + expect(result).toEqual(["U"]); 48 + }); 49 + 50 + it("returns multiple colors for multi-color commander", () => { 51 + const deck = mockDeck(["azorius-commander"]); 52 + const cardLookup = () => mockCard({ color_identity: ["W", "U"] }); 53 + 54 + const result = getCommanderColorIdentity(deck, cardLookup); 55 + 56 + expect(result).toEqual(["U", "W"]); // Sorted 57 + }); 58 + 59 + it("merges colors from partner commanders", () => { 60 + const deck = mockDeck(["white-partner", "blue-partner"]); 61 + const cards: Record<string, Card> = { 62 + "white-partner": mockCard({ color_identity: ["W"] }), 63 + "blue-partner": mockCard({ color_identity: ["U"] }), 64 + }; 65 + const cardLookup = (id: string) => cards[id]; 66 + 67 + const result = getCommanderColorIdentity(deck, cardLookup); 68 + 69 + expect(result).toEqual(["U", "W"]); // Combined and sorted 70 + }); 71 + 72 + it("deduplicates overlapping colors from partners", () => { 73 + const deck = mockDeck(["partner1", "partner2"]); 74 + const cards: Record<string, Card> = { 75 + partner1: mockCard({ color_identity: ["W", "U", "B"] }), 76 + partner2: mockCard({ color_identity: ["U", "B", "R"] }), 77 + }; 78 + const cardLookup = (id: string) => cards[id]; 79 + 80 + const result = getCommanderColorIdentity(deck, cardLookup); 81 + 82 + expect(result).toEqual(["B", "R", "U", "W"]); // Deduped and sorted 83 + }); 84 + 85 + it("returns empty array when no commanders", () => { 86 + const deck: Deck = { 87 + $type: "com.deckbelcher.deck.list", 88 + name: "Test Deck", 89 + format: "commander", 90 + cards: [], 91 + createdAt: new Date().toISOString(), 92 + }; 93 + const cardLookup = () => undefined; 94 + 95 + const result = getCommanderColorIdentity(deck, cardLookup); 96 + 97 + expect(result).toEqual([]); 98 + }); 99 + 100 + it("handles missing card data gracefully", () => { 101 + const deck = mockDeck(["missing-commander"]); 102 + const cardLookup = () => undefined; 103 + 104 + const result = getCommanderColorIdentity(deck, cardLookup); 105 + 106 + expect(result).toEqual([]); 107 + }); 108 + 109 + it("handles card with undefined color_identity", () => { 110 + const deck = mockDeck(["commander"]); 111 + const cardLookup = () => mockCard({ color_identity: undefined }); 112 + 113 + const result = getCommanderColorIdentity(deck, cardLookup); 114 + 115 + expect(result).toEqual([]); 116 + }); 117 + 118 + it("returns empty array when commander data isn't loaded yet", () => { 119 + // This simulates the case where a deck has a commander but the card 120 + // data hasn't been fetched from the query cache yet 121 + const deck = mockDeck(["unloaded-commander"]); 122 + const cardLookup = () => undefined; // Card not in cache yet 123 + 124 + const result = getCommanderColorIdentity(deck, cardLookup); 125 + 126 + // Empty array means colorless-only, which would incorrectly filter out 127 + // all colored cards. This is a bug we need to handle in the UI layer. 128 + expect(result).toEqual([]); 129 + }); 130 + });
+31
src/lib/__tests__/test-helpers.ts
··· 1 + import { readFile } from "node:fs/promises"; 2 + import { join } from "node:path"; 3 + import { vi } from "vitest"; 4 + 5 + const PUBLIC_DIR = join(process.cwd(), "public"); 6 + 7 + /** 8 + * Mock global fetch to serve files from public directory 9 + * Use this in tests that need to load cards.json or other static assets 10 + */ 11 + export function mockFetchFromPublicDir() { 12 + vi.stubGlobal( 13 + "fetch", 14 + vi.fn(async (input: RequestInfo | URL) => { 15 + const url = typeof input === "string" ? input : input.toString(); 16 + 17 + if (url.startsWith("/data/")) { 18 + const filePath = join(PUBLIC_DIR, url); 19 + try { 20 + const content = await readFile(filePath, "utf-8"); 21 + return new Response(content, { 22 + status: 200, 23 + headers: { "Content-Type": "application/json" }, 24 + }); 25 + } catch {} 26 + } 27 + 28 + return new Response(null, { status: 404 }); 29 + }), 30 + ); 31 + }
+12 -3
src/lib/card-data-provider.ts
··· 7 7 */ 8 8 9 9 import { ClientCardProvider } from "./cards-client-provider"; 10 - import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 10 + import type { 11 + Card, 12 + OracleId, 13 + ScryfallId, 14 + SearchRestrictions, 15 + } from "./scryfall-types"; 11 16 12 17 export interface CardDataProvider { 13 18 /** ··· 31 36 getCanonicalPrinting(oracleId: OracleId): Promise<ScryfallId | undefined>; 32 37 33 38 /** 34 - * Search cards by name (optional - may not be available on all providers) 39 + * Search cards by name with optional restrictions (optional - may not be available on all providers) 35 40 */ 36 - searchCards?(query: string, limit?: number): Promise<Card[]>; 41 + searchCards?( 42 + query: string, 43 + restrictions?: SearchRestrictions, 44 + maxResults?: number, 45 + ): Promise<Card[]>; 37 46 } 38 47 39 48 let providerPromise: Promise<CardDataProvider> | null = null;
+12 -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 { Card, OracleId, ScryfallId } from "./scryfall-types"; 9 + import type { 10 + Card, 11 + OracleId, 12 + ScryfallId, 13 + SearchRestrictions, 14 + } from "./scryfall-types"; 10 15 11 16 export class ClientCardProvider implements CardDataProvider { 12 17 async initialize(): Promise<void> { ··· 35 40 return worker.getCanonicalPrinting(oracleId); 36 41 } 37 42 38 - async searchCards(query: string, limit = 100): Promise<Card[]> { 43 + async searchCards( 44 + query: string, 45 + restrictions?: SearchRestrictions, 46 + maxResults = 100, 47 + ): Promise<Card[]> { 39 48 const worker = getCardsWorker(); 40 - return worker.searchCards(query, limit); 49 + return worker.searchCards(query, restrictions, maxResults); 41 50 } 42 51 }
+23 -1
src/lib/deck-types.ts
··· 4 4 */ 5 5 6 6 import type { ComDeckbelcherDeckList } from "./lexicons/index"; 7 - import type { ScryfallId } from "./scryfall-types"; 7 + import type { Card, ManaColor, ScryfallId } from "./scryfall-types"; 8 8 9 9 export type Section = "commander" | "mainboard" | "sideboard" | "maybeboard"; 10 10 ··· 211 211 updatedAt: new Date().toISOString(), 212 212 }; 213 213 } 214 + 215 + /** 216 + * Calculate the combined color identity from all commanders in the deck 217 + * Uses Scryfall's color_identity field which matches Commander format rules 218 + */ 219 + export function getCommanderColorIdentity( 220 + deck: Deck, 221 + cardLookup: (id: ScryfallId) => Card | undefined, 222 + ): ManaColor[] { 223 + const commanders = getCardsInSection(deck, "commander"); 224 + const colors = new Set<ManaColor>(); 225 + 226 + for (const commander of commanders) { 227 + const card = cardLookup(commander.scryfallId); 228 + const identity = card?.color_identity ?? []; 229 + for (const color of identity) { 230 + colors.add(color as ManaColor); 231 + } 232 + } 233 + 234 + return Array.from(colors).sort(); 235 + }
+14 -5
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 { Card, OracleId, ScryfallId } from "./scryfall-types"; 7 + import type { 8 + Card, 9 + OracleId, 10 + ScryfallId, 11 + SearchRestrictions, 12 + } from "./scryfall-types"; 8 13 9 14 /** 10 - * Search cards by name 15 + * Search cards by name with optional restrictions 11 16 */ 12 - export const searchCardsQueryOptions = (query: string) => 17 + export const searchCardsQueryOptions = ( 18 + query: string, 19 + restrictions?: SearchRestrictions, 20 + maxResults = 50, 21 + ) => 13 22 queryOptions({ 14 - queryKey: ["cards", "search", query] as const, 23 + queryKey: ["cards", "search", query, restrictions, maxResults] as const, 15 24 queryFn: async (): Promise<{ cards: Card[]; totalCount: number }> => { 16 25 const provider = await getCardDataProvider(); 17 26 ··· 25 34 return { cards: [], totalCount: 0 }; 26 35 } 27 36 28 - const cards = await provider.searchCards(query, 50); 37 + const cards = await provider.searchCards(query, restrictions, maxResults); 29 38 const metadata = await provider.getMetadata(); 30 39 31 40 return {
+7
src/lib/scryfall-types.ts
··· 117 117 118 118 export type Legality = "legal" | "not_legal" | "restricted" | "banned" | string; 119 119 120 + export type ManaColor = "W" | "U" | "B" | "R" | "G"; 121 + 120 122 export type ImageSize = 121 123 | "small" 122 124 | "normal" ··· 124 126 | "png" 125 127 | "art_crop" 126 128 | "border_crop"; 129 + 130 + export interface SearchRestrictions { 131 + format?: string; // Formats change over time, keep as string 132 + colorIdentity?: ManaColor[]; // Card must be subset of these colors 133 + } 127 134 128 135 export interface Card { 129 136 // Core identity
+123
src/lib/usePersistedState.ts
··· 1 + import { useEffect, useState } from "react"; 2 + 3 + /** 4 + * Storage key type - enforces "deckbelcher:" namespace prefix 5 + */ 6 + type DeckbelcherStorageKey = `deckbelcher:${string}`; 7 + 8 + /** 9 + * Serialization options for custom types 10 + */ 11 + interface PersistedStateOptions<T> { 12 + /** 13 + * Custom serializer (defaults to JSON.stringify) 14 + * Useful for types like Map, Set, Date, etc. 15 + */ 16 + serialize?: (value: T) => string; 17 + 18 + /** 19 + * Custom deserializer (defaults to JSON.parse) 20 + * Useful for types like Map, Set, Date, etc. 21 + */ 22 + deserialize?: (value: string) => T; 23 + } 24 + 25 + /** 26 + * Hook for state that persists to localStorage with proper SSR handling 27 + * 28 + * SSR Behavior: 29 + * - Server render: Always uses defaultValue (no localStorage access) 30 + * - Client hydration: Initially uses defaultValue to match server HTML 31 + * - After mount: Reads from localStorage and updates if different 32 + * - This prevents hydration mismatches while still loading persisted state 33 + * 34 + * Cross-tab sync: 35 + * - Changes in one tab automatically sync to other tabs via storage event 36 + * - Only syncs when other tabs make changes (not same-tab updates) 37 + * 38 + * Type constraints: 39 + * - T cannot be null (we use null internally to check if key exists) 40 + * - Key must be prefixed with "deckbelcher:" for namespacing 41 + * 42 + * Custom serialization: 43 + * - Provide serialize/deserialize for non-JSON types (Map, Set, Date, etc.) 44 + * - Example: Map<string, number> with custom serialization to/from array 45 + * 46 + * @param key - localStorage key (must start with "deckbelcher:") 47 + * @param defaultValue - Default value to use on server and before localStorage loads 48 + * @param options - Optional custom serialize/deserialize functions 49 + * @returns Tuple of [value, setValue] similar to useState 50 + * 51 + * @example 52 + * ```tsx 53 + * // Simple boolean 54 + * const [theme, setTheme] = usePersistedState("deckbelcher:theme", "light"); 55 + * 56 + * // Boolean toggle 57 + * const [filterEnabled, setFilterEnabled] = usePersistedState("deckbelcher:filter", true); 58 + * 59 + * // Complex type with custom serialization 60 + * const [tags, setTags] = usePersistedState( 61 + * "deckbelcher:tags", 62 + * new Map<string, number>(), 63 + * { 64 + * serialize: (map) => JSON.stringify(Array.from(map.entries())), 65 + * deserialize: (str) => new Map(JSON.parse(str)), 66 + * } 67 + * ); 68 + * ``` 69 + */ 70 + export function usePersistedState<T extends NonNullable<unknown>>( 71 + key: DeckbelcherStorageKey, 72 + defaultValue: T, 73 + options?: PersistedStateOptions<T>, 74 + ): [T, (value: T) => void] { 75 + const serialize = options?.serialize ?? ((v: T) => JSON.stringify(v)); 76 + const deserialize = 77 + options?.deserialize ?? ((s: string) => JSON.parse(s) as T); 78 + 79 + // Start with default to match server render (avoids hydration mismatch) 80 + const [value, setValue] = useState<T>(defaultValue); 81 + 82 + // Load from localStorage after mount (client-only) 83 + useEffect(() => { 84 + try { 85 + const stored = localStorage.getItem(key); 86 + if (stored !== null) { 87 + const parsed = deserialize(stored); 88 + setValue(parsed); 89 + } 90 + } catch { 91 + // Ignore parse/storage errors, keep default 92 + } 93 + }, [key, deserialize]); 94 + 95 + // Sync changes across tabs via storage event 96 + useEffect(() => { 97 + const handleStorageChange = (e: StorageEvent) => { 98 + if (e.key === key && e.newValue !== null) { 99 + try { 100 + const parsed = deserialize(e.newValue); 101 + setValue(parsed); 102 + } catch { 103 + // Ignore parse errors 104 + } 105 + } 106 + }; 107 + 108 + window.addEventListener("storage", handleStorageChange); 109 + return () => window.removeEventListener("storage", handleStorageChange); 110 + }, [key, deserialize]); 111 + 112 + // Wrapped setter that persists to localStorage 113 + const setPersistedValue = (newValue: T) => { 114 + setValue(newValue); 115 + try { 116 + localStorage.setItem(key, serialize(newValue)); 117 + } catch { 118 + // Ignore storage errors (quota exceeded, private mode, etc.) 119 + } 120 + }; 121 + 122 + return [value, setPersistedValue]; 123 + }
+1
src/routes/deck/$id.tsx
··· 423 423 <div className="max-w-7xl mx-auto px-6 py-3 flex justify-end"> 424 424 <div className="w-full max-w-md"> 425 425 <CardSearchAutocomplete 426 + deck={deck} 426 427 format={deck.format} 427 428 onCardSelect={handleCardSelect} 428 429 onCardHover={handleCardHover}
+87
src/workers/__tests__/cards.worker.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 searchCards", () => { 6 + let worker: CardsWorker; 7 + 8 + beforeAll(async () => { 9 + mockFetchFromPublicDir(); 10 + 11 + worker = new CardsWorker(); 12 + await worker.initialize(); 13 + }, 20_000); 14 + 15 + it("has the same results with and without space", () => { 16 + const resultsA = worker.searchCards("mark") 17 + const resultsB = worker.searchCards("mark ") 18 + 19 + expect(resultsA).toEqual(resultsB) 20 + }) 21 + 22 + describe("restrictions", () => { 23 + it("applies format legality restriction", () => { 24 + // Search for a card that's banned in some formats 25 + const results = worker.searchCards( 26 + "ancestral recall", 27 + { format: "vintage" }, 28 + 10, 29 + ); 30 + expect(results.length).toBeGreaterThan(0); 31 + 32 + // Should not find it in standard (banned) 33 + const standardResults = worker.searchCards( 34 + "ancestral recall", 35 + { format: "standard" }, 36 + 10, 37 + ); 38 + expect(standardResults.length).toBe(0); 39 + }); 40 + 41 + it("applies color identity restriction correctly", () => { 42 + // Search for blue cards with blue identity restriction 43 + const results = worker.searchCards( 44 + "lightning bolt", 45 + { format: "commander", colorIdentity: ["R"] }, 46 + 10, 47 + ); 48 + expect(results.length).toBeGreaterThan(0); 49 + expect( 50 + results.every((c) => c.color_identity?.every((col) => col === "R")), 51 + ).toBe(true); 52 + }); 53 + 54 + it("empty color identity array filters to colorless only", () => { 55 + // Search with empty color identity (should only allow colorless) 56 + const results = worker.searchCards( 57 + "sol ring", 58 + { format: "commander", colorIdentity: [] }, 59 + 10, 60 + ); 61 + 62 + // Sol Ring is colorless, should be allowed 63 + expect(results.length).toBeGreaterThan(0); 64 + expect(results.every((c) => (c.color_identity?.length ?? 0) === 0)).toBe( 65 + true, 66 + ); 67 + 68 + // Try to find a colored card with empty identity - should fail 69 + const coloredResults = worker.searchCards( 70 + "lightning bolt", 71 + { format: "commander", colorIdentity: [] }, 72 + 10, 73 + ); 74 + expect(coloredResults.length).toBe(0); 75 + }); 76 + 77 + it("undefined color identity allows all colors", () => { 78 + // No color identity restriction should allow all colors 79 + const results = worker.searchCards( 80 + "lightning bolt", 81 + { format: "commander", colorIdentity: undefined }, 82 + 10, 83 + ); 84 + expect(results.length).toBeGreaterThan(0); 85 + }); 86 + }); 87 + });
+47 -10
src/workers/cards.worker.ts
··· 10 10 import type { 11 11 Card, 12 12 CardDataOutput, 13 + ManaColor, 13 14 OracleId, 14 15 ScryfallId, 16 + SearchRestrictions, 15 17 } from "../lib/scryfall-types"; 16 18 17 19 interface CardsWorkerAPI { ··· 21 23 initialize(): Promise<void>; 22 24 23 25 /** 24 - * Search cards by name 26 + * Search cards by name with optional restrictions 25 27 */ 26 - searchCards(query: string, limit?: number): Card[]; 28 + searchCards( 29 + query: string, 30 + restrictions?: SearchRestrictions, 31 + maxResults?: number, 32 + ): Card[]; 27 33 28 34 /** 29 35 * Get card by ID ··· 96 102 ); 97 103 } 98 104 99 - searchCards(query: string, limit = 100): Card[] { 105 + searchCards( 106 + query: string, 107 + restrictions?: SearchRestrictions, 108 + maxResults = 50, 109 + ): Card[] { 100 110 if (!this.data || !this.searchIndex) { 101 111 throw new Error("Worker not initialized - call initialize() first"); 102 112 } ··· 107 117 } 108 118 109 119 // Perform fuzzy search with exact-match priority 110 - const results = this.searchIndex.search(query); 120 + const searchResults = this.searchIndex.search(query); 121 + const results: Card[] = []; 122 + 123 + // Iterate incrementally, applying filters and stopping at maxResults 124 + for (const result of searchResults) { 125 + const card = this.data.cards[result.id as ScryfallId]; 126 + if (!card) continue; 127 + 128 + // Apply restrictions 129 + if (restrictions) { 130 + // Format legality check 131 + if (restrictions.format) { 132 + const legality = card.legalities?.[restrictions.format]; 133 + if (legality !== "legal" && legality !== "restricted") { 134 + continue; 135 + } 136 + } 137 + 138 + // Color identity subset check (Scryfall order not guaranteed) 139 + if (restrictions.colorIdentity) { 140 + const cardIdentity = card.color_identity ?? []; 141 + const allowedSet = new Set(restrictions.colorIdentity); 142 + 143 + // Card must be subset of allowed colors 144 + if (!cardIdentity.every((c) => allowedSet.has(c as ManaColor))) { 145 + continue; 146 + } 147 + } 148 + } 149 + 150 + results.push(card); 151 + if (results.length >= maxResults) break; // Early exit 152 + } 111 153 112 - // Map search results back to full Card objects and limit 113 - const data = this.data; 114 - return results 115 - .map((result) => data.cards[result.id as ScryfallId]) 116 - .filter((card): card is Card => card !== undefined) 117 - .slice(0, limit); 154 + return results; 118 155 } 119 156 120 157 getCardById(id: ScryfallId): Card | undefined {