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

@@ -16,6 +16,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0001_init.sql"),
include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.sql"),
include_str!("sql/library/0004_deck_maybeboard.sql"),
];
/// The attached alias under which the reference db is available at runtime.
@@ -34,6 +35,19 @@ pub fn image_subquery(column: &str) -> String {
)
}
/// Correlated subquery selecting the cheapest known USD price across *all*
/// printings of the oracle card aliased `oc` — deliberately independent of
/// [`image_subquery`]'s printing choice, since the printing that happens to
/// supply the display image (first by rowid) is often a promo/variant with no
/// listed price even when other printings of the same card do have one.
/// Returns NULL (read as 0.0) when no printing has a known price.
pub fn price_subquery() -> String {
format!(
"(SELECT MIN(p.price_usd) FROM {REF_DB}.printings p
WHERE p.oracle_id = oc.oracle_id AND p.price_usd > 0)"
)
}
/// Create (if needed) and open a reference.db, applying the reference schema.
/// Used by the ingester and by test fixtures.
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {

View File

@@ -70,8 +70,8 @@ impl Catalog {
)?;
let new_id = self.conn.last_insert_rowid();
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
SELECT ?1, oracle_id, quantity, is_commander FROM deck_cards WHERE deck_id = ?2",
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards WHERE deck_id = ?2",
params![new_id, id],
)?;
self.get_deck(new_id)
@@ -86,6 +86,7 @@ impl Catalog {
oracle_id: &str,
quantity: i64,
is_commander: bool,
is_maybeboard: bool,
) -> Result<()> {
if quantity <= 0 {
self.conn.execute(
@@ -95,12 +96,13 @@ impl Catalog {
return Ok(());
}
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
VALUES (?1, ?2, ?3, ?4)
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander",
params![deck_id, oracle_id, quantity, is_commander as i64],
is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard",
params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64],
)?;
Ok(())
}
@@ -112,14 +114,16 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price},
dc.is_maybeboard
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let rows = stmt
.query_map(params![deck_id], |r| {
@@ -138,7 +142,9 @@ impl Catalog {
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
produces_colorless: r.get::<_, i64>(12)? != 0,
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
},
is_maybeboard: r.get::<_, i64>(14)? != 0,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -211,7 +217,7 @@ mod tests {
cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
cat.set_deck_card(elves.id, "llanowar-elves", 4, false, false)
.unwrap();
// card_decks reports membership.
@@ -238,8 +244,8 @@ mod tests {
let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", None).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false, false).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
@@ -257,7 +263,7 @@ mod tests {
assert_eq!(d.format, "");
cat.set_deck_format(d.id, "Commander").unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
let d = cat.get_deck(d.id).unwrap().unwrap();
@@ -289,8 +295,8 @@ mod tests {
// serra-angel intentionally NOT owned.
let d = cat.create_deck("Mixed", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
assert_eq!(contents.len(), 2);
@@ -305,16 +311,49 @@ mod tests {
assert!(unowned[0].is_commander);
}
#[test]
fn maybeboard_cards_are_flagged_and_excluded_from_assignment() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
let d = cat.create_deck("Maybe Deck", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap(); // mainboard
cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap(); // maybeboard
let contents = cat.deck_contents(d.id).unwrap();
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!elves.is_maybeboard);
assert!(sol.is_maybeboard);
// Maybeboard membership doesn't count as an assigned copy, so owning
// exactly 1 Sol Ring and maybeboarding it isn't over-assignment.
let lib = cat.list_library().unwrap();
let sol_lib = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol_lib.quantity_assigned, 0);
assert!(!sol_lib.is_over_assigned());
// Flipping a card from maybeboard back to mainboard updates in place.
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!sol.is_maybeboard);
let lib = cat.list_library().unwrap();
let sol_lib = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol_lib.quantity_assigned, 1);
}
#[test]
fn setting_zero_removes_and_cascade_on_delete() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("Temp", None).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false, false).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 1, false, false).unwrap();
cat.delete_deck(d.id).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
}
@@ -341,8 +380,8 @@ mod tests {
let src = cat.create_deck("Original", Some("llanowar-elves")).unwrap();
cat.set_deck_format(src.id, "Commander").unwrap();
cat.set_deck_card(src.id, "llanowar-elves", 2, true).unwrap();
cat.set_deck_card(src.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(src.id, "llanowar-elves", 2, true, false).unwrap();
cat.set_deck_card(src.id, "lightning-bolt", 1, false, false).unwrap();
let copy = cat.duplicate_deck(src.id, "Original (copy)").unwrap();
assert_eq!(copy.name, "Original (copy)");

View File

@@ -19,12 +19,13 @@ impl Catalog {
let mut sql = format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
oc.produced_mana, oc.produces_colorless, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
);
let mut vals: Vec<Value> = Vec::new();
@@ -228,7 +229,7 @@ mod tests {
assert_eq!(bolt, vec!["Lightning Bolt"]);
let deck = cat.create_deck("Mono-R", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
let in_deck = names(&cat.search_library(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()

View File

@@ -612,6 +612,8 @@ struct ScryPrinting {
image_uris: Option<ImageUris>,
#[serde(default)]
card_faces: Option<Vec<PrintFace>>,
#[serde(default)]
prices: Option<ScryPrices>,
}
#[derive(Deserialize)]
@@ -620,6 +622,12 @@ struct ImageUris {
normal: Option<String>,
}
#[derive(Deserialize)]
struct ScryPrices {
#[serde(default)]
usd: Option<String>,
}
#[derive(Deserialize)]
struct PrintFace {
#[serde(default)]
@@ -632,8 +640,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
)?;
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
if cancel.is_cancelled() {
@@ -664,6 +672,12 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
.unwrap_or_default();
let price_usd = p
.prices
.as_ref()
.and_then(|pr| pr.usd.as_ref())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
ins.execute(params![
p.id,
oracle_id,
@@ -673,6 +687,7 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
p.rarity,
image,
back_image,
price_usd,
])?;
count += 1;
Ok(())
@@ -953,4 +968,34 @@ mod tests {
assert_eq!(front, "http://img/split-combined");
assert_eq!(back, "", "split cards have no distinct back face");
}
#[test]
fn captures_usd_price_defaulting_to_zero_when_absent() {
let printings = r#"[
{"id":"p-priced","oracle_id":"oid-priced","set":"m19","collector_number":"1",
"rarity":"common","prices":{"usd":"12.34","usd_foil":"20.00"}},
{"id":"p-null","oracle_id":"oid-null","set":"m19","collector_number":"2",
"rarity":"common","prices":{"usd":null}},
{"id":"p-noprices","oracle_id":"oid-noprices","set":"m19","collector_number":"3",
"rarity":"common"}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_printings(&conn, printings.as_bytes(), &CancelToken::new()).unwrap();
let fetch = |id: &str| -> f64 {
conn.query_row(
"SELECT price_usd FROM printings WHERE scryfall_id=?1",
params![id],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(fetch("p-priced"), 12.34);
assert_eq!(fetch("p-null"), 0.0);
assert_eq!(fetch("p-noprices"), 0.0);
}
}

View File

@@ -11,8 +11,8 @@ use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`]
/// and the criteria search.
/// produces_colorless, price_usd)` into its library parts. Shared by
/// [`Catalog::list_library`] and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
@@ -32,6 +32,7 @@ pub(crate) fn row_to_library_parts(
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
},
))
}
@@ -74,13 +75,14 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
oc.produced_mana, oc.produces_colorless, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let rows = stmt
.query_map([], row_to_library_parts)?
@@ -199,9 +201,12 @@ impl Catalog {
}
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
let mut stmt = self
.conn
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?;
// Maybeboard cards aren't actually committed to a deck, so they
// shouldn't count against a card's owned copies.
let mut stmt = self.conn.prepare(
"SELECT oracle_id, SUM(quantity) FROM deck_cards
WHERE is_maybeboard = 0 GROUP BY oracle_id",
)?;
let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
@@ -292,6 +297,54 @@ mod tests {
assert_eq!(elves.card.back_image_uri, "");
}
#[test]
fn price_usd_is_plumbed_through() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.connection()
.execute(
"UPDATE ref.printings SET price_usd = 12.34 WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
cat.add_owned("sol-ring", 1).unwrap();
cat.add_owned("llanowar-elves", 1).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.price_usd, 12.34);
// Fixture cards default to no known price.
let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap();
assert_eq!(elves.card.price_usd, 0.0);
}
/// Regression test: a card whose *displayed* printing (the one supplying
/// `image_uri`) has no known price shouldn't show as priceless if a
/// different printing does — e.g. a promo/showcase variant with no
/// listed USD price, while the regular printing has one.
#[test]
fn price_uses_cheapest_available_printing_even_if_display_printing_has_none() {
let fx = test_support::make();
let cat = &fx.catalog;
// The fixture's only printing ("print-sol-ring") supplies the image
// and has no price. Add a second, imageless printing with a price.
cat.connection()
.execute(
"INSERT INTO ref.printings (scryfall_id, oracle_id, price_usd)
VALUES ('print-sol-ring-promo', 'sol-ring', 5.67)",
[],
)
.unwrap();
cat.add_owned("sol-ring", 1).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.price_usd, 5.67);
// Image still comes from the original (imaged) printing.
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn location_is_stored_and_listed() {
let fx = test_support::make();

View File

@@ -99,6 +99,9 @@ pub struct CardRef {
/// `produced_mana` including "C"), tracked separately since colorless
/// isn't a WUBRG bit.
pub produces_colorless: bool,
/// USD market price (Scryfall's `prices.usd`) for the same printing that
/// supplied `image_uri`, or 0 if unknown.
pub price_usd: f64,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
@@ -125,6 +128,10 @@ pub struct DeckEntry {
/// Copies of this card in the deck.
pub quantity: i64,
pub is_commander: bool,
/// On the maybeboard: tracked against the deck for consideration, but
/// excluded from deck analysis stats and library assignment totals —
/// it isn't actually part of the deck.
pub is_maybeboard: bool,
/// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64,
}

View File

@@ -34,10 +34,11 @@ impl NameIndex {
pub fn build(conn: &Connection) -> Result<Self> {
let mut stmt = conn.prepare(&format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price}
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let entries = stmt
.query_map([], |r| {
@@ -53,6 +54,7 @@ impl NameIndex {
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
produces_colorless: r.get::<_, i64>(9)? != 0,
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
},
})
})?

View File

@@ -0,0 +1,4 @@
-- v4 — maybeboard: cards tracked against a deck for consideration but not
-- counted as part of it (excluded from deck analysis stats and from
-- library assignment totals).
ALTER TABLE deck_cards ADD COLUMN is_maybeboard INTEGER NOT NULL DEFAULT 0;

View File

@@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS printings (
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
rarity TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT '',
back_image_uri TEXT NOT NULL DEFAULT '' -- transform/MDFC/reversible back face
back_image_uri TEXT NOT NULL DEFAULT '', -- transform/MDFC/reversible back face
price_usd REAL NOT NULL DEFAULT 0 -- Scryfall "prices.usd", 0 if unknown
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);

View File

@@ -207,10 +207,11 @@ pub fn set_deck_card(
oracle_id: String,
quantity: i64,
is_commander: bool,
is_maybeboard: bool,
) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_card(deck_id, &oracle_id, quantity, is_commander)
.set_deck_card(deck_id, &oracle_id, quantity, is_commander, is_maybeboard)
.map_err(|e| e.to_string())
}

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>