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:
2026-10-25 15:04:41 -05:00
parent fae1aa2b05
commit d286d3420c
15 changed files with 2113 additions and 970 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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();

View File

@@ -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.

View 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;

View File

@@ -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();