diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index a0caab8..5d2936f 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -25,6 +25,7 @@ import { type SimpleSide, type TriggerMode, } from './tradePlansState'; +import { useTradePlansNavigationState } from './useTradePlansNavigationState'; type SimpleHolding = { symbol: string; @@ -599,10 +600,7 @@ export function SimpleView() { const [submitting, setSubmitting] = useState(false); const [loadingPrice, setLoadingPrice] = useState(false); const marketPriceRequestSymbolRef = useRef(''); - const consumedPrefillKeyRef = useRef(''); - const consumedSetupFocusKeyRef = useRef(''); const setupCardRefs = useRef>({}); - const focusedSetupTimerRef = useRef(null); const { editingSetupId, @@ -778,66 +776,15 @@ export function SimpleView() { applyHoldingToDraft(availableSellHoldings[0]); }, [draft.side, selectedSellHolding, availableSellHoldings]); - useEffect(() => { - 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; - - const selected = (requestedTradeId - ? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId) - : null) - || (requestedSymbol - ? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol) - : null) - || availableSellHoldings[0]; - - if (selected) { - applyHoldingToDraft(selected); - dispatch({ type: 'set-message', value: `Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.` }); - dispatch({ type: 'set-error', value: null }); - } - consumedPrefillKeyRef.current = prefillKey; - setSearchParams({}, { replace: true }); - }, [availableSellHoldings, searchParams, setSearchParams]); - - useEffect(() => { - 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; - consumedSetupFocusKeyRef.current = requestedSetupId; - setSearchParams({}, { replace: true }); - - if (!targetEntry) return; - - dispatch({ type: 'set-focused-setup-id', value: requestedSetupId }); - dispatch({ type: 'set-message', value: `Focused saved plan for ${targetEntry.symbol}.` }); - dispatch({ type: 'set-error', value: null }); - if (focusedSetupTimerRef.current !== null) { - window.clearTimeout(focusedSetupTimerRef.current); - } - focusedSetupTimerRef.current = window.setTimeout(() => { - dispatch({ type: 'set-focused-setup-id', value: null }); - 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); - } - }; - }, []); + useTradePlansNavigationState({ + searchParams, + setSearchParams, + savedSetups, + availableSellHoldings, + applyHoldingToDraft, + dispatch, + setupCardRefs, + }); async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) { if (!value) return; diff --git a/web/src/views/useTradePlansNavigationState.test.tsx b/web/src/views/useTradePlansNavigationState.test.tsx new file mode 100644 index 0000000..dd8b072 --- /dev/null +++ b/web/src/views/useTradePlansNavigationState.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { act, render, screen } from '@testing-library/react'; +import { useMemo, useReducer, useRef } from 'react'; +import { MemoryRouter, useSearchParams } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_TRADE_PLANS_UI_STATE, reduceTradePlansUiState } from './tradePlansState'; +import { useTradePlansNavigationState } from './useTradePlansNavigationState'; + +function NavigationHarness() { + const [uiState, dispatch] = useReducer(reduceTradePlansUiState, DEFAULT_TRADE_PLANS_UI_STATE); + const [searchParams, setSearchParams] = useSearchParams(); + const setupCardRefs = useRef>({}); + + const availableSellHoldings = useMemo(() => ([ + { + symbol: 'AAPL', + size: 5, + entryPrice: 180, + profileId: 'p1', + tradeId: 'TRD-AAPL', + }, + { + symbol: 'MSFT', + size: 2, + entryPrice: 410, + profileId: 'p1', + tradeId: 'TRD-MSFT', + }, + ]), []); + + const savedSetups = useMemo(() => ([ + { + stock_instance_id: 'setup-aapl', + symbol: 'AAPL', + workflow_type: 'simple', + simple_side: 'buy', + status: 'simple_bought', + active: true, + is_crypto: false, + is_real_trade: false, + }, + { + stock_instance_id: 'setup-msft', + symbol: 'MSFT', + workflow_type: 'simple', + simple_side: 'buy', + status: 'simple_bought', + active: true, + is_crypto: false, + is_real_trade: false, + }, + ] as any[]), []); + + const applyHoldingToDraft = (holding: { symbol: string; size: number; entryPrice: number; profileId?: string; tradeId?: string }) => { + dispatch({ + type: 'apply-holding', + tradeId: holding.tradeId || null, + symbol: holding.symbol, + quantity: String(holding.size), + }); + }; + + useTradePlansNavigationState({ + searchParams, + setSearchParams, + savedSetups, + availableSellHoldings, + applyHoldingToDraft, + dispatch, + setupCardRefs, + }); + + return ( +
+ +
{uiState.draft.symbol}
+
{uiState.selectedHoldingTradeId || ''}
+
{uiState.focusedSetupId || ''}
+
{uiState.message || ''}
+
+ ); +} + +describe('useTradePlansNavigationState', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(window, 'requestAnimationFrame', { + writable: true, + value: (cb: FrameRequestCallback) => { + cb(0); + return 0; + }, + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + writable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('applies sell prefill and responds to repeated same-page setup focus changes', () => { + render( + + + , + ); + + expect(screen.getByTestId('symbol').textContent).toBe('AAPL'); + expect(screen.getByTestId('holding').textContent).toBe('TRD-AAPL'); + expect(screen.getByTestId('message').textContent).toContain('Loaded AAPL from Portfolio'); + + act(() => { + screen.getByRole('button', { name: 'focus msft' }).click(); + }); + + expect(screen.getByTestId('focused').textContent).toBe('setup-msft'); + expect(screen.getByTestId('message').textContent).toContain('Focused saved plan for MSFT'); + + act(() => { + vi.advanceTimersByTime(2200); + }); + + expect(screen.getByTestId('focused').textContent).toBe(''); + }); +}); diff --git a/web/src/views/useTradePlansNavigationState.ts b/web/src/views/useTradePlansNavigationState.ts new file mode 100644 index 0000000..510cfa3 --- /dev/null +++ b/web/src/views/useTradePlansNavigationState.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react'; +import type { Dispatch, MutableRefObject } from 'react'; +import type { SetURLSearchParams } from 'react-router-dom'; +import type { ManualEntryPayload } from '../lib/manualEntriesApi'; +import type { TradePlansUiAction } from './tradePlansState'; + +type SimpleHolding = { + symbol: string; + size: number; + entryPrice: number; + profileId?: string; + tradeId?: string; +}; + +type UseTradePlansNavigationStateInput = { + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + savedSetups: ManualEntryPayload[]; + availableSellHoldings: SimpleHolding[]; + applyHoldingToDraft: (holding: SimpleHolding) => void; + dispatch: Dispatch; + setupCardRefs: MutableRefObject>; +}; + +export function useTradePlansNavigationState({ + searchParams, + setSearchParams, + savedSetups, + availableSellHoldings, + applyHoldingToDraft, + dispatch, + setupCardRefs, +}: UseTradePlansNavigationStateInput) { + const consumedPrefillKeyRef = useRef(''); + const consumedSetupFocusKeyRef = useRef(''); + const focusedSetupTimerRef = useRef(null); + + useEffect(() => { + 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; + + const selected = (requestedTradeId + ? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId) + : null) + || (requestedSymbol + ? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol) + : null) + || availableSellHoldings[0]; + + if (selected) { + applyHoldingToDraft(selected); + dispatch({ type: 'set-message', value: `Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.` }); + dispatch({ type: 'set-error', value: null }); + } + consumedPrefillKeyRef.current = prefillKey; + setSearchParams({}, { replace: true }); + }, [applyHoldingToDraft, availableSellHoldings, dispatch, searchParams, setSearchParams]); + + useEffect(() => { + const requestedSetupId = String(searchParams.get('setupId') || '').trim(); + if (!requestedSetupId || consumedSetupFocusKeyRef.current === requestedSetupId) return; + if (savedSetups.length === 0) return; + + const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null; + consumedSetupFocusKeyRef.current = requestedSetupId; + setSearchParams({}, { replace: true }); + + if (!targetEntry) return; + + dispatch({ type: 'set-focused-setup-id', value: requestedSetupId }); + dispatch({ type: 'set-message', value: `Focused saved plan for ${targetEntry.symbol}.` }); + dispatch({ type: 'set-error', value: null }); + if (focusedSetupTimerRef.current !== null) { + window.clearTimeout(focusedSetupTimerRef.current); + } + focusedSetupTimerRef.current = window.setTimeout(() => { + dispatch({ type: 'set-focused-setup-id', value: null }); + focusedSetupTimerRef.current = null; + }, 2200); + window.requestAnimationFrame(() => { + setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }, [dispatch, savedSetups, searchParams, setSearchParams, setupCardRefs]); + + useEffect(() => { + return () => { + if (focusedSetupTimerRef.current !== null) { + window.clearTimeout(focusedSetupTimerRef.current); + } + }; + }, []); +}