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:
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
12
crates/mtgc-core/src/sql/library/0008_deck_status.sql
Normal file
12
crates/mtgc-core/src/sql/library/0008_deck_status.sql
Normal 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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
53
src/api.ts
53
src/api.ts
@@ -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 }),
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user