👁️
5
fork

Configure Feed

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

big refactor for cardref with uri support pt1

+485 -152
+5 -4
lexicons/com/deckbelcher/collection/list.json
··· 52 52 "cardItem": { 53 53 "type": "object", 54 54 "properties": { 55 - "scryfallId": { 56 - "type": "string", 57 - "description": "Scryfall UUID for the card." 55 + "ref": { 56 + "type": "ref", 57 + "ref": "com.deckbelcher.defs#cardRef", 58 + "description": "Reference to the card (scryfall printing + oracle card)." 58 59 }, 59 60 "addedAt": { 60 61 "type": "string", ··· 64 65 }, 65 66 "description": "A card saved to the list.", 66 67 "required": [ 67 - "scryfallId", 68 + "ref", 68 69 "addedAt" 69 70 ] 70 71 },
+5 -4
lexicons/com/deckbelcher/deck/list.json
··· 55 55 "card": { 56 56 "type": "object", 57 57 "properties": { 58 - "scryfallId": { 59 - "type": "string", 60 - "description": "Scryfall UUID for the specific printing." 58 + "ref": { 59 + "type": "ref", 60 + "ref": "com.deckbelcher.defs#cardRef", 61 + "description": "Reference to the card (scryfall printing + oracle card)." 61 62 }, 62 63 "quantity": { 63 64 "type": "integer", ··· 88 89 }, 89 90 "description": "A card entry in a decklist.", 90 91 "required": [ 91 - "scryfallId", 92 + "ref", 92 93 "quantity", 93 94 "section" 94 95 ]
+26
lexicons/com/deckbelcher/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.deckbelcher.defs", 4 + "defs": { 5 + "cardRef": { 6 + "type": "object", 7 + "properties": { 8 + "scryfallUri": { 9 + "type": "string", 10 + "format": "uri", 11 + "description": "Scryfall printing URI (scry:<uuid>) - authoritative identifier" 12 + }, 13 + "oracleUri": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "Oracle card URI (oracle:<uuid>) - for external indexing.\nDerived from scryfallUri; on conflict, scryfallUri takes precedence." 17 + } 18 + }, 19 + "description": "Reference to a Magic: The Gathering card with printing and oracle identifiers.", 20 + "required": [ 21 + "scryfallUri", 22 + "oracleUri" 23 + ] 24 + } 25 + } 26 + }
+6 -4
lexicons/com/deckbelcher/richtext/facet.json
··· 113 113 "cardRef": { 114 114 "type": "object", 115 115 "properties": { 116 - "scryfallId": { 117 - "type": "string" 116 + "ref": { 117 + "type": "ref", 118 + "ref": "com.deckbelcher.defs#cardRef", 119 + "description": "Reference to the card (scryfall printing + oracle card)." 118 120 } 119 121 }, 120 - "description": "Facet feature for a card reference.\nLinks to a Magic: The Gathering card by Scryfall ID.\nThe text is usually the card name.", 122 + "description": "Facet feature for a card reference.\nLinks to a Magic: The Gathering card.\nThe text is usually the card name.", 121 123 "required": [ 122 - "scryfallId" 124 + "ref" 123 125 ] 124 126 } 125 127 }
+6 -15
src/components/DeckPreview.tsx
··· 4 4 import { CardImage } from "@/components/CardImage"; 5 5 import { ClientDate } from "@/components/ClientDate"; 6 6 import { asRkey, type Rkey } from "@/lib/atproto-client"; 7 + import type { Deck, DeckCard } from "@/lib/deck-types"; 7 8 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 8 9 import { formatDisplayName } from "@/lib/format-utils"; 9 10 import type { ScryfallId } from "@/lib/scryfall-types"; 10 11 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 12 export interface DeckPreviewProps { 20 13 did: Did; 21 14 rkey: Rkey | string; 22 - deck: DeckData; 15 + deck: Deck; 23 16 /** Whether to show handle row (fetches DID document, with skeleton while loading) */ 24 17 showHandle?: boolean; 25 18 /** Whether to show section counts like "100 main · 15 side" (default: true) */ ··· 61 54 return parts.join(" · "); 62 55 } 63 56 64 - function getThumbnailId( 65 - cards: { scryfallId: string; section: string }[], 66 - ): ScryfallId | null { 57 + function getThumbnailId(cards: DeckCard[]): ScryfallId | null { 67 58 const commander = cards.find((c) => c.section === "commander"); 68 - if (commander) return commander.scryfallId as ScryfallId; 59 + if (commander) return commander.scryfallId; 69 60 70 61 const mainboard = cards.find((c) => c.section === "mainboard"); 71 - if (mainboard) return mainboard.scryfallId as ScryfallId; 62 + if (mainboard) return mainboard.scryfallId; 72 63 73 - return cards[0]?.scryfallId as ScryfallId | null; 64 + return cards[0]?.scryfallId ?? null; 74 65 } 75 66 76 67 export function DeckPreview({
+3 -3
src/components/list/SaveToListDialog.tsx
··· 16 16 hasCard, 17 17 hasDeck, 18 18 } from "@/lib/collection-list-types"; 19 - import type { ScryfallId } from "@/lib/scryfall-types"; 19 + import type { OracleId, ScryfallId } from "@/lib/scryfall-types"; 20 20 21 21 export type SaveItem = 22 - | { type: "card"; scryfallId: ScryfallId } 22 + | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 23 23 | { type: "deck"; deckUri: string }; 24 24 25 25 interface SaveToListDialogProps { ··· 214 214 215 215 const updatedList = 216 216 item.type === "card" 217 - ? addCardToList(list, item.scryfallId) 217 + ? addCardToList(list, item.scryfallId, item.oracleId) 218 218 : addDeckToList(list, item.deckUri); 219 219 220 220 updateMutation.mutate(updatedList, {
+6 -7
src/components/richtext/RichtextRenderer.tsx
··· 14 14 } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 15 15 import type { Main as Facet } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 16 16 import { segmentize } from "@/lib/richtext-convert"; 17 - import type { ScryfallId } from "@/lib/scryfall-types"; 17 + import { parseScryfallUri, type ScryfallId } from "@/lib/scryfall-types"; 18 18 19 19 type Block = 20 20 | ParagraphBlock ··· 275 275 </Link> 276 276 ); 277 277 278 - case "com.deckbelcher.richtext.facet#cardRef": 279 - return ( 280 - <CardRefLink scryfallId={feature.scryfallId as ScryfallId}> 281 - {content} 282 - </CardRefLink> 283 - ); 278 + case "com.deckbelcher.richtext.facet#cardRef": { 279 + const scryfallId = parseScryfallUri(feature.ref.scryfallUri); 280 + if (!scryfallId) return <>{content}</>; 281 + return <CardRefLink scryfallId={scryfallId}>{content}</CardRefLink>; 282 + } 284 283 285 284 case "com.deckbelcher.richtext.facet#tag": 286 285 return (
+3
src/components/richtext/schema.ts
··· 233 233 attrs: { 234 234 name: { default: "" }, 235 235 scryfallId: { default: "" }, 236 + oracleId: { default: "" }, 236 237 }, 237 238 toDOM(node) { 238 239 return [ ··· 243 244 "data-cardref": "", 244 245 "data-name": node.attrs.name, 245 246 "data-scryfall-id": node.attrs.scryfallId, 247 + "data-oracle-id": node.attrs.oracleId, 246 248 }, 247 249 node.attrs.name, 248 250 ]; ··· 255 257 return { 256 258 name: dom.getAttribute("data-name") ?? "", 257 259 scryfallId: dom.getAttribute("data-scryfall-id") ?? "", 260 + oracleId: dom.getAttribute("data-oracle-id") ?? "", 258 261 }; 259 262 }, 260 263 },
+4 -2
src/lib/__tests__/deck-import.test.ts
··· 379 379 expect(parsed?.name).toBe(name); 380 380 expect(parsed?.setCode).toBe(set.toUpperCase()); 381 381 expect(parsed?.collectorNumber).toBe(collectorNumber); 382 - expect(parsed?.tags).toEqual(tags); 382 + // Tags are deduplicated during parsing 383 + expect(parsed?.tags).toEqual(Array.from(new Set(tags))); 383 384 }, 384 385 ), 385 386 { numRuns: 200 }, ··· 408 409 expect(parsed?.quantity).toBe(quantity); 409 410 expect(parsed?.name).toBe(name); 410 411 expect(parsed?.setCode).toBeUndefined(); 411 - expect(parsed?.tags).toEqual(tags); 412 + // Tags are deduplicated during parsing 413 + expect(parsed?.tags).toEqual(Array.from(new Set(tags))); 412 414 }), 413 415 { numRuns: 200 }, 414 416 );
+1
src/lib/__tests__/deck-types.test.ts
··· 20 20 format: "commander", 21 21 cards: commanderIds.map((id) => ({ 22 22 scryfallId: asScryfallId(id), 23 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 23 24 quantity: 1, 24 25 section: "commander" as const, 25 26 tags: [],
+38 -10
src/lib/__tests__/printing-selection.test.ts
··· 32 32 function mockDeck( 33 33 cards: Array<{ 34 34 scryfallId: ScryfallId; 35 + oracleId?: OracleId; 35 36 section?: "mainboard" | "sideboard" | "commander" | "maybeboard"; 36 37 }>, 37 38 ): Deck { ··· 41 42 format: "commander", 42 43 cards: cards.map((c) => ({ 43 44 scryfallId: c.scryfallId, 45 + oracleId: 46 + c.oracleId ?? asOracleId("00000000-0000-0000-0000-000000000000"), 44 47 quantity: 1, 45 48 section: c.section ?? "mainboard", 46 49 tags: [], ··· 174 177 cards: [ 175 178 { 176 179 scryfallId: oldId, 180 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 177 181 quantity: 4, 178 182 section: "sideboard", 179 183 tags: ["removal", "instant"], ··· 186 190 187 191 expect(result.cards[0]).toEqual({ 188 192 scryfallId: newId, 193 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 189 194 quantity: 4, 190 195 section: "sideboard", 191 196 tags: ["removal", "instant"], ··· 257 262 }, 258 263 }); 259 264 260 - const deck = mockDeck([{ scryfallId: card1a }]); 265 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 261 266 const updates = await findAllCheapestPrintings(deck, provider); 262 267 263 268 expect(updates.get(card1a)).toBe(card1b); ··· 277 282 }, 278 283 }); 279 284 280 - const deck = mockDeck([{ scryfallId: card1a }]); 285 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 281 286 const updates = await findAllCheapestPrintings(deck, provider); 282 287 283 288 expect(updates.size).toBe(0); ··· 299 304 }); 300 305 301 306 const deck = mockDeck([ 302 - { scryfallId: card1a }, 303 - { scryfallId: card1a, section: "sideboard" }, 307 + { scryfallId: card1a, oracleId: oracle1 }, 308 + { scryfallId: card1a, oracleId: oracle1, section: "sideboard" }, 304 309 ]); 305 310 const updates = await findAllCheapestPrintings(deck, provider); 306 311 ··· 321 326 }, 322 327 }); 323 328 324 - const deck = mockDeck([{ scryfallId: card1a }]); 329 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 325 330 const updates = await findAllCheapestPrintings(deck, provider); 326 331 327 332 expect(updates.size).toBe(0); ··· 344 349 }, 345 350 }); 346 351 347 - const deck = mockDeck([{ scryfallId: card1a }, { scryfallId: card2a }]); 352 + const deck = mockDeck([ 353 + { scryfallId: card1a, oracleId: oracle1 }, 354 + { scryfallId: card2a, oracleId: oracle2 }, 355 + ]); 348 356 const updates = await findAllCheapestPrintings(deck, provider); 349 357 350 358 expect(updates.get(card1a)).toBe(card1b); ··· 391 399 // Same oracle, different printings in same section 392 400 { 393 401 scryfallId: cardExpensive, 402 + oracleId: oracle1, 394 403 quantity: 2, 395 404 section: "mainboard", 396 405 tags: [], 397 406 }, 398 407 { 399 408 scryfallId: cardMid, 409 + oracleId: oracle1, 400 410 quantity: 2, 401 411 section: "mainboard", 402 412 tags: ["burn"], ··· 421 431 cards: [ 422 432 { 423 433 scryfallId: cardExpensive, 434 + oracleId: oracle1, 424 435 quantity: 4, 425 436 section: "mainboard", 426 437 tags: [], 427 438 }, 428 439 { 429 440 scryfallId: cardMid, 441 + oracleId: oracle1, 430 442 quantity: 2, 431 443 section: "sideboard", 432 444 tags: ["sb"], ··· 453 465 cards: [ 454 466 { 455 467 scryfallId: cardExpensive, 468 + oracleId: oracle1, 456 469 quantity: 2, 457 470 section: "mainboard", 458 471 tags: ["burn"], 459 472 }, 460 473 { 461 474 scryfallId: cardExpensive, 475 + oracleId: oracle1, 462 476 quantity: 2, 463 477 section: "mainboard", 464 478 tags: ["removal"], ··· 480 494 name: "Test Deck", 481 495 format: "modern", 482 496 cards: [ 483 - { scryfallId: cardCheap, quantity: 2, section: "mainboard", tags: [] }, 497 + { 498 + scryfallId: cardCheap, 499 + oracleId: oracle1, 500 + quantity: 2, 501 + section: "mainboard", 502 + tags: [], 503 + }, 484 504 { 485 505 scryfallId: cardExpensive, 506 + oracleId: oracle1, 486 507 quantity: 2, 487 508 section: "mainboard", 488 509 tags: [], ··· 512 533 cards: [ 513 534 { 514 535 scryfallId: oldPrinting, 536 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 515 537 quantity: 4, 516 538 section: "mainboard", 517 539 tags: [], 518 540 }, 519 541 { 520 542 scryfallId: oldPrinting, 543 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 521 544 quantity: 2, 522 545 section: "sideboard", 523 546 tags: ["sb"], ··· 541 564 cards: [ 542 565 { 543 566 scryfallId: oldPrinting, 567 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 544 568 quantity: 4, 545 569 section: "mainboard", 546 570 tags: ["burn", "instant"], ··· 563 587 cards: [ 564 588 { 565 589 scryfallId: oldPrinting, 590 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 566 591 quantity: 4, 567 592 section: "mainboard", 568 593 tags: [], ··· 581 606 const printingA = asScryfallId("aaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); 582 607 const printingB = asScryfallId("bbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); 583 608 const cheapest = asScryfallId("cccc-cccc-cccc-cccc-cccccccccccc"); 609 + const oracle = asOracleId("00000000-0000-0000-0000-000000000000"); 584 610 585 611 const deck: Deck = { 586 612 $type: "com.deckbelcher.deck.list", ··· 589 615 cards: [ 590 616 { 591 617 scryfallId: printingA, 618 + oracleId: oracle, 592 619 quantity: 2, 593 620 section: "mainboard", 594 621 tags: ["burn"], 595 622 }, 596 623 { 597 624 scryfallId: printingB, 625 + oracleId: oracle, 598 626 quantity: 3, 599 627 section: "mainboard", 600 628 tags: ["removal"], ··· 655 683 }, 656 684 }); 657 685 658 - const deck = mockDeck([{ scryfallId: card1a }]); 686 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 659 687 const updates = await findAllCanonicalPrintings(deck, provider); 660 688 661 689 expect(updates.get(card1a)).toBe(card1b); ··· 671 699 }, 672 700 }); 673 701 674 - const deck = mockDeck([{ scryfallId: card1a }]); 702 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 675 703 const updates = await findAllCanonicalPrintings(deck, provider); 676 704 677 705 expect(updates.size).toBe(0); ··· 685 713 canonical: {}, 686 714 }); 687 715 688 - const deck = mockDeck([{ scryfallId: card1a }]); 716 + const deck = mockDeck([{ scryfallId: card1a, oracleId: oracle1 }]); 689 717 const updates = await findAllCanonicalPrintings(deck, provider); 690 718 691 719 expect(updates.size).toBe(0);
+11 -4
src/lib/__tests__/richtext-convert.test.ts
··· 1078 1078 features: [ 1079 1079 { 1080 1080 $type: "com.deckbelcher.richtext.facet#cardRef", 1081 - scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1081 + ref: { 1082 + scryfallUri: "scry:e3285e6b-3e79-4d7c-bf96-d920f973b122", 1083 + oracleUri: "oracle:11111111-1111-1111-1111-111111111111", 1084 + }, 1082 1085 }, 1083 1086 ], 1084 1087 }, ··· 1504 1507 schema.nodes.cardRef.create({ 1505 1508 name: "Lightning Bolt", 1506 1509 scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1510 + oracleId: "11111111-1111-1111-1111-111111111111", 1507 1511 }), 1508 1512 schema.text(" for removal"), 1509 1513 ]), ··· 1519 1523 schema.nodes.cardRef.create({ 1520 1524 name: "Lightning Bolt", 1521 1525 scryfallId: "", 1526 + oracleId: "", 1522 1527 }), 1523 1528 ]), 1524 1529 ]); ··· 1540 1545 schema.nodes.cardRef.create({ 1541 1546 name: "Lightning Bolt", 1542 1547 scryfallId: "e3285e6b-3e79-4d7c-bf96-d920f973b122", 1548 + oracleId: "11111111-1111-1111-1111-111111111111", 1543 1549 }), 1544 1550 schema.text(" and "), 1545 1551 schema.nodes.cardRef.create({ 1546 1552 name: "Path to Exile", 1547 1553 scryfallId: "163b68e8-33e9-4e4e-a2c1-e1c884c7a3b8", 1554 + oracleId: "22222222-2222-2222-2222-222222222222", 1548 1555 }), 1549 1556 ]), 1550 1557 ]); ··· 1860 1867 fc.constant("صاعقة"), // Arabic (thunderbolt) - RTL 1861 1868 ); 1862 1869 const arbCardRef = fc 1863 - .tuple(arbCardName, fc.uuid()) 1864 - .map(([name, scryfallId]) => 1865 - schema.nodes.cardRef.create({ name, scryfallId }), 1870 + .tuple(arbCardName, fc.uuid(), fc.uuid()) 1871 + .map(([name, scryfallId, oracleId]) => 1872 + schema.nodes.cardRef.create({ name, scryfallId, oracleId }), 1866 1873 ); 1867 1874 1868 1875 // Arbitrary for tag node (include unicode to catch byte offset bugs)
+60 -10
src/lib/collection-list-queries.ts
··· 12 12 createCollectionListRecord, 13 13 deleteCollectionListRecord, 14 14 getCollectionListRecord, 15 - type ListRecordsResponse, 16 15 listUserCollectionLists, 17 16 type Rkey, 18 17 updateCollectionListRecord, 19 18 } from "./atproto-client"; 20 - import type { CollectionList } from "./collection-list-types"; 19 + import type { 20 + CollectionList, 21 + ListCardItem, 22 + ListItem, 23 + } from "./collection-list-types"; 21 24 import { getPdsForDid } from "./identity"; 22 25 import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 26 + import { parseOracleUri, parseScryfallUri } from "./scryfall-types"; 23 27 import { useAuth } from "./useAuth"; 24 28 import { useMutationWithToast } from "./useMutationWithToast"; 25 29 26 30 /** 31 + * Transform lexicon list record to app CollectionList type 32 + * Parses ref URIs to typed IDs at the boundary 33 + */ 34 + export function transformListRecord( 35 + record: ComDeckbelcherCollectionList.Main, 36 + ): CollectionList { 37 + return { 38 + ...record, 39 + items: record.items.map((item): ListItem => { 40 + if (item.$type === "com.deckbelcher.collection.list#cardItem") { 41 + const cardItem = item as ComDeckbelcherCollectionList.CardItem; 42 + const scryfallId = parseScryfallUri(cardItem.ref.scryfallUri); 43 + const oracleId = parseOracleUri(cardItem.ref.oracleUri); 44 + 45 + if (!scryfallId || !oracleId) { 46 + throw new Error( 47 + `Invalid card ref URIs: ${cardItem.ref.scryfallUri}, ${cardItem.ref.oracleUri}`, 48 + ); 49 + } 50 + 51 + const { ref: _ref, ...rest } = cardItem; 52 + return { ...rest, scryfallId, oracleId } as ListCardItem; 53 + } 54 + if (item.$type === "com.deckbelcher.collection.list#deckItem") { 55 + return item as ComDeckbelcherCollectionList.DeckItem; 56 + } 57 + throw new Error( 58 + `Unknown list item type: ${(item as { $type?: string }).$type}`, 59 + ); 60 + }), 61 + }; 62 + } 63 + 64 + /** 27 65 * Query options for fetching a single collection list 28 66 */ 29 67 export const getCollectionListQueryOptions = (did: Did, rkey: Rkey) => ··· 34 72 if (!result.success) { 35 73 throw result.error; 36 74 } 37 - return result.data.value as CollectionList; 75 + return transformListRecord(result.data.value); 38 76 }, 39 77 staleTime: 30 * 1000, 40 78 }); 41 79 80 + export interface CollectionListRecord { 81 + uri: string; 82 + cid: string; 83 + value: CollectionList; 84 + } 85 + 42 86 /** 43 87 * Query options for listing all collection lists for a user 44 88 */ 45 89 export const listUserCollectionListsQueryOptions = (did: Did) => 46 90 queryOptions({ 47 91 queryKey: ["collection-lists", did] as const, 48 - queryFn: async (): Promise<ListRecordsResponse<CollectionList>> => { 92 + queryFn: async (): Promise<{ records: CollectionListRecord[] }> => { 49 93 const pds = await getPdsForDid(did); 50 94 const result = await listUserCollectionLists(asPdsUrl(pds), did); 51 95 if (!result.success) { 52 96 throw result.error; 53 97 } 54 - return result.data as ListRecordsResponse<CollectionList>; 98 + return { 99 + records: result.data.records.map((record) => ({ 100 + uri: record.uri, 101 + cid: record.cid, 102 + value: transformListRecord(record.value), 103 + })), 104 + }; 55 105 }, 56 106 staleTime: 60 * 1000, 57 107 }); ··· 139 189 rkey, 140 190 ]); 141 191 142 - const previousLists = queryClient.getQueryData< 143 - ListRecordsResponse<CollectionList> 144 - >(["collection-lists", did]); 192 + const previousLists = queryClient.getQueryData<{ 193 + records: CollectionListRecord[]; 194 + }>(["collection-lists", did]); 145 195 146 196 queryClient.setQueryData<CollectionList>( 147 197 ["collection-list", did, rkey], ··· 149 199 ); 150 200 151 201 if (previousLists) { 152 - queryClient.setQueryData<ListRecordsResponse<CollectionList>>( 202 + queryClient.setQueryData<{ records: CollectionListRecord[] }>( 153 203 ["collection-lists", did], 154 204 { 155 205 ...previousLists, ··· 172 222 ); 173 223 } 174 224 if (context?.previousLists) { 175 - queryClient.setQueryData<ListRecordsResponse<CollectionList>>( 225 + queryClient.setQueryData<{ records: CollectionListRecord[] }>( 176 226 ["collection-lists", did], 177 227 context.previousLists, 178 228 );
+10 -2
src/lib/collection-list-types.ts
··· 5 5 6 6 import type { ResourceUri } from "@atcute/lexicons"; 7 7 import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 8 - import type { ScryfallId } from "./scryfall-types"; 8 + import type { OracleId, ScryfallId } from "./scryfall-types"; 9 9 10 + /** 11 + * App-side card item with flat typed IDs. 12 + * The lexicon stores ref.scryfallUri and ref.oracleUri as URIs, 13 + * but app code works with typed IDs after boundary parsing. 14 + */ 10 15 export type ListCardItem = Omit< 11 16 ComDeckbelcherCollectionList.CardItem, 12 - "scryfallId" 17 + "ref" 13 18 > & { 14 19 scryfallId: ScryfallId; 20 + oracleId: OracleId; 15 21 }; 16 22 17 23 export type ListDeckItem = ComDeckbelcherCollectionList.DeckItem; ··· 48 54 export function addCardToList( 49 55 list: CollectionList, 50 56 scryfallId: ScryfallId, 57 + oracleId: OracleId, 51 58 ): CollectionList { 52 59 if (hasCard(list, scryfallId)) { 53 60 return list; ··· 56 63 const newItem: ListCardItem = { 57 64 $type: "com.deckbelcher.collection.list#cardItem", 58 65 scryfallId, 66 + oracleId, 59 67 addedAt: new Date().toISOString(), 60 68 }; 61 69
+1
src/lib/deck-grouping.test.ts
··· 29 29 ): DeckCard { 30 30 return { 31 31 scryfallId: asScryfallId(scryfallId), 32 + oracleId: asOracleId("00000000-0000-0000-0000-000000000000"), 32 33 quantity: 1, 33 34 section: "mainboard", 34 35 tags: [],
+2
src/lib/deck-import.ts
··· 23 23 24 24 export interface ResolvedCard { 25 25 scryfallId: ScryfallId; 26 + oracleId: OracleId; 26 27 quantity: number; 27 28 tags: string[]; 28 29 } ··· 211 212 212 213 resolved.push({ 213 214 scryfallId: finalId, 215 + oracleId: baseCard.oracle_id, 214 216 quantity: line.quantity, 215 217 tags: line.tags, 216 218 });
+65 -20
src/lib/deck-queries.ts
··· 18 18 } from "./atproto-client"; 19 19 import type { Deck } from "./deck-types"; 20 20 import { getPdsForDid } from "./identity"; 21 - import { asScryfallId } from "./scryfall-types"; 21 + import type { ComDeckbelcherDeckList } from "./lexicons/index"; 22 + import { 23 + parseOracleUri, 24 + parseScryfallUri, 25 + toOracleUri, 26 + toScryfallUri, 27 + } from "./scryfall-types"; 22 28 import { useAuth } from "./useAuth"; 23 29 import { useMutationWithToast } from "./useMutationWithToast"; 24 30 25 31 /** 32 + * Transform lexicon deck record to app Deck type 33 + * Parses ref URIs to typed IDs at the boundary 34 + */ 35 + export function transformDeckRecord(record: ComDeckbelcherDeckList.Main): Deck { 36 + return { 37 + ...record, 38 + cards: record.cards.map((card) => { 39 + const scryfallId = parseScryfallUri(card.ref.scryfallUri); 40 + const oracleId = parseOracleUri(card.ref.oracleUri); 41 + 42 + if (!scryfallId || !oracleId) { 43 + throw new Error( 44 + `Invalid card ref URIs: ${card.ref.scryfallUri}, ${card.ref.oracleUri}`, 45 + ); 46 + } 47 + 48 + const { ref: _ref, ...rest } = card; 49 + return { ...rest, scryfallId, oracleId }; 50 + }), 51 + }; 52 + } 53 + 54 + /** 26 55 * Query options for fetching a single deck 27 56 * Uses Slingshot for cached reads 28 57 */ ··· 34 63 if (!result.success) { 35 64 throw result.error; 36 65 } 37 - 38 - // Map DeckRecordResponse to Deck type with branded ScryfallId 39 - return { 40 - ...result.data.value, 41 - cards: result.data.value.cards.map((card) => ({ 42 - ...card, 43 - scryfallId: asScryfallId(card.scryfallId), 44 - })), 45 - }; 66 + return transformDeckRecord(result.data.value); 46 67 }, 47 68 staleTime: 30 * 1000, // 30 seconds - balance between freshness and cache hits 48 69 }); 49 70 71 + export interface DeckListRecord { 72 + uri: string; 73 + cid: string; 74 + value: Deck; 75 + } 76 + 50 77 /** 51 78 * Query options for listing all decks for a user 52 79 * Fetches from user's PDS directly ··· 54 81 export const listUserDecksQueryOptions = (did: Did) => 55 82 queryOptions({ 56 83 queryKey: ["decks", did] as const, 57 - queryFn: async () => { 84 + queryFn: async (): Promise<{ records: DeckListRecord[] }> => { 58 85 const pds = await getPdsForDid(did); 59 86 const result = await listUserDecks(asPdsUrl(pds), did); 60 87 if (!result.success) { 61 88 throw result.error; 62 89 } 63 - return result.data; 90 + return { 91 + records: result.data.records.map((record) => ({ 92 + uri: record.uri, 93 + cid: record.cid, 94 + value: transformDeckRecord(record.value), 95 + })), 96 + }; 64 97 }, 65 98 staleTime: 60 * 1000, // 1 minute 66 99 }); ··· 85 118 name: deck.name, 86 119 format: deck.format, 87 120 primer: deck.primer, 88 - cards: deck.cards.map((card) => ({ 89 - ...card, 90 - scryfallId: card.scryfallId as string, 91 - })), 121 + cards: deck.cards.map((card) => { 122 + const { scryfallId, oracleId, ...rest } = card; 123 + return { 124 + ...rest, 125 + ref: { 126 + scryfallUri: toScryfallUri(scryfallId), 127 + oracleUri: toOracleUri(oracleId), 128 + }, 129 + }; 130 + }), 92 131 createdAt: new Date().toISOString(), 93 132 }); 94 133 ··· 138 177 name: deck.name, 139 178 format: deck.format, 140 179 primer: deck.primer, 141 - cards: deck.cards.map((card) => ({ 142 - ...card, 143 - scryfallId: card.scryfallId as string, 144 - })), 180 + cards: deck.cards.map((card) => { 181 + const { scryfallId, oracleId, ...rest } = card; 182 + return { 183 + ...rest, 184 + ref: { 185 + scryfallUri: toScryfallUri(scryfallId), 186 + oracleUri: toOracleUri(oracleId), 187 + }, 188 + }; 189 + }), 145 190 createdAt: deck.createdAt, 146 191 updatedAt: new Date().toISOString(), 147 192 });
+1
src/lib/deck-stats.test.ts
··· 28 28 function makeDeckCard(overrides: Partial<DeckCard> = {}): DeckCard { 29 29 return { 30 30 scryfallId: "test-id" as DeckCard["scryfallId"], 31 + oracleId: "test-oracle" as DeckCard["oracleId"], 31 32 quantity: 1, 32 33 section: "mainboard", 33 34 ...overrides,
+13 -3
src/lib/deck-types.ts
··· 4 4 */ 5 5 6 6 import type { ComDeckbelcherDeckList } from "./lexicons/index"; 7 - import type { Card, ManaColor, ScryfallId } from "./scryfall-types"; 7 + import type { Card, ManaColor, OracleId, ScryfallId } from "./scryfall-types"; 8 8 9 9 export type Section = "commander" | "mainboard" | "sideboard" | "maybeboard"; 10 10 11 - export type DeckCard = Omit<ComDeckbelcherDeckList.Card, "scryfallId"> & { 11 + /** 12 + * App-side card entry with flat typed IDs. 13 + * The lexicon stores ref.scryfallUri and ref.oracleUri as URIs, 14 + * but app code works with typed IDs after boundary parsing. 15 + */ 16 + export type DeckCard = Omit<ComDeckbelcherDeckList.Card, "ref"> & { 12 17 scryfallId: ScryfallId; 18 + oracleId: OracleId; 13 19 }; 14 20 15 21 export type Deck = Omit<ComDeckbelcherDeckList.Main, "cards"> & { ··· 73 79 export function addCardToDeck( 74 80 deck: Deck, 75 81 scryfallId: ScryfallId, 82 + oracleId: OracleId, 76 83 section: Section, 77 84 quantity = 1, 78 85 ): Deck { ··· 92 99 93 100 return { 94 101 ...deck, 95 - cards: [...deck.cards, { scryfallId, quantity, section, tags: [] }], 102 + cards: [ 103 + ...deck.cards, 104 + { scryfallId, oracleId, quantity, section, tags: [] }, 105 + ], 96 106 updatedAt: new Date().toISOString(), 97 107 }; 98 108 }
+2 -1
src/lib/goldfish/__tests__/engine.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import type { DeckCard } from "@/lib/deck-types"; 3 - import { asScryfallId } from "@/lib/scryfall-types"; 3 + import { asOracleId, asScryfallId } from "@/lib/scryfall-types"; 4 4 import { createSeededRng } from "@/lib/useSeededRandom"; 5 5 import { 6 6 addCounter, ··· 27 27 function mockDeck(count: number): DeckCard[] { 28 28 return Array.from({ length: count }, (_, i) => ({ 29 29 scryfallId: asScryfallId(`card-${i}`), 30 + oracleId: asOracleId(`oracle-${i}`), 30 31 quantity: 1, 31 32 section: "mainboard" as const, 32 33 tags: [],
+1
src/lib/lexicons/index.ts
··· 2 2 export * as ComDeckbelcherActorProfile from "./types/com/deckbelcher/actor/profile.js"; 3 3 export * as ComDeckbelcherCollectionList from "./types/com/deckbelcher/collection/list.js"; 4 4 export * as ComDeckbelcherDeckList from "./types/com/deckbelcher/deck/list.js"; 5 + export * as ComDeckbelcherDefs from "./types/com/deckbelcher/defs.js"; 5 6 export * as ComDeckbelcherRichtext from "./types/com/deckbelcher/richtext.js"; 6 7 export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js"; 7 8 export * as ComDeckbelcherSocialLike from "./types/com/deckbelcher/social/like.js";
+5 -2
src/lib/lexicons/types/com/deckbelcher/collection/list.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComDeckbelcherDefs from "../defs.js"; 4 5 import * as ComDeckbelcherRichtext from "../richtext.js"; 5 6 6 7 const _cardItemSchema = /*#__PURE__*/ v.object({ ··· 12 13 */ 13 14 addedAt: /*#__PURE__*/ v.datetimeString(), 14 15 /** 15 - * Scryfall UUID for the card. 16 + * Reference to the card (scryfall printing + oracle card). 16 17 */ 17 - scryfallId: /*#__PURE__*/ v.string(), 18 + get ref() { 19 + return ComDeckbelcherDefs.cardRefSchema; 20 + }, 18 21 }); 19 22 const _deckItemSchema = /*#__PURE__*/ v.object({ 20 23 $type: /*#__PURE__*/ v.optional(
+5 -2
src/lib/lexicons/types/com/deckbelcher/deck/list.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComDeckbelcherDefs from "../defs.js"; 4 5 import * as ComDeckbelcherRichtext from "../richtext.js"; 5 6 6 7 const _cardSchema = /*#__PURE__*/ v.object({ ··· 15 16 /*#__PURE__*/ v.integerRange(1), 16 17 ]), 17 18 /** 18 - * Scryfall UUID for the specific printing. 19 + * Reference to the card (scryfall printing + oracle card). 19 20 */ 20 - scryfallId: /*#__PURE__*/ v.string(), 21 + get ref() { 22 + return ComDeckbelcherDefs.cardRefSchema; 23 + }, 21 24 /** 22 25 * Which section of the deck this card belongs to. Extensible to support format-specific sections. 23 26 */
+24
src/lib/lexicons/types/com/deckbelcher/defs.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _cardRefSchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.deckbelcher.defs#cardRef"), 7 + ), 8 + /** 9 + * Oracle card URI (oracle:<uuid>) - for external indexing. Derived from scryfallUri; on conflict, scryfallUri takes precedence. 10 + */ 11 + oracleUri: /*#__PURE__*/ v.genericUriString(), 12 + /** 13 + * Scryfall printing URI (scry:<uuid>) - authoritative identifier 14 + */ 15 + scryfallUri: /*#__PURE__*/ v.genericUriString(), 16 + }); 17 + 18 + type cardRef$schematype = typeof _cardRefSchema; 19 + 20 + export interface cardRefSchema extends cardRef$schematype {} 21 + 22 + export const cardRefSchema = _cardRefSchema as cardRefSchema; 23 + 24 + export interface CardRef extends v.InferInput<typeof cardRefSchema> {}
+7 -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 + import * as ComDeckbelcherDefs from "../defs.js"; 3 4 4 5 const _boldSchema = /*#__PURE__*/ v.object({ 5 6 $type: /*#__PURE__*/ v.optional( ··· 23 24 $type: /*#__PURE__*/ v.optional( 24 25 /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#cardRef"), 25 26 ), 26 - scryfallId: /*#__PURE__*/ v.string(), 27 + /** 28 + * Reference to the card (scryfall printing + oracle card). 29 + */ 30 + get ref() { 31 + return ComDeckbelcherDefs.cardRefSchema; 32 + }, 27 33 }); 28 34 const _codeSchema = /*#__PURE__*/ v.object({ 29 35 $type: /*#__PURE__*/ v.optional(
+5 -11
src/lib/printing-selection.ts
··· 71 71 * Group deck cards by oracle ID. 72 72 * Returns a map of oracle ID to deck cards with that oracle. 73 73 */ 74 - async function groupCardsByOracle( 75 - deck: Deck, 76 - provider: CardDataProvider, 77 - ): Promise<Map<OracleId, DeckCard[]>> { 74 + function groupCardsByOracle(deck: Deck): Map<OracleId, DeckCard[]> { 78 75 const byOracle = new Map<OracleId, DeckCard[]>(); 79 76 80 77 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]); 78 + const existing = byOracle.get(card.oracleId) ?? []; 79 + byOracle.set(card.oracleId, [...existing, card]); 86 80 } 87 81 88 82 return byOracle; ··· 98 92 provider: CardDataProvider, 99 93 ): Promise<Map<ScryfallId, ScryfallId>> { 100 94 const updates = new Map<ScryfallId, ScryfallId>(); 101 - const byOracle = await groupCardsByOracle(deck, provider); 95 + const byOracle = groupCardsByOracle(deck); 102 96 103 97 for (const [oracleId, cards] of byOracle) { 104 98 const printingIds = await provider.getPrintingsByOracleId(oracleId); ··· 138 132 provider: CardDataProvider, 139 133 ): Promise<Map<ScryfallId, ScryfallId>> { 140 134 const updates = new Map<ScryfallId, ScryfallId>(); 141 - const byOracle = await groupCardsByOracle(deck, provider); 135 + const byOracle = groupCardsByOracle(deck); 142 136 143 137 for (const [oracleId, cards] of byOracle) { 144 138 const canonicalId = await provider.getCanonicalPrinting(oracleId);
+31 -11
src/lib/richtext-convert.ts
··· 17 17 Italic, 18 18 Link, 19 19 } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 20 + import { 21 + asOracleId, 22 + asScryfallId, 23 + parseOracleUri, 24 + parseScryfallUri, 25 + toOracleUri, 26 + toScryfallUri, 27 + } from "./scryfall-types"; 20 28 21 29 export type { LexiconDocument }; 22 30 ··· 263 271 } else if (child.type.name === "cardRef") { 264 272 const name = (child.attrs.name as string) || ""; 265 273 const scryfallId = (child.attrs.scryfallId as string) || ""; 274 + const oracleId = (child.attrs.oracleId as string) || ""; 266 275 const textBytes = new TextEncoder().encode(name); 267 276 268 277 textParts.push(name); 269 278 270 - if (scryfallId) { 279 + if (scryfallId && oracleId) { 271 280 facets.push({ 272 281 index: { 273 282 byteStart: byteOffset, ··· 276 285 features: [ 277 286 { 278 287 $type: "com.deckbelcher.richtext.facet#cardRef", 279 - scryfallId, 288 + ref: { 289 + scryfallUri: toScryfallUri(asScryfallId(scryfallId)), 290 + oracleUri: toOracleUri(asOracleId(oracleId)), 291 + }, 280 292 }, 281 293 ], 282 294 }); ··· 569 581 (f) => 570 582 (f as { $type?: string }).$type === 571 583 "com.deckbelcher.richtext.facet#cardRef", 572 - ) as { $type: string; scryfallId?: string } | undefined; 584 + ) as 585 + | { $type: string; ref?: { scryfallUri?: string; oracleUri?: string } } 586 + | undefined; 573 587 574 - if (cardRefFeature) { 575 - nodes.push( 576 - schema.nodes.cardRef.create({ 577 - name: segment.text, 578 - scryfallId: cardRefFeature.scryfallId || "", 579 - }), 580 - ); 581 - continue; 588 + if (cardRefFeature?.ref) { 589 + const scryfallId = parseScryfallUri(cardRefFeature.ref.scryfallUri || ""); 590 + const oracleId = parseOracleUri(cardRefFeature.ref.oracleUri || ""); 591 + // Only create cardRef node if both URIs are valid, otherwise fall through to plain text 592 + if (scryfallId && oracleId) { 593 + nodes.push( 594 + schema.nodes.cardRef.create({ 595 + name: segment.text, 596 + scryfallId, 597 + oracleId, 598 + }), 599 + ); 600 + continue; 601 + } 582 602 } 583 603 584 604 // Check for tag facet - these become inline nodes
+35
src/lib/scryfall-types.ts
··· 29 29 return id as OracleId; 30 30 } 31 31 32 + // URI scheme prefixes for external indexing 33 + const SCRYFALL_URI_PREFIX = "scry:" as const; 34 + const ORACLE_URI_PREFIX = "oracle:" as const; 35 + 36 + export type ScryfallUri = `${typeof SCRYFALL_URI_PREFIX}${string}`; 37 + export type OracleUri = `${typeof ORACLE_URI_PREFIX}${string}`; 38 + 39 + /** Convert a ScryfallId to a scry: URI */ 40 + export function toScryfallUri(id: ScryfallId): ScryfallUri { 41 + return `${SCRYFALL_URI_PREFIX}${id}`; 42 + } 43 + 44 + /** Convert an OracleId to an oracle: URI */ 45 + export function toOracleUri(id: OracleId): OracleUri { 46 + return `${ORACLE_URI_PREFIX}${id}`; 47 + } 48 + 49 + /** Parse a scry: URI and return the ScryfallId, or null if invalid */ 50 + export function parseScryfallUri(uri: string): ScryfallId | null { 51 + if (!uri.startsWith(SCRYFALL_URI_PREFIX)) { 52 + return null; 53 + } 54 + const id = uri.slice(SCRYFALL_URI_PREFIX.length); 55 + return isScryfallId(id) ? id : null; 56 + } 57 + 58 + /** Parse an oracle: URI and return the OracleId, or null if invalid */ 59 + export function parseOracleUri(uri: string): OracleId | null { 60 + if (!uri.startsWith(ORACLE_URI_PREFIX)) { 61 + return null; 62 + } 63 + const id = uri.slice(ORACLE_URI_PREFIX.length); 64 + return isOracleId(id) ? id : null; 65 + } 66 + 32 67 export type Rarity = 33 68 | "common" 34 69 | "uncommon"
+29 -2
src/lib/ufos-queries.ts
··· 5 5 6 6 import { queryOptions } from "@tanstack/react-query"; 7 7 import type { Result } from "./atproto-client"; 8 + import { transformListRecord } from "./collection-list-queries"; 9 + import { transformDeckRecord } from "./deck-queries"; 8 10 import type { 9 11 ComDeckbelcherCollectionList, 10 12 ComDeckbelcherDeckList, ··· 13 15 ActivityCollection, 14 16 UfosDeckRecord, 15 17 UfosListRecord, 18 + UfosRawDeckRecord, 19 + UfosRawListRecord, 16 20 UfosRecord, 17 21 } from "./ufos-types"; 18 22 ··· 62 66 return record.collection === "com.deckbelcher.collection.list"; 63 67 } 64 68 69 + function transformActivityRecord( 70 + rawRecord: UfosRecord<unknown>, 71 + ): ActivityRecord | null { 72 + if (rawRecord.collection === "com.deckbelcher.deck.list") { 73 + const deckRecord = rawRecord as UfosRawDeckRecord; 74 + return { 75 + ...deckRecord, 76 + record: transformDeckRecord(deckRecord.record), 77 + } as UfosDeckRecord; 78 + } 79 + if (rawRecord.collection === "com.deckbelcher.collection.list") { 80 + const listRecord = rawRecord as UfosRawListRecord; 81 + return { 82 + ...listRecord, 83 + record: transformListRecord(listRecord.record), 84 + } as UfosListRecord; 85 + } 86 + return null; 87 + } 88 + 65 89 /** 66 90 * Query options for recent activity (decks + lists) 67 91 */ 68 92 export const recentActivityQueryOptions = (limit = 10) => 69 93 queryOptions({ 70 94 queryKey: ["ufos", "recentActivity", limit] as const, 71 - queryFn: async () => { 95 + queryFn: async (): Promise<ActivityRecord[]> => { 72 96 const result = await fetchRecentRecords< 73 97 ComDeckbelcherDeckList.Main | ComDeckbelcherCollectionList.Main 74 98 >( ··· 78 102 if (!result.success) { 79 103 throw result.error; 80 104 } 81 - return result.data as ActivityRecord[]; 105 + 106 + return result.data 107 + .map(transformActivityRecord) 108 + .filter((r): r is ActivityRecord => r !== null); 82 109 }, 83 110 staleTime: 60 * 1000, 84 111 refetchOnWindowFocus: true,
+20 -4
src/lib/ufos-types.ts
··· 4 4 */ 5 5 6 6 import type { Did } from "@atcute/lexicons"; 7 + import type { CollectionList } from "./collection-list-types"; 8 + import type { Deck } from "./deck-types"; 7 9 import type { 8 10 ComDeckbelcherCollectionList, 9 11 ComDeckbelcherDeckList, ··· 22 24 } 23 25 24 26 /** 25 - * Deck record from UFOs API 27 + * Raw deck record from UFOs API (before boundary transformation) 26 28 */ 27 - export type UfosDeckRecord = UfosRecord<ComDeckbelcherDeckList.Main>; 29 + export type UfosRawDeckRecord = UfosRecord<ComDeckbelcherDeckList.Main>; 28 30 29 31 /** 30 - * Collection list record from UFOs API 32 + * Raw list record from UFOs API (before boundary transformation) 31 33 */ 32 - export type UfosListRecord = UfosRecord<ComDeckbelcherCollectionList.Main>; 34 + export type UfosRawListRecord = UfosRecord<ComDeckbelcherCollectionList.Main>; 35 + 36 + /** 37 + * Deck record with transformed app types 38 + */ 39 + export type UfosDeckRecord = UfosRecord<Deck> & { 40 + collection: "com.deckbelcher.deck.list"; 41 + }; 42 + 43 + /** 44 + * Collection list record with transformed app types 45 + */ 46 + export type UfosListRecord = UfosRecord<CollectionList> & { 47 + collection: "com.deckbelcher.collection.list"; 48 + }; 33 49 34 50 /** 35 51 * Supported collection NSIDs for the activity feed
+11 -4
src/routes/card/$id.tsx
··· 12 12 getCardPrintingsQueryOptions, 13 13 getVolatileDataQueryOptions, 14 14 } from "@/lib/queries"; 15 - import type { Card, CardFace, ScryfallId } from "@/lib/scryfall-types"; 15 + import type { 16 + Card, 17 + CardFace, 18 + OracleId, 19 + ScryfallId, 20 + } from "@/lib/scryfall-types"; 16 21 import { asOracleId, isScryfallId } from "@/lib/scryfall-types"; 17 22 import { getImageUri } from "@/lib/scryfall-utils"; 18 23 ··· 248 253 face={face} 249 254 primary={idx === 0} 250 255 cardId={idx === 0 ? id : undefined} 256 + oracleId={idx === 0 ? card.oracle_id : undefined} 251 257 /> 252 258 </div> 253 259 ))} ··· 438 444 face: CardFace; 439 445 primary?: boolean; 440 446 cardId?: ScryfallId; 447 + oracleId?: OracleId; 441 448 } 442 449 443 - function FaceInfo({ face, primary = false, cardId }: FaceInfoProps) { 450 + function FaceInfo({ face, primary = false, cardId, oracleId }: FaceInfoProps) { 444 451 const hasStats = face.power || face.toughness || face.loyalty || face.defense; 445 452 446 453 return ( ··· 464 471 /> 465 472 )} 466 473 </div> 467 - {cardId && ( 474 + {cardId && oracleId && ( 468 475 <SaveToListButton 469 - item={{ type: "card", scryfallId: cardId }} 476 + item={{ type: "card", scryfallId: cardId, oracleId }} 470 477 itemName={face.name} 471 478 /> 472 479 )}
+1
src/routes/profile/$did/deck/$rkey/bulk-edit.tsx
··· 133 133 134 134 const newCards: DeckCard[] = result.resolved.map((r) => ({ 135 135 scryfallId: r.scryfallId, 136 + oracleId: r.oracleId, 136 137 quantity: r.quantity, 137 138 section: activeSection, 138 139 tags: r.tags,
+9 -2
src/routes/profile/$did/deck/$rkey/index.tsx
··· 293 293 getCardByIdQueryOptions(cardId).queryKey, 294 294 ); 295 295 296 + if (!cardData?.oracle_id) { 297 + toast.error("Failed to add card: could not get card data"); 298 + return; 299 + } 300 + 296 301 await toast.promise( 297 - updateDeck((prev) => addCardToDeck(prev, cardId, "mainboard", 1)), 302 + updateDeck((prev) => 303 + addCardToDeck(prev, cardId, cardData.oracle_id, "mainboard", 1), 304 + ), 298 305 { 299 306 loading: "Adding card...", 300 - success: cardData ? `Added ${cardData.name}` : "Card added to deck", 307 + success: `Added ${cardData.name}`, 301 308 error: (err) => `Failed to add card: ${err.message}`, 302 309 }, 303 310 );
+6 -4
src/routes/profile/$did/index.tsx
··· 5 5 import { useMemo } from "react"; 6 6 import { DeckPreview } from "@/components/DeckPreview"; 7 7 import { ListPreview } from "@/components/ListPreview"; 8 - import type { DeckRecordResponse } from "@/lib/atproto-client"; 9 8 import { 10 9 listUserCollectionListsQueryOptions, 11 10 useCreateCollectionListMutation, 12 11 } from "@/lib/collection-list-queries"; 13 - import { listUserDecksQueryOptions } from "@/lib/deck-queries"; 12 + import { 13 + type DeckListRecord, 14 + listUserDecksQueryOptions, 15 + } from "@/lib/deck-queries"; 14 16 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 15 17 import { formatDisplayName } from "@/lib/format-utils"; 16 18 import { useAuth } from "@/lib/useAuth"; ··· 50 52 }); 51 53 52 54 function sortDecks( 53 - records: DeckRecordResponse[], 55 + records: DeckListRecord[], 54 56 sort: SortOption | undefined, 55 - ): DeckRecordResponse[] { 57 + ): DeckListRecord[] { 56 58 const sorted = [...records]; 57 59 58 60 switch (sort) {
+2 -14
src/routes/profile/$did/list/$rkey/index.tsx
··· 6 6 import { ErrorBoundary } from "react-error-boundary"; 7 7 import { CardImage } from "@/components/CardImage"; 8 8 import { ClientDate } from "@/components/ClientDate"; 9 - import { type DeckData, DeckPreview } from "@/components/DeckPreview"; 9 + import { DeckPreview } from "@/components/DeckPreview"; 10 10 import { ListActionsMenu } from "@/components/list/ListActionsMenu"; 11 11 import { RichtextSection } from "@/components/richtext/RichtextSection"; 12 12 import { asRkey, type Rkey } from "@/lib/atproto-client"; ··· 345 345 ); 346 346 } 347 347 348 - const deckData: DeckData = { 349 - name: deck.name, 350 - format: deck.format, 351 - cards: deck.cards.map((c) => ({ 352 - scryfallId: c.scryfallId as string, 353 - quantity: c.quantity, 354 - section: c.section, 355 - })), 356 - createdAt: deck.createdAt, 357 - updatedAt: deck.updatedAt, 358 - }; 359 - 360 348 return ( 361 349 <div className="flex items-center gap-4"> 362 350 <div className="flex-1"> 363 - <DeckPreview did={deckDid} rkey={deckRkey} deck={deckData} /> 351 + <DeckPreview did={deckDid} rkey={deckRkey} deck={deck} /> 364 352 </div> 365 353 {onRemove && ( 366 354 <button
+3 -2
typelex/collection-list.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 import "./richtext.tsp"; 4 + import "./defs.tsp"; 4 5 5 6 namespace com.deckbelcher.collection.list { 6 7 /** A curated list of cards and/or decks. */ ··· 29 30 30 31 /** A card saved to the list. */ 31 32 model CardItem { 32 - /** Scryfall UUID for the card. */ 33 + /** Reference to the card (scryfall printing + oracle card). */ 33 34 @required 34 - scryfallId: string; 35 + ref: com.deckbelcher.defs.CardRef; 35 36 36 37 /** Timestamp when this item was added to the list. */ 37 38 @required
+3 -2
typelex/deck-list.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 import "./richtext.tsp"; 4 + import "./defs.tsp"; 4 5 5 6 namespace com.deckbelcher.deck.list { 6 7 /** A Magic: The Gathering decklist. */ ··· 39 40 40 41 /** A card entry in a decklist. */ 41 42 model Card { 42 - /** Scryfall UUID for the specific printing. */ 43 + /** Reference to the card (scryfall printing + oracle card). */ 43 44 @required 44 - scryfallId: string; 45 + ref: com.deckbelcher.defs.CardRef; 45 46 46 47 /** Number of copies in the deck. */ 47 48 @required
+15
typelex/defs.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.deckbelcher.defs { 4 + /** Reference to a Magic: The Gathering card with printing and oracle identifiers. */ 5 + model CardRef { 6 + /** Scryfall printing URI (scry:<uuid>) - authoritative identifier */ 7 + @required 8 + scryfallUri: uri; 9 + 10 + /** Oracle card URI (oracle:<uuid>) - for external indexing. 11 + * Derived from scryfallUri; on conflict, scryfallUri takes precedence. */ 12 + @required 13 + oracleUri: uri; 14 + } 15 + }
+1
typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 + import "./defs.tsp"; 3 4 import "./richtext-facet.tsp"; 4 5 import "./richtext.tsp"; 5 6 import "./deck-list.tsp";
+4 -2
typelex/richtext-facet.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 + import "./defs.tsp"; 3 4 4 5 namespace com.deckbelcher.richtext.facet { 5 6 /** ··· 84 85 85 86 /** 86 87 * Facet feature for a card reference. 87 - * Links to a Magic: The Gathering card by Scryfall ID. 88 + * Links to a Magic: The Gathering card. 88 89 * The text is usually the card name. 89 90 */ 90 91 model CardRef { 92 + /** Reference to the card (scryfall printing + oracle card). */ 91 93 @required 92 - scryfallId: string; 94 + ref: com.deckbelcher.defs.CardRef; 93 95 } 94 96 }