Files
mtgc/docs/printing-tracking-design.md
Garrett Mills 214a535588 Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.

A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.

UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.

Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.

Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.

reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00

9.9 KiB

Design: optional per-printing tracking

Status: implemented. The as-built decisions are in As built at the end, which takes precedence over the original design below wherever the two differ.

Context

mtgc currently identifies every owned/decked card purely by oracle_id (one row + a quantity column in both library_cards and deck_cards). There is no way to record which printing a physical copy is, or to own multiple copies of the same card across different printings (e.g. one non-foil 2X2 copy and one foil PRNA promo copy of the same card).

Notably, the schema already anticipated this: library_card_copies (0001_init.sql) was defined at the very start of the project as "optional per-physical-copy printing detail (the 'hybrid' identity)" — but it has never been read or written by any code path. This document designs activating it.

This was split out from the "normal default card images" fix (see the ingest change in crates/mtgc-core/src/ingest.rs / db.rs that made the default image for a card come from Scryfall's representative Oracle Cards printing). That fix solves "the default preview looks weird"; this document solves "I want to track/display a specific printing for a specific copy or deck."

Decision from the requesting conversation: a tracked copy should record printing + finish (nonfoil/foil/etched). Condition (NM/LP/etc.) is deferred — the dormant table already has a condition column, so adding it later is a compatible follow-up, not a redesign.

Identity model — "hybrid"

  • library_cards stays authoritative for totals: oracle_id + quantity_owned. Every existing oracle-level feature (legality checks, deck analysis, synergy suggestions, over-assignment warnings) keeps working completely untouched.
  • library_card_copies becomes optional annotations: each row means "N of my copies of this card are this specific printing + finish." Copies are a subset of quantity_owned — a card can have some copies pinned to a printing and the rest left unspecified. The UI must surface that remainder (e.g. "3 owned · 1 tracked (2X2 foil) · 2 unspecified") rather than forcing every copy to be classified.
  • Decks get a per-card display preference — which printing's art to show for that deck slot — independent of quantity/legality. This is deliberately not tied to a specific owned copy; it's just "when I look at this deck, show me this version."

Schema / migrations

library.db (append-only migrations, per the existing convention in db.rs)

  • 0005_copy_quantity_finish.sql — add quantity INTEGER NOT NULL DEFAULT 1 to library_card_copies. Without this, the dormant table is one-row-per-copy, which is awkward for e.g. "4 copies, all the same 2X2 nonfoil printing." A quantity column lets one row represent a group of identical copies. (The finish column already exists in the original table; condition stays, unused for now.)
  • 0006_deck_card_printing.sql — add preferred_scryfall_id TEXT (nullable) to deck_cards. Pure display preference, no FK enforcement needed (printings live in the read-only, rebuildable reference.db, not library.db — a dangling reference after a re-ingest should just fall back to the default image, not error).

reference.db (edit reference_schema.sql directly — rebuilt, not migrated)

  • Add lang TEXT NOT NULL DEFAULT '' and released_at TEXT NOT NULL DEFAULT '' to printings, for picker labels ("English, 2X2, 2022-07-08") and stable chronological ordering. Both fields are already present on Scryfall printing objects, so load_printings just needs to read two more fields — no new ingest pass.

Core (mtgc-core)

  • New PrintingRef model: scryfall_id, set_code, collector_number, lang, finishes, rarity, image_uri, back_image_uri, price_usd, released_at.
  • Catalog::list_printings(oracle_id) -> Vec<PrintingRef>, ordered by (released_at DESC, rowid), for the picker UI.
  • Library copy CRUD in library.rs:
    • set_copy(oracle_id, scryfall_id, finish, quantity) — upsert a copy group.
    • list_copies(oracle_id) -> Vec<LibraryCardCopy>.
    • remove_copy(copy_id).
    • Validation: sum of copy quantities for an oracle_id must stay <= quantity_owned; reducing quantity_owned below the tracked sum should either clamp copies or surface a conflict — pick one and document it before implementing (leaning toward clamping the newest/most-recently-edited copy row, since silently deleting user-entered printing data is worse than losing precise quantity).
  • Deck: set_deck_card_printing(deck_id, oracle_id, scryfall_id | null).
  • Display resolution: extend the image-fallback chain added for the default-image fix so a deck card with preferred_scryfall_id set resolves its image from that specific printing first: COALESCE(<preferred printing image>, oc.image_uri, <old per-printing subquery>). No component changes needed — everything still just reads card.image_uri off the CardRef/DeckEntry it's given.

Commands + src/api.ts

  • Tauri commands: list_printings(oracle_id), set_library_copy(...), list_library_copies(oracle_id), remove_library_copy(copy_id), set_deck_card_printing(deck_id, oracle_id, scryfall_id).
  • src/api.ts: matching invoke wrappers; new PrintingRef and LibraryCardCopy TS interfaces; extend DeckEntry with an optional preferred_scryfall_id.

Frontend (src/)

  • New PrintingPicker modal (in components.tsx): given an oracle_id, fetch printings and render a thumbnail grid with set / language / finish / price labels; onPick(scryfallId).
  • DeckView (src/views/DeckView.tsx): a per-card "Choose printing" affordance opens the picker and calls set_deck_card_printing; the deck's thumbnail/preview then reflects that printing.
  • LibraryBrowser / CardEntry: a per-card "Versions" affordance to assign printing + finish + quantity to copies; show the specified-vs-unspecified breakdown next to the existing quantity stepper.

Open questions to settle before implementing

  1. Should the library list/tile thumbnail follow a pinned copy's printing (and if a card has multiple different tracked printings, which one wins?), or should the thumbnail always show the oracle default and per-copy art only appear in a detail/expanded view? Recommend the latter — simpler, and avoids an arbitrary tie-break in the common list view.
  2. Should decklist import/export (decklist.rs) round-trip printing preferences (many decklist formats support a (SET) 123 suffix)? Out of scope for the initial cut; note it as a natural follow-up once the core model exists.
  3. Quantity-vs-copies conflict resolution (see set_copy validation above) — decide the exact clamping behavior before writing the command, and add a unit test that pins it down.

Verification plan (once implemented)

  • Rust unit tests: copy CRUD + quantity clamping; preferred-printing image resolution (deck card with preferred_scryfall_id set vs. unset vs. dangling after a re-ingest).
  • Command round-trip smoke test through src-tauri.
  • Manual: assign a printing to a deck card and confirm the deck preview/thumbnail changes to that art; record two copies of one card under two different printings and confirm the library view shows the specified/unspecified breakdown correctly.

Critical files

crates/mtgc-core/src/sql/library/0005_*.sql, 0006_*.sql, crates/mtgc-core/src/sql/reference_schema.sql, crates/mtgc-core/src/ingest.rs, crates/mtgc-core/src/library.rs, crates/mtgc-core/src/decks.rs, crates/mtgc-core/src/model.rs, src-tauri/src/commands.rs, src/api.ts, src/components.tsx, src/views/DeckView.tsx, src/views/LibraryBrowser.tsx, src/views/CardEntry.tsx.

As built

Where the implementation settled the open questions, or departed from the design above:

  • Migration is 0006_printings.sql (0005 had gone to deck uuids). It adds library_card_copies.quantity, a unique index on (library_card_id, scryfall_id, finish), and deck_cards.preferred_scryfall_id. Everything lives in crates/mtgc-core/src/printings.rs.
  • No remove_copy(copy_id). set_library_copy(.., quantity) with quantity <= 0 removes a group, mirroring set_deck_card. Row ids are local and are rebuilt on every sync, so they would be a poor handle anyway.
  • Quantity rules (open question 3).
    • Grow: setting a group above the unspecified remainder raises quantity_owned to fit.
    • Clamp: lowering quantity_owned below the tracked total trims the highest-id (most recently added) groups first.
    • Lowering a group never lowers quantity_owned; those copies become unspecified.
    • All of this is pinned by tests in printings.rs.
  • Library thumbnails (open question 1) always show the oracle default. Only deck slots show a chosen printing.
  • Decklists (open question 2) round-trip in this first cut, as (SET) NUM, optionally followed by *F* / *E*. See decklist.rs.
  • UI. There is no per-row button. A Printings section in the click-preview info tile appears whenever the caller passes a PreviewContext, and it opens a PrintingPicker. Both are in src/components.tsx.
  • Sync, which postdates this design.
    • CardEntry.printings is keyed "<scryfall_id>/<finish>", and DeckCardEntry.printing holds the chosen printing.
    • Cards now merge field by field, and their groups merge per key, so edits to one card from two devices both survive. A merge result is clamped by trimming from the end of key order, which comes out the same on every device.
    • A document is written as version 2 only once it holds printing data, so older builds refuse it rather than dropping the data and syncing the loss.
  • Follow-up: choosing a printing while entering cards on Add Cards.