diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 7ae4818..3c30d8a 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -440,17 +440,15 @@ def patch_template(template_id: int, body: dict): @router.delete("/api/causal-lab/template/{template_id}") def delete_template(template_id: int): - """Supprime un template utilisateur (les templates system sont protégés).""" + """Supprime un template (tous les templates peuvent être supprimés).""" try: from services.database import get_conn conn = get_conn() row = conn.execute( - "SELECT created_by FROM causal_graph_templates WHERE id = ?", (template_id,) + "SELECT id FROM causal_graph_templates WHERE id = ?", (template_id,) ).fetchone() if not row: conn.close(); raise HTTPException(404, "Template introuvable") - if row["created_by"] == "system": - conn.close(); raise HTTPException(403, "Les templates système ne peuvent pas être supprimés") conn.execute("DELETE FROM causal_graph_templates WHERE id = ?", (template_id,)) conn.commit(); conn.close() return {"ok": True} diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 53db8d5..9623511 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -510,13 +510,10 @@ function TabLibrary() { v{selected.heuristic_ver} diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 1ee1d33..bdadeb4 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown, @@ -126,6 +126,14 @@ function pctN(a: number | undefined, b: number | undefined): number { return ((a - b) / b) * 100 } +function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean { + if (!selectedDate) return false + if (ev.date > selectedDate) return false + if (ev.end_date) return ev.end_date >= selectedDate + const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000 + return diffDays <= 30 +} + // ── Driver type config ──────────────────────────────────────────────────────── const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const @@ -745,6 +753,184 @@ function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: ) } +// ── EventTimeline ───────────────────────────────────────────────────────────── + +function EventTimeline({ + events, priceData, selectedDate, onNavigate, +}: { + events: SnapshotEvent[] + priceData: PriceCandle[] + selectedDate: string | null + onNavigate: (date: string) => void +}) { + const containerRef = useRef(null) + const [contWidth, setContWidth] = useState(800) + + useEffect(() => { + const el = containerRef.current; if (!el) return + const obs = new ResizeObserver(entries => setContWidth(entries[0].contentRect.width)) + obs.observe(el) + return () => obs.disconnect() + }, []) + + if (!priceData.length || !events.length) { + return ( +
+ Aucun événement sur cette période +
+ ) + } + + const minDate = priceData[0].time + const maxDate = priceData[priceData.length - 1].time + const minTs = new Date(minDate).getTime() + const maxTs = new Date(maxDate).getTime() + const PAD = 24 + const usable = Math.max(contWidth - PAD * 2, 1) + + function tsToX(d: string): number { + return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable + } + + const visible = events.filter(ev => ev.date >= minDate && ev.date <= maxDate) + + const HALF_W = 48 + const ROW_H = 28 + const MAX_ROWS = 5 + const rowEnds: number[] = new Array(MAX_ROWS).fill(-Infinity) + + const placed = visible.map(ev => { + const x = tsToX(ev.date) + let row = 0 + for (let r = 0; r < MAX_ROWS; r++) { + if (rowEnds[r] <= x - HALF_W) { row = r; break } + row = r + } + rowEnds[row] = x + HALF_W + return { ev, x, row } + }) + + const maxRow = placed.length ? Math.max(...placed.map(p => p.row)) : 0 + const containerH = (maxRow + 1) * ROW_H + 28 + const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate + ? tsToX(selectedDate) : null + + return ( +
+
+ {crossX !== null && ( +
+ )} + {placed.map(({ ev, x, row }) => { + const active = isActiveAt(ev, selectedDate) + return ( +
onNavigate(ev.date)} + title={`${ev.title}\n${fmtDateFR(ev.date)}`} + className="absolute flex flex-col items-center cursor-pointer group" + style={{ left: x, top: row * ROW_H, transform: 'translateX(-50%)' }} + > + + + {ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title} + +
+ ) + })} +
+ {fmtDateFR(minDate)} + {fmtDateFR(maxDate)} +
+
+ ) +} + +// ── DriversValidation ───────────────────────────────────────────────────────── + +function DriversValidation({ + drivers, events, selectedDate, +}: { + drivers: Driver[] + events: SnapshotEvent[] + selectedDate: string | null +}) { + if (!drivers.length) { + return
Aucun driver configuré
+ } + return ( +
+ {drivers.map(d => { + const matches = events.filter(ev => ev.category === d.type || ev.category === d.key) + const active = matches.some(ev => isActiveAt(ev, selectedDate)) + const typeCfg = DRIVER_TYPE_CFG[d.type ?? ''] + return ( +
+
+ + {d.label} + + {typeCfg && ( + + {typeCfg.short} + + )} +
+
+ poids {d.weight.toFixed(2)} + +
+
+ ) + })} +
+ ) +} + +// ── ExplanationScore ────────────────────────────────────────────────────────── + +function ExplanationScore({ + drivers, events, selectedDate, +}: { + drivers: Driver[] + events: SnapshotEvent[] + selectedDate: string | null +}) { + const totalWeight = drivers.reduce((s, d) => s + (d.weight || 0), 0) + const activeWeight = drivers + .filter(d => events.filter(ev => ev.category === d.type || ev.category === d.key).some(ev => isActiveAt(ev, selectedDate))) + .reduce((s, d) => s + (d.weight || 0), 0) + const score = totalWeight > 0 ? (activeWeight / totalWeight) * 100 : 0 + const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible' + const badgeCls = score >= 60 + ? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40' + : score >= 30 + ? 'text-amber-400 bg-amber-900/30 border-amber-700/40' + : 'text-slate-400 bg-slate-800/60 border-slate-700/30' + const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600' + + return ( +
+ Note globale +
+
+
+ + {Math.round(score)}% — {verdict} + +
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── const PERIODS = [ @@ -764,6 +950,7 @@ export default function InstrumentDashboard() { const [selectorOpen, setSelectorOpen] = useState(false) const [selectedDate, setSelectedDate] = useState(null) const [editDrivers, setEditDrivers] = useState(false) + const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters') const [localDrivers, setLocalDrivers] = useState(null) const [macroAtDate, setMacroAtDate] = useState(null) @@ -995,7 +1182,7 @@ export default function InstrumentDashboard() { @@ -1014,27 +1201,87 @@ export default function InstrumentDashboard() {
- {/* 3-column grid */} -
- - - + {/* ── Tabs sous la courbe ── */} +
+ {([ + { key: 'counters', label: 'Compteurs' }, + { key: 'analyse', label: 'Analyse de la courbe' }, + ] as const).map(t => ( + + ))}
- {/* Macro gauge detail panel — shown when on a historical date */} - {macroAtDate && ( - + {tabUnder === 'counters' && ( + <> +
+ + + +
+ {macroAtDate && } + + )} + + {tabUnder === 'analyse' && ( +
+ {/* Zone haute — frise des événements */} +
+
+ + Frise des événements + survol = détail · clic = timeline +
+ navigate(`/timeline?date=${date}`)} + /> +
+ + {/* Zone basse — validation des drivers */} +
+
+ + Validation des drivers + {fmtDateFR(effectiveDate)} +
+ +
+ + {/* Note globale */} + +
)} {editDrivers && (