From d014609244d3a775a65c01205035ea2e4e3c4159 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 14 Aug 2026 01:02:21 -0500 Subject: [PATCH] Let suggestion filtering narrow to a specific archetype or tribe Filtering by category alone wasn't granular enough: a deck usually matches several archetypes and tribes at once, so "show me Archetype suggestions" still returns most of the list. SynergyReason now carries the matched thing itself (`subject`) alongside the human-readable `detail`, populated at all four match sites. The modal adds a second chip row listing every subject present in the current suggestions, sorted by how many cards each explains and labelled with that count, so the most useful narrowing is obvious before clicking. --- crates/mtgc-core/src/synergy.rs | 38 ++++++++++++++- src/SuggestionsModal.tsx | 84 +++++++++++++++++++++++++++++++-- src/api.ts | 4 ++ src/styles.css | 9 ++++ 4 files changed, 128 insertions(+), 7 deletions(-) diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs index 16c9f6a..80ad00f 100644 --- a/crates/mtgc-core/src/synergy.rs +++ b/crates/mtgc-core/src/synergy.rs @@ -92,6 +92,11 @@ pub fn compute_themes(oracle_text: &str) -> i64 { #[derive(Debug, Clone, Serialize)] pub struct SynergyReason { pub category: String, + /// The specific thing matched — "Dragon", "Flying", "Landfall". Carried + /// separately from `detail` so the UI can filter on it: knowing a + /// suggestion is an Archetype hit is rarely enough, since a deck usually + /// has several and you want one of them. + pub subject: String, pub detail: String, } @@ -411,7 +416,11 @@ impl Catalog { let n = tribal_counts.get(s).copied().unwrap_or(0); format!("Shares creature type: {s} ({n} in deck)") }; - reasons.push(SynergyReason { category: "Tribal".into(), detail }); + reasons.push(SynergyReason { + category: "Tribal".into(), + subject: s.clone(), + detail, + }); } // Mechanic @@ -430,7 +439,11 @@ impl Catalog { let n = keyword_counts.get(k).copied().unwrap_or(0); format!("Shares keyword: {k} ({n} in deck)") }; - reasons.push(SynergyReason { category: "Mechanic".into(), detail }); + reasons.push(SynergyReason { + category: "Mechanic".into(), + subject: k.clone(), + detail, + }); } // Archetype @@ -444,6 +457,7 @@ impl Catalog { score += ARCHETYPE_POINTS; reasons.push(SynergyReason { category: "Archetype".into(), + subject: (*name).to_string(), detail: format!("{name} payoff/enabler"), }); arche += 1; @@ -461,6 +475,7 @@ impl Catalog { .collect(); reasons.push(SynergyReason { category: "Mana base".into(), + subject: letters.clone(), detail: format!("Produces {letters}, which your deck can't currently make"), }); } @@ -790,4 +805,23 @@ mod tests { let d = cat.create_deck("Empty", None).unwrap(); assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty()); } + +#[cfg(test)] +mod subject_tests { + use super::*; + + #[test] + fn a_reason_names_the_specific_thing_it_matched() { + // The subject is what the UI filters on, so it must be the bare + // "Dragon"/"Landfall" rather than the sentence shown to the user — + // otherwise picking one archetype would mean matching prose. + let r = SynergyReason { + category: "Archetype".into(), + subject: "Landfall".into(), + detail: "Landfall payoff/enabler".into(), + }; + assert_eq!(r.subject, "Landfall"); + assert!(r.detail.contains(&r.subject), "detail should still read naturally"); + } +} } diff --git a/src/SuggestionsModal.tsx b/src/SuggestionsModal.tsx index 0c29278..e1e9046 100644 --- a/src/SuggestionsModal.tsx +++ b/src/SuggestionsModal.tsx @@ -41,6 +41,10 @@ export function SuggestionsModal({ const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [activeCategories, setActiveCategories] = useState>(new Set()); + // Second level: the specific tribe/keyword/archetype. Knowing a suggestion is + // an "Archetype" hit is rarely enough — a deck usually has several and you + // want one of them. + const [activeSubjects, setActiveSubjects] = useState>(new Set()); const [unusual, setUnusual] = useState(false); useEffect(() => { @@ -100,15 +104,52 @@ export function SuggestionsModal({ else next.add(cat); return next; }); + // Subjects belong to categories, so narrowing the categories can strand a + // selected subject that's no longer offered — clear rather than leave an + // invisible filter applied. + setActiveSubjects(new Set()); + } + + function toggleSubject(subject: string) { + setActiveSubjects((cur) => { + const next = new Set(cur); + if (next.has(subject)) next.delete(subject); + else next.add(subject); + return next; + }); } const availableCategories = suggestions ? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat))) : []; - const visible = - suggestions && activeCategories.size > 0 - ? suggestions.filter((s) => s.reasons.some((r) => activeCategories.has(r.category))) - : suggestions; + /** Reasons still in play once the category chips are applied. */ + const inCategory = (r: { category: string }) => + activeCategories.size === 0 || activeCategories.has(r.category); + + // Offered subjects follow the category selection, so the second row is always + // a drill-down into the first rather than an unrelated list. Ordered by how + // many suggestions each explains, so the most useful narrowing comes first. + const subjectCounts = new Map(); + for (const s of suggestions ?? []) { + for (const r of s.reasons) { + if (!inCategory(r) || !r.subject) continue; + const prev = subjectCounts.get(r.subject); + subjectCounts.set(r.subject, { + count: (prev?.count ?? 0) + 1, + category: prev?.category ?? r.category, + }); + } + } + const availableSubjects = [...subjectCounts.entries()] + .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0])) + .map(([subject, meta]) => ({ subject, ...meta })); + + const visible = suggestions?.filter((s) => + s.reasons.some( + (r) => + inCategory(r) && (activeSubjects.size === 0 || activeSubjects.has(r.subject)), + ), + ); return ( @@ -155,7 +196,40 @@ export function SuggestionsModal({ + )} + + )} + + {availableSubjects.length > 1 && ( +
+ + Narrow to: + + {availableSubjects.map(({ subject, count, category }) => ( + + ))} + {activeSubjects.size > 0 && ( + diff --git a/src/api.ts b/src/api.ts index 751fd97..dc53c45 100644 --- a/src/api.ts +++ b/src/api.ts @@ -70,6 +70,10 @@ export interface LibraryImportResult { export interface SynergyReason { category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base" + /** The specific thing matched — "Dragon", "Flying", "Landfall". Separate from + * `detail` so the UI can filter on it: a deck usually has several archetypes + * and you want one of them, not all. */ + subject: string; detail: string; } diff --git a/src/styles.css b/src/styles.css index 4f52838..21347ab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2757,3 +2757,12 @@ tbody tr:hover { overflow-wrap: anywhere; min-width: 0; } + +/* Suggestion count inside a "narrow to" chip — how many suggestions that + specific tribe/keyword/archetype explains, so the most useful narrowing is + obvious before clicking. */ +.filter-chip-count { + opacity: 0.55; + font-variant-numeric: tabular-nums; + margin-left: 2px; +}