Files
OpenFin/frontend/src/pages/ExternalSnapshot.tsx
OpenSquared aeb5233deb 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>
2026-06-24 20:31:57 +02:00

584 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} from 'lucide-react'
const api = axios.create({ baseURL: '/api' })
function todayStr() { return new Date().toISOString().split('T')[0] }
function fmtShort(d: string) {
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
}
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; market_impact: string
affected_assets: 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 }
// ── Config ─────────────────────────────────────────────────────────────────────
const LEVEL = {
long: {
label: 'Long Terme', badge: 'L',
ring: 'border-violet-700/50', bg: 'bg-violet-950/20',
header: 'bg-violet-950/40 border-violet-700/40',
accent: 'text-violet-400', dot: 'bg-violet-500',
badgeCls: 'bg-violet-800 text-violet-200',
},
medium: {
label: 'Moyen Terme', badge: 'M',
ring: 'border-blue-700/50', bg: 'bg-blue-950/20',
header: 'bg-blue-950/40 border-blue-700/40',
accent: 'text-blue-400', dot: 'bg-blue-500',
badgeCls: 'bg-blue-800 text-blue-200',
},
short: {
label: 'Court Terme', badge: 'C',
ring: 'border-emerald-700/50', bg: 'bg-emerald-950/20',
header: 'bg-emerald-950/40 border-emerald-700/40',
accent: 'text-emerald-400', dot: 'bg-emerald-500',
badgeCls: 'bg-emerald-800 text-emerald-200',
},
} as const
const REGIME_LABELS: Record<string, string> = {
risk_on: 'Risk On', risk_off: 'Risk Off', stagflation: 'Stagflation',
goldilocks: 'Goldilocks', recession: 'Récession', reflation: 'Reflation',
soft_landing: 'Soft Landing', desinflation: 'Désinflation', incertain: 'Incertain',
}
// Assets relevant to each temporal level — fallback to standard set
const LEVEL_ASSETS: Record<string, string[]> = {
long: ['SPY', 'GLD', 'USO', 'TLT'],
medium: ['SPY', 'USO', 'GLD', 'UUP'],
short: ['QQQ', 'TLT', 'UUP', 'GLD'],
}
// Try to map affected_assets field to quote symbols we have
function relevantAssets(ev: MarketEvent | null, level: string): string[] {
if (!ev) return LEVEL_ASSETS[level] ?? []
try {
const raw: string[] = JSON.parse(ev.affected_assets || '[]')
const MAP: Record<string, string> = {
SPX: 'SPY', SPY: 'SPY', QQQ: 'QQQ', NVDA: 'NVDA',
GC: 'GLD', Gold: 'GLD', GLD: 'GLD',
CL: 'USO', Oil: 'USO', USO: 'USO',
NG: 'USO', TLT: 'TLT', USD: 'UUP', UUP: 'UUP',
VIX: 'VIX', HYG: 'HYG',
}
const mapped = raw.map(a => MAP[a]).filter(Boolean) as string[]
const unique = [...new Set(mapped)].slice(0, 4)
return unique.length >= 2 ? unique : LEVEL_ASSETS[level] ?? []
} catch { return LEVEL_ASSETS[level] ?? [] }
}
// ── 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,
}: {
ev: MarketEvent | null; level: keyof typeof LEVEL; refDate: string
}) {
const cfg = LEVEL[level]
if (!ev) return (
<div className="flex items-center justify-center h-full text-xs text-slate-600 italic p-3">
Aucun événement {cfg.label.toLowerCase()} actif
</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>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Clock className="w-3 h-3 shrink-0" />
<span>{ev.start_date}</span>
<span className={clsx('font-semibold', cfg.accent)}>J+{days}</span>
{!ev.end_date && <span className="text-amber-500/70">en cours</span>}
</div>
<p className="text-xs text-slate-400 leading-relaxed line-clamp-3">{ev.description}</p>
{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, maData,
}: {
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null
maData: Record<string, number | null>
}) {
// 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 (
<div key={sym} className="flex items-center justify-between py-0.5">
<span className="text-xs text-slate-600">{sym}</span>
<span className="text-xs text-slate-700"></span>
</div>
)
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="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>
)
})}
</div>
)
}
function CellMacro({
level, macro,
}: {
level: keyof typeof LEVEL; macro: MacroRegime | null
}) {
const cfg = LEVEL[level]
const dominant = macro?.scenarios?.dominant ?? 'incertain'
const dominantLabel = REGIME_LABELS[dominant] ?? dominant
const scores = macro?.scenarios?.scores ?? {}
const maxScore = Math.max(...Object.values(scores).map(Number), 1)
const top3 = Object.entries(scores)
.sort((a, b) => Number(b[1]) - Number(a[1]))
.slice(0, 3)
// Key gauge per level
const gaugeKeys = {
long: ['vix', 'us10y'],
medium: ['dxy', 'credit_spread'],
short: ['vix', 'dxy'],
}[level] ?? ['vix', 'dxy']
return (
<div className="p-3 space-y-2.5 h-full">
<div className={clsx('text-sm font-bold', cfg.accent)}>{dominantLabel}</div>
<div className="space-y-1.5">
{top3.map(([name, score]) => {
const pct = Math.min(100, Math.round((Number(score) / maxScore) * 100))
const label = REGIME_LABELS[name] ?? name
return (
<div key={name} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-20 truncate">{label}</span>
<div className="flex-1 bg-dark-700 rounded-full h-1.5 overflow-hidden">
<div
className={clsx('h-1.5 rounded-full transition-all', cfg.dot)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-slate-600 w-7 text-right tabular-nums">{pct}%</span>
</div>
)
})}
</div>
<div className="flex gap-2 flex-wrap pt-1 border-t border-slate-700/20">
{gaugeKeys.map(k => {
const g = macro?.gauges?.[k]
if (!g?.value) return null
const up = (g.change_pct ?? 0) > 0
return (
<div key={k} className="flex items-center gap-1 bg-dark-900/40 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' : 'text-red-400')}>
{g.value?.toFixed(g.value > 10 ? 1 : 2)}
</span>
</div>
)
})}
</div>
</div>
)
}
// Calendar events filtered by EXCLUSIVE temporal horizon ranges
function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
// 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 => {
const d = e.date
return d >= offsetDate(refDate, from) && d <= offsetDate(refDate, to)
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 4)
}
function CellCalendar({
level, events, refDate,
}: {
level: keyof typeof LEVEL; events: CalEvent[] | null; refDate: string
}) {
const cfg = LEVEL[level]
const horizon = { long: '3 mois', medium: '1 mois', short: '7 jours' }[level]
const items = calForLevel(events ?? [], level, refDate)
return (
<div className="p-3 space-y-1.5 h-full">
<div className="text-xs text-slate-600 mb-2">Horizon {horizon}</div>
{items.length === 0 && (
<div className="text-xs text-slate-700 italic">Aucun événement</div>
)}
{items.map((ev, i) => {
const imp = ev.importance ?? 1
const impColor = imp >= 3 ? 'text-red-400' : imp >= 2 ? 'text-yellow-400' : 'text-slate-500'
return (
<div key={i} className="flex items-start gap-2">
<span className="text-xs text-slate-600 shrink-0 w-14">{fmtShort(ev.date)}</span>
<span className={clsx('text-xs truncate flex-1', impColor)}>
{ev.currency && <span className="text-slate-600 mr-1">[{ev.currency}]</span>}
{ev.title}
</span>
<div className="flex gap-0.5 shrink-0 mt-0.5">
{[1, 2, 3].map(n => (
<div key={n} className={clsx(
'w-1 h-1 rounded-full',
n <= imp
? (imp >= 3 ? 'bg-red-500' : imp >= 2 ? 'bg-yellow-500' : 'bg-slate-500')
: 'bg-slate-700'
)} />
))}
</div>
</div>
)
})}
</div>
)
}
// ── Temporal Row ───────────────────────────────────────────────────────────────
function TemporalRow({
level, ev, quotes, macro, calEvents, refDate, maData,
}: {
level: keyof typeof LEVEL
ev: MarketEvent | null
quotes: Record<string, Quote> | null
macro: MacroRegime | null
calEvents: CalEvent[] | null
refDate: string
maData: Record<string, number | null>
}) {
const cfg = LEVEL[level]
return (
<div className={clsx('rounded-xl border overflow-hidden', cfg.ring)}>
{/* Row header */}
<div className={clsx('flex items-center gap-3 px-4 py-2.5 border-b', cfg.header)}>
<span className={clsx('text-xs font-bold px-2 py-0.5 rounded', cfg.badgeCls)}>
{cfg.badge}
</span>
<span className={clsx('text-sm font-semibold', cfg.accent)}>{cfg.label}</span>
{ev && (
<>
<span className="text-slate-600">·</span>
<span className="text-sm text-slate-300 font-medium">{ev.name}</span>
<span className="text-xs text-slate-500 ml-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
J+{daysSince(refDate, ev.start_date)} depuis {ev.start_date}
</span>
</>
)}
{!ev && (
<span className="text-xs text-slate-600 italic ml-2">Aucun événement actif</span>
)}
</div>
{/* Row body: 4 columns */}
<div className={clsx('grid grid-cols-4 divide-x divide-slate-700/30', cfg.bg)}>
{/* Pilier 1: Géopolitique */}
<div className="flex flex-col">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
<Globe className="w-3 h-3 text-slate-500" />
<span className="text-xs text-slate-500 font-medium">Géopolitique</span>
<Link to={`/timeline?date=${refDate}`} className={clsx('ml-auto text-xs flex items-center gap-0.5 hover:opacity-100 opacity-50', cfg.accent)}>
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellGeo ev={ev} level={level} refDate={refDate} />
</div>
{/* Pilier 2: Prix & Marchés */}
<div className="flex flex-col">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
<BarChart2 className="w-3 h-3 text-slate-500" />
<span className="text-xs text-slate-500 font-medium">Prix & Marchés</span>
<Link to="/markets" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellMarkets ev={ev} level={level} quotes={quotes} maData={maData} />
</div>
{/* Pilier 3: Régimes Macro */}
<div className="flex flex-col">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
<Activity className="w-3 h-3 text-slate-500" />
<span className="text-xs text-slate-500 font-medium">Régimes Macro</span>
<Link to="/macro" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellMacro level={level} macro={macro} />
</div>
{/* Pilier 4: Calendrier */}
<div className="flex flex-col">
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
<Calendar className="w-3 h-3 text-slate-500" />
<span className="text-xs text-slate-500 font-medium">Calendrier</span>
<Link to="/calendar" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellCalendar level={level} events={calEvents} refDate={refDate} />
</div>
</div>
</div>
)
}
// ── 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}`)
.then(r => setCtx(r.data))
.catch(() => setCtx(null))
}, [refDate])
useEffect(() => {
api.get('/market/quotes').then(r => {
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(() => {})
// 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) => {
const next = offsetDate(refDate, days)
if (next <= todayStr()) setRefDate(next)
}
const evLong = ctx?.events?.long ?? null
const evMedium = ctx?.events?.medium ?? null
const evShort = ctx?.events?.short ?? null
return (
<div className="p-6 space-y-4 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<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">
3 temporalités × 4 piliers vision situationnelle complète
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-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">-7J</button>
<button onClick={() => navigate(-1)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1J</button>
<input
type="date"
value={refDate}
min="2020-01-01"
max={todayStr()}
onChange={e => setRefDate(e.target.value)}
className="bg-dark-800 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="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">
Aujourd'hui
</button>
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded disabled:opacity-20">+1J</button>
</div>
</div>
{/* Column headers */}
<div className="grid grid-cols-4 gap-0 text-xs text-slate-600 uppercase tracking-wider px-1">
<div className="flex items-center gap-1.5"><Globe className="w-3 h-3" /> Géopolitique</div>
<div className="flex items-center gap-1.5"><BarChart2 className="w-3 h-3" /> Prix & Marchés</div>
<div className="flex items-center gap-1.5"><Activity className="w-3 h-3" /> Régimes Macro</div>
<div className="flex items-center gap-1.5"><Calendar className="w-3 h-3" /> Calendrier</div>
</div>
{/* 3 temporal rows */}
<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">
<span>Voir aussi :</span>
{[
['/timeline', 'Timeline'],
['/specialist-desks','COT & Curves'],
['/geo', 'Geo Radar'],
['/institutional', 'Rapports'],
].map(([to, label]) => (
<Link key={to} to={to} className="hover:text-slate-300 flex items-center gap-1">
{label} <ArrowRight className="w-3 h-3" />
</Link>
))}
</div>
</div>
)
}