feat: frise chronologique + page Snapshot Externe 4 piliers
- TimelineFrise.tsx : visualisation horizontale scrollable COVID→aujourd'hui 3 lanes (long/medium/short), événements cliquables, marqueur aujourd'hui/date, axe années - ExternalSnapshot.tsx (/snapshot) : dashboard 4 piliers du contexte externe Pilier 1 Géopolitique : 3 badges L/M/C avec J+ depuis événement + lien /timeline?date= Pilier 2 Prix & Marchés : quotes SPY/QQQ/GLD/USO/TLT/UUP live + lien Specialist Desks Pilier 3 Régimes Macro : régime dominant + scores scénarios + jauges VIX/DXY/10Y Pilier 4 Calendrier Économique : prochains événements + récents, liens Calendar/Institutional - Timeline.tsx : remplace mini-strip par TimelineFrise, lit ?date= query param - Sidebar : Snapshot Externe (ScanEye) + Timeline (Layers) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import PositionHistory from './pages/PositionHistory'
|
||||
import InstitutionalReports from './pages/InstitutionalReports'
|
||||
import SpecialistDesks from './pages/SpecialistDesks'
|
||||
import Timeline from './pages/Timeline'
|
||||
import ExternalSnapshot from './pages/ExternalSnapshot'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
function GlobalWatcher() {
|
||||
@@ -63,6 +64,7 @@ export default function App() {
|
||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||
<Route path="/timeline" element={<Timeline />} />
|
||||
<Route path="/snapshot" element={<ExternalSnapshot />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
222
frontend/src/components/TimelineFrise.tsx
Normal file
222
frontend/src/components/TimelineFrise.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface MarketEvent {
|
||||
id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
level: 'long' | 'medium' | 'short'
|
||||
category: string
|
||||
impact_score: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: MarketEvent[]
|
||||
refDate: string
|
||||
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
|
||||
const FRISE_START = '2020-02-01'
|
||||
|
||||
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: Record<string, number> = { long: 0, medium: 1, short: 2 }
|
||||
|
||||
function toDay(date: string): number {
|
||||
const origin = new Date(FRISE_START).getTime()
|
||||
const d = new Date(date).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 }[] {
|
||||
const origin = new Date(FRISE_START)
|
||||
const markers = []
|
||||
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; 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) })
|
||||
}
|
||||
return markers
|
||||
}
|
||||
|
||||
export default function TimelineFrise({ events, refDate, onDateChange }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const totalDays = toDay(new Date().toISOString().split('T')[0]) + 60
|
||||
const totalWidth = totalDays * SCALE
|
||||
const totalHeight = PADDING_TOP + 3 * LANE_H + AXIS_H + 8
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const target = refX - el.clientWidth / 2
|
||||
el.scrollLeft = Math.max(0, target)
|
||||
}, [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
|
||||
}
|
||||
|
||||
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 => (
|
||||
<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">
|
||||
{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>
|
||||
|
||||
{/* 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' }}>
|
||||
|
||||
{/* 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%' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Lane backgrounds */}
|
||||
{(['long', 'medium', 'short'] as const).map(l => (
|
||||
<div key={l}
|
||||
className="absolute"
|
||||
style={{
|
||||
top: laneY(l) + 'px',
|
||||
left: 0, right: 0,
|
||||
height: LANE_H + 'px',
|
||||
background: LEVEL_COLORS[l].track,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Events */}
|
||||
{events.map(ev => {
|
||||
const x = toDay(ev.start_date) * SCALE
|
||||
const w = evWidth(ev)
|
||||
const y = laneY(ev.level)
|
||||
const c = LEVEL_COLORS[ev.level]
|
||||
const isSelected = refDate >= ev.start_date && (!ev.end_date || refDate <= 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"
|
||||
style={{
|
||||
left: x + 'px',
|
||||
top: y + 3 + 'px',
|
||||
width: w + 'px',
|
||||
height: LANE_H - 6 + 'px',
|
||||
background: isSelected
|
||||
? c.border
|
||||
: c.bg,
|
||||
border: `1px solid ${c.border}`,
|
||||
opacity: isSelected ? 1 : 0.8,
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="px-1.5 text-xs font-medium whitespace-nowrap select-none"
|
||||
style={{ color: c.text, fontSize: '10px' }}
|
||||
>
|
||||
{ev.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Today 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)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Selected date line (if not today) */}
|
||||
{refDate !== new Date().toISOString().split('T')[0] && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: refX + 'px',
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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' }}
|
||||
>
|
||||
▲ aujourd'hui
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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, Layers
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye
|
||||
} 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: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||
|
||||
425
frontend/src/pages/ExternalSnapshot.tsx
Normal file
425
frontend/src/pages/ExternalSnapshot.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Globe, BarChart2, Activity, Calendar,
|
||||
ArrowRight, Clock, TrendingUp, TrendingDown, Minus, ExternalLink,
|
||||
} from 'lucide-react'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
function todayStr() { return new Date().toISOString().split('T')[0] }
|
||||
function fmtDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
function daysSince(ref: string, start: string) {
|
||||
return Math.floor((new Date(ref).getTime() - new Date(start).getTime()) / 86_400_000)
|
||||
}
|
||||
function offsetDate(d: string, days: number) {
|
||||
const dt = new Date(d); dt.setDate(dt.getDate() + days)
|
||||
return dt.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MarketEvent {
|
||||
id: number; name: string; start_date: string; end_date: string | null
|
||||
level: string; category: string; description: string; impact_score: number
|
||||
}
|
||||
|
||||
interface DayContext {
|
||||
ref_date: string
|
||||
events: { long: MarketEvent | null; medium: MarketEvent | null; short: MarketEvent | null }
|
||||
has_commentary: boolean
|
||||
long_commentary: string; medium_commentary: string; short_commentary: string
|
||||
}
|
||||
|
||||
interface Quote { symbol: string; price: number; change_pct: number; name?: string }
|
||||
interface MacroRegime {
|
||||
scenarios: { dominant: string; scores: Record<string, number> }
|
||||
gauges: Record<string, { value: number | null; label: string; change_pct?: number | null }>
|
||||
}
|
||||
interface CalEvent { title: string; date: string; importance: number; currency?: string }
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LEVEL_CFG = {
|
||||
long: { label: 'L', color: 'text-violet-400', bg: 'bg-violet-900/30', border: 'border-violet-700/40' },
|
||||
medium: { label: 'M', color: 'text-blue-400', bg: 'bg-blue-900/30', border: 'border-blue-700/40' },
|
||||
short: { label: 'C', color: 'text-emerald-400', bg: 'bg-emerald-900/30', border: 'border-emerald-700/40' },
|
||||
}
|
||||
|
||||
function EventBadge({ ev, level, refDate }: { ev: MarketEvent | null; level: 'long' | 'medium' | 'short'; refDate: string }) {
|
||||
const cfg = LEVEL_CFG[level]
|
||||
if (!ev) return (
|
||||
<div className={clsx('flex items-center gap-2 rounded-lg px-3 py-2 border opacity-40', cfg.bg, cfg.border)}>
|
||||
<span className={clsx('text-xs font-bold w-4 shrink-0', cfg.color)}>{cfg.label}</span>
|
||||
<span className="text-xs text-slate-600 italic">—</span>
|
||||
</div>
|
||||
)
|
||||
const days = daysSince(refDate, ev.start_date)
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2 rounded-lg px-3 py-2 border', cfg.bg, cfg.border)}>
|
||||
<span className={clsx('text-xs font-bold w-4 shrink-0', cfg.color)}>{cfg.label}</span>
|
||||
<span className={clsx('text-xs font-semibold flex-1 truncate', cfg.color)}>{ev.name}</span>
|
||||
<span className="text-xs text-slate-500 shrink-0 tabular-nums">J+{days}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pillar 1 — Géopolitique / Temporal Context
|
||||
function PillarGeo({ ctx, refDate }: { ctx: DayContext | null; refDate: string }) {
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl border border-violet-700/30 p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-sm font-semibold text-violet-300">Géopolitique</span>
|
||||
</div>
|
||||
<Link to={`/timeline?date=${refDate}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-violet-400 transition-colors">
|
||||
Timeline <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{ctx ? (
|
||||
<>
|
||||
<EventBadge ev={ctx.events.long} level="long" refDate={refDate} />
|
||||
<EventBadge ev={ctx.events.medium} level="medium" refDate={refDate} />
|
||||
<EventBadge ev={ctx.events.short} level="short" refDate={refDate} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(['long', 'medium', 'short'] as const).map(l => (
|
||||
<div key={l} className={clsx('h-9 rounded-lg border animate-pulse', LEVEL_CFG[l].bg, LEVEL_CFG[l].border)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx?.has_commentary && (
|
||||
<div className="text-xs text-slate-500 italic border-t border-slate-700/30 pt-2">
|
||||
Commentaires IA disponibles → voir Timeline
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pillar 2 — Prix & Marchés
|
||||
const KEY_SYMBOLS = ['SPY', 'QQQ', 'GLD', 'USO', 'TLT', 'UUP']
|
||||
|
||||
function QuoteRow({ symbol, quote }: { symbol: string; quote?: Quote }) {
|
||||
if (!quote) return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-slate-500">{symbol}</span>
|
||||
<span className="text-xs text-slate-700">—</span>
|
||||
</div>
|
||||
)
|
||||
const up = quote.change_pct > 0.1
|
||||
const dn = quote.change_pct < -0.1
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-slate-400">{quote.name || symbol}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white tabular-nums">{quote.price?.toFixed(2)}</span>
|
||||
<span className={clsx('text-xs tabular-nums flex items-center gap-0.5', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500')}>
|
||||
{up ? <TrendingUp className="w-3 h-3" /> : dn ? <TrendingDown className="w-3 h-3" /> : <Minus className="w-3 h-3" />}
|
||||
{quote.change_pct > 0 ? '+' : ''}{quote.change_pct?.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillarMarkets({ quotes }: { quotes: Record<string, Quote> | null }) {
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl border border-blue-700/30 p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-blue-300">Prix & Marchés</span>
|
||||
</div>
|
||||
<Link to="/markets" className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-400 transition-colors">
|
||||
Marchés <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-700/20">
|
||||
{KEY_SYMBOLS.map(sym => (
|
||||
<QuoteRow key={sym} symbol={sym} quote={quotes?.[sym]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link to="/specialist-desks" className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-400 transition-colors border-t border-slate-700/30 pt-2">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
COT & Forward Curves → Specialist Desks
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pillar 3 — Régimes Macro
|
||||
const REGIME_LABELS: Record<string, { label: string; color: string }> = {
|
||||
risk_on: { label: 'Risk On', color: 'text-emerald-400' },
|
||||
risk_off: { label: 'Risk Off', color: 'text-red-400' },
|
||||
stagflation: { label: 'Stagflation', color: 'text-orange-400' },
|
||||
goldilocks: { label: 'Goldilocks', color: 'text-emerald-300' },
|
||||
recession: { label: 'Récession', color: 'text-red-500' },
|
||||
reflation: { label: 'Reflation', color: 'text-yellow-400' },
|
||||
incertain: { label: 'Incertain', color: 'text-slate-400' },
|
||||
}
|
||||
|
||||
function PillarMacro({ macro }: { macro: MacroRegime | null }) {
|
||||
const dominant = macro?.scenarios?.dominant ?? 'incertain'
|
||||
const cfg = REGIME_LABELS[dominant] ?? { label: dominant, color: 'text-slate-400' }
|
||||
const scores = macro?.scenarios?.scores ?? {}
|
||||
const top3 = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 4)
|
||||
|
||||
const KEY_GAUGES = ['vix', 'dxy', 'us10y', 'credit_spread']
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl border border-amber-700/30 p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-amber-300">Régimes Macro</span>
|
||||
</div>
|
||||
<Link to="/macro" className="flex items-center gap-1 text-xs text-slate-500 hover:text-amber-400 transition-colors">
|
||||
Macro <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Dominant regime */}
|
||||
<div className="bg-dark-900/40 rounded-lg px-3 py-2 border border-slate-700/20">
|
||||
<div className="text-xs text-slate-500 mb-0.5">Régime dominant</div>
|
||||
<div className={clsx('text-base font-bold', cfg.color)}>{cfg.label}</div>
|
||||
</div>
|
||||
|
||||
{/* Top scenarios */}
|
||||
<div className="space-y-1.5">
|
||||
{top3.map(([name, score]) => {
|
||||
const r = REGIME_LABELS[name] ?? { label: name, color: 'text-slate-400' }
|
||||
const pct = Math.round((score as number) * 100)
|
||||
return (
|
||||
<div key={name} className="flex items-center gap-2">
|
||||
<span className={clsx('text-xs w-24 truncate', r.color)}>{r.label}</span>
|
||||
<div className="flex-1 bg-dark-700 rounded-full h-1.5">
|
||||
<div className={clsx('h-1.5 rounded-full', pct > 60 ? 'bg-amber-500' : pct > 30 ? 'bg-amber-700' : 'bg-slate-600')}
|
||||
style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 w-8 text-right tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Key gauges */}
|
||||
<div className="grid grid-cols-2 gap-1.5 border-t border-slate-700/30 pt-2">
|
||||
{KEY_GAUGES.map(k => {
|
||||
const g = macro?.gauges?.[k]
|
||||
if (!g) return null
|
||||
const up = (g.change_pct ?? 0) > 0
|
||||
const dn = (g.change_pct ?? 0) < 0
|
||||
return (
|
||||
<div key={k} className="flex items-center justify-between bg-dark-900/30 rounded px-2 py-1">
|
||||
<span className="text-xs text-slate-500">{g.label}</span>
|
||||
<span className={clsx('text-xs font-medium tabular-nums', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-400')}>
|
||||
{g.value?.toFixed(g.value > 10 ? 1 : 2) ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pillar 4 — Calendrier Économique
|
||||
function PillarCalendar({ events }: { events: CalEvent[] | null }) {
|
||||
const upcoming = (events ?? [])
|
||||
.filter(e => e.date >= todayStr())
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 5)
|
||||
|
||||
const recent = (events ?? [])
|
||||
.filter(e => e.date < todayStr())
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.slice(0, 3)
|
||||
|
||||
const importanceColor = (n: number) =>
|
||||
n >= 3 ? 'text-red-400' : n >= 2 ? 'text-yellow-400' : 'text-slate-500'
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl border border-emerald-700/30 p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-semibold text-emerald-300">Calendrier Économique</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/institutional" className="flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-400 transition-colors">
|
||||
Rapports <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
<Link to="/calendar" className="flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-400 transition-colors">
|
||||
Calendrier <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1.5">À venir</div>
|
||||
<div className="space-y-1.5">
|
||||
{upcoming.length === 0 && <div className="text-xs text-slate-600 italic">Aucun événement</div>}
|
||||
{upcoming.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-1 shrink-0 w-20">
|
||||
<Clock className="w-3 h-3 text-slate-600" />
|
||||
<span className="text-xs text-slate-500">{fmtDate(ev.date)}</span>
|
||||
</div>
|
||||
<span className={clsx('text-xs font-medium flex-1 truncate', importanceColor(ev.importance))}>
|
||||
{ev.currency && <span className="text-slate-600 mr-1">[{ev.currency}]</span>}
|
||||
{ev.title}
|
||||
</span>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{[1, 2, 3].map(n => (
|
||||
<div key={n} className={clsx('w-1 h-1 rounded-full', n <= ev.importance ? importanceColor(ev.importance).replace('text-', 'bg-') : 'bg-slate-700')} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent */}
|
||||
{recent.length > 0 && (
|
||||
<div className="border-t border-slate-700/30 pt-2">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1.5">Récents</div>
|
||||
<div className="space-y-1">
|
||||
{recent.map((ev, i) => (
|
||||
<div key={i} className="flex items-center gap-2 opacity-60">
|
||||
<span className="text-xs text-slate-600 w-20 shrink-0">{fmtDate(ev.date)}</span>
|
||||
<span className="text-xs text-slate-500 truncate">
|
||||
{ev.currency && <span className="mr-1">[{ev.currency}]</span>}
|
||||
{ev.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
const [loadingCtx, setLoadingCtx] = useState(false)
|
||||
|
||||
// Fetch temporal context when date changes
|
||||
useEffect(() => {
|
||||
setLoadingCtx(true)
|
||||
api.get(`/timeline/day/${refDate}`)
|
||||
.then(r => setCtx(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingCtx(false))
|
||||
}, [refDate])
|
||||
|
||||
// Fetch live data once (always current)
|
||||
useEffect(() => {
|
||||
api.get('/market/quotes').then(r => {
|
||||
// Flatten: quotes is Record<assetClass, Quote[]> → flatten to Record<symbol, Quote>
|
||||
const flat: Record<string, Quote> = {}
|
||||
for (const arr of Object.values(r.data as Record<string, Quote[]>)) {
|
||||
for (const q of arr) flat[q.symbol] = q
|
||||
}
|
||||
setQuotes(flat)
|
||||
}).catch(() => {})
|
||||
|
||||
api.get('/market/macro-regime').then(r => setMacro(r.data)).catch(() => {})
|
||||
api.get('/geo/calendar').then(r => setCalEvents(r.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const navigate = (days: number) => {
|
||||
const next = offsetDate(refDate, days)
|
||||
if (next <= todayStr()) setRefDate(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-slate-400" />
|
||||
Snapshot Externe
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
Vue d'ensemble des 4 piliers du contexte externe — {fmtDate(refDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Date navigator */}
|
||||
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(-30)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1M</button>
|
||||
<button onClick={() => navigate(-7)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-7J</button>
|
||||
<button onClick={() => navigate(-1)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1J</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center gap-3">
|
||||
<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-slate-500"
|
||||
/>
|
||||
<button onClick={() => setRefDate(todayStr())} className="text-xs text-slate-400 hover:text-white">
|
||||
Aujourd'hui
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{refDate < todayStr() && (
|
||||
<span className="text-amber-500/70">Piliers 2-3-4 en données live</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4 pillar grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PillarGeo ctx={loadingCtx ? null : ctx} refDate={refDate} />
|
||||
<PillarMarkets quotes={quotes} />
|
||||
<PillarMacro macro={macro} />
|
||||
<PillarCalendar events={calEvents} />
|
||||
</div>
|
||||
|
||||
{/* Links bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-slate-600 border-t border-slate-700/30 pt-4">
|
||||
<span>Navigation rapide :</span>
|
||||
{[
|
||||
{ to: '/timeline', label: 'Timeline' },
|
||||
{ to: '/geo', label: 'Geo Radar' },
|
||||
{ to: '/markets', label: 'Marchés' },
|
||||
{ to: '/macro', label: 'Macro' },
|
||||
{ to: '/calendar', label: 'Calendrier' },
|
||||
{ to: '/institutional', label: 'Rapports' },
|
||||
{ to: '/specialist-desks', label: 'Desks' },
|
||||
].map(({ to, label }) => (
|
||||
<Link key={to} to={to} className="hover:text-slate-300 transition-colors">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
import TimelineFrise from '../components/TimelineFrise'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
@@ -83,51 +85,6 @@ 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,
|
||||
@@ -222,7 +179,11 @@ function ContextPanel({
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const [refDate, setRefDate] = useState(todayStr())
|
||||
const [searchParams] = useSearchParams()
|
||||
const [refDate, setRefDate] = useState(() => {
|
||||
const p = searchParams.get('date')
|
||||
return p && p >= '2020-01-01' && p <= todayStr() ? p : todayStr()
|
||||
})
|
||||
const [ctx, setCtx] = useState<DayContext | null>(null)
|
||||
const [allEvents, setAllEvents] = useState<MarketEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -365,26 +326,13 @@ export default function Timeline() {
|
||||
</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>
|
||||
|
||||
{/* Frise chronologique */}
|
||||
{allEvents.length > 0 && (
|
||||
<TimelineFrise events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
||||
)}
|
||||
|
||||
{/* 3 context panels */}
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-12">Chargement du contexte...</div>
|
||||
|
||||
Reference in New Issue
Block a user