feat: Timeline Navigator — contexte historique 3 temporalités COVID → aujourd'hui
- 2 nouvelles tables SQLite : market_events + timeline_context
- 32 événements historiques seedés (long/medium/short de feb 2020 à juin 2026)
- timeline_service.py : bootstrap, get_events_for_date, génération commentaires GPT-4o-mini
- /api/timeline router : GET /day/{date}, GET /events, POST /generate/{date}, POST /bootstrap
- Timeline.tsx : navigateur date avec strip visuel, 3 panneaux contextuels, catalogue d'événements
- Sidebar : entrée Timeline avec icône Layers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import VaRAnalysis from './pages/VaRAnalysis'
|
||||
import PositionHistory from './pages/PositionHistory'
|
||||
import InstitutionalReports from './pages/InstitutionalReports'
|
||||
import SpecialistDesks from './pages/SpecialistDesks'
|
||||
import Timeline from './pages/Timeline'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
function GlobalWatcher() {
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route path="/logs" element={<SystemLogs />} />
|
||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||
<Route path="/timeline" element={<Timeline />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers
|
||||
} from 'lucide-react'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -27,6 +27,7 @@ const nav = [
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||
]
|
||||
|
||||
449
frontend/src/pages/Timeline.tsx
Normal file
449
frontend/src/pages/Timeline.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const API = 'http://localhost:8000'
|
||||
|
||||
interface MarketEvent {
|
||||
id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
level: 'long' | 'medium' | 'short'
|
||||
category: string
|
||||
description: string
|
||||
market_impact: string
|
||||
affected_assets: string
|
||||
impact_score: number
|
||||
}
|
||||
|
||||
interface DayContext {
|
||||
ref_date: string
|
||||
events: {
|
||||
long: MarketEvent | null
|
||||
medium: MarketEvent | null
|
||||
short: MarketEvent | null
|
||||
}
|
||||
long_commentary: string
|
||||
medium_commentary: string
|
||||
short_commentary: string
|
||||
evidence_headlines: string[]
|
||||
generated_at: string | null
|
||||
has_commentary: boolean
|
||||
}
|
||||
|
||||
const LEVEL_CONFIG = {
|
||||
long: {
|
||||
label: 'Long Terme',
|
||||
sublabel: 'Régime structurel',
|
||||
color: 'text-violet-400',
|
||||
border: 'border-violet-700/50',
|
||||
bg: 'bg-violet-950/30',
|
||||
dot: 'bg-violet-500',
|
||||
badge: 'bg-violet-900/50 text-violet-300 border-violet-700/50',
|
||||
},
|
||||
medium: {
|
||||
label: 'Moyen Terme',
|
||||
sublabel: 'Régime courant',
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-700/50',
|
||||
bg: 'bg-blue-950/30',
|
||||
dot: 'bg-blue-500',
|
||||
badge: 'bg-blue-900/50 text-blue-300 border-blue-700/50',
|
||||
},
|
||||
short: {
|
||||
label: 'Court Terme',
|
||||
sublabel: 'Catalyseur récent',
|
||||
color: 'text-emerald-400',
|
||||
border: 'border-emerald-700/50',
|
||||
bg: 'bg-emerald-950/30',
|
||||
dot: 'bg-emerald-500',
|
||||
badge: 'bg-emerald-900/50 text-emerald-300 border-emerald-700/50',
|
||||
},
|
||||
}
|
||||
|
||||
function daysSince(refDate: string, startDate: string): number {
|
||||
const d0 = new Date(startDate)
|
||||
const d1 = new Date(refDate)
|
||||
return Math.floor((d1.getTime() - d0.getTime()) / 86_400_000)
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function offsetDate(d: string, days: number): string {
|
||||
const dt = new Date(d)
|
||||
dt.setDate(dt.getDate() + days)
|
||||
return dt.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Simple mini timeline strip
|
||||
function TimelineStrip({ events, refDate }: { events: MarketEvent[], refDate: string }) {
|
||||
const today = new Date(refDate)
|
||||
// show ±6 months window
|
||||
const start = new Date(today); start.setMonth(start.getMonth() - 6)
|
||||
const end = new Date(today); end.setMonth(end.getMonth() + 1)
|
||||
const totalDays = (end.getTime() - start.getTime()) / 86_400_000
|
||||
|
||||
const relevant = events.filter(ev => {
|
||||
const s = new Date(ev.start_date)
|
||||
const e = ev.end_date ? new Date(ev.end_date) : end
|
||||
return s <= end && e >= start
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative h-10 bg-dark-800 rounded-lg border border-slate-700/40 overflow-hidden mt-3">
|
||||
{/* Today marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-white/40 z-10"
|
||||
style={{ left: `${((today.getTime() - start.getTime()) / 86_400_000 / totalDays) * 100}%` }}
|
||||
/>
|
||||
{relevant.map(ev => {
|
||||
const s = Math.max(0, (new Date(ev.start_date).getTime() - start.getTime()) / 86_400_000)
|
||||
const e_raw = ev.end_date ? new Date(ev.end_date) : end
|
||||
const e = Math.min(totalDays, (e_raw.getTime() - start.getTime()) / 86_400_000)
|
||||
const left = (s / totalDays) * 100
|
||||
const width = Math.max(0.5, ((e - s) / totalDays) * 100)
|
||||
const cfg = LEVEL_CONFIG[ev.level as keyof typeof LEVEL_CONFIG]
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
title={`[${ev.level.toUpperCase()}] ${ev.name}`}
|
||||
className={clsx('absolute top-1/2 h-3 rounded-sm opacity-80 cursor-pointer', cfg.dot)}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
top: ev.level === 'long' ? '25%' : ev.level === 'medium' ? '50%' : '75%',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPanel({
|
||||
level,
|
||||
event,
|
||||
commentary,
|
||||
refDate,
|
||||
}: {
|
||||
level: 'long' | 'medium' | 'short'
|
||||
event: MarketEvent | null
|
||||
commentary: string
|
||||
refDate: string
|
||||
}) {
|
||||
const cfg = LEVEL_CONFIG[level]
|
||||
|
||||
return (
|
||||
<div className={clsx('rounded-xl border p-5 flex flex-col gap-3', cfg.bg, cfg.border)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx('w-2.5 h-2.5 rounded-full', cfg.dot)} />
|
||||
<div>
|
||||
<div className={clsx('text-sm font-semibold', cfg.color)}>{cfg.label}</div>
|
||||
<div className="text-xs text-slate-500">{cfg.sublabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event ? (
|
||||
<>
|
||||
{/* Event card */}
|
||||
<div className="bg-dark-800/60 rounded-lg p-3 border border-slate-700/30">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className={clsx('text-sm font-bold', cfg.color)}>{event.name}</div>
|
||||
<span className={clsx('text-xs px-1.5 py-0.5 rounded border shrink-0', cfg.badge)}>
|
||||
{event.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
J+{daysSince(refDate, event.start_date)} ({fmtDate(event.start_date)})
|
||||
</span>
|
||||
{event.end_date && (
|
||||
<span className="text-slate-600">→ {fmtDate(event.end_date)}</span>
|
||||
)}
|
||||
{!event.end_date && (
|
||||
<span className="text-amber-500/70 text-xs">en cours</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2 leading-relaxed">{event.description}</p>
|
||||
{event.market_impact && (
|
||||
<p className="text-xs text-slate-500 mt-1.5 italic">{event.market_impact}</p>
|
||||
)}
|
||||
{/* Affected assets */}
|
||||
{event.affected_assets && (() => {
|
||||
try {
|
||||
const assets = JSON.parse(event.affected_assets)
|
||||
if (assets.length > 0) return (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{assets.map((a: string) => (
|
||||
<span key={a} className="text-xs px-1.5 py-0.5 bg-dark-700 rounded text-slate-500 border border-slate-700/30">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* AI Commentary */}
|
||||
{commentary ? (
|
||||
<div className="text-sm text-slate-300 leading-relaxed bg-dark-900/40 rounded-lg p-3 border border-slate-700/20">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-2">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Analyse IA
|
||||
</div>
|
||||
{commentary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 italic text-center py-2">
|
||||
Commentaire IA non généré — cliquez "Générer IA"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm italic py-4">
|
||||
Aucun événement {cfg.label.toLowerCase()} actif à cette date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const [refDate, setRefDate] = useState(todayStr())
|
||||
const [ctx, setCtx] = useState<DayContext | null>(null)
|
||||
const [allEvents, setAllEvents] = useState<MarketEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [bootstrapped, setBootstrapped] = useState(false)
|
||||
|
||||
const fetchDay = useCallback(async (d: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API}/api/timeline/day/${d}`)
|
||||
if (res.ok) setCtx(await res.json())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
const res = await fetch(`${API}/api/timeline/events`)
|
||||
if (res.ok) setAllEvents(await res.json())
|
||||
}, [])
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const res = await fetch(`${API}/api/timeline/bootstrap`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
setBootstrapped(true)
|
||||
await fetchEvents()
|
||||
await fetchDay(refDate)
|
||||
}
|
||||
}, [refDate, fetchDay, fetchEvents])
|
||||
|
||||
const generateAI = useCallback(async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await fetch(`${API}/api/timeline/generate/${refDate}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCtx(prev => prev ? {
|
||||
...prev,
|
||||
long_commentary: data.long_commentary,
|
||||
medium_commentary: data.medium_commentary,
|
||||
short_commentary: data.short_commentary,
|
||||
has_commentary: true,
|
||||
} : prev)
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [refDate])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents()
|
||||
}, [fetchEvents])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDay(refDate)
|
||||
}, [refDate, fetchDay])
|
||||
|
||||
// Auto-bootstrap on first load if no events
|
||||
useEffect(() => {
|
||||
if (!bootstrapped && allEvents.length === 0) {
|
||||
bootstrap()
|
||||
}
|
||||
}, [allEvents.length, bootstrapped, bootstrap])
|
||||
|
||||
const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days))
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-violet-400" />
|
||||
Timeline Navigator
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
Contexte historique 3 temporalités — COVID (2020) → aujourd'hui
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Navigator */}
|
||||
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Prev buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-1M</button>
|
||||
<button onClick={() => navigate(-7)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-7J</button>
|
||||
<button onClick={() => navigate(-1)} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date input */}
|
||||
<div className="flex-1 flex items-center justify-center gap-3">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={refDate}
|
||||
min="2020-01-01"
|
||||
max={todayStr()}
|
||||
onChange={e => setRefDate(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
<span className="text-sm text-slate-400">{fmtDate(refDate)}</span>
|
||||
<button
|
||||
onClick={() => setRefDate(todayStr())}
|
||||
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => navigate(7)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+7J</button>
|
||||
<button onClick={() => navigate(30)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+1M</button>
|
||||
</div>
|
||||
|
||||
{/* Generate AI */}
|
||||
<button
|
||||
onClick={generateAI}
|
||||
disabled={generating || loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-violet-700/30 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{generating ? 'Génération...' : 'Générer IA'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline strip */}
|
||||
{allEvents.length > 0 && (
|
||||
<TimelineStrip events={allEvents} refDate={refDate} />
|
||||
)}
|
||||
|
||||
{/* Strip legend */}
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
{(['long', 'medium', 'short'] as const).map(l => (
|
||||
<div key={l} className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<div className={clsx('w-3 h-1.5 rounded', LEVEL_CONFIG[l].dot)} />
|
||||
{LEVEL_CONFIG[l].label}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-600 ml-auto">
|
||||
<div className="w-px h-3 bg-white/40" />
|
||||
Date sélectionnée
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 context panels */}
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-12">Chargement du contexte...</div>
|
||||
) : ctx ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ContextPanel
|
||||
level="long"
|
||||
event={ctx.events?.long ?? null}
|
||||
commentary={ctx.long_commentary}
|
||||
refDate={refDate}
|
||||
/>
|
||||
<ContextPanel
|
||||
level="medium"
|
||||
event={ctx.events?.medium ?? null}
|
||||
commentary={ctx.medium_commentary}
|
||||
refDate={refDate}
|
||||
/>
|
||||
<ContextPanel
|
||||
level="short"
|
||||
event={ctx.events?.short ?? null}
|
||||
commentary={ctx.short_commentary}
|
||||
refDate={refDate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user