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; +}