feat: replace tree with faceted search + delete pattern button

- PatternExplorer: replace rigid taxonomy tree with FacetedSearch — combinable chip filters on Direction, Asset class, Category, Horizon, Source + full-text search (name/description/regime) + sort. No fixed hierarchy, scales with library size.
- PatternCard: delete button (trash icon) for non-builtin patterns, two-step confirm/cancel to prevent accidental deletion. Shows #regime_tag chip inline.
- RegimeCard and RegimeView unchanged.
- Remove dead code: TaxonomyNode, TreeNodeRow, enrichTree, pathStartsWith, TreeView, usePatternTaxonomy import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 21:01:16 +02:00
parent a435c11246
commit f98ac112a3

View File

@@ -4,29 +4,19 @@ import clsx from 'clsx'
import {
TreePine, Search, ChevronRight, ChevronDown,
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
Layers, GitMerge, Tag,
Layers, GitMerge, Tag, Trash2,
} from 'lucide-react'
import {
useAllPatterns,
usePatternTaxonomy,
usePatternsByInstrument,
useLastScores,
useDeletePattern,
validateTicker,
} from '../hooks/useApi'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Local types ───────────────────────────────────────────────────────────────
interface TaxonomyNode {
id: string
label: string
icon?: string
description?: string
children?: TaxonomyNode[]
count?: number
path: string[]
}
interface SuggestedTrade {
strategy: string
underlying: string
@@ -90,12 +80,6 @@ function probStars(prob: number): string {
return '★'.repeat(n) + '☆'.repeat(3 - n)
}
function pathStartsWith(full: string[] | undefined, prefix: string[]): boolean {
if (!full || prefix.length === 0) return true
if (full.length < prefix.length) return false
return prefix.every((seg, i) => seg === full[i])
}
function formatTaxPath(path: string[] | undefined): string {
if (!path || path.length === 0) return ''
return path.join(' ')
@@ -156,11 +140,14 @@ function PatternCard({
pattern: Pattern
highlightTrades?: string[]
}) {
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const { mutate: deletePattern, isPending: deleting } = useDeletePattern()
const trades = pattern.suggested_trades ?? []
const instances = pattern.historical_instances ?? []
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
const isDeletable = pattern.source !== 'builtin'
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
@@ -173,12 +160,44 @@ function PatternCard({
<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} />}
{pattern.regime_tag && (
<span className="text-[10px] text-violet-400 font-mono">#{pattern.regime_tag}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{pattern.ai_quality_score != null && (
<span className="text-[10px] font-mono text-slate-500">
AI <span className="text-slate-300 font-semibold">{pattern.ai_quality_score}</span>/100
</span>
)}
{isDeletable && (
confirmDel ? (
<div className="flex items-center gap-1">
<button
onClick={() => deletePattern(pattern.id)}
disabled={deleting}
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 border border-red-700/60 text-red-400 hover:bg-red-900/70 transition-colors"
>
{deleting ? '…' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDel(false)}
className="text-[10px] px-1.5 py-0.5 rounded border border-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDel(true)}
className="text-slate-700 hover:text-red-400 transition-colors p-0.5 rounded"
title="Delete pattern"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)
)}
</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 */}
@@ -254,277 +273,192 @@ function PatternCard({
)
}
// ── Taxonomy Tree Node ────────────────────────────────────────────────────────
function TreeNodeRow({
node,
level,
selectedPath,
onSelect,
defaultExpandedDepth,
}: {
node: TaxonomyNode
level: number
selectedPath: string[]
onSelect: (path: string[]) => void
defaultExpandedDepth: number
}) {
const hasChildren = (node.children ?? []).length > 0
const [expanded, setExpanded] = useState(level < defaultExpandedDepth)
const isSelected =
selectedPath.length === node.path.length &&
node.path.every((seg, i) => seg === selectedPath[i])
const handleClick = useCallback(() => {
onSelect(node.path)
if (hasChildren) setExpanded(v => !v)
}, [node.path, onSelect, hasChildren])
const indent = level * 16
// ── Faceted Search ────────────────────────────────────────────────────────────
function FilterChip({
active, label, onClick, color,
}: { active: boolean; label: string; onClick: () => void; color?: string }) {
return (
<div>
<button
className={clsx(
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
isSelected
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
)}
style={{ paddingLeft: `${8 + indent}px` }}
onClick={handleClick}
>
{hasChildren ? (
expanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-slate-500" />
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-slate-500" />
) : (
<span className="w-3.5 shrink-0" />
)}
{node.icon && <span className="text-sm">{node.icon}</span>}
<span className="flex-1 truncate">{node.label}</span>
{node.count != null && node.count > 0 && (
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full ml-1 shrink-0">
{node.count}
</span>
)}
</button>
{hasChildren && expanded && (
<div>
{node.children!.map(child => (
<TreeNodeRow
key={child.id}
node={child}
level={level + 1}
selectedPath={selectedPath}
onSelect={onSelect}
defaultExpandedDepth={defaultExpandedDepth}
/>
))}
</div>
<button
onClick={onClick}
className={clsx(
'text-xs px-2.5 py-1 rounded-full border transition-colors whitespace-nowrap',
active
? color ?? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-700/40 text-slate-500 hover:text-slate-300 hover:border-slate-600',
)}
</div>
>
{label}
</button>
)
}
// ── Helper: add computed path arrays to each node ────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function enrichTree(node: any, parentPath: string[] = []): TaxonomyNode {
const path = [...parentPath, node.id]
return {
...node,
path,
children: (node.children ?? []).map((c: any) => enrichTree(c, path)),
}
const DIR_COLORS: Record<string, string> = {
bullish: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
up: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
bearish: 'bg-red-900/40 border-red-600/50 text-red-300',
down: 'bg-red-900/40 border-red-600/50 text-red-300',
neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300',
}
// ── Tree View ─────────────────────────────────────────────────────────────────
function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
const [query, setQuery] = useState('')
const [direction, setDirection] = useState('')
const [assetClass, setAssetClass] = useState('')
const [category, setCategory] = useState('')
const [horizon, setHorizon] = useState('')
const [source, setSource] = useState('')
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
function TreeView({ patterns }: { patterns: Pattern[] }) {
const { data: taxonomyData } = usePatternTaxonomy()
// API returns { tree: {...}, patterns: [...] } — extract the tree and enrich nodes with path arrays.
// Start enriching from root's children with [] so paths match pattern taxonomy_path (no "root" prefix).
const taxonomy: TaxonomyNode | undefined = useMemo(() => {
const raw = (taxonomyData as any)?.tree
if (!raw) return undefined
return { ...raw, path: [], children: (raw.children ?? []).map((c: any) => enrichTree(c, [])) }
}, [taxonomyData])
const allDirections = useMemo(() => {
const s = new Set(patterns.map(p => p.signal_direction).filter(Boolean) as string[])
return Array.from(s).sort()
}, [patterns])
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 allAssets = useMemo(() => {
const s = new Set(patterns.map(p => p.asset_class).filter(Boolean))
return Array.from(s).sort()
}, [patterns])
// Patterns matching the selected taxonomy node + name filter
const nodePatterns = useMemo(() => {
const allCategories = useMemo(() => {
const s = new Set(patterns.map(p => p.category).filter(Boolean) as string[])
return Array.from(s).sort()
}, [patterns])
const filtered = useMemo(() => {
let list = patterns
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))
if (query.trim()) {
const q = query.trim().toLowerCase()
list = list.filter(p =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
(p.regime_tag ?? '').toLowerCase().includes(q),
)
}
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)
if (direction) list = list.filter(p => p.signal_direction === direction)
if (assetClass) list = list.filter(p => p.asset_class === assetClass)
if (category) list = list.filter(p => p.category === category)
if (horizon === 'short') list = list.filter(p => p.horizon_days < 30)
if (horizon === 'medium') list = list.filter(p => p.horizon_days >= 30 && p.horizon_days <= 90)
if (horizon === 'long') list = list.filter(p => p.horizon_days > 90)
if (source) list = list.filter(p => p.source === source)
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 === 'ai_score') return (b.ai_quality_score ?? -1) - (a.ai_quality_score ?? -1)
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)
return ((b as any).created_at ?? '').localeCompare((a as any).created_at ?? '')
})
}, [nodePatterns, categoryFilter, sortBy])
}, [patterns, query, direction, assetClass, category, horizon, source, 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'
const hasFilters = !!(query || direction || assetClass || category || horizon || source)
const toggle = (cur: string, val: string, set: (v: string) => void) => set(cur === val ? '' : val)
return (
<div className="flex gap-4 min-h-0 flex-1">
{/* Left sidebar */}
<div className="w-72 shrink-0 card flex flex-col gap-1 overflow-y-auto max-h-[calc(100vh-200px)]">
<div className="text-[10px] uppercase tracking-widest text-slate-500 font-semibold px-2 pt-1 pb-2">
Taxonomy
<div className="flex flex-col gap-4">
{/* Search + sort */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-lg">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search name, description, or #regime…"
value={query}
onChange={e => setQuery(e.target.value)}
className="input text-sm pl-9 py-2 w-full"
/>
</div>
{/* Root "All" entry */}
<button
className={clsx(
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
selectedPath.length === 0
? '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([]); setCategoryFilter('') }}
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as typeof sortBy)}
className="input text-xs py-2 pr-6 pl-2 shrink-0"
>
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
<span className="flex-1">All Patterns</span>
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full">
{patterns.length}
</span>
</button>
{/* Taxonomy tree */}
{taxonomy ? (
(taxonomy.children ?? []).map(node => (
<TreeNodeRow
key={node.id}
node={node}
level={0}
selectedPath={selectedPath}
onSelect={path => { setSelectedPath(path); setCategoryFilter('') }}
defaultExpandedDepth={1}
/>
))
) : (
<div className="text-xs text-slate-600 px-2 py-4">Loading taxonomy</div>
)}
<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>
{/* Right panel */}
<div className="flex-1 flex flex-col gap-3 min-w-0">
{/* 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>
<span className="text-slate-300">{breadcrumb}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Filter by name…"
value={nameFilter}
onChange={e => setNameFilter(e.target.value)}
className="input text-sm pl-8 py-1.5 w-52"
/>
</div>
<span className="text-xs text-slate-500 shrink-0">{filteredPatterns.length} patterns</span>
</div>
</div>
{/* Row 2: category pills + sort */}
{/* Filter panel */}
<div className="flex flex-col gap-2.5 bg-dark-700/30 border border-slate-700/30 rounded-lg px-4 py-3">
{/* Direction */}
<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>
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Direction</span>
<FilterChip active={!direction} label="All" onClick={() => setDirection('')} />
{allDirections.map(d => (
<FilterChip key={d} active={direction === d}
label={d === 'bullish' || d === 'up' ? `${d}` : d === 'bearish' || d === 'down' ? `${d}` : `${d}`}
onClick={() => toggle(direction, d, setDirection)}
color={DIR_COLORS[d]} />
))}
</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-260px)] pr-1">
{filteredPatterns.map(p => (
<PatternCard key={p.id} pattern={p} />
{/* Asset class */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Asset</span>
<FilterChip active={!assetClass} label="All" onClick={() => setAssetClass('')} />
{allAssets.map(a => (
<FilterChip key={a} active={assetClass === a}
label={ASSET_CLASS_LABELS[a] ?? a}
onClick={() => toggle(assetClass, a, setAssetClass)} />
))}
</div>
{/* Category */}
{allCategories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Category</span>
<FilterChip active={!category} label="All" onClick={() => setCategory('')} />
{allCategories.map(c => (
<FilterChip key={c} active={category === c} label={c}
onClick={() => toggle(category, c, setCategory)}
color={CATEGORY_COLORS[c] ?? 'bg-violet-900/40 border-violet-600/50 text-violet-300'} />
))}
</div>
)}
{/* Horizon */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Horizon</span>
<FilterChip active={!horizon} label="All" onClick={() => setHorizon('')} />
<FilterChip active={horizon === 'short'} label="< 30d" onClick={() => toggle(horizon, 'short', setHorizon)} />
<FilterChip active={horizon === 'medium'} label="3090d" onClick={() => toggle(horizon, 'medium', setHorizon)} />
<FilterChip active={horizon === 'long'} label="> 90d" onClick={() => toggle(horizon, 'long', setHorizon)} />
</div>
{/* Source */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Source</span>
<FilterChip active={!source} label="All" onClick={() => setSource('')} />
<FilterChip active={source === 'builtin'} label="Built-in" onClick={() => toggle(source, 'builtin', setSource)} />
<FilterChip active={source === 'backtested'} label="Backtested" onClick={() => toggle(source, 'backtested', setSource)}
color="bg-violet-900/40 border-violet-600/50 text-violet-300" />
</div>
</div>
{/* Count + clear */}
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">{filtered.length} pattern{filtered.length !== 1 ? 's' : ''}</span>
{hasFilters && (
<button
onClick={() => { setQuery(''); setDirection(''); setAssetClass(''); setCategory(''); setHorizon(''); setSource('') }}
className="text-xs text-slate-600 hover:text-slate-400 underline transition-colors"
>
Clear all filters
</button>
)}
</div>
{/* Pattern grid */}
{filtered.length === 0 ? (
<div className="flex items-center justify-center py-24 text-slate-600 text-sm">
No patterns match these filters
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
{filtered.map(p => <PatternCard key={p.id} pattern={p} />)}
</div>
)}
</div>
)
}
@@ -883,10 +817,10 @@ function RegimeView({ patterns }: { patterns: Pattern[] }) {
// ── Page root ─────────────────────────────────────────────────────────────────
type ViewMode = 'tree' | 'instrument' | 'regime'
type ViewMode = 'search' | 'instrument' | 'regime'
export default function PatternExplorer() {
const [view, setView] = useState<ViewMode>('tree')
const [view, setView] = useState<ViewMode>('search')
const { data: patternsData, isLoading } = useAllPatterns()
const { data: _scores } = useLastScores()
@@ -921,14 +855,14 @@ export default function PatternExplorer() {
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'tree'
view === 'search'
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('tree')}
onClick={() => setView('search')}
>
<TreePine className="w-3.5 h-3.5" />
Tree
<Search className="w-3.5 h-3.5" />
Search
</button>
<button
className={clsx(
@@ -965,7 +899,7 @@ export default function PatternExplorer() {
</div>
) : (
<>
{view === 'tree' && <TreeView patterns={patterns} />}
{view === 'search' && <FacetedSearch patterns={patterns} />}
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
{view === 'regime' && <RegimeView patterns={patterns} />}
</>