👁️
5
fork

Configure Feed

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

set all to xyz printing options, card update highlighting

+1030 -8
+120
src/components/deck/DeckActionsMenu.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 + import { MoreVertical } from "lucide-react"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { toast } from "sonner"; 5 + import { getCardDataProvider } from "@/lib/card-data-provider"; 6 + import { prefetchCards } from "@/lib/card-prefetch"; 7 + import type { Deck } from "@/lib/deck-types"; 8 + import { 9 + findAllCanonicalPrintings, 10 + findAllCheapestPrintings, 11 + updateDeckPrintings, 12 + } from "@/lib/printing-selection"; 13 + import type { ScryfallId } from "@/lib/scryfall-types"; 14 + 15 + interface DeckActionsMenuProps { 16 + deck: Deck; 17 + onUpdateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 18 + onCardsChanged?: (changedIds: Set<ScryfallId>) => void; 19 + } 20 + 21 + export function DeckActionsMenu({ 22 + deck, 23 + onUpdateDeck, 24 + onCardsChanged, 25 + }: DeckActionsMenuProps) { 26 + const queryClient = useQueryClient(); 27 + const [isOpen, setIsOpen] = useState(false); 28 + const menuRef = useRef<HTMLDivElement>(null); 29 + 30 + useEffect(() => { 31 + const handleClickOutside = (event: MouseEvent) => { 32 + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 33 + setIsOpen(false); 34 + } 35 + }; 36 + 37 + if (isOpen) { 38 + document.addEventListener("mousedown", handleClickOutside); 39 + return () => 40 + document.removeEventListener("mousedown", handleClickOutside); 41 + } 42 + }, [isOpen]); 43 + 44 + const handleSetAllToCheapest = async () => { 45 + setIsOpen(false); 46 + const toastId = toast.loading("Finding cheapest printings..."); 47 + 48 + try { 49 + const provider = await getCardDataProvider(); 50 + const updates = await findAllCheapestPrintings(deck, provider); 51 + 52 + if (updates.size > 0) { 53 + const newIds = [...new Set(updates.values())]; 54 + await prefetchCards(queryClient, newIds); 55 + await onUpdateDeck((prev) => updateDeckPrintings(prev, updates)); 56 + onCardsChanged?.(new Set(newIds)); 57 + toast.success(`Updated ${updates.size} printing(s)`, { id: toastId }); 58 + } else { 59 + toast.success("All cards already at cheapest", { id: toastId }); 60 + } 61 + } catch { 62 + toast.error("Failed to update printings", { id: toastId }); 63 + } 64 + }; 65 + 66 + const handleSetAllToBest = async () => { 67 + setIsOpen(false); 68 + const toastId = toast.loading("Finding best printings..."); 69 + 70 + try { 71 + const provider = await getCardDataProvider(); 72 + const updates = await findAllCanonicalPrintings(deck, provider); 73 + 74 + if (updates.size > 0) { 75 + const newIds = [...new Set(updates.values())]; 76 + await prefetchCards(queryClient, newIds); 77 + await onUpdateDeck((prev) => updateDeckPrintings(prev, updates)); 78 + onCardsChanged?.(new Set(newIds)); 79 + toast.success(`Updated ${updates.size} printing(s)`, { id: toastId }); 80 + } else { 81 + toast.success("All cards already at best", { id: toastId }); 82 + } 83 + } catch { 84 + toast.error("Failed to update printings", { id: toastId }); 85 + } 86 + }; 87 + 88 + return ( 89 + <div className="relative" ref={menuRef}> 90 + <button 91 + type="button" 92 + onClick={() => setIsOpen(!isOpen)} 93 + 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" 94 + aria-label="Deck actions" 95 + aria-expanded={isOpen} 96 + > 97 + <MoreVertical size={16} /> 98 + </button> 99 + 100 + {isOpen && ( 101 + <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"> 102 + <button 103 + type="button" 104 + onClick={handleSetAllToCheapest} 105 + 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" 106 + > 107 + Set all to cheapest 108 + </button> 109 + <button 110 + type="button" 111 + onClick={handleSetAllToBest} 112 + 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" 113 + > 114 + Set all to best 115 + </button> 116 + </div> 117 + )} 118 + </div> 119 + ); 120 + }
+5
src/components/deck/DeckSection.tsx
··· 18 18 onCardClick?: (card: DeckCard) => void; 19 19 isDragging: boolean; 20 20 readOnly?: boolean; 21 + highlightedCards?: Set<ScryfallId>; 21 22 } 22 23 23 24 export function DeckSection({ ··· 29 30 onCardClick, 30 31 isDragging, 31 32 readOnly = false, 33 + highlightedCards, 32 34 }: DeckSectionProps) { 33 35 const sectionNames: Record<Section, string> = { 34 36 commander: "Commander", ··· 116 118 onCardClick={onCardClick} 117 119 disabled={readOnly} 118 120 isDraggingGlobal={isDragging} 121 + isHighlighted={highlightedCards?.has(card.scryfallId)} 119 122 /> 120 123 </div> 121 124 ); ··· 131 134 onCardClick={onCardClick} 132 135 disabled={readOnly} 133 136 isDraggingGlobal={isDragging} 137 + isHighlighted={highlightedCards?.has(card.scryfallId)} 134 138 /> 135 139 </div> 136 140 ); ··· 174 178 onCardClick={onCardClick} 175 179 disabled={readOnly} 176 180 isDraggingGlobal={isDragging} 181 + isHighlighted={highlightedCards?.has(card.scryfallId)} 177 182 /> 178 183 ); 179 184 })}
+19 -1
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"; 4 5 import { getPrimaryFace } from "@/lib/card-faces"; 5 6 import type { DeckCard } from "@/lib/deck-types"; ··· 13 14 onCardClick?: (card: DeckCard) => void; 14 15 disabled?: boolean; 15 16 isDraggingGlobal?: boolean; 17 + isHighlighted?: boolean; 16 18 } 17 19 18 20 export interface DragData { ··· 28 30 onCardClick, 29 31 disabled = false, 30 32 isDraggingGlobal = false, 33 + isHighlighted = false, 31 34 }: DraggableCardProps) { 32 35 const { data: cardData, isLoading } = useQuery( 33 36 getCardByIdQueryOptions(card.scryfallId), ··· 45 48 disabled, 46 49 }); 47 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 + 48 62 const primaryFace = cardData ? getPrimaryFace(cardData) : null; 49 63 50 64 return ( ··· 53 67 {...attributes} 54 68 {...(disabled ? {} : listeners)} 55 69 type="button" 56 - 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 md: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" 57 71 style={{ 58 72 opacity: isDragging ? 0.5 : 1, 59 73 cursor: disabled ? "pointer" : isDragging ? "grabbing" : "grab", ··· 74 88 } 75 89 }} 76 90 > 91 + <div 92 + ref={highlightRef} 93 + className="absolute inset-0 rounded bg-[var(--highlight-color)] opacity-0 pointer-events-none" 94 + /> 77 95 <div className="flex items-center gap-2"> 78 96 <span className="text-gray-600 dark:text-gray-400 font-mono text-xs w-4 text-right flex-shrink-0"> 79 97 {card.quantity}
+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 + });
+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 + }
+37 -7
src/routes/profile/$did/deck/$rkey/index.tsx
··· 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"; 14 15 import { DeckStats } from "@/components/deck/DeckStats"; ··· 123 124 const [draggedCardId, setDraggedCardId] = useState<ScryfallId | null>(null); 124 125 const [isDragging, setIsDragging] = useState(false); 125 126 const [statsSelection, setStatsSelection] = useState<StatsSelection>(null); 127 + const [highlightedCards, setHighlightedCards] = useState<Set<ScryfallId>>( 128 + new Set(), 129 + ); 126 130 127 131 const statsCards = useMemo( 128 132 () => [ ··· 154 158 if (!isOwner) return; 155 159 const updated = updater(deck); 156 160 await mutation.mutateAsync(updated); 161 + }; 162 + 163 + // Highlight cards that were changed - clear after render so it can trigger again 164 + const handleCardsChanged = (changedIds: Set<ScryfallId>) => { 165 + setHighlightedCards(changedIds); 166 + setTimeout(() => setHighlightedCards(new Set()), 0); 157 167 }; 158 168 159 169 const handleCardHover = (cardId: ScryfallId | null) => { ··· 405 415 setGroupBy={setGroupBy} 406 416 setSortBy={setSortBy} 407 417 allTags={allTags} 418 + updateDeck={updateDeck} 419 + highlightedCards={highlightedCards} 420 + handleCardsChanged={handleCardsChanged} 408 421 /> 409 422 </DragDropProvider> 410 423 ); ··· 440 453 setGroupBy: (groupBy: GroupBy) => void; 441 454 setSortBy: (sortBy: SortBy) => void; 442 455 allTags: string[]; 456 + updateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 457 + highlightedCards: Set<ScryfallId>; 458 + handleCardsChanged: (changedIds: Set<ScryfallId>) => void; 443 459 } 444 460 445 461 function DeckEditorInner({ ··· 472 488 setGroupBy, 473 489 setSortBy, 474 490 allTags, 491 + updateDeck, 492 + highlightedCards, 493 + handleCardsChanged, 475 494 }: DeckEditorInnerProps) { 476 495 // Track drag state globally (must be inside DndContext) 477 496 useDndMonitor({ ··· 509 528 {isOwner && ( 510 529 <div className="sticky top-0 z-10 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 shadow-sm"> 511 530 <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 py-3 flex items-center justify-between gap-4"> 512 - <Link 513 - to="/profile/$did/deck/$rkey/bulk-edit" 514 - params={{ did, rkey }} 515 - className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" 516 - > 517 - Bulk Edit 518 - </Link> 531 + <div className="flex items-center gap-2"> 532 + <DeckActionsMenu 533 + deck={deck} 534 + onUpdateDeck={updateDeck} 535 + onCardsChanged={handleCardsChanged} 536 + /> 537 + <Link 538 + to="/profile/$did/deck/$rkey/bulk-edit" 539 + params={{ did, rkey }} 540 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" 541 + > 542 + Bulk Edit 543 + </Link> 544 + </div> 519 545 <div className="w-full max-w-md"> 520 546 <CardSearchAutocomplete 521 547 deck={deck} ··· 579 605 onCardClick={handleCardClick} 580 606 isDragging={isDragging} 581 607 readOnly={!isOwner} 608 + highlightedCards={highlightedCards} 582 609 /> 583 610 )} 584 611 <DeckSection ··· 590 617 onCardClick={handleCardClick} 591 618 isDragging={isDragging} 592 619 readOnly={!isOwner} 620 + highlightedCards={highlightedCards} 593 621 /> 594 622 <DeckSection 595 623 section="sideboard" ··· 600 628 onCardClick={handleCardClick} 601 629 isDragging={isDragging} 602 630 readOnly={!isOwner} 631 + highlightedCards={highlightedCards} 603 632 /> 604 633 <DeckSection 605 634 section="maybeboard" ··· 610 639 onCardClick={handleCardClick} 611 640 isDragging={isDragging} 612 641 readOnly={!isOwner} 642 + highlightedCards={highlightedCards} 613 643 /> 614 644 615 645 <DeckStats