feat: multi-tab instrument analysis

Each /instruments/:id navigation opens a persistent tab in the TabBar.
Multiple instruments can be open simultaneously — state is preserved when switching.

- TabsContext: adds instrumentIds[], openInstrument(), closeInstrument()
- TabBar: renders instrument tabs (teal, monospaced, TrendingUp icon) after static tabs, separated by a divider
- InstrumentDashboard: accepts instrumentIdProp so keep-alive instances use the right id regardless of URL
- App: InstrumentRoute registers the tab on navigation; InstrumentKeepAlive mounts one dashboard per open instrument; NormalRoutes hides on instrument paths (hidden div, Routes still fires)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-30 09:17:05 +02:00
parent 230ce3ff2b
commit 9a1e943be0
4 changed files with 146 additions and 45 deletions

View File

@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, useLocation, useNavigate, useParams, Navigate } from 'react-router-dom'
import { useEffect, ComponentType } from 'react'
import Sidebar from './components/layout/Sidebar'
import TabBar from './components/layout/TabBar'
@@ -96,18 +96,41 @@ function KeepAlivePage({ path, component: Component }: { path: string; component
)
}
// ── NormalRoutes — only InstrumentDashboard (uses :id param) + redirects ─────
// ── Instrument keep-alive tabs ────────────────────────────────────────────────
// Registers the instrument tab on navigation (renders nothing itself)
function InstrumentRoute() {
const { id = 'EURUSD' } = useParams<{ id: string }>()
const { openInstrument } = useTabs()
useEffect(() => { openInstrument(id.toUpperCase()) }, [id, openInstrument])
return null
}
// Keeps one InstrumentDashboard mounted per open instrument tab
function InstrumentKeepAlive({ id }: { id: string }) {
const location = useLocation()
const isActive = location.pathname.toLowerCase() === `/instruments/${id.toLowerCase()}`
return (
<div className={isActive ? 'flex-1 min-h-0 overflow-y-auto' : 'hidden'}>
<InstrumentDashboard instrumentIdProp={id} />
</div>
)
}
// ── NormalRoutes — legacy redirects + instrument tab registration ─────────────
function NormalRoutes() {
const location = useLocation()
if (KEEP_ALIVE_PATHS.has(location.pathname)) return null
// On instrument paths the keep-alive layer renders the UI; this Routes just registers the tab
const isInstrument = /^\/instruments\/\w/.test(location.pathname)
return (
<div className="flex-1 overflow-auto">
<div className={isInstrument ? 'hidden' : 'flex-1 overflow-auto'}>
<Routes>
{/* InstrumentDashboard kept here: useParams() reads :id from URL */}
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/instruments" element={<Navigate to="/instruments/EURUSD" replace />} />
<Route path="/instruments/:id" element={<InstrumentRoute />} />
{/* Legacy redirects */}
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
@@ -125,23 +148,33 @@ function GlobalWatcher() {
// ── App ───────────────────────────────────────────────────────────────────────
function AppInner() {
const { instrumentIds } = useTabs()
return (
<div className="flex h-screen overflow-hidden">
<GlobalWatcher />
<Sidebar />
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabBar />
<div className="flex-1 flex flex-col min-h-0">
{KEEP_ALIVE_DEFS.map(def => (
<KeepAlivePage key={def.path} path={def.path} component={def.component} />
))}
{instrumentIds.map(id => (
<InstrumentKeepAlive key={id} id={id} />
))}
<NormalRoutes />
</div>
</div>
</div>
)
}
export default function App() {
return (
<BrowserRouter>
<TabsProvider>
<div className="flex h-screen overflow-hidden">
<GlobalWatcher />
<Sidebar />
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabBar />
<div className="flex-1 flex flex-col min-h-0">
{KEEP_ALIVE_DEFS.map(def => (
<KeepAlivePage key={def.path} path={def.path} component={def.component} />
))}
<NormalRoutes />
</div>
</div>
</div>
<AppInner />
</TabsProvider>
</BrowserRouter>
)

View File

@@ -1,22 +1,27 @@
import clsx from 'clsx'
import { X, ExternalLink } from 'lucide-react'
import { X, ExternalLink, TrendingUp } from 'lucide-react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useTabs } from '../../context/TabsContext'
import { KEEP_ALIVE_META } from '../../config/keepAliveMeta'
export default function TabBar() {
const { kept, forgetRoute } = useTabs()
const { kept, forgetRoute, instrumentIds, closeInstrument } = useTabs()
const location = useLocation()
const navigate = useNavigate()
const visible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
if (visible.length === 0) return null
const staticVisible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
const hasAny = staticVisible.length > 0 || instrumentIds.length > 0
if (!hasAny) return null
const activePath = location.pathname
return (
<div className="flex items-end bg-dark-900 border-b border-slate-700/40 overflow-x-auto shrink-0 scrollbar-none">
<div className="flex items-end gap-0.5 px-2 pt-1 min-w-max">
{visible.map(({ path, label, icon: Icon }) => {
const isActive = location.pathname === path || location.pathname.startsWith(path + '/')
{/* Static keep-alive tabs */}
{staticVisible.map(({ path, label, icon: Icon }) => {
const isActive = activePath === path || activePath.startsWith(path + '/')
return (
<button
key={path}
@@ -31,27 +36,72 @@ export default function TabBar() {
<Icon size={11} className="shrink-0" />
<span className="max-w-24 truncate">{label}</span>
<span
role="button"
tabIndex={-1}
role="button" tabIndex={-1}
title="Ouvrir dans une nouvelle fenêtre"
onClick={e => {
e.stopPropagation()
window.open(path, '_blank')
}}
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
className="ml-1 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
>
<ExternalLink size={9} />
</span>
<span
role="button"
tabIndex={-1}
role="button" tabIndex={-1}
title="Fermer"
onClick={e => {
e.stopPropagation()
const remaining = visible.filter(m => m.path !== path)
const remaining = staticVisible.filter(m => m.path !== path)
forgetRoute(path)
if (isActive && remaining.length > 0) {
navigate(remaining[remaining.length - 1].path)
if (isActive && remaining.length > 0) navigate(remaining[remaining.length - 1].path)
else if (isActive && instrumentIds.length > 0) navigate(`/instruments/${instrumentIds[instrumentIds.length - 1]}`)
}}
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
>
<X size={9} />
</span>
</button>
)
})}
{/* Separator between static and instrument tabs */}
{staticVisible.length > 0 && instrumentIds.length > 0 && (
<div className="w-px h-5 bg-slate-700/50 self-center mx-1 shrink-0" />
)}
{/* Dynamic instrument tabs */}
{instrumentIds.map(id => {
const path = `/instruments/${id}`
const isActive = activePath.toLowerCase() === path.toLowerCase()
return (
<button
key={id}
onClick={() => navigate(path)}
className={clsx(
'group flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-t border border-b-0 transition-all -mb-px select-none shrink-0',
isActive
? 'bg-dark-800 border-emerald-700/50 text-emerald-300 font-medium z-10'
: 'bg-transparent border-transparent text-slate-500 hover:text-emerald-400 hover:bg-dark-800/40 hover:border-slate-700/40'
)}
>
<TrendingUp size={11} className="shrink-0" />
<span className="font-mono">{id}</span>
<span
role="button" tabIndex={-1}
title="Ouvrir dans une nouvelle fenêtre"
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
className="ml-0.5 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
>
<ExternalLink size={9} />
</span>
<span
role="button" tabIndex={-1}
title="Fermer"
onClick={e => {
e.stopPropagation()
const remaining = instrumentIds.filter(x => x !== id)
closeInstrument(id)
if (isActive) {
if (remaining.length > 0) navigate(`/instruments/${remaining[remaining.length - 1]}`)
else if (staticVisible.length > 0) navigate(staticVisible[staticVisible.length - 1].path)
else navigate('/')
}
}}
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"

View File

@@ -1,19 +1,26 @@
import { createContext, useContext, useCallback, useState, ReactNode } from 'react'
interface TabsCtx {
kept: ReadonlySet<string>
keepRoute: (r: string) => void
forgetRoute: (r: string) => void
kept: ReadonlySet<string>
keepRoute: (r: string) => void
forgetRoute: (r: string) => void
instrumentIds: string[]
openInstrument: (id: string) => void
closeInstrument: (id: string) => void
}
const Ctx = createContext<TabsCtx>({
kept: new Set(),
keepRoute: () => {},
forgetRoute: () => {},
keepRoute: () => {},
forgetRoute: () => {},
instrumentIds: [],
openInstrument: () => {},
closeInstrument: () => {},
})
export function TabsProvider({ children }: { children: ReactNode }) {
const [kept, setKept] = useState<Set<string>>(new Set())
const [instrumentIds, setInstrumentIds] = useState<string[]>([])
const keepRoute = useCallback((r: string) =>
setKept(p => p.has(r) ? p : new Set([...p, r])), [])
@@ -21,7 +28,17 @@ export function TabsProvider({ children }: { children: ReactNode }) {
const forgetRoute = useCallback((r: string) =>
setKept(p => { const n = new Set(p); n.delete(r); return n }), [])
return <Ctx.Provider value={{ kept, keepRoute, forgetRoute }}>{children}</Ctx.Provider>
const openInstrument = useCallback((id: string) =>
setInstrumentIds(p => p.includes(id) ? p : [...p, id]), [])
const closeInstrument = useCallback((id: string) =>
setInstrumentIds(p => p.filter(x => x !== id)), [])
return (
<Ctx.Provider value={{ kept, keepRoute, forgetRoute, instrumentIds, openInstrument, closeInstrument }}>
{children}
</Ctx.Provider>
)
}
export const useTabs = () => useContext(Ctx)

View File

@@ -1202,8 +1202,8 @@ const PERIODS = [
{ key: '5y', label: '5Y' },
]
export default function InstrumentDashboard() {
const { id = 'SPY' } = useParams<{ id: string }>()
export default function InstrumentDashboard({ instrumentIdProp }: { instrumentIdProp?: string } = {}) {
const { id: paramId = 'EURUSD' } = useParams<{ id: string }>()
const navigate = useNavigate()
const [period, setPeriod] = useState('1y')
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
@@ -1226,7 +1226,8 @@ export default function InstrumentDashboard() {
const chartCanvasLeftRef = useRef<number>(0)
const [chartReady, setChartReady] = useState(0) // bump to trigger frise re-render
const instrumentId = id.toUpperCase()
// instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances)
const instrumentId = (instrumentIdProp ?? paramId).toUpperCase()
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})