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.
This commit is contained in:
2026-08-14 01:02:21 -05:00
parent dfba2403a8
commit d014609244
4 changed files with 128 additions and 7 deletions

View File

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

View File

@@ -41,6 +41,10 @@ export function SuggestionsModal({
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [activeCategories, setActiveCategories] = useState<Set<string>>(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<Set<string>>(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<string, { count: number; category: string }>();
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 (
<DialogFrame title="Suggested cards" onCancel={onClose} wide>
@@ -155,7 +196,40 @@ export function SuggestionsModal({
<button
type="button"
className="filter-chip-clear"
onClick={() => setActiveCategories(new Set())}
onClick={() => {
setActiveCategories(new Set());
setActiveSubjects(new Set());
}}
>
Clear
</button>
)}
</div>
)}
{availableSubjects.length > 1 && (
<div className="suggestion-filters">
<span className="muted" style={{ fontSize: 12 }}>
Narrow to:
</span>
{availableSubjects.map(({ subject, count, category }) => (
<button
key={subject}
type="button"
className={`filter-chip filter-chip-${categorySlug(category)} ${
activeSubjects.has(subject) ? "active" : ""
}`}
onClick={() => toggleSubject(subject)}
title={`${category} · ${count} suggestion${count === 1 ? "" : "s"}`}
>
{subject} <span className="filter-chip-count">{count}</span>
</button>
))}
{activeSubjects.size > 0 && (
<button
type="button"
className="filter-chip-clear"
onClick={() => setActiveSubjects(new Set())}
>
Clear
</button>

View File

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

View File

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