feat: frise sub-lanes + event manager + MA indicators + absorption

Frise chronologique:
- Sub-lane stacking (assignSubLanes) — overlapping events se décalent verticalement
- Zone d'overlap semi-transparente sur la période commune entre 2 événements
- Hauteur dynamique selon nb de sub-lanes par niveau
- Événements en cours avec flèche ▶ à droite, gradient de fin
- Tri par start_date pour placement greedy

Event Manager (composant EventManager.tsx):
- Tableau filtrable par niveau (Long/Moyen/Court)
- Edit modal complet : tous les champs + absorption_pct éditable
- Bouton "IA — Enrichir" par événement → POST /api/timeline/events/{id}/ai-enrich
  → GPT-4o-mini suggère absorption_pct + indicateurs pertinents par niveau temporel
- Delete avec confirmation double-clic
- Expand row pour voir description + indicateurs
- Intégré Timeline page via bouton "Gérer événements"

Backend:
- Nouvelles colonnes market_events: absorption_pct + relevant_indicators (ALTER idempotent)
- DELETE /api/timeline/events/{id}
- POST /api/timeline/events/{id}/ai-enrich

Snapshot Externe:
- AbsorptionBar par événement dans cellule Géopolitique
- MA indicators : fetch 200j history, compute MA10/MA20/MA100 per level (short/med/long)
- Affichage prix vs MA + % écart dans CellMarkets
- Si relevant_indicators configurés sur l'event → utilise ces symbols au lieu des défauts
- Calendar : horizons exclusifs (short 0-7j, medium 8-30j, long 31-90j) — bug corrigé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 20:31:57 +02:00
parent 43b4816596
commit aeb5233deb
6 changed files with 950 additions and 175 deletions

View File

@@ -0,0 +1,434 @@
import { useState } from 'react'
import axios from 'axios'
import clsx from 'clsx'
import { Plus, Edit2, Trash2, Sparkles, X, Save, ChevronDown, ChevronUp, Check } from 'lucide-react'
const api = axios.create({ baseURL: '/api' })
interface Indicator { symbol: string; indicator: string; label: string; rationale?: string }
interface MarketEvent {
id: number; name: string; start_date: string; end_date: string | null
level: string; category: string; description: string; market_impact: string
affected_assets: string; impact_score: number
absorption_pct: number | null; relevant_indicators: string
}
interface Props {
events: MarketEvent[]
onRefresh: () => void
}
const LEVELS = ['long', 'medium', 'short'] as const
const LEVEL_LABELS = { long: 'Long Terme', medium: 'Moyen Terme', short: 'Court Terme' }
const LEVEL_COLORS = {
long: { badge: 'bg-violet-900/60 text-violet-300 border-violet-700/50', dot: 'bg-violet-500' },
medium: { badge: 'bg-blue-900/60 text-blue-300 border-blue-700/50', dot: 'bg-blue-500' },
short: { badge: 'bg-emerald-900/60 text-emerald-300 border-emerald-700/50', dot: 'bg-emerald-500' },
}
const CATEGORIES = ['macro', 'monetary', 'geopolitical', 'market', 'fiscal']
const EMPTY_FORM = {
name: '', start_date: '', end_date: '', level: 'medium', category: 'macro',
description: '', market_impact: '', affected_assets: '', impact_score: 0.5,
absorption_pct: '', relevant_indicators: '',
}
type FormState = typeof EMPTY_FORM
function AbsorptionBadge({ pct }: { pct: number | null }) {
if (pct === null || pct === undefined) return <span className="text-xs text-slate-600"></span>
const color = pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400'
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not yet'
return (
<span className={clsx('text-xs font-medium', color)}>
{pct.toFixed(0)}% <span className="text-slate-600">({label})</span>
</span>
)
}
function fmtDate(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: '2-digit' })
}
function parseIndicators(raw: string | null | undefined): Indicator[] {
try { return JSON.parse(raw || '[]') } catch { return [] }
}
// ── Edit / Add Modal ───────────────────────────────────────────────────────────
function EventModal({
event,
onClose,
onSaved,
}: {
event: MarketEvent | null // null = create new
onClose: () => void
onSaved: () => void
}) {
const [form, setForm] = useState<FormState>(event ? {
name: event.name,
start_date: event.start_date,
end_date: event.end_date ?? '',
level: event.level,
category: event.category,
description: event.description,
market_impact: event.market_impact,
affected_assets: (() => {
try { return JSON.parse(event.affected_assets || '[]').join(', ') } catch { return '' }
})(),
impact_score: event.impact_score,
absorption_pct: event.absorption_pct !== null && event.absorption_pct !== undefined
? String(event.absorption_pct) : '',
relevant_indicators: (() => {
try {
const inds = JSON.parse(event.relevant_indicators || '[]')
return inds.map((i: Indicator) => i.label).join(', ')
} catch { return '' }
})(),
} : { ...EMPTY_FORM })
const [saving, setSaving] = useState(false)
const [enriching, setEnriching] = useState(false)
const [enrichResult, setEnrichResult] = useState<{ absorption_pct: number; absorption_rationale: string; relevant_indicators: Indicator[] } | null>(null)
const set = (k: keyof FormState, v: string | number) =>
setForm(f => ({ ...f, [k]: v }))
async function save() {
setSaving(true)
try {
const payload = {
name: form.name,
start_date: form.start_date,
end_date: form.end_date || null,
level: form.level,
category: form.category,
description: form.description,
market_impact: form.market_impact,
affected_assets: form.affected_assets.split(',').map(s => s.trim()).filter(Boolean),
impact_score: Number(form.impact_score),
absorption_pct: form.absorption_pct ? Number(form.absorption_pct) : null,
relevant_indicators: enrichResult?.relevant_indicators ?? [],
}
if (event) {
await api.put(`/timeline/events/${event.id}`, payload)
} else {
await api.post('/timeline/events', payload)
}
onSaved()
onClose()
} finally {
setSaving(false)
}
}
async function enrich() {
if (!event) return
setEnriching(true)
try {
const res = await api.post(`/timeline/events/${event.id}/ai-enrich`)
const d = res.data
setEnrichResult(d)
set('absorption_pct', String(d.absorption_pct ?? ''))
} finally {
setEnriching(false)
}
}
const indicators = enrichResult?.relevant_indicators ?? parseIndicators(event?.relevant_indicators)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-dark-800 border border-slate-700/40 rounded-xl w-[680px] max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/30">
<div className="text-sm font-semibold text-white">
{event ? `Éditer — ${event.name}` : 'Nouvel événement'}
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="p-5 space-y-4">
{/* Name + Level + Category */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3">
<label className="text-xs text-slate-500 mb-1 block">Nom *</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Niveau *</label>
<select value={form.level} onChange={e => set('level', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
{LEVELS.map(l => <option key={l} value={l}>{LEVEL_LABELS[l]}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Catégorie</label>
<select value={form.category} onChange={e => set('category', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Impact score (0-1)</label>
<input type="number" min={0} max={1} step={0.05}
value={form.impact_score} onChange={e => set('impact_score', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Date début *</label>
<input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Date fin (vide = en cours)</label>
<input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
</div>
{/* Description + Market impact */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)} rows={2}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Impact marché</label>
<textarea value={form.market_impact} onChange={e => set('market_impact', e.target.value)} rows={2}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Assets affectés (séparés par virgule)</label>
<input value={form.affected_assets} onChange={e => set('affected_assets', e.target.value)}
placeholder="CL, GC, VIX, SPX..."
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
{/* Absorption */}
<div className="bg-dark-900/40 rounded-lg p-3 border border-slate-700/20 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-slate-400 font-medium">Absorption du marché (%)</label>
{event && (
<button
onClick={enrich}
disabled={enriching}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded transition-colors disabled:opacity-40"
>
<Sparkles className="w-3 h-3" />
{enriching ? 'Analyse IA...' : 'IA — Enrichir'}
</button>
)}
</div>
<input type="number" min={0} max={100} step={1}
value={form.absorption_pct} onChange={e => set('absorption_pct', e.target.value)}
placeholder="0 = non pricé, 100 = fully priced"
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
{enrichResult?.absorption_rationale && (
<p className="text-xs text-slate-500 italic">{enrichResult.absorption_rationale}</p>
)}
</div>
{/* Indicators */}
{indicators.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-slate-400 font-medium">Indicateurs pertinents suggérés</div>
{indicators.map((ind, i) => (
<div key={i} className="flex items-start gap-2 bg-dark-900/30 rounded px-3 py-1.5">
<div className="flex-1">
<span className="text-xs text-white font-medium">{ind.label}</span>
<span className="text-xs text-slate-500 ml-2">({ind.symbol} · {ind.indicator})</span>
</div>
{ind.rationale && (
<span className="text-xs text-slate-600 italic text-right max-w-[200px]">{ind.rationale}</span>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-slate-700/30">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-400 hover:text-white">Annuler</button>
<button
onClick={save}
disabled={saving || !form.name || !form.start_date}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-blue-700/50 hover:bg-blue-600/60 text-blue-200 border border-blue-600/40 rounded-lg disabled:opacity-40"
>
<Save className="w-3.5 h-3.5" />
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
</div>
</div>
</div>
)
}
// ── Event Row ──────────────────────────────────────────────────────────────────
function EventRow({
ev, onEdit, onDelete,
}: {
ev: MarketEvent; onEdit: () => void; onDelete: () => void
}) {
const [deleting, setDeleting] = useState(false)
const [expanded, setExpanded] = useState(false)
const level = ev.level as keyof typeof LEVEL_COLORS
const cfg = LEVEL_COLORS[level] ?? LEVEL_COLORS.medium
const indicators = parseIndicators(ev.relevant_indicators)
async function confirmDelete() {
if (!deleting) { setDeleting(true); return }
await api.delete(`/timeline/events/${ev.id}`)
onDelete()
}
return (
<>
<tr className="border-b border-slate-700/20 hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2">
<span className={clsx('text-xs px-1.5 py-0.5 rounded border font-bold', cfg.badge)}>
{ev.level[0].toUpperCase()}
</span>
</td>
<td className="px-3 py-2">
<div className="text-sm text-white font-medium">{ev.name}</div>
<div className="text-xs text-slate-500">{ev.category}</div>
</td>
<td className="px-3 py-2 text-xs text-slate-400 tabular-nums">{fmtDate(ev.start_date)}</td>
<td className="px-3 py-2 text-xs tabular-nums">
{ev.end_date ? <span className="text-slate-400">{fmtDate(ev.end_date)}</span> : <span className="text-amber-500/70">en cours</span>}
</td>
<td className="px-3 py-2"><AbsorptionBadge pct={ev.absorption_pct} /></td>
<td className="px-3 py-2 text-xs text-slate-500">{indicators.length > 0 ? `${indicators.length} ind.` : '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<button onClick={() => setExpanded(e => !e)} className="p-1 text-slate-600 hover:text-slate-300">
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
<button onClick={onEdit} className="p-1 text-slate-600 hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
<button
onClick={confirmDelete}
className={clsx('p-1', deleting ? 'text-red-400 animate-pulse' : 'text-slate-600 hover:text-red-400')}
title={deleting ? 'Cliquer à nouveau pour confirmer' : 'Supprimer'}
>
{deleting ? <Check className="w-3.5 h-3.5" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-slate-700/20 bg-dark-900/20">
<td />
<td colSpan={6} className="px-3 py-2 space-y-1.5">
{ev.description && <p className="text-xs text-slate-400">{ev.description}</p>}
{ev.market_impact && <p className="text-xs text-slate-500 italic">{ev.market_impact}</p>}
{indicators.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{indicators.map((ind, i) => (
<span key={i} className="text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-slate-400">
{ind.label} <span className="text-slate-600">({ind.indicator})</span>
</span>
))}
</div>
)}
</td>
</tr>
)}
</>
)
}
// ── Main ───────────────────────────────────────────────────────────────────────
export default function EventManager({ events, onRefresh }: Props) {
const [filter, setFilter] = useState<'all' | 'long' | 'medium' | 'short'>('all')
const [editing, setEditing] = useState<MarketEvent | null | 'new'>('new' as unknown as null)
const [modalTarget, setModalTarget] = useState<MarketEvent | null | false>(false)
const filtered = events.filter(ev => filter === 'all' || ev.level === filter)
.sort((a, b) => b.start_date.localeCompare(a.start_date))
return (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/30">
<span className="text-sm font-semibold text-slate-300">Événements ({events.length})</span>
{/* Level filter */}
<div className="flex items-center gap-1">
{(['all', 'long', 'medium', 'short'] as const).map(l => (
<button key={l}
onClick={() => setFilter(l)}
className={clsx(
'px-2 py-1 text-xs rounded transition-colors',
filter === l ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-300'
)}
>
{l === 'all' ? 'Tous' : LEVEL_LABELS[l]}
</button>
))}
</div>
<button
onClick={() => setModalTarget(null)}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-800/40 hover:bg-blue-700/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Ajouter
</button>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-72 overflow-y-auto">
<table className="w-full text-left">
<thead className="text-xs text-slate-500 uppercase tracking-wider border-b border-slate-700/30 sticky top-0 bg-dark-800">
<tr>
<th className="px-3 py-2 w-10">Niv.</th>
<th className="px-3 py-2">Nom</th>
<th className="px-3 py-2">Début</th>
<th className="px-3 py-2">Fin</th>
<th className="px-3 py-2">Absorption</th>
<th className="px-3 py-2">Indicateurs</th>
<th className="px-3 py-2 w-20" />
</tr>
</thead>
<tbody>
{filtered.map(ev => (
<EventRow
key={ev.id}
ev={ev}
onEdit={() => setModalTarget(ev)}
onDelete={onRefresh}
/>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-3 py-6 text-center text-xs text-slate-600 italic">Aucun événement</td></tr>
)}
</tbody>
</table>
</div>
{/* Modal */}
{modalTarget !== false && (
<EventModal
event={modalTarget}
onClose={() => setModalTarget(false)}
onSaved={onRefresh}
/>
)}
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { useRef, useEffect } from 'react'
import clsx from 'clsx'
import { useRef, useEffect, useMemo } from 'react'
interface MarketEvent {
id: number
@@ -17,201 +16,328 @@ interface Props {
onDateChange: (d: string) => void
}
const SCALE = 2.2 // pixels per day
const LANE_H = 30 // height of each event lane
const AXIS_H = 24 // height of year axis
const PADDING_TOP = 8
// ── Layout constants ───────────────────────────────────────────────────────────
const SCALE = 2.4 // px per day
const SUB_H = 26 // height per sub-lane
const SUB_GAP = 3 // gap between sub-lanes in same level
const LEVEL_GAP = 14 // gap between levels
const AXIS_H = 28
const PADDING_TOP = 12
const FRISE_START = '2020-02-01'
const TODAY = new Date().toISOString().split('T')[0]
const LEVEL_COLORS: Record<string, { bg: string; border: string; text: string; track: string }> = {
long: { bg: '#4c1d95', border: '#7c3aed', text: '#ddd6fe', track: '#1e1b4b' },
medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#bfdbfe', track: '#0f172a' },
short: { bg: '#064e3b', border: '#10b981', text: '#a7f3d0', track: '#022c22' },
const LEVEL_ORDER = ['long', 'medium', 'short'] as const
const COLORS = {
long: { bg: '#5b21b6', border: '#7c3aed', text: '#ede9fe', track: 'rgba(109,40,217,0.12)', overlap: 'rgba(124,58,237,0.25)' },
medium: { bg: '#1d4ed8', border: '#3b82f6', text: '#dbeafe', track: 'rgba(59,130,246,0.10)', overlap: 'rgba(59,130,246,0.22)' },
short: { bg: '#065f46', border: '#10b981', text: '#d1fae5', track: 'rgba(16,185,129,0.10)', overlap: 'rgba(16,185,129,0.22)' },
}
const LEVEL_ORDER: Record<string, number> = { long: 0, medium: 1, short: 2 }
// ── Helpers ────────────────────────────────────────────────────────────────────
function toDay(date: string): number {
const origin = new Date(FRISE_START).getTime()
const d = new Date(date).getTime()
const d = new Date(date + 'T00:00:00Z').getTime()
return Math.floor((d - origin) / 86_400_000)
}
function fmtYear(d: Date): string {
return d.getFullYear().toString()
}
function buildYearMarkers(totalDays: number): { x: number; label: string }[] {
function buildYearMarkers(totalDays: number) {
const origin = new Date(FRISE_START)
const markers = []
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; y++) {
const out: { day: number; label: string }[] = []
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 2; y++) {
const jan1 = new Date(y, 0, 1)
const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000)
if (day >= 0 && day <= totalDays + 30)
markers.push({ x: day * SCALE, label: fmtYear(jan1) })
if (day >= 0 && day <= totalDays + 60) out.push({ day, label: String(y) })
}
return markers
return out
}
// Assign sub-lane index per event within a level (greedy interval scheduling)
function assignSubLanes(evs: MarketEvent[]): Map<number, number> {
const sorted = [...evs].sort((a, b) => a.start_date.localeCompare(b.start_date))
const laneEnds: string[] = []
const result = new Map<number, number>()
for (const ev of sorted) {
const end = ev.end_date ?? '2099-12-31'
let placed = false
for (let i = 0; i < laneEnds.length; i++) {
if (ev.start_date >= laneEnds[i]) {
result.set(ev.id, i)
laneEnds[i] = end
placed = true
break
}
}
if (!placed) {
result.set(ev.id, laneEnds.length)
laneEnds.push(end)
}
}
return result
}
// Find overlapping pairs within a level (A in lane 0, B in lane 1+, B starts before A ends)
function findOverlaps(evs: MarketEvent[], subLanes: Map<number, number>): { a: MarketEvent; b: MarketEvent }[] {
const pairs: { a: MarketEvent; b: MarketEvent }[] = []
for (let i = 0; i < evs.length; i++) {
for (let j = i + 1; j < evs.length; j++) {
const a = evs[i], b = evs[j]
const aEnd = a.end_date ?? TODAY
const bEnd = b.end_date ?? TODAY
const overlapStart = a.start_date > b.start_date ? a.start_date : b.start_date
const overlapEnd = aEnd < bEnd ? aEnd : bEnd
if (overlapStart < overlapEnd) {
const lA = subLanes.get(a.id) ?? 0
const lB = subLanes.get(b.id) ?? 0
if (lA !== lB) pairs.push({ a, b })
}
}
}
return pairs
}
// ── Component ──────────────────────────────────────────────────────────────────
export default function TimelineFrise({ events, refDate, onDateChange }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const totalDays = toDay(new Date().toISOString().split('T')[0]) + 60
const totalDays = toDay(TODAY) + 90
const totalWidth = totalDays * SCALE
const totalHeight = PADDING_TOP + 3 * LANE_H + AXIS_H + 8
const todayX = toDay(TODAY) * SCALE
const refX = toDay(refDate) * SCALE
const yearMarkers = buildYearMarkers(totalDays)
const todayX = toDay(new Date().toISOString().split('T')[0]) * SCALE
const refX = toDay(refDate) * SCALE
// Scroll so selected date is centred on mount / when refDate changes
// Per-level sub-lane assignments
const byLevel = useMemo(() => {
const g: Record<string, MarketEvent[]> = { long: [], medium: [], short: [] }
for (const ev of events) { if (g[ev.level]) g[ev.level].push(ev) }
return g
}, [events])
const subLanesMap = useMemo(() => {
const m: Record<string, Map<number, number>> = {}
for (const l of LEVEL_ORDER) m[l] = assignSubLanes(byLevel[l] ?? [])
return m
}, [byLevel])
const maxSubLanes = useMemo(() => {
const m: Record<string, number> = {}
for (const l of LEVEL_ORDER) {
const vals = [...(subLanesMap[l]?.values() ?? [])]
m[l] = vals.length ? Math.max(...vals) + 1 : 1
}
return m
}, [subLanesMap])
// Compute Y offset for each level
const levelY = useMemo(() => {
const y: Record<string, number> = {}
let cur = PADDING_TOP
for (const l of LEVEL_ORDER) {
y[l] = cur
cur += maxSubLanes[l] * (SUB_H + SUB_GAP) + LEVEL_GAP
}
return y
}, [maxSubLanes])
const totalHeight = useMemo(() => {
return PADDING_TOP +
LEVEL_ORDER.reduce((acc, l) => acc + maxSubLanes[l] * (SUB_H + SUB_GAP) + LEVEL_GAP, 0) +
AXIS_H
}, [maxSubLanes])
// Auto-scroll to selected date
useEffect(() => {
const el = scrollRef.current
if (!el) return
const target = refX - el.clientWidth / 2
el.scrollLeft = Math.max(0, target)
el.scrollLeft = Math.max(0, refX - el.clientWidth / 2)
}, [refDate, refX])
// Group events by level, resolve overlaps within a level by stacking sub-rows
const grouped: Record<string, MarketEvent[]> = { long: [], medium: [], short: [] }
for (const ev of events) {
if (grouped[ev.level]) grouped[ev.level].push(ev)
}
function laneY(level: string): number {
return PADDING_TOP + LEVEL_ORDER[level] * LANE_H
}
function evWidth(ev: MarketEvent): number {
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
const startDay = toDay(ev.start_date)
const days = Math.max(endDay - startDay, 14)
return days * SCALE
}
// Compute overlap zones for background shading
const overlapZones = useMemo(() => {
const zones: { x: number; w: number; level: string; laneTop: number; laneBot: number }[] = []
for (const l of LEVEL_ORDER) {
const evs = byLevel[l] ?? []
const sl = subLanesMap[l]
const pairs = findOverlaps(evs, sl)
for (const { a, b } of pairs) {
const overlapStart = a.start_date > b.start_date ? a.start_date : b.start_date
const overlapEnd = (a.end_date ?? TODAY) < (b.end_date ?? TODAY)
? (a.end_date ?? TODAY) : (b.end_date ?? TODAY)
if (overlapStart >= overlapEnd) continue
const lA = sl.get(a.id) ?? 0
const lB = sl.get(b.id) ?? 0
const minLane = Math.min(lA, lB)
const maxLane = Math.max(lA, lB)
zones.push({
x: toDay(overlapStart) * SCALE,
w: toDay(overlapEnd) * SCALE - toDay(overlapStart) * SCALE,
level: l,
laneTop: levelY[l] + minLane * (SUB_H + SUB_GAP),
laneBot: levelY[l] + (maxLane + 1) * (SUB_H + SUB_GAP) - SUB_GAP,
})
}
}
return zones
}, [byLevel, subLanesMap, levelY])
return (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
{/* Level legend */}
<div className="flex items-center gap-5 px-4 pt-3 pb-2">
{(['long', 'medium', 'short'] as const).map(l => (
{/* Legend */}
<div className="flex items-center gap-5 px-4 pt-3 pb-2 border-b border-slate-700/30">
{LEVEL_ORDER.map(l => (
<div key={l} className="flex items-center gap-1.5 text-xs">
<div className="w-3 h-2 rounded-sm" style={{ background: LEVEL_COLORS[l].border }} />
<span className="text-slate-400 capitalize">
<div className="w-3 h-2 rounded-sm" style={{ background: COLORS[l].border }} />
<span className="text-slate-400">
{l === 'long' ? 'Long terme' : l === 'medium' ? 'Moyen terme' : 'Court terme'}
</span>
</div>
))}
<span className="ml-auto text-xs text-slate-600">cliquer sur un événement pour naviguer</span>
<div className="ml-auto flex items-center gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-white/20 border border-white/20" />
Zone d'overlap
</span>
<span>— cliquer sur un événement pour naviguer</span>
</div>
</div>
{/* Scrollable frise */}
<div ref={scrollRef} className="overflow-x-auto overflow-y-hidden cursor-grab" style={{ height: totalHeight + 'px' }}>
<div className="relative" style={{ width: totalWidth + 'px', height: totalHeight + 'px' }}>
{/* Frise */}
<div
ref={scrollRef}
className="overflow-x-auto overflow-y-hidden"
style={{ height: totalHeight + 'px', cursor: 'grab' }}
>
<div className="relative select-none" style={{ width: totalWidth + 'px', height: totalHeight + 'px' }}>
{/* Year grid lines */}
{yearMarkers.map(m => (
<div key={m.label} className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: m.x + 'px', borderLeft: '1px dashed rgba(255,255,255,0.07)', top: 0, bottom: 0, height: '100%' }}
<div key={m.label}
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: m.day * SCALE, width: 1, background: 'rgba(255,255,255,0.05)', top: 0, height: '100%' }}
/>
))}
{/* Lane backgrounds */}
{(['long', 'medium', 'short'] as const).map(l => (
{/* Level track backgrounds */}
{LEVEL_ORDER.map(l => (
<div key={l}
className="absolute"
className="absolute left-0 right-0"
style={{
top: laneY(l) + 'px',
left: 0, right: 0,
height: LANE_H + 'px',
background: LEVEL_COLORS[l].track,
opacity: 0.5,
top: levelY[l],
height: maxSubLanes[l] * (SUB_H + SUB_GAP) - SUB_GAP,
background: COLORS[l].track,
borderRadius: 4,
}}
/>
))}
{/* Overlap zone shading */}
{overlapZones.map((z, i) => (
<div key={i}
className="absolute pointer-events-none"
style={{
left: z.x,
top: z.laneTop,
width: z.w,
height: z.laneBot - z.laneTop,
background: COLORS[z.level as keyof typeof COLORS].overlap,
borderRadius: 3,
border: `1px solid ${COLORS[z.level as keyof typeof COLORS].border}33`,
}}
/>
))}
{/* Events */}
{events.map(ev => {
const level = ev.level as keyof typeof COLORS
const c = COLORS[level]
const sl = subLanesMap[level]?.get(ev.id) ?? 0
const x = toDay(ev.start_date) * SCALE
const w = evWidth(ev)
const y = laneY(ev.level)
const c = LEVEL_COLORS[ev.level]
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
const w = Math.max((endDay - toDay(ev.start_date)) * SCALE, 6)
const y = levelY[level] + sl * (SUB_H + SUB_GAP)
const isSelected = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date)
const isOngoing = !ev.end_date
return (
<div
key={ev.id}
onClick={() => onDateChange(ev.start_date)}
title={`${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' (en cours)'}`}
className="absolute flex items-center overflow-hidden rounded cursor-pointer transition-all"
title={`[${ev.level.toUpperCase()}] ${ev.name}\n${ev.start_date}${ev.end_date ? ' ' + ev.end_date : ' en cours'}`}
className="absolute overflow-hidden cursor-pointer transition-opacity"
style={{
left: x + 'px',
top: y + 3 + 'px',
width: w + 'px',
height: LANE_H - 6 + 'px',
background: isSelected
? c.border
: c.bg,
left: x,
top: y + 2,
width: w,
height: SUB_H - 4,
background: isSelected ? c.border : c.bg,
border: `1px solid ${c.border}`,
opacity: isSelected ? 1 : 0.8,
zIndex: isSelected ? 10 : 1,
borderRadius: 4,
opacity: isSelected ? 1 : 0.82,
zIndex: isSelected ? 10 : 2,
// Pill shape: if event ends cleanly, right end is rounded normally
// If ongoing, indicate continuation visually
backgroundImage: isOngoing && !isSelected
? `linear-gradient(90deg, ${c.bg} 85%, ${c.border}44 100%)`
: undefined,
}}
>
{/* Event name text */}
<span
className="px-1.5 text-xs font-medium whitespace-nowrap select-none"
style={{ color: c.text, fontSize: '10px' }}
className="absolute left-0 top-0 bottom-0 flex items-center px-1.5 font-medium whitespace-nowrap"
style={{ color: c.text, fontSize: 10 }}
>
{ev.name}
</span>
{/* Ongoing arrow indicator */}
{isOngoing && (
<span
className="absolute right-0 top-0 bottom-0 flex items-center pr-1"
style={{ color: c.border, fontSize: 10 }}
>
</span>
)}
</div>
)
})}
{/* Today line */}
{/* Today vertical line */}
<div
className="absolute top-0 pointer-events-none z-20"
style={{
left: todayX + 'px',
top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px',
width: '2px',
background: 'rgba(239,68,68,0.8)',
}}
className="absolute pointer-events-none z-20"
style={{ left: todayX, top: 0, width: 2, height: totalHeight - AXIS_H, background: 'rgba(239,68,68,0.75)' }}
/>
{/* Selected date line (if not today) */}
{refDate !== new Date().toISOString().split('T')[0] && (
{/* Selected date line */}
{refDate !== TODAY && (
<div
className="absolute pointer-events-none z-20"
style={{
left: refX + 'px',
left: refX,
top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px',
width: '1px',
background: 'rgba(255,255,255,0.5)',
borderLeft: '1px dashed rgba(255,255,255,0.5)',
width: 1,
height: totalHeight - AXIS_H,
borderLeft: '1.5px dashed rgba(255,255,255,0.45)',
}}
/>
)}
{/* Year axis */}
<div
className="absolute left-0 right-0 flex"
style={{ top: PADDING_TOP + 3 * LANE_H + 2 + 'px', height: AXIS_H + 'px' }}
>
{yearMarkers.map(m => (
<div
key={m.label}
className="absolute text-xs text-slate-500 select-none"
style={{ left: m.x + 4 + 'px', top: '4px' }}
>
{m.label}
</div>
))}
</div>
{yearMarkers.map(m => (
<div
key={m.label + 'lbl'}
className="absolute text-xs text-slate-600 select-none"
style={{ left: m.day * SCALE + 4, top: totalHeight - AXIS_H + 6 }}
>
{m.label}
</div>
))}
{/* Today label */}
<div
className="absolute text-xs text-red-400 font-medium select-none pointer-events-none z-20"
style={{ left: todayX + 4 + 'px', top: PADDING_TOP + 3 * LANE_H + 6 + 'px' }}
style={{ left: todayX + 4, top: totalHeight - AXIS_H + 6 }}
>
▲ aujourd'hui
</div>

View File

@@ -104,6 +104,25 @@ function relevantAssets(ev: MarketEvent | null, level: string): string[] {
// ── Cells ──────────────────────────────────────────────────────────────────────
function AbsorptionBar({ pct }: { pct: number | null | undefined }) {
if (pct === null || pct === undefined) return null
const color = pct >= 80 ? 'bg-red-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-emerald-500'
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not priced'
return (
<div className="mt-2">
<div className="flex items-center justify-between text-xs mb-0.5">
<span className="text-slate-600">Absorption marché</span>
<span className={clsx('font-medium', pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400')}>
{pct.toFixed(0)}% {label}
</span>
</div>
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
</div>
)
}
function CellGeo({
ev, level, refDate,
}: {
@@ -116,6 +135,7 @@ function CellGeo({
</div>
)
const days = daysSince(refDate, ev.start_date)
const absorption = (ev as any).absorption_pct as number | null
return (
<div className="p-3 space-y-2 h-full">
<div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div>
@@ -129,18 +149,39 @@ function CellGeo({
{ev.market_impact && (
<p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p>
)}
<AbsorptionBar pct={absorption} />
</div>
)
}
// MA period per temporal level
const MA_PERIOD: Record<string, number> = { long: 100, medium: 20, short: 10 }
function CellMarkets({
ev, level, quotes,
ev, level, quotes, maData,
}: {
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null
maData: Record<string, number | null>
}) {
const assets = relevantAssets(ev, level)
// Use event's relevant_indicators if configured
let configuredInds: { symbol: string; indicator: string; label: string }[] = []
if (ev) {
try { configuredInds = JSON.parse((ev as any).relevant_indicators || '[]') } catch { /**/ }
}
const assets = configuredInds.length >= 2
? configuredInds.map(i => i.symbol).slice(0, 4)
: relevantAssets(ev, level)
const maPeriod = MA_PERIOD[level] ?? 20
const maLabel = `MA${maPeriod}`
return (
<div className="p-3 space-y-1.5 h-full">
{/* MA context label */}
<div className="text-xs text-slate-600 mb-1">
{configuredInds.length >= 2 ? 'Indicateurs configurés' : `Prix spot + ${maLabel}`}
</div>
{assets.map(sym => {
const q = quotes?.[sym]
if (!q) return (
@@ -151,19 +192,32 @@ function CellMarkets({
)
const up = q.change_pct > 0.1
const dn = q.change_pct < -0.1
const ma = maData[sym]
const maVsPct = ma && q.price ? ((q.price - ma) / ma * 100) : null
const maUp = (maVsPct ?? 0) > 0
return (
<div key={sym} className="flex items-center justify-between py-0.5">
<span className="text-xs text-slate-400 truncate max-w-[6rem]">{q.name || sym}</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
<span className={clsx(
'text-xs flex items-center gap-0.5 tabular-nums w-14 justify-end',
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
)}>
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
</span>
<div key={sym} className="py-0.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 truncate max-w-[7rem]">{q.name || sym}</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
<span className={clsx(
'text-xs flex items-center gap-0.5 tabular-nums',
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
)}>
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
</span>
</div>
</div>
{ma !== undefined && ma !== null && (
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-slate-600">{maLabel} {ma.toFixed(ma > 100 ? 1 : 2)}</span>
<span className={clsx('text-xs tabular-nums', maUp ? 'text-emerald-500/70' : 'text-red-500/70')}>
{maVsPct !== null ? `${maUp ? '+' : ''}${maVsPct.toFixed(1)}%` : ''}
</span>
</div>
)}
</div>
)
})}
@@ -233,12 +287,23 @@ function CellMacro({
)
}
// Calendar events filtered by temporal horizon relative to refDate
// Calendar events filtered by EXCLUSIVE temporal horizon ranges
function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
const horizons: Record<string, number> = { long: 90, medium: 30, short: 7 }
const horizon = horizons[level] ?? 30
// Exclusive ranges so each level shows different events:
// short = 0-7 days ahead
// medium = 8-30 days ahead
// long = 31-90 days ahead
const ranges: Record<string, [number, number]> = {
short: [0, 7],
medium: [8, 30],
long: [31, 90],
}
const [from, to] = ranges[level] ?? [0, 30]
return events
.filter(e => e.date >= refDate && e.date <= offsetDate(refDate, horizon))
.filter(e => {
const d = e.date
return d >= offsetDate(refDate, from) && d <= offsetDate(refDate, to)
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 4)
}
@@ -288,7 +353,7 @@ function CellCalendar({
// ── Temporal Row ───────────────────────────────────────────────────────────────
function TemporalRow({
level, ev, quotes, macro, calEvents, refDate,
level, ev, quotes, macro, calEvents, refDate, maData,
}: {
level: keyof typeof LEVEL
ev: MarketEvent | null
@@ -296,6 +361,7 @@ function TemporalRow({
macro: MacroRegime | null
calEvents: CalEvent[] | null
refDate: string
maData: Record<string, number | null>
}) {
const cfg = LEVEL[level]
@@ -345,7 +411,7 @@ function TemporalRow({
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellMarkets ev={ev} level={level} quotes={quotes} />
<CellMarkets ev={ev} level={level} quotes={quotes} maData={maData} />
</div>
{/* Pilier 3: Régimes Macro */}
@@ -378,12 +444,23 @@ function TemporalRow({
// ── Main ───────────────────────────────────────────────────────────────────────
// Compute simple moving average from array of closes (most recent last)
function computeMA(closes: number[], period: number): number | null {
if (closes.length < period) return null
const slice = closes.slice(-period)
return slice.reduce((a, b) => a + b, 0) / period
}
export default function ExternalSnapshot() {
const [refDate, setRefDate] = useState(todayStr())
const [ctx, setCtx] = useState<DayContext | null>(null)
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
const [macro, setMacro] = useState<MacroRegime | null>(null)
const [calEvents, setCalEvents] = useState<CalEvent[] | null>(null)
// maData[level][symbol] = MA value
const [maData, setMaData] = useState<Record<string, Record<string, number | null>>>({
long: {}, medium: {}, short: {},
})
useEffect(() => {
api.get(`/timeline/day/${refDate}`)
@@ -401,6 +478,36 @@ export default function ExternalSnapshot() {
api.get('/market/macro-regime').then(r => setMacro(r.data)).catch(() => {})
api.get('/geo/calendar').then(r => setCalEvents(r.data)).catch(() => {})
// Fetch MA data for key symbols (200 days history)
const KEY_SYMS = ['SPY', 'GLD', 'USO', 'TLT', 'UUP', 'QQQ']
const MA_PERIODS = { long: 100, medium: 20, short: 10 }
Promise.allSettled(
KEY_SYMS.map(sym =>
api.get(`/market/history/${sym}`, { params: { days: 200 } })
.then(r => {
const candles: { close: number }[] = Array.isArray(r.data) ? r.data : []
const closes = candles.map(c => c.close).filter(Boolean)
const result: Record<string, Record<string, number | null>> = {
long: {}, medium: {}, short: {},
}
for (const [level, period] of Object.entries(MA_PERIODS)) {
result[level][sym] = computeMA(closes, period as number)
}
return result
})
)
).then(results => {
const merged: Record<string, Record<string, number | null>> = { long: {}, medium: {}, short: {} }
for (const res of results) {
if (res.status === 'fulfilled') {
for (const level of ['long', 'medium', 'short']) {
Object.assign(merged[level], res.value[level])
}
}
}
setMaData(merged)
})
}, [])
const navigate = (days: number) => {
@@ -453,9 +560,9 @@ export default function ExternalSnapshot() {
</div>
{/* 3 temporal rows */}
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.long} />
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.medium} />
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.short} />
{/* Footer links */}
<div className="flex items-center gap-4 text-xs text-slate-600 pt-2">

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw } from 'lucide-react'
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List } from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import TimelineFrise from '../components/TimelineFrise'
import EventManager from '../components/EventManager'
const api = axios.create({ baseURL: '/api' })
@@ -189,6 +190,7 @@ export default function Timeline() {
const [loading, setLoading] = useState(false)
const [generating, setGenerating] = useState(false)
const [bootstrapped, setBootstrapped] = useState(false)
const [showManager, setShowManager] = useState(false)
const fetchDay = useCallback(async (d: string) => {
setLoading(true)
@@ -264,12 +266,24 @@ export default function Timeline() {
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowManager(s => !s)}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 text-xs border rounded-lg transition-colors',
showManager
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
: 'text-slate-400 border-slate-700/40 hover:bg-dark-700'
)}
>
<List className="w-3 h-3" />
Gérer événements
</button>
<button
onClick={() => bootstrap()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Réinitialiser événements
Réinitialiser
</button>
</div>
</div>
@@ -361,37 +375,9 @@ export default function Timeline() {
<div className="text-center text-slate-500 py-12">Aucun contexte disponible</div>
)}
{/* Event list (compact) */}
{allEvents.length > 0 && (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
<h2 className="text-sm font-semibold text-slate-300 mb-3">
Catalogue d'événements ({allEvents.length})
</h2>
<div className="space-y-1 max-h-72 overflow-y-auto">
{(['long', 'medium', 'short'] as const).map(lvl => (
<div key={lvl}>
<div className={clsx('text-xs font-semibold uppercase tracking-wider mb-1 mt-2', LEVEL_CONFIG[lvl].color)}>
{LEVEL_CONFIG[lvl].label}
</div>
{allEvents.filter(e => e.level === lvl).map(ev => (
<div
key={ev.id}
onClick={() => setRefDate(ev.start_date)}
className="flex items-center gap-2 text-xs py-1 px-2 rounded hover:bg-dark-700/60 cursor-pointer group"
>
<div className={clsx('w-1.5 h-1.5 rounded-full shrink-0', LEVEL_CONFIG[lvl].dot)} />
<span className="text-slate-300 group-hover:text-white flex-1">{ev.name}</span>
<span className="text-slate-600">{fmtDate(ev.start_date)}</span>
{ev.end_date
? <span className="text-slate-600"> {fmtDate(ev.end_date)}</span>
: <span className="text-amber-600 text-xs">en cours</span>
}
</div>
))}
</div>
))}
</div>
</div>
{/* Event Manager */}
{showManager && (
<EventManager events={allEvents as any} onRefresh={fetchEvents} />
)}
</div>
)