import { useEffect, useState } from "react"; import { api, CardSuggestion } from "./api"; import { CardThumb, ColorPips, DialogFrame } 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(null); const [error, setError] = useState(null); const [busy, setBusy] = useState>(new Set()); 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(() => { let cancelled = false; setSuggestions(null); setError(null); api .suggestCards(deckId, 0, unusual) .then((s) => { if (cancelled) return; setSuggestions(s); setHasMore(s.length === PAGE_SIZE); }) .catch((e) => { if (!cancelled) setError(String(e)); }); return () => { cancelled = true; }; }, [deckId, unusual]); async function loadMore() { if (!suggestions) return; setLoadingMore(true); try { const more = await api.suggestCards(deckId, suggestions.length, unusual); 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(); 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 (
{error ? (

{error}

) : suggestions === null ? (

Analyzing your deck…

) : suggestions.length === 0 ? (

{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."}

) : ( <> {availableCategories.length > 1 && (
Filter by why: {availableCategories.map((cat) => ( ))} {activeCategories.size > 0 && ( )}
)} {availableSubjects.length > 1 && (
Narrow to: {availableSubjects.map(({ subject, count, category }) => ( ))} {activeSubjects.size > 0 && ( )}
)} {visible && visible.length === 0 ? (

No suggestions match the selected filters.

) : (
    {visible?.map((s) => (
  • {s.card.name} {s.quantity_owned > 0 ? ( Owned ×{s.quantity_owned} ) : ( Not owned )} {s.edhrec_rank !== null && s.edhrec_rank < 2000 && ( ★ Popular )} {s.score.toFixed(0)}
    {s.reasons.map((r, i) => ( {r.category} {r.detail} ))}
  • ))}
)} {hasMore && (
)} )}
); }