Add card USD pricing and a deck maybeboard

Prices come from Scryfall's prices.usd on printings, shown as a hover badge
on card tiles. The price is picked independently of whichever printing
supplies the display image (the cheapest known price across all printings),
since the display printing is often a promo/variant with no listed price.

The maybeboard lets a deck track cards under consideration without counting
them in deck analysis stats or library assignment totals. Cards move between
mainboard and maybeboard via a per-card toggle; maybeboarding the commander
clears commander status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:32:25 -05:00
parent e3f8a299de
commit cf9c930b02
14 changed files with 327 additions and 48 deletions

View File

@@ -14,6 +14,7 @@ export interface CardRef {
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C}
price_usd: number; // USD market price, or 0 if unknown
}
export interface LibraryCard extends CardRef {
@@ -26,6 +27,9 @@ export interface LibraryCard extends CardRef {
export interface DeckEntry extends CardRef {
quantity: number;
is_commander: boolean;
/** On the maybeboard: tracked against the deck but excluded from deck
* analysis stats and library assignment totals. */
is_maybeboard: boolean;
quantity_owned: number;
}
@@ -142,7 +146,8 @@ export const api = {
oracleId: string,
quantity: number,
isCommander = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }),
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
};

View File

@@ -49,6 +49,20 @@ const ICON_CLOSE = icon(
<line x1="6" y1="6" x2="18" y2="18" />
</>,
);
const ICON_HELP_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>,
);
const ICON_ARROW_LEFT_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 8 8 12 12 16" />
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
@@ -92,6 +106,8 @@ export const Icons = {
copy: ICON_COPY,
pencil: ICON_PENCIL,
close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
};
// --- Dialogs ------------------------------------------------------------
@@ -974,6 +990,11 @@ export function CardTile({
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
</div>
)}
</div>
{footer && <div className="tile-footer">{footer}</div>}
</div>

View File

@@ -1359,6 +1359,17 @@ tbody tr:hover {
.tile-selected .tile-corner-left {
opacity: 1;
}
.tile-corner-bottom-right {
position: absolute;
bottom: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-right,
.tile:focus-within .tile-corner-bottom-right {
opacity: 1;
}
.tile-selected .tile-img {
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
}

View File

@@ -23,6 +23,13 @@ import {
useViewMode,
} from "../components";
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
function boardToggleProps(e: DeckEntry) {
return e.is_maybeboard
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
}
type DeckDialog =
| { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck }
@@ -55,11 +62,12 @@ interface DeckStats {
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) — matches the usual "99 + commander" framing even for
* non-Commander decks, where it's just "everything but the flagged card".
* own hero display) and the maybeboard (cards under consideration, not
* actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/
function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander);
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
@@ -208,6 +216,8 @@ export function DeckView() {
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const [maybeQuery, setMaybeQuery] = useState("");
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
@@ -278,6 +288,13 @@ export function DeckView() {
return () => clearTimeout(t);
}, [anyQuery]);
useEffect(() => {
const q = maybeQuery.trim();
if (!q) return setMaybeResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
return () => clearTimeout(t);
}, [maybeQuery]);
useEffect(() => {
const q = cmdQuery.trim();
if (!q) return setCmdResults([]);
@@ -297,7 +314,7 @@ export function DeckView() {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false);
await api.setDeckCard(selected, card.oracle_id, qty, false, false);
setLibQuery("");
setLibResults([]);
setAnyQuery("");
@@ -305,10 +322,40 @@ export function DeckView() {
await loadContents();
}
async function addToMaybeboard(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false, true);
setMaybeQuery("");
setMaybeResults([]);
await loadContents();
}
/** Move a card between the mainboard and the maybeboard. Clears commander
* status (and the deck's commander, if this was it) when maybeboarding. */
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
if (selected === null) return;
const isCommander = isMaybeboard ? false : entry.is_commander;
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) {
await api.setDeckCommander(selected, null);
await loadDecks();
}
await loadContents();
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
const clamped = Math.max(0, qty);
await api.setDeckCard(selected, oracleId, clamped, false);
const existing = contents.find((e) => e.oracle_id === oracleId);
await api.setDeckCard(
selected,
oracleId,
clamped,
existing?.is_commander ?? false,
existing?.is_maybeboard ?? false,
);
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
// Re-adding during the grace period below cancels the pending removal.
@@ -408,10 +455,10 @@ export function DeckView() {
async function setCommander(card: CardRef) {
if (selected === null) return;
// Ensure the commander is in the deck, then flag it.
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
await api.setDeckCard(selected, card.oracle_id, 1, true);
}
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
// commander — covers both "not in the deck yet" and "was maybeboarded".
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
await api.setDeckCommander(selected, card.oracle_id);
setCmdQuery("");
setCmdResults([]);
@@ -426,8 +473,9 @@ export function DeckView() {
await loadDecks();
}
const owned = contents.filter((e) => e.quantity_owned > 0);
const unowned = contents.filter((e) => e.quantity_owned === 0);
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0);
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0);
const maybeboard = contents.filter((e) => e.is_maybeboard);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
@@ -458,7 +506,13 @@ export function DeckView() {
pending={pendingRemoval.has(e.oracle_id)}
/>
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
footer={
<>
{e.is_commander && <span className="tile-badge">★ commander</span>}
<span className="spacer" />
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</>
}
/>
))}
</div>
@@ -477,6 +531,7 @@ export function DeckView() {
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -510,6 +565,9 @@ export function DeckView() {
/>
</td>
<td>{e.quantity_owned}</td>
<td>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</td>
</tr>
))}
</tbody>
@@ -737,6 +795,23 @@ export function DeckView() {
/>
{cardsGroup(unowned)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
Maybeboard <span className="badge">{maybeboard.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Cards under consideration — not counted in deck analysis.
</p>
<CardSearchBox
placeholder="Search all cards to add one to the maybeboard…"
value={maybeQuery}
onChange={setMaybeQuery}
results={maybeResults}
onPick={addToMaybeboard}
/>
{cardsGroup(maybeboard)}
</section>
</>
)}
</div>