From 9b6cbc1e676b1efa592f974f970e0fc0deac4ffc Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 18:38:00 +0000 Subject: [PATCH] fix(plans): harden holding selection and deep links --- web/src/tabs/PositionsTab.dom.test.tsx | 43 ++++++++++++++++++++++++-- web/src/tabs/PositionsTab.tsx | 6 ++-- web/src/views/PortfolioView.tsx | 6 ++-- web/src/views/SimpleView.tsx | 39 ++++++++++++++++++----- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index 20ae6b0..0dbba2e 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -386,7 +386,7 @@ describe('PositionsTab DOM behavior', () => { render(); await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); - const manageButtons = await screen.findAllByRole('button', { name: /Open Plan|Manage in Plans/ }); + const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' }); await user.click(manageButtons[0]); expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ @@ -394,7 +394,46 @@ describe('PositionsTab DOM behavior', () => { profileId: 'p1', tradeId: 'TRD-POS-1', planEntryId: 'simple-setup-1' - })); + }), 'open-plan'); + }); + + it('exposes a create-exit-plan action for unmanaged live holdings', async () => { + const now = Date.now(); + fetchPositionsBootstrapMock.mockResolvedValue({ + entries: [], + orders: [{ + id: 'entry-order', + order_id: 'entry-order', + profile_id: 'p1', + symbol: 'BTC/USDT', + type: 'Market', + side: 'BUY', + qty: 1, + price: 100, + status: 'filled', + timestamp: now - 1_000, + created_at: new Date(now - 1_000).toISOString(), + trade_id: 'TRD-POS-1', + action: 'ENTRY', + source: 'BOT' + }], + historyTradeKeys: [], + profiles: [{ id: 'p1', name: 'High Risk Scalper' }] + }); + + const onManageHolding = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' })); + const manageButtons = await screen.findAllByRole('button', { name: 'Create Exit Plan' }); + await user.click(manageButtons[0]); + + expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({ + symbol: 'BTC/USDT', + profileId: 'p1', + tradeId: 'TRD-POS-1', + }), 'create-exit-plan'); }); it('handles non-admin bootstrap failures with empty-state fallback', async () => { diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 1b1f8f9..e54ef8e 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -10,7 +10,7 @@ import { fetchPositionsBootstrap } from '../lib/positionsApi'; interface PositionsTabProps { botState: BotState; - onManageHolding?: (position: HybridPosition) => void; + onManageHolding?: (position: HybridPosition, action: 'open-plan' | 'create-exit-plan') => void; } interface HybridPosition { @@ -1453,10 +1453,10 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
{pos.source === 'BOT' && pos.profileId && pos.tradeId && onManageHolding && ( )} {pos.source === 'BOT' && ( diff --git a/web/src/views/PortfolioView.tsx b/web/src/views/PortfolioView.tsx index 37b80ff..5c03da8 100644 --- a/web/src/views/PortfolioView.tsx +++ b/web/src/views/PortfolioView.tsx @@ -36,14 +36,14 @@ export function PortfolioView() { {tab === 'Positions & Orders' && ( { - const params = position.planEntryId + onManageHolding={(position, action) => { + const params = action === 'open-plan' && position.planEntryId ? new URLSearchParams({ setupId: position.planEntryId }) : new URLSearchParams({ mode: 'sell', symbol: position.symbol, }); - if (!position.planEntryId && position.tradeId) params.set('tradeId', position.tradeId); + if (action !== 'open-plan' && 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 ce3993c..c77870f 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -631,9 +631,10 @@ export function SimpleView() { const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState(null); const [focusedSetupId, setFocusedSetupId] = useState(null); const marketPriceRequestSymbolRef = useRef(''); - const consumedPrefillRef = useRef(false); - const consumedSetupFocusRef = useRef(false); + const consumedPrefillKeyRef = useRef(''); + const consumedSetupFocusKeyRef = useRef(''); const setupCardRefs = useRef>({}); + const focusedSetupTimerRef = useRef(null); const normalizedSymbol = draft.symbol.trim().toUpperCase(); const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {}; @@ -804,10 +805,11 @@ export function SimpleView() { }, [draft.side, selectedSellHolding, availableSellHoldings]); useEffect(() => { - if (consumedPrefillRef.current) return; const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase(); const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase(); const requestedTradeId = String(searchParams.get('tradeId') || '').trim(); + const prefillKey = `${requestedMode}|${requestedSymbol}|${requestedTradeId}`; + if (!requestedMode || consumedPrefillKeyRef.current === prefillKey) return; if (requestedMode !== 'sell') return; if (availableSellHoldings.length === 0) return; @@ -824,18 +826,18 @@ export function SimpleView() { setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`); setError(null); } - consumedPrefillRef.current = true; + consumedPrefillKeyRef.current = prefillKey; setSearchParams({}, { replace: true }); }, [availableSellHoldings, searchParams, setSearchParams]); useEffect(() => { - if (consumedSetupFocusRef.current) return; const requestedSetupId = String(searchParams.get('setupId') || '').trim(); + if (!requestedSetupId || consumedSetupFocusKeyRef.current === requestedSetupId) return; if (!requestedSetupId) return; if (savedSetups.length === 0) return; const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null; - consumedSetupFocusRef.current = true; + consumedSetupFocusKeyRef.current = requestedSetupId; setSearchParams({}, { replace: true }); if (!targetEntry) return; @@ -843,12 +845,26 @@ export function SimpleView() { setFocusedSetupId(requestedSetupId); setMessage(`Focused saved plan for ${targetEntry.symbol}.`); setError(null); - window.setTimeout(() => setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)), 2200); + if (focusedSetupTimerRef.current !== null) { + window.clearTimeout(focusedSetupTimerRef.current); + } + focusedSetupTimerRef.current = window.setTimeout(() => { + setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)); + focusedSetupTimerRef.current = null; + }, 2200); window.requestAnimationFrame(() => { setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }, [savedSetups, searchParams, setSearchParams]); + useEffect(() => { + return () => { + if (focusedSetupTimerRef.current !== null) { + window.clearTimeout(focusedSetupTimerRef.current); + } + }; + }, []); + async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) { if (!value) return; try { @@ -960,6 +976,7 @@ export function SimpleView() { await refreshSetupList(); setEditingSetupId(null); + setSelectedHoldingTradeId(null); setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null); setDraft({ ...DEFAULT_DRAFT, @@ -974,6 +991,7 @@ export function SimpleView() { function handleEdit(entry: ManualEntryPayload) { setEditingSetupId(String(entry.stock_instance_id || '')); + setSelectedHoldingTradeId(String(entry.linked_trade_id || '').trim() || null); setMarketPriceSource(inferMarketPriceSourceFromEntry(entry)); setDraft(buildDraftFromEntry(entry)); setMessage(null); @@ -986,6 +1004,7 @@ export function SimpleView() { await deleteManualEntry(entryId); if (editingSetupId === entryId) { setEditingSetupId(null); + setSelectedHoldingTradeId(null); setMarketPriceSource(null); setDraft(DEFAULT_DRAFT); } @@ -1149,6 +1168,9 @@ export function SimpleView() { value={draft.symbol} onChange={(e) => { setMarketPriceSource(null); + if (draft.side === 'sell') { + setSelectedHoldingTradeId(null); + } setDraft((prev) => ({ ...prev, symbol: e.target.value.toUpperCase(), @@ -1179,6 +1201,9 @@ export function SimpleView() { type="button" onClick={() => { setMarketPriceSource(null); + if (draft.side === 'sell') { + setSelectedHoldingTradeId(null); + } setDraft((prev) => ({ ...prev, symbol,