Deck status (Idea/Building/Built/Retired) and pulled-card tracking

Only building and built decks now claim owned copies, so several idea
decks can share one copy without an over-assignment warning. Deck cards
track how many copies are physically pulled: physical decks list cards
to pull with their source (a storage box, or another deck to take a copy
from), and idea decks split cards into available / in other decks / not
owned. The deck list shows status chips with to-pull counts, filters by
status, and collapses retired decks. Status and pulled counts sync,
which bumps the library document to version 4.

Existing decks migrate as ideas.

Closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:35:41 +00:00
parent eb342f5fbb
commit 99a579a697
20 changed files with 1087 additions and 103 deletions

View File

@@ -20,6 +20,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0005_deck_uuid.sql"), include_str!("sql/library/0005_deck_uuid.sql"),
include_str!("sql/library/0006_printings.sql"), include_str!("sql/library/0006_printings.sql"),
include_str!("sql/library/0007_deck_cover.sql"), include_str!("sql/library/0007_deck_cover.sql"),
include_str!("sql/library/0008_deck_status.sql"),
]; ];
/// The attached alias under which the reference db is available at runtime. /// The attached alias under which the reference db is available at runtime.

View File

@@ -877,6 +877,7 @@ mod tests {
commander_oracle_id: None, commander_oracle_id: None,
notes: String::new(), notes: String::new(),
cover_oracle_id: None, cover_oracle_id: None,
status: Default::default(),
}; };
let text = format_deck_text(&deck, &[], &HashMap::new()); let text = format_deck_text(&deck, &[], &HashMap::new());
assert_eq!(text, "// mtgc deck export\n"); assert_eq!(text, "// mtgc deck export\n");

View File

@@ -6,7 +6,9 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB; use crate::db::REF_DB;
use crate::error::Result; use crate::error::Result;
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities}; use crate::model::{
CardRef, ColorSet, Deck, DeckEntry, DeckHolding, DeckStatus, Legalities, PHYSICAL_STATUSES_SQL,
};
use crate::partner::{pairs_with, Partner}; use crate::partner::{pairs_with, Partner};
use crate::Catalog; use crate::Catalog;
@@ -29,7 +31,7 @@ impl Catalog {
Ok(self Ok(self
.conn .conn
.query_row( .query_row(
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks WHERE id = ?1", "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id, status FROM decks WHERE id = ?1",
params![id], params![id],
Self::map_deck, Self::map_deck,
) )
@@ -38,7 +40,7 @@ impl Catalog {
pub fn list_decks(&self) -> Result<Vec<Deck>> { pub fn list_decks(&self) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id, status
FROM decks ORDER BY name COLLATE NOCASE", FROM decks ORDER BY name COLLATE NOCASE",
)?; )?;
let rows = stmt let rows = stmt
@@ -64,7 +66,8 @@ impl Catalog {
/// Duplicate a deck under a new name: same format/commander/notes/cover, plus a /// Duplicate a deck under a new name: same format/commander/notes/cover, plus a
/// copy of every deck_cards row (quantity, flags, chosen printing). Fails if /// copy of every deck_cards row (quantity, flags, chosen printing). Fails if
/// `new_name` is already taken. /// `new_name` is already taken. The copy starts as an idea: it's a new list,
/// not a second set of physical cards.
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> { pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
let src = self let src = self
.get_deck(id)? .get_deck(id)?
@@ -89,7 +92,8 @@ impl Catalog {
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes /// Set how many copies of a card belong to a deck. `quantity <= 0` removes
/// the card from the deck. Upserts on (deck_id, oracle_id), leaving any /// the card from the deck. Upserts on (deck_id, oracle_id), leaving any
/// chosen printing in place. /// chosen printing in place. Pulled copies beyond the new quantity (all of
/// them, on the maybeboard) go back to the box.
pub fn set_deck_card( pub fn set_deck_card(
&self, &self,
deck_id: i64, deck_id: i64,
@@ -111,7 +115,9 @@ impl Catalog {
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity, quantity = excluded.quantity,
is_commander = excluded.is_commander, is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard", is_maybeboard = excluded.is_maybeboard,
quantity_pulled = CASE WHEN excluded.is_maybeboard THEN 0
ELSE MIN(deck_cards.quantity_pulled, excluded.quantity) END",
params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64], params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64],
)?; )?;
Ok(()) Ok(())
@@ -153,7 +159,7 @@ impl Catalog {
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END, CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
oc.produced_mana, oc.produces_colorless, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id, oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
oc.oracle_text, dc.deck_id oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, '')
FROM deck_cards dc FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id 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 LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
@@ -192,17 +198,172 @@ impl Catalog {
is_maybeboard: r.get::<_, i64>(15)? != 0, is_maybeboard: r.get::<_, i64>(15)? != 0,
preferred_scryfall_id: r.get(17)?, preferred_scryfall_id: r.get(17)?,
partner, partner,
quantity_pulled: r.get(20)?,
location: r.get(21)?,
quantity_spare: 0,
quantity_in_box: 0,
claimed_by: Vec::new(),
}; };
Ok((r.get(19)?, entry)) Ok((r.get(19)?, entry))
})? })?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
// Fill in where each card's copies physically are, relative to the
// deck it's listed in.
let holdings = self.physical_holdings()?;
let mut rows = rows;
for (deck_id, e) in &mut rows {
let held = holdings.get(&e.card.oracle_id).map(Vec::as_slice).unwrap_or_default();
let others = held.iter().filter(|h| h.deck_id != *deck_id);
let committed_elsewhere: i64 = others.clone().map(|h| h.quantity).sum();
let pulled_total: i64 = held.iter().map(|h| h.pulled).sum();
e.quantity_spare = (e.quantity_owned - committed_elsewhere).max(0);
e.quantity_in_box = (e.quantity_owned - pulled_total).max(0);
e.claimed_by = others.cloned().collect();
}
Ok(rows) Ok(rows)
} }
/// Every building/built deck's main-board claim on each card, keyed by
/// oracle id.
fn physical_holdings(&self) -> Result<HashMap<String, Vec<DeckHolding>>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.oracle_id, d.id, d.name, dc.quantity, dc.quantity_pulled
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
WHERE dc.is_maybeboard = 0 AND d.status IN {PHYSICAL_STATUSES_SQL}
ORDER BY d.name COLLATE NOCASE"
))?;
let mut map: HashMap<String, Vec<DeckHolding>> = HashMap::new();
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
DeckHolding { deck_id: r.get(1)?, name: r.get(2)?, quantity: r.get(3)?, pulled: r.get(4)? },
))
})?;
for row in rows {
let (oid, h) = row?;
map.entry(oid).or_default().push(h);
}
Ok(map)
}
/// Owned copies of a card not pulled into any deck.
fn copies_in_box(&self, oracle_id: &str) -> Result<i64> {
Ok(self.conn.query_row(
&format!(
"SELECT COALESCE((SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1), 0)
- COALESCE((SELECT SUM(dc.quantity_pulled) FROM deck_cards dc
JOIN decks d ON d.id = dc.deck_id
WHERE dc.oracle_id = ?1 AND d.status IN {PHYSICAL_STATUSES_SQL}), 0)"
),
params![oracle_id],
|r| r.get::<_, i64>(0),
)?
.max(0))
}
/// Change a deck's status. Leaving building/built puts every pulled copy
/// back in its box. Entering it with `assembled` records the deck as
/// already put together: each card is marked pulled as far as the copies
/// in the box allow.
pub fn set_deck_status(&self, deck_id: i64, status: DeckStatus, assembled: bool) -> Result<()> {
let tx = self.nested_tx()?;
self.conn.execute(
"UPDATE decks SET status = ?1 WHERE id = ?2",
params![status.as_str(), deck_id],
)?;
if !status.is_physical() {
self.conn.execute(
"UPDATE deck_cards SET quantity_pulled = 0 WHERE deck_id = ?1",
params![deck_id],
)?;
} else if assembled {
let cards: Vec<(String, i64, i64)> = {
let mut stmt = self.conn.prepare(
"SELECT oracle_id, quantity, quantity_pulled FROM deck_cards
WHERE deck_id = ?1 AND is_maybeboard = 0",
)?;
let rows = stmt.query_map(params![deck_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect::<std::result::Result<_, _>>()?
};
for (oid, quantity, pulled) in cards {
let pulled = quantity.min(pulled + self.copies_in_box(&oid)?);
self.conn.execute(
"UPDATE deck_cards SET quantity_pulled = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
params![pulled, deck_id, oid],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Record how many copies of a card are physically in a building/built
/// deck. Clamped to the deck's quantity, and an increase to the copies
/// actually in the box. Returns the count stored.
pub fn set_deck_card_pulled(&self, deck_id: i64, oracle_id: &str, pulled: i64) -> Result<i64> {
let tx = self.nested_tx()?;
let row: Option<(i64, i64, String)> = self
.conn
.query_row(
"SELECT dc.quantity, dc.quantity_pulled, d.status FROM deck_cards dc
JOIN decks d ON d.id = dc.deck_id
WHERE dc.deck_id = ?1 AND dc.oracle_id = ?2 AND dc.is_maybeboard = 0",
params![deck_id, oracle_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let Some((quantity, current, status)) = row else {
return Ok(0);
};
if !DeckStatus::parse(&status).is_physical() {
return Ok(0);
}
let mut pulled = pulled.clamp(0, quantity);
if pulled > current {
pulled = pulled.min(current + self.copies_in_box(oracle_id)?);
}
self.conn.execute(
"UPDATE deck_cards SET quantity_pulled = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
params![pulled, deck_id, oracle_id],
)?;
tx.commit()?;
Ok(pulled)
}
/// Move one pulled copy of a card from one deck to another, leaving a
/// copy to pull in the deck it came from. A no-op (returning false)
/// unless the source has a copy pulled and the destination is short one.
pub fn take_deck_card_copy(&self, from_deck: i64, to_deck: i64, oracle_id: &str) -> Result<bool> {
if from_deck == to_deck {
return Ok(false);
}
let tx = self.nested_tx()?;
let taken = self.conn.execute(
"UPDATE deck_cards SET quantity_pulled = quantity_pulled - 1
WHERE deck_id = ?1 AND oracle_id = ?2 AND quantity_pulled > 0",
params![from_deck, oracle_id],
)?;
let given = self.conn.execute(
&format!(
"UPDATE deck_cards SET quantity_pulled = quantity_pulled + 1
WHERE deck_id = ?1 AND oracle_id = ?2 AND is_maybeboard = 0
AND quantity_pulled < quantity
AND (SELECT status FROM decks WHERE id = ?1) IN {PHYSICAL_STATUSES_SQL}"
),
params![to_deck, oracle_id],
)?;
if taken == 0 || given == 0 {
return Ok(false); // dropping `tx` rolls back the half that applied
}
tx.commit()?;
Ok(true)
}
/// The decks a given card is currently assigned to. /// The decks a given card is currently assigned to.
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> { pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id "SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id, d.status
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
WHERE dc.oracle_id = ?1 WHERE dc.oracle_id = ?1
ORDER BY d.name COLLATE NOCASE", ORDER BY d.name COLLATE NOCASE",
@@ -221,6 +382,7 @@ impl Catalog {
commander_oracle_id: r.get(3)?, commander_oracle_id: r.get(3)?,
notes: r.get(4)?, notes: r.get(4)?,
cover_oracle_id: r.get(5)?, cover_oracle_id: r.get(5)?,
status: DeckStatus::parse(&r.get::<_, String>(6)?),
}) })
} }
@@ -323,6 +485,7 @@ mod tests {
cat.add_owned("llanowar-elves", 4).unwrap(); cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap(); let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
cat.set_deck_status(elves.id, crate::model::DeckStatus::Built, false).unwrap();
cat.set_deck_card(elves.id, "llanowar-elves", 4, false, false) cat.set_deck_card(elves.id, "llanowar-elves", 4, false, false)
.unwrap(); .unwrap();
@@ -377,6 +540,8 @@ mod tests {
let a = cat.create_deck("Deck A", None).unwrap(); let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", None).unwrap(); let b = cat.create_deck("Deck B", None).unwrap();
cat.set_deck_status(a.id, crate::model::DeckStatus::Built, false).unwrap();
cat.set_deck_status(b.id, crate::model::DeckStatus::Building, false).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false, 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(); cat.set_deck_card(b.id, "sol-ring", 1, false, false).unwrap();
@@ -451,6 +616,7 @@ mod tests {
cat.add_owned("sol-ring", 1).unwrap(); cat.add_owned("sol-ring", 1).unwrap();
let d = cat.create_deck("Maybe Deck", None).unwrap(); let d = cat.create_deck("Maybe Deck", None).unwrap();
cat.set_deck_status(d.id, crate::model::DeckStatus::Built, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap(); // mainboard 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 cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap(); // maybeboard
@@ -661,4 +827,132 @@ mod tests {
.collect(); .collect();
assert_eq!(found, vec!["llanowar-elves"], "same variant only, and not itself"); assert_eq!(found, vec!["llanowar-elves"], "same variant only, and not itself");
} }
#[test]
fn idea_and_retired_decks_claim_no_copies() {
use crate::model::DeckStatus;
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
// The report this exists for: one copy, several decks still on paper.
let a = cat.create_deck("Idea A", None).unwrap();
let b = cat.create_deck("Idea B", None).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 sol = |cat: &crate::Catalog| {
cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == "sol-ring").unwrap()
};
assert_eq!(sol(cat).quantity_assigned, 0);
assert!(sol(cat).physical_decks.is_empty());
let in_b = |cat: &crate::Catalog| cat.deck_contents(b.id).unwrap().remove(0);
assert_eq!(in_b(cat).quantity_spare, 1);
// Building A claims the copy, so B no longer has one spare.
cat.set_deck_status(a.id, DeckStatus::Built, false).unwrap();
assert_eq!(sol(cat).quantity_assigned, 1);
assert_eq!(sol(cat).physical_decks, vec!["Idea A".to_string()]);
assert_eq!(in_b(cat).quantity_spare, 0);
// Retiring it releases the claim again.
cat.set_deck_status(a.id, DeckStatus::Retired, false).unwrap();
assert_eq!(sol(cat).quantity_assigned, 0);
assert_eq!(in_b(cat).quantity_spare, 1);
}
#[test]
fn status_changes_fill_and_clear_pulled_copies() {
use crate::model::DeckStatus;
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
cat.add_owned("llanowar-elves", 2).unwrap();
let d = cat.create_deck("Deck", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false, false).unwrap(); // only 2 owned
cat.set_deck_card(d.id, "serra-angel", 1, false, false).unwrap(); // not owned
cat.set_deck_card(d.id, "lightning-bolt", 1, false, true).unwrap(); // maybeboard
let pulled = |cat: &crate::Catalog, oid: &str| {
cat.deck_contents(d.id).unwrap().into_iter().find(|e| e.card.oracle_id == oid).unwrap().quantity_pulled
};
// Starting to build pulls nothing yet.
cat.set_deck_status(d.id, DeckStatus::Building, false).unwrap();
assert_eq!(pulled(cat, "sol-ring"), 0);
// "Already assembled" pulls as far as owned copies allow.
cat.set_deck_status(d.id, DeckStatus::Built, true).unwrap();
assert_eq!(pulled(cat, "sol-ring"), 1);
assert_eq!(pulled(cat, "llanowar-elves"), 2);
assert_eq!(pulled(cat, "serra-angel"), 0);
assert_eq!(pulled(cat, "lightning-bolt"), 0);
let elves = cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap();
assert_eq!(elves.quantity_in_decks, 2);
// Lowering the quantity clamps the pulled count; maybeboarding clears it.
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
assert_eq!(pulled(cat, "llanowar-elves"), 1);
cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap();
assert_eq!(pulled(cat, "sol-ring"), 0);
// Retiring puts everything back in the box.
cat.set_deck_status(d.id, DeckStatus::Retired, false).unwrap();
assert_eq!(pulled(cat, "llanowar-elves"), 0);
}
#[test]
fn pulling_is_limited_to_copies_in_the_box_and_can_take_from_another_deck() {
use crate::model::DeckStatus;
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
let a = cat.create_deck("A", None).unwrap();
let b = cat.create_deck("B", None).unwrap();
for d in [&a, &b] {
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_status(d.id, DeckStatus::Building, false).unwrap();
}
// A pulls the only copy; B can't pull a copy that isn't in the box.
assert_eq!(cat.set_deck_card_pulled(a.id, "sol-ring", 1).unwrap(), 1);
assert_eq!(cat.set_deck_card_pulled(b.id, "sol-ring", 1).unwrap(), 0);
let entry = |id: i64| cat.deck_contents(id).unwrap().remove(0);
assert_eq!(entry(b.id).quantity_in_box, 0);
assert_eq!(entry(b.id).claimed_by.len(), 1);
assert_eq!(entry(b.id).claimed_by[0].name, "A");
assert_eq!(entry(b.id).claimed_by[0].pulled, 1);
assert_eq!(entry(a.id).location, "");
// B takes it from A, which is left with a copy to pull.
assert!(cat.take_deck_card_copy(a.id, b.id, "sol-ring").unwrap());
assert_eq!(entry(a.id).quantity_pulled, 0);
assert_eq!(entry(b.id).quantity_pulled, 1);
// B is already full, and A has nothing left to give.
assert!(!cat.take_deck_card_copy(a.id, b.id, "sol-ring").unwrap());
assert_eq!(entry(b.id).quantity_pulled, 1);
// Idea decks can't hold pulled copies at all.
cat.set_deck_status(a.id, DeckStatus::Idea, false).unwrap();
assert!(!cat.take_deck_card_copy(b.id, a.id, "sol-ring").unwrap());
assert_eq!(entry(b.id).quantity_pulled, 1, "a failed move leaves the source alone");
assert_eq!(cat.set_deck_card_pulled(a.id, "sol-ring", 1).unwrap(), 0);
}
#[test]
fn duplicates_start_as_ideas() {
use crate::model::DeckStatus;
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
let d = cat.create_deck("Built", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_status(d.id, DeckStatus::Built, true).unwrap();
let copy = cat.duplicate_deck(d.id, "Copy").unwrap();
assert_eq!(copy.status, DeckStatus::Idea);
assert_eq!(cat.deck_contents(copy.id).unwrap()[0].quantity_pulled, 0);
}
} }

View File

@@ -195,31 +195,38 @@ fn push_card_filters(
} }
} }
/// `spares_only`: more copies owned than the decks' main boards use. Both /// `spares_only`: more copies owned than the building/built decks' main boards
/// searches alias the library row `lc` (LEFT JOINed by the reference search, /// use. Both searches alias the library row `lc` (LEFT JOINed by the reference
/// hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`], which /// search, hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`],
/// is what the over-assigned ⚠ is computed from. /// which is what the over-assigned ⚠ is computed from.
const SPARES_PREDICATE: &str = " AND COALESCE(lc.quantity_owned, 0) > COALESCE( const SPARES_PREDICATE: &str = " AND COALESCE(lc.quantity_owned, 0) > COALESCE(
(SELECT SUM(dc.quantity) FROM deck_cards dc (SELECT SUM(dc.quantity) FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0), 0)"; WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0
AND d.status IN ('building', 'built')), 0)";
/// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck /// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck
/// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`], /// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`],
/// and [`Catalog::list_library`]. /// and [`Catalog::list_library`].
fn enrich_with_deck_context( pub(crate) fn enrich_with_deck_context(
cat: &Catalog, cat: &Catalog,
rows: Vec<(String, i64, String, crate::model::CardRef)>, rows: Vec<(String, i64, String, crate::model::CardRef)>,
) -> Result<Vec<LibraryCard>> { ) -> Result<Vec<LibraryCard>> {
let deck_names = cat.deck_names_by_oracle()?; let deck_names = cat.deck_names_by_oracle()?;
let assigned = cat.assigned_totals_by_oracle()?; let assigned = cat.assigned_totals_by_oracle()?;
let pulled = cat.pulled_totals_by_oracle()?;
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(oid, qty, location, card)| LibraryCard { .map(|(oid, qty, location, card)| {
quantity_owned: qty, let decks = deck_names.get(&oid).map(Vec::as_slice).unwrap_or_default();
decks: deck_names.get(&oid).cloned().unwrap_or_default(), LibraryCard {
quantity_assigned: *assigned.get(&oid).unwrap_or(&0), quantity_owned: qty,
location, decks: decks.iter().map(|(name, _)| name.clone()).collect(),
card, physical_decks: decks.iter().filter(|(_, p)| *p).map(|(name, _)| name.clone()).collect(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
quantity_in_decks: *pulled.get(&oid).unwrap_or(&0),
location,
card,
}
}) })
.collect()) .collect())
} }
@@ -933,6 +940,7 @@ mod tests {
cat.add_owned("serra-angel", 1).unwrap(); // in no deck → spare cat.add_owned("serra-angel", 1).unwrap(); // in no deck → spare
cat.add_owned("llanowar-elves", 1).unwrap(); // only on a maybeboard → spare cat.add_owned("llanowar-elves", 1).unwrap(); // only on a maybeboard → spare
let deck = cat.create_deck("Deck", None).unwrap(); let deck = cat.create_deck("Deck", None).unwrap();
cat.set_deck_status(deck.id, crate::model::DeckStatus::Built, false).unwrap();
cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap(); cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap(); cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
cat.set_deck_card(deck.id, "llanowar-elves", 1, false, true).unwrap(); cat.set_deck_card(deck.id, "llanowar-elves", 1, false, true).unwrap();

View File

@@ -30,8 +30,8 @@ use rusqlite::Connection;
pub use error::{CoreError, Result}; pub use error::{CoreError, Result};
pub use http::fetch_image; pub use http::fetch_image;
pub use model::{ pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy, CardRef, ColorSet, Deck, DeckEntry, DeckHolding, DeckStatus, LegalStatus, Legalities,
LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, LibraryCard, LibraryCopy, LibraryFacets, LibraryFilter, LibraryStats, PrintingRef,
}; };
pub use search::NameIndex; pub use search::NameIndex;
pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason}; pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason};

View File

@@ -6,7 +6,9 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB; use crate::db::REF_DB;
use crate::error::Result; use crate::error::Result;
use crate::model::{CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; use crate::model::{
CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, PHYSICAL_STATUSES_SQL, WUBRG,
};
use crate::Catalog; use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
@@ -78,9 +80,6 @@ impl Catalog {
/// List every owned card (quantity_owned > 0) with deck context, ordered by /// List every owned card (quantity_owned > 0) with deck context, ordered by
/// name. This is the "browse my library" view without extra criteria. /// name. This is the "browse my library" view without extra criteria.
pub fn list_library(&self) -> Result<Vec<LibraryCard>> { pub fn list_library(&self) -> Result<Vec<LibraryCard>> {
let deck_names = self.deck_names_by_oracle()?;
let assigned = self.assigned_totals_by_oracle()?;
let mut stmt = self.conn.prepare(&format!( let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location, "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.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
@@ -97,17 +96,7 @@ impl Catalog {
let rows = stmt let rows = stmt
.query_map([], row_to_library_parts)? .query_map([], row_to_library_parts)?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
crate::filters::enrich_with_deck_context(self, rows)
Ok(rows
.into_iter()
.map(|(oid, qty, location, card)| LibraryCard {
quantity_owned: qty,
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
location,
card,
})
.collect())
} }
/// Aggregate stats about the owned library, for the sidebar summary. /// Aggregate stats about the owned library, for the sidebar summary.
@@ -216,29 +205,44 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Map each owned/assigned oracle_id to the deck names it appears in. /// Map each owned/assigned oracle_id to the deck names it appears in,
pub(crate) fn deck_names_by_oracle(&self) -> Result<HashMap<String, Vec<String>>> { /// each flagged with whether that deck physically exists.
let mut stmt = self.conn.prepare( pub(crate) fn deck_names_by_oracle(&self) -> Result<HashMap<String, Vec<(String, bool)>>> {
"SELECT dc.oracle_id, d.name let mut stmt = self.conn.prepare(&format!(
"SELECT dc.oracle_id, d.name, d.status IN {PHYSICAL_STATUSES_SQL}
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
ORDER BY d.name COLLATE NOCASE", ORDER BY d.name COLLATE NOCASE"
)?; ))?;
let mut map: HashMap<String, Vec<String>> = HashMap::new(); let mut map: HashMap<String, Vec<(String, bool)>> = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, bool>(2)?))
})?;
for row in rows { for row in rows {
let (oid, name) = row?; let (oid, name, physical) = row?;
map.entry(oid).or_default().push(name); map.entry(oid).or_default().push((name, physical));
} }
Ok(map) Ok(map)
} }
/// Copies each card's building/built decks claim. Idea and retired decks
/// don't hold cards, and maybeboard cards aren't actually committed to a
/// deck, so neither counts against a card's owned copies.
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> { pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
// Maybeboard cards aren't actually committed to a deck, so they self.physical_totals_by_oracle("dc.quantity")
// shouldn't count against a card's owned copies. }
let mut stmt = self.conn.prepare(
"SELECT oracle_id, SUM(quantity) FROM deck_cards /// Copies of each card physically pulled into decks.
WHERE is_maybeboard = 0 GROUP BY oracle_id", pub(crate) fn pulled_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
)?; self.physical_totals_by_oracle("dc.quantity_pulled")
}
fn physical_totals_by_oracle(&self, column: &str) -> Result<HashMap<String, i64>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.oracle_id, SUM({column}) FROM deck_cards dc
JOIN decks d ON d.id = dc.deck_id
WHERE dc.is_maybeboard = 0 AND d.status IN {PHYSICAL_STATUSES_SQL}
GROUP BY dc.oracle_id"
))?;
let mut map = HashMap::new(); let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows { for row in rows {

View File

@@ -188,12 +188,19 @@ pub struct LibraryCard {
pub quantity_owned: i64, pub quantity_owned: i64,
/// Names of decks this card is currently assigned to. /// Names of decks this card is currently assigned to.
pub decks: Vec<String>, pub decks: Vec<String>,
/// Total copies assigned across all decks. If it exceeds `quantity_owned`, /// Copies claimed by building/built decks' main boards (idea and retired
/// the physical copies are over-committed. /// decks claim nothing). If it exceeds `quantity_owned`, the physical
/// copies are over-committed.
pub quantity_assigned: i64, pub quantity_assigned: i64,
/// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for /// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for
/// cards not currently in a deck. /// cards not currently in a deck.
pub location: String, pub location: String,
/// The subset of `decks` that physically exist (building or built).
#[serde(default)]
pub physical_decks: Vec<String>,
/// Copies physically pulled into decks; the rest are at `location`.
#[serde(default)]
pub quantity_in_decks: i64,
} }
/// A card in a deck, whether or not the user owns it. /// A card in a deck, whether or not the user owns it.
@@ -218,6 +225,75 @@ pub struct DeckEntry {
/// zone with a second commander. /// zone with a second commander.
#[serde(default)] #[serde(default)]
pub partner: Option<crate::partner::Partner>, pub partner: Option<crate::partner::Partner>,
/// Copies physically in this deck; the rest are still to be pulled. Only
/// meaningful for building/built decks.
#[serde(default)]
pub quantity_pulled: i64,
/// Owned copies not claimed by any *other* building/built deck.
#[serde(default)]
pub quantity_spare: i64,
/// Owned copies not pulled into any deck — still in their box.
#[serde(default)]
pub quantity_in_box: i64,
/// The *other* building/built decks claiming copies — those with copies
/// pulled are where to take one from when none are left in the box.
#[serde(default)]
pub claimed_by: Vec<DeckHolding>,
/// Where the card's library copies are stored (e.g. "Box 1").
#[serde(default)]
pub location: String,
}
/// A building/built deck's claim on a card's copies.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeckHolding {
pub deck_id: i64,
pub name: String,
/// Copies the deck's main board lists.
pub quantity: i64,
/// Of those, copies physically in the deck.
pub pulled: i64,
}
/// Where a deck stands physically. Only building and built decks hold cards
/// and claim owned copies; ideas and retired decks are just lists.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeckStatus {
#[default]
Idea,
Building,
Built,
Retired,
}
/// SQL list of the statuses for which [`DeckStatus::is_physical`] holds.
pub const PHYSICAL_STATUSES_SQL: &str = "('building', 'built')";
impl DeckStatus {
pub fn as_str(self) -> &'static str {
match self {
DeckStatus::Idea => "idea",
DeckStatus::Building => "building",
DeckStatus::Built => "built",
DeckStatus::Retired => "retired",
}
}
/// Unknown values (say, from a newer build) read as an idea, which holds
/// nothing — the safe direction to be wrong in.
pub fn parse(s: &str) -> Self {
match s {
"building" => DeckStatus::Building,
"built" => DeckStatus::Built,
"retired" => DeckStatus::Retired,
_ => DeckStatus::Idea,
}
}
pub fn is_physical(self) -> bool {
matches!(self, DeckStatus::Building | DeckStatus::Built)
}
} }
/// One printing of a card, for the printing picker and copy labels. /// One printing of a card, for the printing picker and copy labels.
@@ -272,6 +348,8 @@ pub struct Deck {
/// The card whose art headlines the deck in the deck list, if chosen; /// The card whose art headlines the deck in the deck list, if chosen;
/// `None` leaves the pick to the UI. /// `None` leaves the pick to the UI.
pub cover_oracle_id: Option<String>, pub cover_oracle_id: Option<String>,
#[serde(default)]
pub status: DeckStatus,
} }
/// Aggregate stats about the owned library, for a dashboard-style summary. /// Aggregate stats about the owned library, for a dashboard-style summary.

View File

@@ -0,0 +1,12 @@
-- v8 — deck status and pulled copies.
--
-- `status` says whether a deck physically exists: 'idea' (theory-crafting) and
-- 'retired' (taken apart) hold no cards; 'building' and 'built' do, and only
-- they claim owned copies. Existing decks start as ideas, so nothing is
-- considered physically moved until the user says so.
ALTER TABLE decks ADD COLUMN status TEXT NOT NULL DEFAULT 'idea';
-- How many of a deck card's copies are physically in the deck. Short of
-- `quantity`, the rest are still to be pulled from their box (or another
-- deck). Always 0 outside building/built decks and on the maybeboard.
ALTER TABLE deck_cards ADD COLUMN quantity_pulled INTEGER NOT NULL DEFAULT 0;

View File

@@ -44,6 +44,7 @@ use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::Result; use crate::error::Result;
use crate::model::DeckStatus;
pub mod account; pub mod account;
pub mod remote; pub mod remote;
@@ -56,8 +57,9 @@ pub mod remote;
/// dropped printing data would publish its loss as a deletion on the next /// dropped printing data would publish its loss as a deletion on the next
/// sync, so documents that carry any are marked 2 and older builds refuse /// sync, so documents that carry any are marked 2 and older builds refuse
/// them — while libraries that don't use printings stay readable everywhere. /// them — while libraries that don't use printings stay readable everywhere.
/// Version 3 does the same for deck covers. /// Version 3 does the same for deck covers, and 4 for deck statuses and
pub const DOC_VERSION: u32 = 3; /// pulled copies.
pub const DOC_VERSION: u32 = 4;
/// The format of a document with no printing data in it. /// The format of a document with no printing data in it.
const BASE_VERSION: u32 = 1; const BASE_VERSION: u32 = 1;
@@ -85,6 +87,9 @@ pub struct DeckCardEntry {
/// Scryfall id of the printing whose art this slot shows. /// Scryfall id of the printing whose art this slot shows.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub printing: Option<String>, pub printing: Option<String>,
/// Copies physically in the deck (see migration 0008).
#[serde(default, skip_serializing_if = "is_zero")]
pub pulled: i64,
} }
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`. /// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
@@ -112,6 +117,8 @@ pub struct DeckDoc {
/// The oracle id of the card chosen to headline the deck in the deck list. /// The oracle id of the card chosen to headline the deck in the deck list.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cover: Option<String>, pub cover: Option<String>,
#[serde(default, skip_serializing_if = "is_default")]
pub status: DeckStatus,
#[serde(default)] #[serde(default)]
pub cards: BTreeMap<String, DeckCardEntry>, pub cards: BTreeMap<String, DeckCardEntry>,
} }
@@ -146,6 +153,15 @@ fn is_false(b: &bool) -> bool {
!*b !*b
} }
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(n: &i64) -> bool {
*n == 0
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
impl LibraryDoc { impl LibraryDoc {
pub fn to_json(&self) -> Result<String> { pub fn to_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)?) Ok(serde_json::to_string_pretty(self)?)
@@ -169,9 +185,17 @@ impl LibraryDoc {
self.cards.is_empty() && self.decks.is_empty() self.cards.is_empty() && self.decks.is_empty()
} }
/// The lowest format that can hold this document: 3 once any deck has a /// The lowest format that can hold this document: 4 once any deck has a
/// cover, 2 once it carries any printing data, else 1. See [`DOC_VERSION`]. /// status or pulled copies, 3 once any deck has a cover, 2 once it carries
/// any printing data, else 1. See [`DOC_VERSION`].
pub fn required_version(&self) -> u32 { pub fn required_version(&self) -> u32 {
if self
.decks
.values()
.any(|d| d.status != DeckStatus::default() || d.cards.values().any(|c| c.pulled != 0))
{
return 4;
}
if self.decks.values().any(|d| d.cover.is_some()) { if self.decks.values().any(|d| d.cover.is_some()) {
return 3; return 3;
} }
@@ -425,6 +449,9 @@ fn merge_deck(
cover: merge_field(&b.cover, &o.cover, &t.cover, || { cover: merge_field(&b.cover, &o.cover, &t.cover, || {
report.conflicts.push(format!("cover of deck \"{name}\"")) report.conflicts.push(format!("cover of deck \"{name}\""))
}), }),
status: merge_field(&b.status, &o.status, &t.status, || {
report.conflicts.push(format!("status of deck \"{name}\""))
}),
cards: BTreeMap::new(), cards: BTreeMap::new(),
}; };
@@ -493,7 +520,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
} }
} }
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks")?; let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id, status FROM decks")?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
let mut by_id: BTreeMap<i64, String> = BTreeMap::new(); let mut by_id: BTreeMap<i64, String> = BTreeMap::new();
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
@@ -508,13 +535,15 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
commander: row.get(4)?, commander: row.get(4)?,
notes: row.get(5)?, notes: row.get(5)?,
cover: row.get(6)?, cover: row.get(6)?,
status: DeckStatus::parse(&row.get::<_, String>(7)?),
cards: BTreeMap::new(), cards: BTreeMap::new(),
}, },
); );
} }
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id,
quantity_pulled
FROM deck_cards", FROM deck_cards",
)?; )?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
@@ -531,6 +560,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
commander: row.get::<_, i64>(3)? != 0, commander: row.get::<_, i64>(3)? != 0,
maybeboard: row.get::<_, i64>(4)? != 0, maybeboard: row.get::<_, i64>(4)? != 0,
printing: row.get(5)?, printing: row.get(5)?,
pulled: row.get(6)?,
}, },
); );
} }
@@ -596,35 +626,47 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
for (uuid, deck) in &doc.decks { for (uuid, deck) in &doc.decks {
tx.execute( tx.execute(
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id) "INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(uuid) DO UPDATE SET ON CONFLICT(uuid) DO UPDATE SET
name = excluded.name, name = excluded.name,
format = excluded.format, format = excluded.format,
commander_oracle_id = excluded.commander_oracle_id, commander_oracle_id = excluded.commander_oracle_id,
notes = excluded.notes, notes = excluded.notes,
cover_oracle_id = excluded.cover_oracle_id", cover_oracle_id = excluded.cover_oracle_id,
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes, deck.cover], status = excluded.status",
rusqlite::params![
uuid,
deck.name,
deck.format,
deck.commander,
deck.notes,
deck.cover,
deck.status.as_str()
],
)?; )?;
let deck_id: i64 = let deck_id: i64 =
tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?; tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?;
for (oracle_id, card) in &deck.cards { for (oracle_id, card) in &deck.cards {
tx.execute( tx.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard,
VALUES (?1, ?2, ?3, ?4, ?5, ?6) preferred_scryfall_id, quantity_pulled)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity, quantity = excluded.quantity,
is_commander = excluded.is_commander, is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard, is_maybeboard = excluded.is_maybeboard,
preferred_scryfall_id = excluded.preferred_scryfall_id", preferred_scryfall_id = excluded.preferred_scryfall_id,
quantity_pulled = excluded.quantity_pulled",
rusqlite::params![ rusqlite::params![
deck_id, deck_id,
oracle_id, oracle_id,
card.quantity, card.quantity,
card.commander as i64, card.commander as i64,
card.maybeboard as i64, card.maybeboard as i64,
card.printing card.printing,
card.pulled
], ],
)?; )?;
} }
@@ -960,6 +1002,44 @@ mod tests {
assert_eq!(report.conflicts, vec!["cover of deck \"Fub\"".to_string()]); assert_eq!(report.conflicts, vec!["cover of deck \"Fub\"".to_string()]);
} }
#[test]
fn version_4_is_used_only_once_a_deck_has_a_status_or_pulled_copies() {
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
d.decks.get_mut("u1").unwrap().cover = Some("a".into());
assert_eq!(d.required_version(), 3);
assert!(!d.to_json().unwrap().contains("status"));
d.decks.get_mut("u1").unwrap().status = DeckStatus::Built;
assert_eq!(d.required_version(), 4);
d.decks.get_mut("u1").unwrap().status = DeckStatus::Idea;
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().pulled = 1;
assert_eq!(d.required_version(), 4);
let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default());
let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap();
assert_eq!(back, merged);
}
#[test]
fn deck_status_merges_like_other_deck_fields() {
let base = doc(&[], &[("u1", "Fub", &[])]);
let mut ours = base.clone();
let mut theirs = base.clone();
ours.decks.get_mut("u1").unwrap().status = DeckStatus::Built;
theirs.decks.get_mut("u1").unwrap().name = "Renamed".into();
let (merged, report) = merge3(&base, &ours, &theirs);
assert_eq!(merged.decks["u1"].status, DeckStatus::Built);
assert_eq!(merged.decks["u1"].name, "Renamed");
assert!(report.conflicts.is_empty());
let mut theirs = base.clone();
theirs.decks.get_mut("u1").unwrap().status = DeckStatus::Retired;
let (merged, report) = merge3(&base, &ours, &theirs);
assert_eq!(merged.decks["u1"].status, DeckStatus::Retired);
assert_eq!(report.conflicts, vec!["status of deck \"Fub\"".to_string()]);
}
#[test] #[test]
fn a_version_1_document_carries_no_printing_fields() { fn a_version_1_document_carries_no_printing_fields() {
// Older builds must see exactly what they always saw. // Older builds must see exactly what they always saw.
@@ -990,9 +1070,13 @@ mod tests {
c.set_deck_cover(deck.id, Some("card-a")).unwrap(); c.set_deck_cover(deck.id, Some("card-a")).unwrap();
c.set_owned("card-a", 4).unwrap(); c.set_owned("card-a", 4).unwrap();
c.set_location("card-a", "Box 1").unwrap(); c.set_location("card-a", "Box 1").unwrap();
c.set_deck_status(deck.id, DeckStatus::Building, false).unwrap();
c.set_deck_card_pulled(deck.id, "card-a", 1).unwrap();
let before = read_doc(&c.conn).unwrap(); let before = read_doc(&c.conn).unwrap();
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a")); assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
assert_eq!(before.decks.values().next().unwrap().status, DeckStatus::Building);
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1);
// Round-trip through JSON too, so the serde attributes are exercised. // Round-trip through JSON too, so the serde attributes are exercised.
let text = before.to_json().unwrap(); let text = before.to_json().unwrap();
let parsed = LibraryDoc::from_json(&text).unwrap(); let parsed = LibraryDoc::from_json(&text).unwrap();

View File

@@ -874,6 +874,7 @@ mod tests {
} }
cat.add_owned("owned-elf", 2).unwrap(); cat.add_owned("owned-elf", 2).unwrap();
let other = cat.create_deck("Other", None).unwrap(); let other = cat.create_deck("Other", None).unwrap();
cat.set_deck_status(other.id, crate::model::DeckStatus::Built, false).unwrap();
cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap(); cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap();
let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();

View File

@@ -7,7 +7,7 @@ use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use mtgc_core::{ use mtgc_core::{
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets, CardRef, CardSuggestion, Deck, DeckEntry, DeckStatus, LibraryCard, LibraryCopy, LibraryFacets,
LibraryFilter, LibraryStats, PrintingRef, SuggestOptions, LibraryFilter, LibraryStats, PrintingRef, SuggestOptions,
}; };
use serde::Serialize; use serde::Serialize;
@@ -329,6 +329,30 @@ pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn set_deck_status(db: Db, deck_id: i64, status: DeckStatus, assembled: bool) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_status(deck_id, status, assembled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_deck_card_pulled(db: Db, deck_id: i64, oracle_id: String, pulled: i64) -> Res<i64> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_card_pulled(deck_id, &oracle_id, pulled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res<bool> {
let s = db.lock().unwrap();
s.catalog()?
.take_deck_card_copy(from_deck_id, to_deck_id, &oracle_id)
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn set_deck_cover(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> { pub fn set_deck_cover(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
let s = db.lock().unwrap(); let s = db.lock().unwrap();

View File

@@ -108,6 +108,9 @@ pub fn run() {
commands::duplicate_deck, commands::duplicate_deck,
commands::set_deck_format, commands::set_deck_format,
commands::set_deck_cover, commands::set_deck_cover,
commands::set_deck_status,
commands::set_deck_card_pulled,
commands::take_deck_card_copy,
commands::set_deck_commander, commands::set_deck_commander,
commands::set_deck_commanders, commands::set_deck_commanders,
commands::search_partners, commands::search_partners,

View File

@@ -25,7 +25,12 @@ export interface CardRef {
export interface LibraryCard extends CardRef { export interface LibraryCard extends CardRef {
quantity_owned: number; quantity_owned: number;
decks: string[]; decks: string[];
/** The subset of `decks` that physically exist (building or built). */
physical_decks: string[];
/** Copies claimed by building/built decks (idea/retired decks claim none). */
quantity_assigned: number; quantity_assigned: number;
/** Copies physically pulled into decks; the rest are at `location`. */
quantity_in_decks: number;
location: string; location: string;
} }
@@ -46,8 +51,41 @@ export interface DeckEntry extends CardRef {
/** The printing whose art this slot shows; `image_uri` already reflects it. */ /** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null; preferred_scryfall_id: string | null;
partner: Partner | null; partner: Partner | null;
/** Copies physically in this deck; meaningful for building/built decks. */
quantity_pulled: number;
/** Owned copies not claimed by any *other* building/built deck. */
quantity_spare: number;
/** Owned copies not pulled into any deck — still in their box. */
quantity_in_box: number;
/** The *other* building/built decks claiming copies; those with copies
* pulled are where to take one from when the box is empty. */
claimed_by: DeckHolding[];
/** Where the card's library copies are stored ("" if unset). */
location: string;
} }
export interface DeckHolding {
deck_id: number;
name: string;
/** Copies the deck's main board lists. */
quantity: number;
/** Of those, copies physically in the deck. */
pulled: number;
}
/** Where a deck stands physically. Building and built decks hold cards and
* claim owned copies; ideas and retired decks are just lists. */
export type DeckStatus = "idea" | "building" | "built" | "retired";
export const DECK_STATUSES: { value: DeckStatus; label: string }[] = [
{ value: "idea", label: "Idea" },
{ value: "building", label: "Building" },
{ value: "built", label: "Built" },
{ value: "retired", label: "Retired" },
];
export const isPhysical = (s: DeckStatus) => s === "building" || s === "built";
/** One printing of a card, for the printing picker and copy labels. */ /** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef { export interface PrintingRef {
scryfall_id: string; scryfall_id: string;
@@ -82,6 +120,9 @@ export interface LibraryCopy {
export type PreviewCard = CardRef & { export type PreviewCard = CardRef & {
quantity_owned?: number; quantity_owned?: number;
decks?: string[]; decks?: string[];
physical_decks?: string[];
quantity_in_decks?: number;
location?: string;
preferred_scryfall_id?: string | null; preferred_scryfall_id?: string | null;
}; };
@@ -107,6 +148,7 @@ export interface Deck {
notes: string; notes: string;
/** The card chosen to headline the deck in the deck list; null = automatic. */ /** The card chosen to headline the deck in the deck list; null = automatic. */
cover_oracle_id: string | null; cover_oracle_id: string | null;
status: DeckStatus;
} }
export interface DeckImportResult { export interface DeckImportResult {
@@ -497,6 +539,17 @@ export const api = {
invoke<Deck>("duplicate_deck", { deckId, newName }), invoke<Deck>("duplicate_deck", { deckId, newName }),
setDeckFormat: (deckId: number, format: string) => setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }), invoke<void>("set_deck_format", { deckId, format }),
/** Change a deck's status. Leaving building/built returns pulled copies to
* their boxes; entering it with `assembled` marks the copies in the box as
* already in the deck. */
setDeckStatus: (deckId: number, status: DeckStatus, assembled = false) =>
invoke<void>("set_deck_status", { deckId, status, assembled }),
/** Record copies physically in a deck; returns the (clamped) count stored. */
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
/** Move one pulled copy between decks; false if there was nothing to move. */
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
/** Choose the card headlining the deck in the deck list; null = automatic. */ /** Choose the card headlining the deck in the deck list; null = automatic. */
setDeckCover: (deckId: number, oracleId: string | null) => setDeckCover: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_cover", { deckId, oracleId }), invoke<void>("set_deck_cover", { deckId, oracleId }),

View File

@@ -816,18 +816,23 @@ export function DeckChip({
name, name,
className = "deck-chip", className = "deck-chip",
onOpen, onOpen,
idea,
}: { }: {
name: string; name: string;
className?: string; className?: string;
onOpen?: () => void; onOpen?: () => void;
/** The deck is an idea or retired: listed, but holding no copies. */
idea?: boolean;
}) { }) {
const openDeck = useContext(OpenDeckCtx); const openDeck = useContext(OpenDeckCtx);
if (!openDeck) return <span className={className}>{name}</span>; if (idea) className += " deck-chip-idea";
const note = idea ? " (not built — holds no copies)" : "";
if (!openDeck) return <span className={className} title={note ? `${name}${note}` : undefined}>{name}</span>;
return ( return (
<button <button
type="button" type="button"
className={`${className} deck-chip-link`} className={`${className} deck-chip-link`}
title={`Open ${name}`} title={`Open ${name}${note}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpen?.(); onOpen?.();
@@ -1037,11 +1042,27 @@ const CardInfoTile = forwardRef<
<span className="preview-info-label">In decks</span> <span className="preview-info-label">In decks</span>
<div className="preview-info-deck-chips"> <div className="preview-info-deck-chips">
{card.decks.map((d) => ( {card.decks.map((d) => (
<DeckChip key={d} name={d} className="badge" onOpen={onClose} /> <DeckChip
key={d}
name={d}
className="badge"
onOpen={onClose}
idea={card.physical_decks && !card.physical_decks.includes(d)}
/>
))} ))}
</div> </div>
</div> </div>
)} )}
{!!card.quantity_owned && !!card.quantity_in_decks && (
<div className="preview-info-section">
<span className="preview-info-label">Where</span>
<span>
{card.quantity_in_decks} in decks
{card.quantity_owned > card.quantity_in_decks &&
` · ${card.quantity_owned - card.quantity_in_decks} in ${card.location || "your library"}`}
</span>
</div>
)}
<div className="preview-info-section"> <div className="preview-info-section">
<span className="preview-info-label">Legalities</span> <span className="preview-info-label">Legalities</span>
<LegalityPills legalities={card.legalities} /> <LegalityPills legalities={card.legalities} />

View File

@@ -1,7 +1,7 @@
// Pure deck analysis shared by the deck builder and the deck list: composition // Pure deck analysis shared by the deck builder and the deck list: composition
// stats, format legality, commander pairing, and the deck list's per-deck // stats, format legality, commander pairing, and the deck list's per-deck
// summary. // summary.
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api"; import { COLORS, Deck, DeckEntry, isPhysical, legalStatusFor, Partner } from "./api";
/** Copies of a deck card you'd need to acquire to cover the deck. */ /** Copies of a deck card you'd need to acquire to cover the deck. */
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned); export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
@@ -391,6 +391,8 @@ export interface DeckSummary {
cost: number; cost: number;
legality: DeckLegality; legality: DeckLegality;
maybeboardCount: number; maybeboardCount: number;
/** Mainboard cards not yet physically in the deck (building/built decks). */
toPull: number;
} }
/** /**
@@ -435,5 +437,6 @@ export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0), cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
legality: computeDeckLegality(deck, contents), legality: computeDeckLegality(deck, contents),
maybeboardCount: contents.length - main.length, maybeboardCount: contents.length - main.length,
toPull: isPhysical(deck.status) ? main.filter((e) => e.quantity_pulled < e.quantity).length : 0,
}; };
} }

View File

@@ -1715,6 +1715,78 @@ tbody tr:hover {
color: var(--text-strong); color: var(--text-strong);
} }
/* A deck that's an idea or retired: listed, but holding no copies. */
.deck-chip.deck-chip-idea,
.badge.deck-chip-idea {
border-style: dashed;
color: var(--muted);
}
/* ============================================================
Deck status: Idea / Building / Built / Retired
============================================================ */
.deck-status-chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
padding: 1px 8px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
border: 1px solid var(--border);
color: var(--muted);
margin-right: 4px;
}
.deck-status-chip.idea {
border-style: dashed;
}
.deck-status-chip.building {
border-color: var(--warn);
color: var(--warn);
}
.deck-status-chip.built {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.deck-status-chip.retired {
color: var(--faint);
}
.deck-status-hint {
flex-basis: 100%;
font-size: var(--fs-caption);
}
.deck-gallery-retired {
margin-top: var(--space-5);
}
.deck-gallery-retired > summary {
cursor: pointer;
font-weight: 600;
color: var(--muted);
margin-bottom: var(--space-3);
}
/* Where a deck card's copies are: the "To pull" source and its action. */
.whereabouts {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 6px;
font-size: 12px;
}
.whereabouts.compact {
font-size: 11px;
}
.pull-source {
color: var(--text);
}
button.pull-action {
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
}
/* ============================================================ /* ============================================================
Bulk-selection action bar (Library tab) Bulk-selection action bar (Library tab)
============================================================ */ ============================================================ */

View File

@@ -254,7 +254,7 @@ export function CardSearch({
</td> </td>
<td data-label="In decks"> <td data-label="In decks">
{c.decks.map((d) => ( {c.decks.map((d) => (
<DeckChip key={d} name={d} /> <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
))} ))}
</td> </td>
<td> <td>
@@ -309,7 +309,7 @@ export function CardSearch({
} }
footer={ footer={
c.decks.length > 0 c.decks.length > 0
? c.decks.map((d) => <DeckChip key={d} name={d} />) ? c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
: undefined : undefined
} }
/> />

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { api, Deck, DeckEntry } from "../api"; import { api, Deck, DECK_STATUSES, DeckEntry, DeckStatus } from "../api";
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components"; import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis"; import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
@@ -82,6 +82,19 @@ function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?
); );
} }
/** "Built", "Building · 5 to pull", etc. Ideas — the default — say so too,
* so it's clear at a glance which decks are real. */
function StatusChip({ row }: { row: Row }) {
const { deck, summary: s } = row;
const label = DECK_STATUSES.find((d) => d.value === deck.status)?.label ?? deck.status;
return (
<span className={`deck-status-chip ${deck.status}`}>
{label}
{s.toPull > 0 && <span> · {s.toPull} to pull</span>}
</span>
);
}
function OwnedBar({ summary: s }: { summary: DeckSummary }) { function OwnedBar({ summary: s }: { summary: DeckSummary }) {
return ( return (
<span className="deck-owned-bar" aria-hidden="true"> <span className="deck-owned-bar" aria-hidden="true">
@@ -210,6 +223,7 @@ function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
<div className="deck-tile-body"> <div className="deck-tile-body">
<div className="deck-tile-sub">{subtitle(row)}</div> <div className="deck-tile-sub">{subtitle(row)}</div>
<div className="deck-tile-meta"> <div className="deck-tile-meta">
<StatusChip row={row} />
{deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>} {deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>}
{s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>} {s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>}
<span className="spacer" /> <span className="spacer" />
@@ -239,6 +253,7 @@ function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
<div className="deck-row-sub">{subtitle(row)}</div> <div className="deck-row-sub">{subtitle(row)}</div>
</div> </div>
<div className="deck-row-stats"> <div className="deck-row-stats">
<StatusChip row={row} />
{deck.format && <span className="deck-chip">{deck.format}</span>} {deck.format && <span className="deck-chip">{deck.format}</span>}
<DeckSize summary={s} /> <DeckSize summary={s} />
<span className="deck-row-owned" title={ownedLabel(s)}> <span className="deck-row-owned" title={ownedLabel(s)}>
@@ -278,6 +293,7 @@ export function DeckList({
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent"); const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [format, setFormat] = useState<string | null>(null); const [format, setFormat] = useState<string | null>(null);
const [status, setStatus] = useState<DeckStatus | null>(null);
// `decks` is refetched after anything that adds a deck, so it doubles as // `decks` is refetched after anything that adds a deck, so it doubles as
// the signal that contents may have changed. // the signal that contents may have changed.
@@ -298,6 +314,7 @@ export function DeckList({
); );
const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]); const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]);
const statuses = useMemo(() => DECK_STATUSES.filter((s) => decks.some((d) => d.status === s.value)), [decks]);
const shown = useMemo(() => { const shown = useMemo(() => {
const q = fold(query.trim()); const q = fold(query.trim());
@@ -321,11 +338,34 @@ export function DeckList({
owned: desc((r) => (r.summary.size ? r.summary.owned : -1)), owned: desc((r) => (r.summary.size ? r.summary.owned : -1)),
}; };
return rows return rows
.filter((r) => (format === null || r.deck.format === format) && matches(r)) .filter(
(r) => (format === null || r.deck.format === format) && (status === null || r.deck.status === status) && matches(r),
)
.sort(compare[sort] ?? byName); .sort(compare[sort] ?? byName);
}, [rows, query, format, sort, openedAt]); }, [rows, query, format, status, sort, openedAt]);
const filtering = query.trim() !== "" || format !== null; // Retired decks are kept for reference, so unless they're asked for they
// sit collapsed below the rest.
const active = status === null ? shown.filter((r) => r.deck.status !== "retired") : shown;
const retired = status === null ? shown.filter((r) => r.deck.status === "retired") : [];
const filtering = query.trim() !== "" || format !== null || status !== null;
function renderDecks(list: Row[]) {
return view === "tiles" ? (
<div className="deck-gallery-grid">
{list.map((r) => (
<DeckTile key={r.deck.id} row={r} actions={actions} />
))}
</div>
) : (
<div className="deck-gallery-rows">
{list.map((r) => (
<DeckRow key={r.deck.id} row={r} actions={actions} />
))}
</div>
);
}
return ( return (
<div className="deck-gallery"> <div className="deck-gallery">
@@ -358,6 +398,20 @@ export function DeckList({
))} ))}
</select> </select>
)} )}
{statuses.length > 1 && (
<select
value={status ?? "*"}
onChange={(e) => setStatus(e.target.value === "*" ? null : (e.target.value as DeckStatus))}
title="Filter by status"
>
<option value="*">All statuses</option>
{statuses.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
)}
<select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks"> <select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks">
{SORTS.map((s) => ( {SORTS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@@ -402,24 +456,25 @@ export function DeckList({
onClick={() => { onClick={() => {
setQuery(""); setQuery("");
setFormat(null); setFormat(null);
setStatus(null);
}} }}
> >
Clear filters Clear filters
</button> </button>
)} )}
</div> </div>
) : view === "tiles" ? (
<div className="deck-gallery-grid">
{shown.map((r) => (
<DeckTile key={r.deck.id} row={r} actions={actions} />
))}
</div>
) : ( ) : (
<div className="deck-gallery-rows"> <>
{shown.map((r) => ( {renderDecks(active)}
<DeckRow key={r.deck.id} row={r} actions={actions} /> {retired.length > 0 && (
))} <details className="deck-gallery-retired">
</div> <summary>
Retired <span className="badge">{retired.length}</span>
</summary>
{renderDecks(retired)}
</details>
)}
</>
)} )}
</div> </div>
); );

View File

@@ -1,6 +1,18 @@
import { open, save } from "@tauri-apps/plugin-dialog"; import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, Deck, DeckEntry, DeckImportResult, libraryKey, PreviewContext } from "../api"; import {
api,
CardRef,
Deck,
DECK_STATUSES,
DeckEntry,
DeckHolding,
DeckImportResult,
DeckStatus,
isPhysical,
libraryKey,
PreviewContext,
} from "../api";
import { import {
CardSearchBox, CardSearchBox,
CardThumb, CardThumb,
@@ -164,6 +176,18 @@ const REMOVAL_GRACE_MS = 5000;
const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`; const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`;
const statusLabel = (s: DeckStatus) => DECK_STATUSES.find((d) => d.value === s)?.label ?? s;
/** One line under the status select saying what the status means. */
const STATUS_HINTS: Record<DeckStatus, string> = {
idea: "Just a list — it claims none of your cards.",
building: "Being assembled: pull its cards as you go.",
built: "Assembled: its cards are in the deck box.",
retired: "Taken apart, kept for reference.",
};
const STARTING_HAND_SIZE = 7; const STARTING_HAND_SIZE = 7;
/** /**
@@ -231,6 +255,9 @@ function DeckBuilder({
const [hand, setHand] = useState<DeckEntry[]>([]); const [hand, setHand] = useState<DeckEntry[]>([]);
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]); const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false); const [suggestOpen, setSuggestOpen] = useState(false);
const [statusDialog, setStatusDialog] = useState<{ to: DeckStatus; kind: "assemble" | "release" } | null>(
null,
);
// Keyed by deck id in `DeckView`, so switching decks remounts with fresh // Keyed by deck id in `DeckView`, so switching decks remounts with fresh
// state rather than resetting it here. // state rather than resetting it here.
@@ -528,6 +555,53 @@ function DeckBuilder({
await onDecksChanged(); await onDecksChanged();
} }
async function applyStatus(status: DeckStatus, assembled = false) {
setStatusDialog(null);
await api.setDeckStatus(deckId, status, assembled);
await onDecksChanged();
await loadContents();
}
/** Change status, first asking how to treat the cards when the deck starts
* or stops physically holding them. */
function changeStatus(status: DeckStatus) {
if (status === deck.status) return;
if (isPhysical(status) && !isPhysical(deck.status) && contents.some((e) => !e.is_maybeboard)) {
setStatusDialog({ to: status, kind: "assemble" });
} else if (!isPhysical(status) && pulledCopies > 0) {
setStatusDialog({ to: status, kind: "release" });
} else {
applyStatus(status);
}
}
/** Mark as many copies pulled as the deck needs (the backend clamps to
* what's actually in the box). */
async function pullCard(e: DeckEntry) {
await api.setDeckCardPulled(deckId, e.oracle_id, e.quantity);
await loadContents();
}
async function takeCard(e: DeckEntry, from: DeckHolding) {
await api.takeDeckCardCopy(from.deck_id, deckId, e.oracle_id);
await loadContents();
}
async function bulkSetPulled(pulled: boolean) {
setBulkBusy(true);
try {
await Promise.all(
contents
.filter((e) => selectedCards.has(e.oracle_id) && !e.is_maybeboard)
.map((e) => api.setDeckCardPulled(deckId, e.oracle_id, pulled ? e.quantity : 0)),
);
clearSelection();
await loadContents();
} finally {
setBulkBusy(false);
}
}
async function setCommander(card: CardRef) { async function setCommander(card: CardRef) {
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged // Ensure the card is in the deck (mainboard, not maybeboard) and flagged
// commander — covers both "not in the deck yet" and "was maybeboarded". // commander — covers both "not in the deck yet" and "was maybeboarded".
@@ -579,8 +653,17 @@ function DeckBuilder({
return fold(e.name).includes(q) || fold(e.type_line).includes(q); return fold(e.name).includes(q) || fold(e.type_line).includes(q);
} }
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e)); // Building/built decks split by what's physically in them; ideas and
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e)); // retired decks by whether the cards are free to use.
const physical = isPhysical(deck.status);
const mainboard = contents.filter((e) => !e.is_maybeboard && matchesFilter(e));
const toPull = mainboard.filter((e) => e.quantity_pulled < e.quantity);
const inDeck = mainboard.filter((e) => e.quantity_pulled >= e.quantity);
const available = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare >= e.quantity);
const elsewhere = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare < e.quantity);
const unowned = mainboard.filter((e) => e.quantity_owned === 0);
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e)); const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
// The primary commander first, then its partner, if any. // The primary commander first, then its partner, if any.
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id); const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
@@ -602,6 +685,68 @@ function DeckBuilder({
}, },
}; };
/** Where a card's copies are, when that's worth saying: for a deck being
* built, where to pull what's missing from; for an idea, which real decks
* already use the copies. Null when there's nothing to act on. */
function whereabouts(e: DeckEntry, compact = false) {
if (e.is_maybeboard) return null;
if (physical) {
if (e.quantity_pulled >= e.quantity) return null;
const progress = e.quantity_pulled > 0 && (
<span className="muted">
{e.quantity_pulled}/{e.quantity} pulled
</span>
);
const holders = e.claimed_by.filter((h) => h.pulled > 0);
let source;
if (e.quantity_in_box > 0) {
source = (
<>
<span className="pull-source">In {e.location || "your library"}</span>
<button className="pull-action" onClick={() => pullCard(e)}>
Pulled
</button>
</>
);
} else if (holders.length > 0) {
source = holders.map((h) => (
<span key={h.deck_id} className="row" style={{ gap: 4 }}>
<span className="pull-source">In {h.name}</span>
<button className="pull-action" onClick={() => takeCard(e, h)} title={`Move a copy from ${h.name}`}>
Take
</button>
</span>
));
} else if (e.quantity_owned === 0) {
source = <span className="pull-source muted">Not owned</span>;
} else {
source = (
<span className="pull-source muted">Need {copies(e.quantity - e.quantity_pulled)} more</span>
);
}
return (
<span className={`whereabouts ${compact ? "compact" : ""}`}>
{progress}
{source}
</span>
);
}
if (e.quantity_owned === 0 || e.quantity_spare >= e.quantity) return null;
const names = e.claimed_by.map((h) => h.name).join(", ");
return (
<span className={`whereabouts ${compact ? "compact" : ""}`}>
<span className="pull-source" title={`Copies claimed by: ${names}`}>
In {names}
</span>
{e.quantity_spare > 0 && (
<span className="muted">
{e.quantity_spare} of {e.quantity} free
</span>
)}
</span>
);
}
function deckTiles(entries: DeckEntry[]) { function deckTiles(entries: DeckEntry[]) {
return ( return (
<div {...tiles.gridProps}> <div {...tiles.gridProps}>
@@ -644,12 +789,13 @@ function DeckBuilder({
</span> </span>
} }
footer={ footer={
e.is_commander || partialRemoval.has(e.oracle_id) ? ( e.is_commander || partialRemoval.has(e.oracle_id) || whereabouts(e) ? (
<span className="row" style={{ gap: 4 }}> <span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
{e.is_commander && <span className="tile-badge">★ commander</span>} {e.is_commander && <span className="tile-badge">★ commander</span>}
{partialRemoval.has(e.oracle_id) && ( {partialRemoval.has(e.oracle_id) && (
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span> <span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
)} )}
{whereabouts(e, true)}
</span> </span>
) : undefined ) : undefined
} }
@@ -671,6 +817,7 @@ function DeckBuilder({
<th>Type</th> <th>Type</th>
<th>In deck</th> <th>In deck</th>
<th>Owned</th> <th>Owned</th>
<th>Where</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -710,6 +857,7 @@ function DeckBuilder({
/> />
</td> </td>
<td data-label="Owned">{e.quantity_owned}</td> <td data-label="Owned">{e.quantity_owned}</td>
<td data-label="Where">{whereabouts(e)}</td>
<td> <td>
<span className="row" style={{ gap: 4 }}> <span className="row" style={{ gap: 4 }}>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} /> <IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
@@ -796,6 +944,23 @@ function DeckBuilder({
))} ))}
</select> </select>
</label> </label>
<label className="row" style={{ gap: 6 }}>
<span className="muted">Status</span>
<select value={deck.status} onChange={(e) => changeStatus(e.target.value as DeckStatus)}>
{DECK_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
<span className="muted deck-status-hint">
{deck.status === "building" && leftToPull > 0
? `Being assembled: ${leftToPull} card${leftToPull === 1 ? "" : "s"} left to pull.`
: deck.status === "built" && leftToPull > 0
? `Assembled, with ${leftToPull} card${leftToPull === 1 ? "" : "s"} still to pull.`
: STATUS_HINTS[deck.status]}
</span>
{COMMANDER_FORMATS.includes(deck.format) && ( {COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box"> <div className="commander-box">
@@ -979,6 +1144,35 @@ function DeckBuilder({
> >
Add to library Add to library
</button> </button>
{physical && (
<>
<button
onClick={() => bulkSetPulled(true)}
disabled={
bulkBusy ||
!contents.some(
(e) =>
selectedCards.has(e.oracle_id) &&
!e.is_maybeboard &&
e.quantity_pulled < e.quantity &&
e.quantity_in_box > 0,
)
}
title="Record that these cards' copies have been taken from their boxes"
>
Mark pulled
</button>
<button
onClick={() => bulkSetPulled(false)}
disabled={
bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && e.quantity_pulled > 0)
}
title="Record that these cards' copies are back in their boxes"
>
Mark not pulled
</button>
</>
)}
</div> </div>
<span className="spacer" /> <span className="spacer" />
<button onClick={clearSelection} disabled={bulkBusy}> <button onClick={clearSelection} disabled={bulkBusy}>
@@ -990,7 +1184,7 @@ function DeckBuilder({
{combineSections ? ( {combineSections ? (
<section style={{ marginTop: 10 }}> <section style={{ marginTop: 10 }}>
<h3> <h3>
All cards <span className="badge">{owned.length + unowned.length}</span> All cards <span className="badge">{mainboard.length}</span>
</h3> </h3>
<CardSearchBox <CardSearchBox
placeholder="Search all cards to add to your deck…" placeholder="Search all cards to add to your deck…"
@@ -999,14 +1193,52 @@ function DeckBuilder({
results={anyResults} results={anyResults}
onPick={addToDeck} onPick={addToDeck}
/> />
{cardsGroup([...owned, ...unowned])} {cardsGroup(physical ? [...toPull, ...inDeck] : [...available, ...elsewhere, ...unowned])}
</section> </section>
) : physical ? (
<>
<section style={{ marginTop: 10 }}>
<h3>
To pull <span className="badge">{toPull.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
In the deck's list, but not physically in it yet. New cards land here.
</p>
<CardSearchBox
placeholder="Search all cards to add to your deck…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{toPull.length === 0 && deck.status === "building" && mainboard.length > 0 ? (
<p className="hint row" style={{ gap: 8 }}>
Everything's pulled.
<button className="primary" onClick={() => applyStatus("built")}>
Mark as Built
</button>
</p>
) : (
cardsGroup(toPull)
)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
In deck <span className="badge">{inDeck.length}</span>
</h3>
{cardsGroup(inDeck)}
</section>
</>
) : ( ) : (
<> <>
<section style={{ marginTop: 10 }}> <section style={{ marginTop: 10 }}>
<h3> <h3>
In your library <span className="badge">{owned.length}</span> Available <span className="badge">{available.length}</span>
</h3> </h3>
<p className="hint" style={{ marginTop: -4 }}>
Owned, with copies no built deck is using.
</p>
<CardSearchBox <CardSearchBox
placeholder="Search your library to add owned cards…" placeholder="Search your library to add owned cards…"
value={libQuery} value={libQuery}
@@ -1014,9 +1246,21 @@ function DeckBuilder({
results={libResults} results={libResults}
onPick={addToDeck} onPick={addToDeck}
/> />
{cardsGroup(owned)} {cardsGroup(available)}
</section> </section>
{elsewhere.length > 0 && (
<section style={{ marginTop: 26 }}>
<h3>
In other decks <span className="badge">{elsewhere.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Owned, but your copies are used by decks you're building or have built.
</p>
{cardsGroup(elsewhere)}
</section>
)}
<section style={{ marginTop: 26 }}> <section style={{ marginTop: 26 }}>
<h3> <h3>
Not owned <span className="badge">{unowned.length}</span> Not owned <span className="badge">{unowned.length}</span>
@@ -1059,6 +1303,32 @@ function DeckBuilder({
onClose={() => setHandOpen(false)} onClose={() => setHandOpen(false)}
/> />
)} )}
{statusDialog?.kind === "assemble" && (
<DialogFrame title={`Mark as ${statusLabel(statusDialog.to)}`} onCancel={() => setStatusDialog(null)}>
<p className="dialog-message">
Are this deck's cards already together? If so, the copies in your library are recorded as in the
deck. Otherwise, they're listed under "To pull" until you take them out of their boxes.
</p>
<div className="dialog-actions">
<button onClick={() => setStatusDialog(null)}>Cancel</button>
<button onClick={() => applyStatus(statusDialog.to, false)}>Start pulling</button>
<button className="primary" onClick={() => applyStatus(statusDialog.to, true)} autoFocus>
Already assembled
</button>
</div>
</DialogFrame>
)}
{statusDialog?.kind === "release" && (
<ConfirmDialog
title={`Mark as ${statusLabel(statusDialog.to)}`}
message={`${copies(pulledCopies)} in this deck will be recorded as back in ${
pulledCopies === 1 ? "its box" : "their boxes"
}.`}
confirmLabel={`Mark as ${statusLabel(statusDialog.to)}`}
onConfirm={() => applyStatus(statusDialog.to)}
onCancel={() => setStatusDialog(null)}
/>
)}
{suggestOpen && ( {suggestOpen && (
<SuggestionsModal <SuggestionsModal
deckId={deck.id} deckId={deck.id}

View File

@@ -300,7 +300,7 @@ export function LibraryBrowser({
onSaved={() => setRefreshKey((k) => k + 1)} onSaved={() => setRefreshKey((k) => k + 1)}
/> />
) : ( ) : (
c.decks.map((d) => <DeckChip key={d} name={d} />) c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
); );
// Under the spares filter, how many copies are free — only worth saying for // Under the spares filter, how many copies are free — only worth saying for
@@ -308,7 +308,7 @@ export function LibraryBrowser({
const spareNote = (c: LibraryCard) => const spareNote = (c: LibraryCard) =>
criteria.sparesOnly && criteria.sparesOnly &&
c.quantity_assigned > 0 && ( c.quantity_assigned > 0 && (
<span className="muted" title="Copies no deck is using"> <span className="muted" title="Copies no deck you're building or have built is using">
{c.quantity_owned - c.quantity_assigned} spare {c.quantity_owned - c.quantity_assigned} spare
</span> </span>
); );