feat: PatternExplorer — sort + category filter + category badge on cards
- Sort dropdown: AI Score / Probability / Expected Move / Date Added - Category filter pills auto-populated from patterns in current node - CategoryBadge component with colored pills (matches Trade Ideas palette) - AI score shown top-right on each pattern card - Changing taxonomy node resets category filter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,28 @@ function SignalBadge({ direction }: { direction?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Category Badge ────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
geopolitical: 'bg-red-900/40 border-red-700/40 text-red-300',
|
||||
macro: 'bg-blue-900/40 border-blue-700/40 text-blue-300',
|
||||
volatility: 'bg-amber-900/40 border-amber-700/40 text-amber-300',
|
||||
'risk-off': 'bg-orange-900/40 border-orange-700/40 text-orange-300',
|
||||
supply: 'bg-teal-900/40 border-teal-700/40 text-teal-300',
|
||||
credit: 'bg-purple-900/40 border-purple-700/40 text-purple-300',
|
||||
'geo-eco': 'bg-emerald-900/40 border-emerald-700/40 text-emerald-300',
|
||||
sentiment: 'bg-yellow-900/40 border-yellow-700/40 text-yellow-300',
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }: { category: string }) {
|
||||
const cls = CATEGORY_COLORS[category] ?? 'bg-violet-900/40 border-violet-700/40 text-violet-300'
|
||||
return (
|
||||
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded border capitalize', cls)}>
|
||||
{category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pattern Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PatternCard({
|
||||
@@ -143,7 +165,13 @@ function PatternCard({
|
||||
<SignalBadge direction={pattern.signal_direction} />
|
||||
<span className="text-[10px] font-mono text-slate-500">{pattern.id}</span>
|
||||
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
|
||||
{pattern.category && <CategoryBadge category={pattern.category} />}
|
||||
</div>
|
||||
{pattern.ai_quality_score != null && (
|
||||
<span className="text-[10px] font-mono shrink-0 text-slate-500">
|
||||
AI <span className="text-slate-300 font-semibold">{pattern.ai_quality_score}</span>/100
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
@@ -318,12 +346,14 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
|
||||
|
||||
const filteredPatterns = useMemo(() => {
|
||||
// Patterns matching the selected taxonomy node + name filter
|
||||
const nodePatterns = useMemo(() => {
|
||||
let list = patterns
|
||||
if (selectedPath.length > 0) {
|
||||
if (selectedPath.length > 0)
|
||||
list = list.filter(p => pathStartsWith(p.taxonomy_path, selectedPath))
|
||||
}
|
||||
if (nameFilter.trim()) {
|
||||
const q = nameFilter.trim().toLowerCase()
|
||||
list = list.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q))
|
||||
@@ -331,8 +361,46 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
return list
|
||||
}, [patterns, selectedPath, nameFilter])
|
||||
|
||||
// Available categories within current node (for filter pills)
|
||||
const availableCategories = useMemo(() => {
|
||||
const cats = new Set(nodePatterns.map(p => p.category).filter(Boolean) as string[])
|
||||
return Array.from(cats).sort()
|
||||
}, [nodePatterns])
|
||||
|
||||
// Apply category filter + sort
|
||||
const filteredPatterns = useMemo(() => {
|
||||
let list = nodePatterns
|
||||
if (categoryFilter) list = list.filter(p => p.category === categoryFilter)
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortBy === 'ai_score') {
|
||||
const sa = a.ai_quality_score ?? -1
|
||||
const sb = b.ai_quality_score ?? -1
|
||||
return sb - sa
|
||||
}
|
||||
if (sortBy === 'probability') return b.probability - a.probability
|
||||
if (sortBy === 'expected_move') return Math.abs(b.expected_move_pct) - Math.abs(a.expected_move_pct)
|
||||
// date
|
||||
const da = (a as any).created_at ?? ''
|
||||
const db = (b as any).created_at ?? ''
|
||||
return db.localeCompare(da)
|
||||
})
|
||||
}, [nodePatterns, categoryFilter, sortBy])
|
||||
|
||||
const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns'
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
geopolitical: 'bg-red-900/40 border-red-700/40 text-red-300',
|
||||
macro: 'bg-blue-900/40 border-blue-700/40 text-blue-300',
|
||||
volatility: 'bg-amber-900/40 border-amber-700/40 text-amber-300',
|
||||
'risk-off': 'bg-orange-900/40 border-orange-700/40 text-orange-300',
|
||||
supply: 'bg-teal-900/40 border-teal-700/40 text-teal-300',
|
||||
credit: 'bg-purple-900/40 border-purple-700/40 text-purple-300',
|
||||
'geo-eco': 'bg-emerald-900/40 border-emerald-700/40 text-emerald-300',
|
||||
sentiment: 'bg-yellow-900/40 border-yellow-700/40 text-yellow-300',
|
||||
}
|
||||
const catColor = (cat: string) =>
|
||||
CATEGORY_COLORS[cat] ?? 'bg-slate-700/40 border-slate-600/40 text-slate-300'
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 min-h-0 flex-1">
|
||||
{/* Left sidebar */}
|
||||
@@ -349,7 +417,7 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setSelectedPath([])}
|
||||
onClick={() => { setSelectedPath([]); setCategoryFilter('') }}
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
<span className="flex-1">All Patterns</span>
|
||||
@@ -366,7 +434,7 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
node={node}
|
||||
level={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
onSelect={path => { setSelectedPath(path); setCategoryFilter('') }}
|
||||
defaultExpandedDepth={1}
|
||||
/>
|
||||
))
|
||||
@@ -377,7 +445,7 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{/* Breadcrumb + filter */}
|
||||
{/* Row 1: breadcrumb + search + count */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="text-sm text-slate-400 font-mono">
|
||||
<span className="text-slate-600">Path: </span>
|
||||
@@ -398,13 +466,52 @@ function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: category pills + sort */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{availableCategories.length > 0 && (
|
||||
<>
|
||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider shrink-0">Category:</span>
|
||||
<button
|
||||
onClick={() => setCategoryFilter('')}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
categoryFilter === ''
|
||||
? 'bg-slate-600/50 border-slate-500/50 text-slate-200'
|
||||
: 'border-slate-700/30 text-slate-500 hover:text-slate-300')}
|
||||
>All</button>
|
||||
{availableCategories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(c => c === cat ? '' : cat)}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors capitalize',
|
||||
categoryFilter === cat
|
||||
? catColor(cat)
|
||||
: 'border-slate-700/30 text-slate-500 hover:text-slate-300')}
|
||||
>{cat}</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider">Sort:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="input text-xs py-1 pr-6 pl-2"
|
||||
>
|
||||
<option value="ai_score">AI Score ↓</option>
|
||||
<option value="probability">Probability ↓</option>
|
||||
<option value="expected_move">Expected Move ↓</option>
|
||||
<option value="date">Date Added ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern grid */}
|
||||
{filteredPatterns.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1 text-slate-600 text-sm py-16">
|
||||
No patterns match the selected filter
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-220px)] pr-1">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-260px)] pr-1">
|
||||
{filteredPatterns.map(p => (
|
||||
<PatternCard key={p.id} pattern={p} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user