Add deck analysis panel with mana production/consumption bars and oracle text search

Threads a new produced_mana/produces_colorless field through the whole card
read path (ingest, library, filters, decks, search) so the deck view can
show what colors a deck's manabase actually produces, alongside its existing
color identity. Also adds a mana consumption bar (colored casting costs of
nonland spells) and stacks the mana curve histogram by color. Adds an
oracle-text substring filter to the library search, mirroring the existing
location filter. Rearranges the deck sidebar list into two rows (name, then
format chip + actions) so long names and action buttons no longer collide,
and removes the drop shadows from the commander hero panel/image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:54:47 -05:00
parent 4ac536caa2
commit e3f8a299de
17 changed files with 1054 additions and 161 deletions

View File

@@ -6,12 +6,13 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryStats, WUBRG};
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri)` into its
/// library parts. Shared by [`Catalog::list_library`] and the criteria search.
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`]
/// and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
@@ -29,6 +30,8 @@ pub(crate) fn row_to_library_parts(
type_line: r.get(7)?,
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
},
))
}
@@ -70,7 +73,8 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back}
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
@@ -144,6 +148,29 @@ impl Catalog {
})
}
/// Distinct type/subtype/keyword values among owned cards, for filter
/// autocomplete.
pub fn library_facets(&self) -> Result<LibraryFacets> {
Ok(LibraryFacets {
types: self.distinct_owned_facet("card_types", "type")?,
subtypes: self.distinct_owned_facet("card_subtypes", "subtype")?,
keywords: self.distinct_owned_facet("card_keywords", "keyword")?,
})
}
/// `table`/`column` are always one of the three hardcoded call sites above
/// (never user input), so interpolating them into the query is safe.
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT DISTINCT x.{column}
FROM {REF_DB}.{table} x
WHERE x.oracle_id IN (SELECT oracle_id FROM library_cards WHERE quantity_owned > 0)
ORDER BY x.{column} COLLATE NOCASE"
))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// Set the free-text storage location for a card (creates the library row if
/// absent). Empty string clears it.
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
@@ -207,6 +234,22 @@ mod tests {
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn library_facets_only_include_owned_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
// Own llanowar-elves (Creature — Elf Druid) and lightning-bolt
// (Instant); leave serra-angel (Creature — Angel, Flying/Vigilance)
// unowned.
cat.add_owned("llanowar-elves", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
let facets = cat.library_facets().unwrap();
assert_eq!(facets.types, vec!["Creature", "Instant"]);
assert_eq!(facets.subtypes, vec!["Druid", "Elf"]);
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
}
#[test]
fn library_stats_aggregates_counts_and_colors() {
let fx = test_support::make();