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.
This commit is contained in:
2026-10-16 21:06:17 -05:00
parent 1c5e045591
commit 214a535588
23 changed files with 2092 additions and 138 deletions

View File

@@ -1,6 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -169,7 +169,15 @@ function computeDeckStats(contents: DeckEntry[]): DeckStats {
}
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
function CommanderHero({
card,
onClear,
preview,
}: {
card: DeckEntry;
onClear: () => void;
preview?: PreviewContext;
}) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
@@ -179,7 +187,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card)}
onClick={() => open(card, preview)}
{...handlers(card)}
/>
) : (
@@ -740,6 +748,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
// Lets the click preview track copies and choose each slot's printing.
const preview: PreviewContext | undefined =
selected === null ? undefined : { deckId: selected, onChanged: loadContents };
function deckTiles(entries: DeckEntry[]) {
return (
<div className="tile-grid">
@@ -747,6 +759,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<CardTile
key={e.oracle_id}
card={e}
preview={preview}
selected={selectedCards.has(e.oracle_id)}
cornerLeft={
<span className="tile-select">
@@ -803,7 +816,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
/>
</td>
<td className="cell-thumb">
<CardThumb card={e} />
<CardThumb card={e} preview={preview} />
</td>
<td>
<ColorPips mask={e.color_identity} />
@@ -961,7 +974,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<div className="deck-top-grid">
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<div className="deck-top-commander">
<CommanderHero card={commanderEntry} onClear={clearCommander} />
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
</div>
)}

View File

@@ -1,6 +1,14 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
import {
api,
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
import {
CardThumb,
CardTile,
@@ -330,6 +338,9 @@ export function LibraryBrowser({
</span>
);
// Lets the click preview track which printings the copies are.
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
function libTable(cards: LibraryCard[]) {
return (
<table>
@@ -356,7 +367,7 @@ export function LibraryBrowser({
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} />
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
@@ -399,6 +410,7 @@ export function LibraryBrowser({
<CardTile
key={c.oracle_id}
card={c}
preview={preview}
selected={selected.has(c.oracle_id)}
cornerLeft={
<span className="tile-select">