From 8afa06118c0a9803af4a4b6b415ffa916f43f500 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 24 Jun 2026 18:36:12 +0200 Subject: [PATCH] feat: frise chronologique + page Snapshot Externe 4 piliers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 2 + frontend/src/components/TimelineFrise.tsx | 222 +++++++++++ frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/pages/ExternalSnapshot.tsx | 425 +++++++++++++++++++++ frontend/src/pages/Timeline.tsx | 76 +--- 5 files changed, 663 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/TimelineFrise.tsx create mode 100644 frontend/src/pages/ExternalSnapshot.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51b2da1..3f6f2b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/components/TimelineFrise.tsx b/frontend/src/components/TimelineFrise.tsx new file mode 100644 index 0000000..b4f8217 --- /dev/null +++ b/frontend/src/components/TimelineFrise.tsx @@ -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 = { + 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 = { 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(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 = { 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 ( +
+ {/* Level legend */} +
+ {(['long', 'medium', 'short'] as const).map(l => ( +
+
+ + {l === 'long' ? 'Long terme' : l === 'medium' ? 'Moyen terme' : 'Court terme'} + +
+ ))} + cliquer sur un événement pour naviguer +
+ + {/* Scrollable frise */} +
+
+ + {/* Year grid lines */} + {yearMarkers.map(m => ( +
+ ))} + + {/* Lane backgrounds */} + {(['long', 'medium', 'short'] as const).map(l => ( +
+ ))} + + {/* 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 ( +
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, + }} + > + + {ev.name} + +
+ ) + })} + + {/* Today line */} +
+ + {/* Selected date line (if not today) */} + {refDate !== new Date().toISOString().split('T')[0] && ( +
+ )} + + {/* Year axis */} +
+ {yearMarkers.map(m => ( +
+ {m.label} +
+ ))} +
+ + {/* Today label */} +
+ ▲ aujourd'hui +
+
+
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7d00276..1ca03fc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, diff --git a/frontend/src/pages/ExternalSnapshot.tsx b/frontend/src/pages/ExternalSnapshot.tsx new file mode 100644 index 0000000..72016a3 --- /dev/null +++ b/frontend/src/pages/ExternalSnapshot.tsx @@ -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 } + gauges: Record +} +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 ( +
+ {cfg.label} + +
+ ) + const days = daysSince(refDate, ev.start_date) + return ( +
+ {cfg.label} + {ev.name} + J+{days} +
+ ) +} + +// Pillar 1 — Géopolitique / Temporal Context +function PillarGeo({ ctx, refDate }: { ctx: DayContext | null; refDate: string }) { + return ( +
+
+
+ + Géopolitique +
+ + Timeline + +
+ +
+ {ctx ? ( + <> + + + + + ) : ( +
+ {(['long', 'medium', 'short'] as const).map(l => ( +
+ ))} +
+ )} +
+ + {ctx?.has_commentary && ( +
+ Commentaires IA disponibles → voir Timeline +
+ )} +
+ ) +} + +// 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 ( +
+ {symbol} + +
+ ) + const up = quote.change_pct > 0.1 + const dn = quote.change_pct < -0.1 + return ( +
+ {quote.name || symbol} +
+ {quote.price?.toFixed(2)} + + {up ? : dn ? : } + {quote.change_pct > 0 ? '+' : ''}{quote.change_pct?.toFixed(2)}% + +
+
+ ) +} + +function PillarMarkets({ quotes }: { quotes: Record | null }) { + return ( +
+
+
+ + Prix & Marchés +
+ + Marchés + +
+ +
+ {KEY_SYMBOLS.map(sym => ( + + ))} +
+ + + + COT & Forward Curves → Specialist Desks + +
+ ) +} + +// Pillar 3 — Régimes Macro +const REGIME_LABELS: Record = { + 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 ( +
+
+
+ + Régimes Macro +
+ + Macro + +
+ + {/* Dominant regime */} +
+
Régime dominant
+
{cfg.label}
+
+ + {/* Top scenarios */} +
+ {top3.map(([name, score]) => { + const r = REGIME_LABELS[name] ?? { label: name, color: 'text-slate-400' } + const pct = Math.round((score as number) * 100) + return ( +
+ {r.label} +
+
60 ? 'bg-amber-500' : pct > 30 ? 'bg-amber-700' : 'bg-slate-600')} + style={{ width: `${pct}%` }} /> +
+ {pct}% +
+ ) + })} +
+ + {/* Key gauges */} +
+ {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 ( +
+ {g.label} + + {g.value?.toFixed(g.value > 10 ? 1 : 2) ?? '—'} + +
+ ) + })} +
+
+ ) +} + +// 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 ( +
+
+
+ + Calendrier Économique +
+
+ + Rapports + + + Calendrier + +
+
+ + {/* Upcoming */} +
+
À venir
+
+ {upcoming.length === 0 &&
Aucun événement
} + {upcoming.map((ev, i) => ( +
+
+ + {fmtDate(ev.date)} +
+ + {ev.currency && [{ev.currency}]} + {ev.title} + +
+ {[1, 2, 3].map(n => ( +
+ ))} +
+
+ ))} +
+
+ + {/* Recent */} + {recent.length > 0 && ( +
+
Récents
+
+ {recent.map((ev, i) => ( +
+ {fmtDate(ev.date)} + + {ev.currency && [{ev.currency}]} + {ev.title} + +
+ ))} +
+
+ )} +
+ ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export default function ExternalSnapshot() { + const [refDate, setRefDate] = useState(todayStr()) + const [ctx, setCtx] = useState(null) + const [quotes, setQuotes] = useState | null>(null) + const [macro, setMacro] = useState(null) + const [calEvents, setCalEvents] = useState(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 → flatten to Record + const flat: Record = {} + for (const arr of Object.values(r.data as Record)) { + 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 ( +
+ {/* Header */} +
+

+ + Snapshot Externe +

+

+ Vue d'ensemble des 4 piliers du contexte externe — {fmtDate(refDate)} +

+
+ + {/* Date navigator */} +
+
+
+ + + +
+
+ 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" + /> + +
+
+ {refDate < todayStr() && ( + Piliers 2-3-4 en données live + )} +
+
+
+ + {/* 4 pillar grid */} +
+ + + + +
+ + {/* Links bar */} +
+ Navigation rapide : + {[ + { 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 }) => ( + + {label} + + ))} +
+
+ ) +} diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx index 07d2aef..8a40bef 100644 --- a/frontend/src/pages/Timeline.tsx +++ b/frontend/src/pages/Timeline.tsx @@ -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 ( -
- {/* Today marker */} -
- {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 ( -
- ) - })} -
- ) -} 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(null) const [allEvents, setAllEvents] = useState([]) const [loading, setLoading] = useState(false) @@ -365,26 +326,13 @@ export default function Timeline() {
- {/* Timeline strip */} - {allEvents.length > 0 && ( - - )} - - {/* Strip legend */} -
- {(['long', 'medium', 'short'] as const).map(l => ( -
-
- {LEVEL_CONFIG[l].label} -
- ))} -
-
- Date sélectionnée -
-
+ {/* Frise chronologique */} + {allEvents.length > 0 && ( + + )} + {/* 3 context panels */} {loading ? (
Chargement du contexte...