feat: market events filters, no-auto-bootstrap, star label deoverlap

- MarketEvents: date presets (7j/30j/3m/6m/1an/Tout/Perso), instrument
  impact filter (ticker + min score + direction → auto-sort by inst score),
  sort controls (date/score/nom + asc/desc), clear-all button, count bar
- Market events list endpoint: full SQL JOIN rewrite supporting instrument
  filter, date range, origin, sort_by=instrument_score
- Disable auto-bootstrap on startup (macro/eco/categories) — manual only
- CycleActions: bootstrap group (Macro/Eco/Categories) with force checkbox,
  grouped layout (detection / bootstrap / data / ai / portfolio)
- cycle_actions router: /bootstrap-macro, /bootstrap-eco, /bootstrap-categories
  endpoints + group field on all action catalogue entries
- InstrumentChart: greedy row placement for star labels (4 rows × 20px)
  to eliminate horizontal overlap; labels now inline (★ + text)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 21:27:21 +02:00
parent 0b1fcff49c
commit f71e3be3ff
6 changed files with 545 additions and 190 deletions

View File

@@ -1034,16 +1034,45 @@ export default function MarketEvents() {
const [bulking, setBulking] = useState(false)
const [bulkMsg, setBulkMsg] = useState<string | null>(null)
// Filters
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [level, setLevel] = useState('')
const [minScore, setMinScore] = useState(0)
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// ── Filter state ─────────────────────────────────────────────────────────
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [level, setLevel] = useState('')
const [minScore, setMinScore] = useState(0)
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [datePreset, setDatePreset] = useState<string>('all') // 7d|30d|90d|6m|1y|all|custom
// Instrument impact filter
const [instTicker, setInstTicker] = useState('')
const [instMinScore, setInstMin] = useState(0.3)
const [instDir, setInstDir] = useState('')
const [showInstFilter, setShowInst] = useState(false)
// Sort
const [sortBy, setSortBy] = useState<'date'|'score'|'name'|'instrument_score'>('date')
const [sortDir, setSortDir] = useState<'desc'|'asc'>('desc')
// UI
const [showFilters, setShowFilters] = useState(false)
// ── Date preset helper ────────────────────────────────────────────────────
const applyPreset = useCallback((preset: string) => {
setDatePreset(preset)
if (preset === 'custom' || preset === 'all') {
setDateFrom(''); setDateTo('')
return
}
const now = new Date()
const to = now.toISOString().slice(0, 10)
const from = new Date(now)
if (preset === '7d') from.setDate(now.getDate() - 7)
if (preset === '30d') from.setDate(now.getDate() - 30)
if (preset === '90d') from.setDate(now.getDate() - 90)
if (preset === '6m') from.setMonth(now.getMonth() - 6)
if (preset === '1y') from.setFullYear(now.getFullYear() - 1)
setDateFrom(from.toISOString().slice(0, 10))
setDateTo(to)
}, [])
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
@@ -1056,6 +1085,13 @@ export default function MarketEvents() {
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (evaluated) params.set('evaluated', evaluated)
if (instTicker) {
params.set('instrument', instTicker)
if (instMinScore > 0) params.set('min_instrument_score', String(instMinScore))
if (instDir) params.set('instrument_direction', instDir)
}
params.set('sort_by', instTicker ? 'instrument_score' : sortBy)
params.set('sort_dir', sortDir)
params.set('limit', '500')
const r = await fetch(`/api/market-events?${params}`)
if (r.ok) {
@@ -1064,7 +1100,8 @@ export default function MarketEvents() {
setTotal(d.total)
}
setLoading(false)
}, [search, category, level, minScore, dateFrom, dateTo, evaluated])
}, [search, category, level, minScore, dateFrom, dateTo, evaluated,
instTicker, instMinScore, instDir, sortBy, sortDir])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
@@ -1117,55 +1154,98 @@ export default function MarketEvents() {
{/* ── Events tab ── */}
{activeTab === 'events' && <div className="flex flex-1 overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* Top bar */}
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* ── Search + actions ── */}
<div className="px-3 pt-3 pb-2 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-base font-bold text-slate-100 flex-1">Événements</h1>
<div className="relative flex-1">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Recherche nom, description..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
</div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2.5 py-1.5">
<Plus size={12} /> Nouveau
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2 py-1.5 shrink-0">
<Plus size={12} />
</button>
<button onClick={load} disabled={loading}
className="p-1.5 text-slate-500 hover:text-slate-300 hover:bg-slate-700/50 rounded">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
className="p-1.5 text-slate-500 hover:text-slate-300 rounded shrink-0">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Recherche..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
{/* ── Date presets ── */}
<div className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-slate-600 mr-1">Période:</span>
{[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => (
<button key={v} onClick={() => applyPreset(v)}
className={clsx('px-2 py-0.5 rounded text-xs border transition-colors',
datePreset === v
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{lbl}
</button>
))}
</div>
{datePreset === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
</div>
)}
{/* Quick filter chips */}
<div className="flex flex-wrap gap-1.5">
{/* ── Category chips ── */}
<div className="flex flex-wrap gap-1">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
className={clsx(
'px-2 py-0.5 rounded-full text-xs border transition-colors',
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors',
category === c
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400',
)}>
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{c.replace('_', ' ')}
</button>
))}
<button onClick={() => setShowFilters(x => !x)}
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors ml-auto',
showFilters ? 'bg-slate-700 text-slate-300 border-slate-600' : 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={11} className="inline mr-0.5" />
Filtres
</button>
</div>
</div>
{/* Extended filters */}
{showFilters && (
<div className="grid grid-cols-2 gap-2 pt-1">
{/* ── Sort + more filters toggle ── */}
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-2">
<span className="text-xs text-slate-600">Tri:</span>
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
className="bg-transparent border-0 text-xs text-slate-400 cursor-pointer focus:outline-none">
<option value="date">Date</option>
<option value="score">Score global</option>
<option value="name">Nom</option>
</select>
<button onClick={() => setSortDir(d => d === 'desc' ? 'asc' : 'desc')}
className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700 rounded px-1.5 py-0.5">
{sortDir === 'desc' ? '' : ''}
</button>
<div className="flex-1" />
<button onClick={() => setShowFilters(x => !x)}
className={clsx('flex items-center gap-1 text-xs border rounded px-2 py-0.5 transition-colors',
(showFilters || instTicker)
? 'bg-slate-700 border-slate-600 text-slate-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={10} />
Filtres {(level || minScore > 0 || evaluated || instTicker) ? '' : ''}
</button>
</div>
{/* ── Extended filters ── */}
{showFilters && (
<div className="px-3 py-2 border-b border-slate-800/40 space-y-2 bg-dark-900/50">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-1">Niveau</div>
<div className="text-xs text-slate-600 mb-0.5">Niveau</div>
<select value={level} onChange={e => setLevel(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
@@ -1173,44 +1253,83 @@ export default function MarketEvents() {
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Évaluación</div>
<div className="text-xs text-slate-600 mb-0.5">Évaluation</div>
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
<option value="true">Évalués</option>
<option value="true">Évalués IA</option>
<option value="false">Non évalués</option>
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div className="col-span-2">
<div className="text-xs text-slate-600 mb-1">Score min: {minScore.toFixed(2)}</div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)}
className="w-full accent-slate-400" />
</div>
</div>
)}
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Score global min: <span className="text-slate-400">{minScore.toFixed(2)}</span></div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)} className="w-full accent-slate-400" />
</div>
{/* Bulk actions */}
{/* ── Instrument impact filter ── */}
<div className="border-t border-slate-800/60 pt-2">
<button onClick={() => setShowInst(x => !x)}
className={clsx('flex items-center gap-1.5 text-xs font-semibold mb-2 transition-colors',
instTicker ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
<BarChart2 size={11} />
Impact sur instrument {instTicker ? `(${instTicker})` : ''}
{showInstFilter ? ' ' : ' '}
</button>
{showInstFilter && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Instrument</div>
<select value={instTicker} onChange={e => { setInstTicker(e.target.value); if (e.target.value) setSortBy('instrument_score') }}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Direction</div>
<select value={instDir} onChange={e => setInstDir(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Toutes</option>
<option value="bullish">🔼 Bullish</option>
<option value="bearish">🔽 Bearish</option>
<option value="neutral">● Neutre</option>
<option value="depends_on_outcome">◆ Conditionnel</option>
</select>
</div>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">
Score impact min: <span className="text-amber-300">{instMinScore.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" value={instMinScore}
onChange={e => setInstMin(+e.target.value)} className="w-full accent-amber-400" />
</div>
{instTicker && (
<div className="text-xs text-amber-300 bg-amber-900/20 rounded px-2 py-1 border border-amber-800/30">
★ Tri automatique par impact sur {instTicker}
</div>
)}
{instTicker && (
<button onClick={() => { setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date') }}
className="text-xs text-slate-500 hover:text-red-400">
× Effacer filtre instrument
</button>
)}
</div>
)}
</div>
</div>
)}
{/* ── Bulk evaluate ── */}
{unevaluatedIds.length > 0 && (
<div className="px-4 py-2 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<Users size={12} className="text-blue-400" />
<span className="text-xs text-blue-300 flex-1">
{unevaluatedIds.length} non évalués
</span>
<div className="px-3 py-1.5 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<span className="text-xs text-blue-300 flex-1">{unevaluatedIds.length} non évalués</span>
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
<button
onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
<button onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
disabled={bulking}
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
@@ -1219,13 +1338,20 @@ export default function MarketEvents() {
</div>
)}
{/* Count */}
<div className="px-4 py-1.5 text-xs text-slate-600 border-b border-slate-800/30">
{total} événement{total !== 1 ? 's' : ''}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo) && ' (filtré)'}
{/* ── Count bar ── */}
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || instTicker) && (
<button onClick={() => {
setSearch(''); setCategory(''); setLevel(''); setMinScore(0)
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
}} className="ml-auto text-xs text-slate-600 hover:text-red-400">× tout effacer</button>
)}
</div>
{/* List */}
{/* ── List ── */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">