Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every screen size the Decks tab opens to a deck list page (DeckList), and picking a deck opens its builder full-width with an "All decks" back button. The list shows each deck as an art tile (or a compact row, toggleable) with its colors, size against its format, owned %, cost and legality, and can be searched, filtered by format and sorted (recently opened, name, format, size, cost, owned %). Rename/duplicate/export/delete live in each deck's menu and beside the builder's title; New deck now asks for a format too, defaulting to the last one used. A deck's cover art is the commander, else its priciest nonland, unless a card is chosen with "Use as cover" in the card preview. The cover is stored in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a document carrying one is version 3, so the sync server must be updated before clients publish covers. The list's stats come from one all_deck_contents query, summarised with the builder's own analysis code, now in deckAnalysis.ts. Closes #9 Closes #11 Closes #12
This commit is contained in:
@@ -19,6 +19,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
||||
include_str!("sql/library/0004_deck_maybeboard.sql"),
|
||||
include_str!("sql/library/0005_deck_uuid.sql"),
|
||||
include_str!("sql/library/0006_printings.sql"),
|
||||
include_str!("sql/library/0007_deck_cover.sql"),
|
||||
];
|
||||
|
||||
/// The attached alias under which the reference db is available at runtime.
|
||||
|
||||
@@ -876,6 +876,7 @@ mod tests {
|
||||
format: String::new(),
|
||||
commander_oracle_id: None,
|
||||
notes: String::new(),
|
||||
cover_oracle_id: None,
|
||||
};
|
||||
let text = format_deck_text(&deck, &[], &HashMap::new());
|
||||
assert_eq!(text, "// mtgc deck export\n");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Deck operations and deck membership lookups.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
@@ -27,7 +29,7 @@ impl Catalog {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
|
||||
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks WHERE id = ?1",
|
||||
params![id],
|
||||
Self::map_deck,
|
||||
)
|
||||
@@ -36,7 +38,7 @@ impl Catalog {
|
||||
|
||||
pub fn list_decks(&self) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, format, commander_oracle_id, notes
|
||||
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id
|
||||
FROM decks ORDER BY name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt
|
||||
@@ -60,7 +62,7 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Duplicate a deck under a new name: same format/commander/notes, 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
|
||||
/// `new_name` is already taken.
|
||||
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
||||
@@ -70,9 +72,9 @@ impl Catalog {
|
||||
// A duplicate is a genuinely new deck, so it gets its own identity
|
||||
// rather than inheriting the source's.
|
||||
self.conn.execute(
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, lower(hex(randomblob(16))))",
|
||||
params![new_name, src.format, src.commander_oracle_id, src.notes],
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes, cover_oracle_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, lower(hex(randomblob(16))))",
|
||||
params![new_name, src.format, src.commander_oracle_id, src.notes, src.cover_oracle_id],
|
||||
)?;
|
||||
let new_id = self.conn.last_insert_rowid();
|
||||
self.conn.execute(
|
||||
@@ -123,6 +125,26 @@ impl Catalog {
|
||||
/// from it, so a double-faced card never mixes two printings. An id that
|
||||
/// no longer resolves (or has no image) falls back to the default.
|
||||
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
|
||||
Ok(self
|
||||
.deck_entries(Some(deck_id))?
|
||||
.into_iter()
|
||||
.map(|(_, e)| e)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every deck's contents in one query, keyed by deck id — what the deck
|
||||
/// list summarises. Decks with no cards are absent.
|
||||
pub fn all_deck_contents(&self) -> Result<HashMap<i64, Vec<DeckEntry>>> {
|
||||
let mut by_deck: HashMap<i64, Vec<DeckEntry>> = HashMap::new();
|
||||
for (deck_id, entry) in self.deck_entries(None)? {
|
||||
by_deck.entry(deck_id).or_default().push(entry);
|
||||
}
|
||||
Ok(by_deck)
|
||||
}
|
||||
|
||||
/// The rows behind [`Self::deck_contents`], for one deck or (with `None`)
|
||||
/// all of them, each tagged with its deck id.
|
||||
fn deck_entries(&self, deck_id: Option<i64>) -> Result<Vec<(i64, DeckEntry)>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
||||
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||
@@ -131,23 +153,24 @@ impl Catalog {
|
||||
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
|
||||
oc.produced_mana, oc.produces_colorless,
|
||||
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
|
||||
oc.oracle_text
|
||||
oc.oracle_text, dc.deck_id
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||
LEFT JOIN {REF_DB}.printings pp ON pp.scryfall_id = dc.preferred_scryfall_id
|
||||
WHERE dc.deck_id = ?1
|
||||
{filter}
|
||||
ORDER BY oc.name COLLATE NOCASE",
|
||||
filter = if deck_id.is_some() { "WHERE dc.deck_id = ?1" } else { "" },
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![deck_id], |r| {
|
||||
.query_map(rusqlite::params_from_iter(deck_id), |r| {
|
||||
let type_line: String = r.get(8)?;
|
||||
let partner = crate::partner::parse(&r.get::<_, String>(18)?, &type_line);
|
||||
Ok(DeckEntry {
|
||||
let entry = DeckEntry {
|
||||
quantity: r.get(0)?,
|
||||
is_commander: r.get::<_, i64>(1)? != 0,
|
||||
quantity_owned: r.get(2)?,
|
||||
@@ -169,7 +192,8 @@ impl Catalog {
|
||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||
preferred_scryfall_id: r.get(17)?,
|
||||
partner,
|
||||
})
|
||||
};
|
||||
Ok((r.get(19)?, entry))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
@@ -178,7 +202,7 @@ impl Catalog {
|
||||
/// The decks a given card is currently assigned to.
|
||||
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
|
||||
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id
|
||||
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
|
||||
WHERE dc.oracle_id = ?1
|
||||
ORDER BY d.name COLLATE NOCASE",
|
||||
@@ -196,6 +220,7 @@ impl Catalog {
|
||||
format: r.get(2)?,
|
||||
commander_oracle_id: r.get(3)?,
|
||||
notes: r.get(4)?,
|
||||
cover_oracle_id: r.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,6 +233,17 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Choose (or clear, with `None`) the card whose art headlines the deck in
|
||||
/// the deck list. Not checked against the deck's contents: a cover that
|
||||
/// leaves the deck is simply passed over by the UI.
|
||||
pub fn set_deck_cover(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE decks SET cover_oracle_id = ?1 WHERE id = ?2",
|
||||
params![oracle_id, deck_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
||||
/// the corresponding deck_cards row as the commander when present.
|
||||
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
||||
@@ -508,6 +544,56 @@ mod tests {
|
||||
assert!(cat.duplicate_deck(src.id, "Original").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deck_contents_groups_each_decks_cards_by_deck() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let a = cat.create_deck("A", None).unwrap();
|
||||
let b = cat.create_deck("B", None).unwrap();
|
||||
let empty = cat.create_deck("Empty", None).unwrap();
|
||||
cat.set_deck_card(a.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card(a.id, "lightning-bolt", 4, false, true).unwrap();
|
||||
cat.set_deck_card(b.id, "sol-ring", 2, false, false).unwrap();
|
||||
cat.set_deck_card_printing(b.id, "sol-ring", Some("print-sol-ring")).unwrap();
|
||||
|
||||
let all = cat.all_deck_contents().unwrap();
|
||||
assert!(!all.contains_key(&empty.id));
|
||||
let names = |id: i64| -> Vec<(String, i64)> {
|
||||
all[&id].iter().map(|e| (e.card.oracle_id.clone(), e.quantity)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
names(a.id),
|
||||
vec![("lightning-bolt".to_string(), 4), ("sol-ring".to_string(), 1)]
|
||||
);
|
||||
assert_eq!(names(b.id), vec![("sol-ring".to_string(), 2)]);
|
||||
// Each deck's slot carries its own flags and chosen printing.
|
||||
assert!(all[&a.id][0].is_maybeboard);
|
||||
assert_eq!(all[&b.id][0].preferred_scryfall_id.as_deref(), Some("print-sol-ring"));
|
||||
assert_eq!(all[&a.id][1].preferred_scryfall_id, None);
|
||||
// Same rows as the per-deck query.
|
||||
assert_eq!(all[&a.id].len(), cat.deck_contents(a.id).unwrap().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_cover_is_set_cleared_and_copied_by_duplicate() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Covered", None).unwrap();
|
||||
assert_eq!(d.cover_oracle_id, None);
|
||||
|
||||
cat.set_deck_cover(d.id, Some("serra-angel")).unwrap();
|
||||
let got = cat.get_deck(d.id).unwrap().unwrap();
|
||||
assert_eq!(got.cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
let listed = cat.list_decks().unwrap();
|
||||
assert_eq!(listed[0].cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
|
||||
let copy = cat.duplicate_deck(d.id, "Covered (copy)").unwrap();
|
||||
assert_eq!(copy.cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
|
||||
cat.set_deck_cover(d.id, None).unwrap();
|
||||
assert_eq!(cat.get_deck(d.id).unwrap().unwrap().cover_oracle_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_deck_commanders_flags_each_and_keeps_the_first_as_primary() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -269,6 +269,9 @@ pub struct Deck {
|
||||
/// The commander card's oracle id (meaningful for Commander/Brawl formats).
|
||||
pub commander_oracle_id: Option<String>,
|
||||
pub notes: String,
|
||||
/// The card whose art headlines the deck in the deck list, if chosen;
|
||||
/// `None` leaves the pick to the UI.
|
||||
pub cover_oracle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Aggregate stats about the owned library, for a dashboard-style summary.
|
||||
|
||||
4
crates/mtgc-core/src/sql/library/0007_deck_cover.sql
Normal file
4
crates/mtgc-core/src/sql/library/0007_deck_cover.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- v7 — a deck's chosen cover card: whose art headlines it in the deck list.
|
||||
-- NULL means pick one automatically (the commander, else a standout card). An
|
||||
-- oracle id that's no longer in the deck falls back the same way.
|
||||
ALTER TABLE decks ADD COLUMN cover_oracle_id TEXT;
|
||||
@@ -56,7 +56,8 @@ pub mod remote;
|
||||
/// 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
|
||||
/// them — while libraries that don't use printings stay readable everywhere.
|
||||
pub const DOC_VERSION: u32 = 2;
|
||||
/// Version 3 does the same for deck covers.
|
||||
pub const DOC_VERSION: u32 = 3;
|
||||
|
||||
/// The format of a document with no printing data in it.
|
||||
const BASE_VERSION: u32 = 1;
|
||||
@@ -108,6 +109,9 @@ pub struct DeckDoc {
|
||||
pub commander: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub notes: String,
|
||||
/// The oracle id of the card chosen to headline the deck in the deck list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cover: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards: BTreeMap<String, DeckCardEntry>,
|
||||
}
|
||||
@@ -165,9 +169,12 @@ impl LibraryDoc {
|
||||
self.cards.is_empty() && self.decks.is_empty()
|
||||
}
|
||||
|
||||
/// The lowest format that can hold this document: 2 once it carries any
|
||||
/// printing data, else 1. See [`DOC_VERSION`].
|
||||
/// The lowest format that can hold this document: 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 {
|
||||
if self.decks.values().any(|d| d.cover.is_some()) {
|
||||
return 3;
|
||||
}
|
||||
let has_copies = self.cards.values().any(|c| !c.printings.is_empty());
|
||||
let has_deck_printings = self
|
||||
.decks
|
||||
@@ -415,6 +422,9 @@ fn merge_deck(
|
||||
notes: merge_field(&b.notes, &o.notes, &t.notes, || {
|
||||
report.conflicts.push(format!("notes of deck \"{name}\""))
|
||||
}),
|
||||
cover: merge_field(&b.cover, &o.cover, &t.cover, || {
|
||||
report.conflicts.push(format!("cover of deck \"{name}\""))
|
||||
}),
|
||||
cards: BTreeMap::new(),
|
||||
};
|
||||
|
||||
@@ -483,7 +493,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?;
|
||||
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut by_id: BTreeMap<i64, String> = BTreeMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
@@ -497,6 +507,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
format: row.get(3)?,
|
||||
commander: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
cover: row.get(6)?,
|
||||
cards: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
@@ -585,14 +596,15 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
||||
|
||||
for (uuid, deck) in &doc.decks {
|
||||
tx.execute(
|
||||
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
format = excluded.format,
|
||||
commander_oracle_id = excluded.commander_oracle_id,
|
||||
notes = excluded.notes",
|
||||
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes],
|
||||
notes = excluded.notes,
|
||||
cover_oracle_id = excluded.cover_oracle_id",
|
||||
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes, deck.cover],
|
||||
)?;
|
||||
let deck_id: i64 =
|
||||
tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?;
|
||||
@@ -911,6 +923,43 @@ mod tests {
|
||||
assert_eq!(back, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_3_is_used_only_once_a_deck_has_a_cover() {
|
||||
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
||||
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into());
|
||||
assert_eq!(d.required_version(), 2);
|
||||
|
||||
d.decks.get_mut("u1").unwrap().cover = Some("a".into());
|
||||
assert_eq!(d.required_version(), 3);
|
||||
|
||||
let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default());
|
||||
assert_eq!(merged.version, 3);
|
||||
let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap();
|
||||
assert_eq!(back, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_chosen_on_one_side_survives_an_edit_on_the_other() {
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let mut ours = base.clone();
|
||||
let mut theirs = base.clone();
|
||||
ours.decks.get_mut("u1").unwrap().cover = Some("a".into());
|
||||
theirs.decks.get_mut("u1").unwrap().name = "Renamed".into();
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
let deck = &merged.decks["u1"];
|
||||
assert_eq!(deck.cover.as_deref(), Some("a"));
|
||||
assert_eq!(deck.name, "Renamed");
|
||||
assert!(report.conflicts.is_empty());
|
||||
|
||||
// Both choosing different covers is a conflict that takes theirs.
|
||||
let mut theirs = base.clone();
|
||||
theirs.decks.get_mut("u1").unwrap().cover = Some("b".into());
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
assert_eq!(merged.decks["u1"].cover.as_deref(), Some("b"));
|
||||
assert_eq!(report.conflicts, vec!["cover of deck \"Fub\"".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_1_document_carries_no_printing_fields() {
|
||||
// Older builds must see exactly what they always saw.
|
||||
@@ -938,10 +987,12 @@ mod tests {
|
||||
c.set_deck_format(deck.id, "Commander").unwrap();
|
||||
c.set_deck_card(deck.id, "card-a", 2, false, false).unwrap();
|
||||
c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap();
|
||||
c.set_deck_cover(deck.id, Some("card-a")).unwrap();
|
||||
c.set_owned("card-a", 4).unwrap();
|
||||
c.set_location("card-a", "Box 1").unwrap();
|
||||
|
||||
let before = read_doc(&c.conn).unwrap();
|
||||
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
|
||||
// Round-trip through JSON too, so the serde attributes are exercised.
|
||||
let text = before.to_json().unwrap();
|
||||
let parsed = LibraryDoc::from_json(&text).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user