Files
mtgc/src/SuggestionsModal.tsx

336 lines
13 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from "react";
import { api, CardSuggestion } from "./api";
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
import { CardThumb, ColorPips, DialogFrame, usePersistedState } from "./components";
// Matches the backend's hardcoded per-page limit (src-tauri/src/commands.rs).
// A batch shorter than this means there's nothing left to fetch.
const PAGE_SIZE = 50;
// Fixed display order, matching the four synergy families the backend scores.
const CATEGORY_ORDER = ["Tribal", "Mechanic", "Archetype", "Mana base"];
function categorySlug(category: string): string {
return category.toLowerCase().replace(/[^a-z]+/g, "-");
}
/**
* On-demand modal listing scored card suggestions for a deck, each with an
* explainable synergy breakdown and "Add to deck"/"Add to maybeboard"
* actions. Fetches a page on open; toggleable chips filter which reason
* categories must be present (client-side, over everything loaded so far);
* "Suggest more" fetches and appends the next page. Adding a card removes
* its row in place so several can be added in one sitting. An "Unusual
* picks" toggle re-fetches from the backend restricted to (and reordered
* toward) cards that aren't widely played, surfacing underappreciated
* synergy hits instead of the usual staples — that reordering only makes
* sense server-side, before pagination slices the result, so toggling it
* resets and re-fetches rather than filtering what's already loaded.
*/
export function SuggestionsModal({
deckId,
onClose,
onCardAdded,
}: {
deckId: number;
onClose: () => void;
onCardAdded: () => void;
}) {
const [suggestions, setSuggestions] = useState<CardSuggestion[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<Set<string>>(new Set());
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);
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
// Remembered across decks: building from what you own is a way of working,
// not a one-off filter.
const [ownedOnly, setOwnedOnly] = usePersistedState("suggest-owned-only", false);
const options = { unusual, owned_only: ownedOnly };
useEffect(() => {
let cancelled = false;
setSuggestions(null);
setError(null);
api
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
.suggestCards(deckId, 0, options)
.then((s) => {
if (cancelled) return;
setSuggestions(s);
setHasMore(s.length === PAGE_SIZE);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
// `options` is rebuilt every render; its two inputs are the real deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deckId, unusual, ownedOnly]);
async function loadMore() {
if (!suggestions) return;
setLoadingMore(true);
try {
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
const more = await api.suggestCards(deckId, suggestions.length, options);
setSuggestions((cur) => {
const seen = new Set((cur ?? []).map((s) => s.card.oracle_id));
return [...(cur ?? []), ...more.filter((s) => !seen.has(s.card.oracle_id))];
});
setHasMore(more.length === PAGE_SIZE);
} finally {
setLoadingMore(false);
}
}
async function add(s: CardSuggestion, maybeboard: boolean) {
const oid = s.card.oracle_id;
setBusy((b) => new Set(b).add(oid));
try {
await api.setDeckCard(deckId, oid, 1, false, maybeboard);
onCardAdded();
setSuggestions((cur) => (cur ? cur.filter((x) => x.card.oracle_id !== oid) : cur));
} finally {
setBusy((b) => {
const next = new Set(b);
next.delete(oid);
return next;
});
}
}
function toggleCategory(cat: string) {
setActiveCategories((cur) => {
const next = new Set(cur);
if (next.has(cat)) next.delete(cat);
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)))
: [];
/** 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>
<div className="row" style={{ gap: 8, marginBottom: 12 }}>
<button
type="button"
className={`filter-chip ${unusual ? "active" : ""}`}
onClick={() => setUnusual((u) => !u)}
title="Only show cards that fit your deck's strategy but aren't widely played — cool, underappreciated picks."
>
✦ Unusual picks
</button>
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
<button
type="button"
className={`filter-chip ${ownedOnly ? "active" : ""}`}
onClick={() => setOwnedOnly(!ownedOnly)}
title="Only suggest cards you already own at least one copy of."
>
From my collection
</button>
</div>
{error ? (
<p className="hint">{error}</p>
) : suggestions === null ? (
<p className="hint">Analyzing your deck…</p>
) : suggestions.length === 0 ? (
<p className="hint">
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
{ownedOnly
? "Nothing you own fits this deck yet — turn off \"From my collection\" to see every card that would."
: unusual
? "No unusual picks found — try turning off \"Unusual picks\" to see the widely-played suggestions too."
: "No suggestions yet — add a few cards (and a commander, if it's a Commander deck) so there's a strategy to build on."}
</p>
) : (
<>
{availableCategories.length > 1 && (
<div className="suggestion-filters">
<span className="muted" style={{ fontSize: 12 }}>
Filter by why:
</span>
{availableCategories.map((cat) => (
<button
key={cat}
type="button"
className={`filter-chip filter-chip-${categorySlug(cat)} ${
activeCategories.has(cat) ? "active" : ""
}`}
onClick={() => toggleCategory(cat)}
>
{cat}
</button>
))}
{activeCategories.size > 0 && (
<button
type="button"
className="filter-chip-clear"
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>
)}
</div>
)}
{visible && visible.length === 0 ? (
<p className="hint">No suggestions match the selected filters.</p>
) : (
<ul className="result-list suggestion-list">
{visible?.map((s) => (
<li key={s.card.oracle_id}>
<div className="suggestion-row">
<CardThumb card={s.card} />
<div className="suggestion-main">
<div className="suggestion-head">
<ColorPips mask={s.card.color_identity} />
<span className="card-name">{s.card.name}</span>
{s.quantity_owned > 0 ? (
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
<>
<span className="badge">Owned ×{s.quantity_owned}</span>
{s.quantity_assigned > 0 && (
<span
className="muted"
style={{ fontSize: 12 }}
title={`${s.quantity_assigned} in your other decks`}
>
{s.quantity_assigned >= s.quantity_owned
? "all in other decks"
: `${s.quantity_owned - s.quantity_assigned} spare`}
</span>
)}
</>
) : (
<span className="muted" style={{ fontSize: 12 }}>
Not owned
</span>
)}
{s.edhrec_rank !== null && s.edhrec_rank < 2000 && (
<span className="muted" style={{ fontSize: 12 }}>
★ Popular
</span>
)}
<span className="spacer" />
<span className="suggestion-score">{s.score.toFixed(0)}</span>
</div>
<div className="suggestion-reasons">
{s.reasons.map((r, i) => (
<span key={i} className={`reason-chip reason-chip-${categorySlug(r.category)}`}>
<span className="reason-chip-category">{r.category}</span>
{r.detail}
</span>
))}
</div>
</div>
<div className="suggestion-actions">
<button onClick={() => add(s, false)} disabled={busy.has(s.card.oracle_id)}>
Add to deck
</button>
<button onClick={() => add(s, true)} disabled={busy.has(s.card.oracle_id)}>
Add to maybeboard
</button>
</div>
</div>
</li>
))}
</ul>
)}
{hasMore && (
<div className="row" style={{ justifyContent: "center", marginTop: 12 }}>
<button onClick={loadMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Suggest more"}
</button>
</div>
)}
</>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}