From 36bd21b7cd41d47342dda2a0e72564d9a1f9191f Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 18:31:29 +0000 Subject: [PATCH] feat(portfolio): drill into saved trade plans --- web/src/tabs/PositionsTab.dom.test.tsx | 16 +++++++++--- web/src/tabs/PositionsTab.tsx | 23 +++++++++++++--- web/src/views/PortfolioView.tsx | 12 +++++---- web/src/views/SimpleView.tsx | 36 +++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index d2641dc..20ae6b0 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -350,10 +350,17 @@ describe('PositionsTab DOM behavior', () => { }); }); - it('exposes a manage-in-plans action for eligible live bot holdings', async () => { + it('exposes an open-plan action for holdings already linked to a saved plan', async () => { const now = Date.now(); fetchPositionsBootstrapMock.mockResolvedValue({ - entries: [], + entries: [{ + stock_instance_id: 'simple-setup-1', + symbol: 'BTC/USDT', + workflow_type: 'simple', + linked_trade_id: 'TRD-POS-1', + holding_mode: 'short_term', + automation_state: 'holding_managed' + }], orders: [{ id: 'entry-order', order_id: 'entry-order', @@ -379,13 +386,14 @@ describe('PositionsTab DOM behavior', () => { render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); - const manageButtons = await screen.findAllByRole('button', { name: 'Manage in Plans' }); + const manageButtons = await screen.findAllByRole('button', { name: /Open Plan|Manage in Plans/ }); await user.click(manageButtons[0]); expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ symbol: 'BTC/USDT', profileId: 'p1', - tradeId: 'TRD-POS-1' + tradeId: 'TRD-POS-1', + planEntryId: 'simple-setup-1' })); }); diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 2cb153b..1b1f8f9 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -30,6 +30,7 @@ interface HybridPosition { tradeId?: string; planMode?: 'short_term' | 'long_term'; planState?: string | null; + planEntryId?: string; } interface Profile { @@ -418,7 +419,7 @@ export const assignLifecycleTradeIds = ( export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) => { const { user, profile } = useAuth(); const [manualPositions, setManualPositions] = useState([]); - const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); + const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); const [dbOrders, setDbOrders] = useState([]); const [historyTradeKeys, setHistoryTradeKeys] = useState([]); const [profiles, setProfiles] = useState([]); @@ -514,11 +515,12 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = if (!tradeId) return null; const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term'; return [tradeId, { + entryId: String(entry.stock_instance_id || '').trim() || undefined, holdingMode, automationState: String(entry.automation_state || '').trim() || null, }] as const; }) - .filter(Boolean) as Array + .filter(Boolean) as Array ); setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId); } @@ -570,6 +572,9 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId] ? simplePlanMetaByTradeId[p.tradeId].automationState || null : null, + planEntryId: p.tradeId && simplePlanMetaByTradeId[p.tradeId] + ? simplePlanMetaByTradeId[p.tradeId].entryId + : undefined, }; const tradeId = String(normalized.tradeId || '').trim(); @@ -1078,7 +1083,16 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = takeProfit: Number(position.takeProfit || 0) || undefined, profileId: position.profileId, profileName: position.profileName, - tradeId: position.tradeId + tradeId: position.tradeId, + planMode: position.tradeId && simplePlanMetaByTradeId[position.tradeId] + ? simplePlanMetaByTradeId[position.tradeId].holdingMode + : undefined, + planState: position.tradeId && simplePlanMetaByTradeId[position.tradeId] + ? simplePlanMetaByTradeId[position.tradeId].automationState || null + : null, + planEntryId: position.tradeId && simplePlanMetaByTradeId[position.tradeId] + ? simplePlanMetaByTradeId[position.tradeId].entryId + : undefined } as HybridPosition)); const deduped = new Map(); @@ -1116,6 +1130,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = selectedProfileId, filteredBotPositions, filteredManualPositions, + simplePlanMetaByTradeId, hasManagedSymbolScope, managedSymbolTokens, EPSILON @@ -1441,7 +1456,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) = onClick={() => onManageHolding(pos)} className="px-2 py-1 bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/20 rounded text-[10px] font-bold uppercase transition-colors" > - Manage in Plans + {(pos.planEntryId || pos.planMode) ? 'Open Plan' : 'Manage in Plans'} )} {pos.source === 'BOT' && ( diff --git a/web/src/views/PortfolioView.tsx b/web/src/views/PortfolioView.tsx index 6672e19..37b80ff 100644 --- a/web/src/views/PortfolioView.tsx +++ b/web/src/views/PortfolioView.tsx @@ -37,11 +37,13 @@ export function PortfolioView() { { - const params = new URLSearchParams({ - mode: 'sell', - symbol: position.symbol, - }); - if (position.tradeId) params.set('tradeId', position.tradeId); + const params = position.planEntryId + ? new URLSearchParams({ setupId: position.planEntryId }) + : new URLSearchParams({ + mode: 'sell', + symbol: position.symbol, + }); + if (!position.planEntryId && position.tradeId) params.set('tradeId', position.tradeId); navigate(`/simple?${params.toString()}`); }} /> diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index 1f84e56..ce3993c 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -629,8 +629,11 @@ export function SimpleView() { const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState(null); + const [focusedSetupId, setFocusedSetupId] = useState(null); const marketPriceRequestSymbolRef = useRef(''); const consumedPrefillRef = useRef(false); + const consumedSetupFocusRef = useRef(false); + const setupCardRefs = useRef>({}); const normalizedSymbol = draft.symbol.trim().toUpperCase(); const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {}; @@ -825,6 +828,27 @@ export function SimpleView() { setSearchParams({}, { replace: true }); }, [availableSellHoldings, searchParams, setSearchParams]); + useEffect(() => { + if (consumedSetupFocusRef.current) return; + const requestedSetupId = String(searchParams.get('setupId') || '').trim(); + if (!requestedSetupId) return; + if (savedSetups.length === 0) return; + + const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null; + consumedSetupFocusRef.current = true; + setSearchParams({}, { replace: true }); + + if (!targetEntry) return; + + setFocusedSetupId(requestedSetupId); + setMessage(`Focused saved plan for ${targetEntry.symbol}.`); + setError(null); + window.setTimeout(() => setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)), 2200); + window.requestAnimationFrame(() => { + setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }, [savedSetups, searchParams, setSearchParams]); + async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) { if (!value) return; try { @@ -1404,7 +1428,17 @@ export function SimpleView() { const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled'; const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled'; return ( -
+
{ + setupCardRefs.current[entryId] = node; + }} + className={`rounded-[1.5rem] border bg-[var(--card-elevated)] p-5 transition ${ + focusedSetupId === entryId + ? 'border-[var(--primary)] ring-2 ring-[var(--primary)]/20' + : 'border-[var(--border)]' + }`} + >