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:
OpenSquared
2026-06-22 17:08:24 +02:00
parent 7b2e333a9d
commit 1cc192a221

View File

@@ -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} />
))}