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:
@@ -2,7 +2,9 @@
|
||||
Timeline Navigator — historical market context browser.
|
||||
Prefix: /api/timeline
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -23,6 +25,8 @@ class EventCreate(BaseModel):
|
||||
market_impact: str = ""
|
||||
affected_assets: List[str] = []
|
||||
impact_score: float = 0.5
|
||||
absorption_pct: Optional[float] = None
|
||||
relevant_indicators: List[Dict[str, Any]] = []
|
||||
parent_event_id: Optional[int] = None
|
||||
|
||||
|
||||
@@ -52,11 +56,108 @@ def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]:
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}")
|
||||
def delete_event(event_id: int) -> Dict[str, Any]:
|
||||
from services.database import delete_market_event
|
||||
delete_market_event(event_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/ai-enrich")
|
||||
def ai_enrich_event(event_id: int) -> Dict[str, Any]:
|
||||
"""Ask GPT-4o-mini to suggest absorption_pct + relevant_indicators for this event."""
|
||||
from services.database import get_all_market_events, update_market_event
|
||||
from datetime import datetime
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not api_key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
|
||||
# Fetch event
|
||||
all_evs = get_all_market_events()
|
||||
ev = next((e for e in all_evs if e["id"] == event_id), None)
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event not found")
|
||||
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
days_elapsed = max(0, (datetime.strptime(today, "%Y-%m-%d") -
|
||||
datetime.strptime(ev["start_date"][:10], "%Y-%m-%d")).days)
|
||||
is_ongoing = not ev.get("end_date")
|
||||
|
||||
prompt = f"""Tu es un analyste macro senior spécialisé en options géopolitiques.
|
||||
|
||||
Événement à analyser:
|
||||
- Nom: {ev['name']}
|
||||
- Niveau: {ev['level']} (long=structurel, medium=régime, short=catalyseur)
|
||||
- Catégorie: {ev['category']}
|
||||
- Début: {ev['start_date']}
|
||||
- Fin: {ev.get('end_date') or 'en cours'}
|
||||
- Jours écoulés depuis début: {days_elapsed}j
|
||||
- Description: {ev['description']}
|
||||
- Impact marché: {ev.get('market_impact', '')}
|
||||
- Assets affectés: {ev.get('affected_assets', '[]')}
|
||||
|
||||
Ta mission:
|
||||
1. Estime le taux d'absorption (0-100%) de cet événement par le marché à ce jour.
|
||||
Absorption = à quel point l'impact attendu est déjà "pricé" par les marchés.
|
||||
Exemples: 0%=pas encore pricé, 50%=à moitié absorbé, 100%=fully priced in.
|
||||
|
||||
2. Suggère 3-5 indicateurs techniques PERTINENTS pour suivre cet événement dans le contexte d'un trader d'options.
|
||||
Pour chaque indicateur:
|
||||
- symbol: ticker yfinance (ex: "CL=F", "GC=F", "SPY", "^VIX")
|
||||
- indicator: type ("MA10", "MA20", "MA50", "MA100", "price", "RSI14")
|
||||
- label: nom affiché (ex: "Brent MA100", "Gold prix spot", "VIX 20j avg")
|
||||
- rationale: 1 phrase pourquoi cet indicateur est pertinent POUR CET ÉVÉNEMENT PRÉCIS
|
||||
|
||||
Règle: choisis les indicateurs selon le niveau temporel:
|
||||
- Long terme → préfère MA100, MA50 (tendances structurelles)
|
||||
- Moyen terme → MA20, MA50
|
||||
- Court terme → MA10, MA20, prix spot
|
||||
|
||||
FORMAT JSON STRICT:
|
||||
{{
|
||||
"absorption_pct": <0-100>,
|
||||
"absorption_rationale": "<1 phrase expliquant pourquoi ce niveau>",
|
||||
"relevant_indicators": [
|
||||
{{"symbol": "...", "indicator": "...", "label": "...", "rationale": "..."}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.3,
|
||||
max_tokens=600,
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
|
||||
# Update event in DB
|
||||
ev_update = dict(ev)
|
||||
ev_update["affected_assets"] = json.loads(ev.get("affected_assets", "[]") or "[]")
|
||||
ev_update["relevant_indicators"] = result.get("relevant_indicators", [])
|
||||
ev_update["absorption_pct"] = result.get("absorption_pct")
|
||||
update_market_event(event_id, ev_update)
|
||||
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"absorption_pct": result.get("absorption_pct"),
|
||||
"absorption_rationale": result.get("absorption_rationale", ""),
|
||||
"relevant_indicators": result.get("relevant_indicators", []),
|
||||
"status": "enriched",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Timeline] ai_enrich failed for event {event_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/day/{ref_date}")
|
||||
def get_day_context(ref_date: str) -> Dict[str, Any]:
|
||||
"""Return cached or freshly generated timeline context for a date (YYYY-MM-DD)."""
|
||||
from services.database import get_events_for_date, get_timeline_context
|
||||
import json
|
||||
|
||||
events = get_events_for_date(ref_date)
|
||||
cached = get_timeline_context(ref_date)
|
||||
@@ -85,9 +186,7 @@ def get_day_context(ref_date: str) -> Dict[str, Any]:
|
||||
|
||||
@router.post("/generate/{ref_date}")
|
||||
def generate_commentary(ref_date: str) -> Dict[str, Any]:
|
||||
"""Generate (or regenerate) AI commentary for a specific date and cache it."""
|
||||
from services.timeline_service import get_or_generate_context
|
||||
import json
|
||||
|
||||
try:
|
||||
ctx = get_or_generate_context(ref_date)
|
||||
@@ -96,7 +195,7 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
|
||||
"long_commentary": ctx.get("long_commentary", ""),
|
||||
"medium_commentary": ctx.get("medium_commentary", ""),
|
||||
"short_commentary": ctx.get("short_commentary", ""),
|
||||
"evidence_headlines": json.loads(ctx.get("evidence_headlines", "[]") or "[]") if isinstance(ctx.get("evidence_headlines"), str) else ctx.get("evidence_headlines", []),
|
||||
"evidence_headlines": ctx.get("evidence_headlines", []),
|
||||
"events": ctx.get("events", {}),
|
||||
"has_commentary": True,
|
||||
}
|
||||
@@ -107,7 +206,6 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
|
||||
|
||||
@router.post("/bootstrap")
|
||||
def bootstrap_events() -> Dict[str, Any]:
|
||||
"""Seed historical market events (idempotent — only runs if table is empty)."""
|
||||
from services.timeline_service import bootstrap_events as _bootstrap
|
||||
count = _bootstrap()
|
||||
return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"}
|
||||
|
||||
@@ -746,9 +746,20 @@ def init_db():
|
||||
market_impact TEXT DEFAULT '',
|
||||
affected_assets TEXT DEFAULT '[]',
|
||||
impact_score REAL DEFAULT 0.5,
|
||||
absorption_pct REAL DEFAULT NULL,
|
||||
relevant_indicators TEXT DEFAULT '[]',
|
||||
parent_event_id INTEGER REFERENCES market_events(id),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)""")
|
||||
# Add columns to existing DBs (idempotent via catch)
|
||||
for _col, _def in [
|
||||
("absorption_pct", "REAL DEFAULT NULL"),
|
||||
("relevant_indicators", "TEXT DEFAULT '[]'"),
|
||||
]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE market_events ADD COLUMN {_col} {_def}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS timeline_context (
|
||||
ref_date TEXT PRIMARY KEY,
|
||||
@@ -4487,12 +4498,25 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
conn.execute("""UPDATE market_events SET
|
||||
name=?, start_date=?, end_date=?, level=?, category=?, description=?,
|
||||
market_impact=?, affected_assets=?, impact_score=?
|
||||
market_impact=?, affected_assets=?, impact_score=?,
|
||||
absorption_pct=?, relevant_indicators=?
|
||||
WHERE id=?""",
|
||||
(ev["name"], ev["start_date"], ev.get("end_date"),
|
||||
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
|
||||
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
|
||||
ev.get("impact_score", 0.5), event_id))
|
||||
ev.get("impact_score", 0.5),
|
||||
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
|
||||
event_id))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_market_event(event_id: int) -> bool:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM market_events WHERE id=?", (event_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
|
||||
434
frontend/src/components/EventManager.tsx
Normal file
434
frontend/src/components/EventManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user